不敬だ!どこにデータベースをインストールしようとしているのか? ESENT (ESE) DB 活用記

고남현 @gnh1201@hackers.pub

不敬なものが多い世の中

世の中には本当に不敬(?)なものが多いです。

開発に使うツールが不敬だと言って、どんなツールもインストールせずにメモ帳(Notepad)だけでコーディングをするように言われたかと思えば、今度はデータベースが不敬だと言って、PCにすでにインストールされている基本機能だけでデータベースのように使用できる方法を考えるよう言われています。

ああ...生きていくのは簡単ではありません。

しかし、メモ帳「だけ」でのコーディングも成功させた私が、データベースを成功させられないはずがありません。やってみましょう。

これまでの話

外部開発ツールが非常に限定的に供給される環境(実質的に使える開発ツールが「メモ帳」しかない環境)でも高度な機能を実装できる方法についての要求は、Windowsオペレーティングシステムの内部ECMAScriptを活用するWelsonJSフレームワークの公開によって解決することができました。

WelsonJSフレームワークは2025年6月26日現在、GitHub上で350のスター(肯定的評価)を獲得するという成果も達成しました。

その後、これまでの経験をデータベース領域にも拡大しようという意見が出始めました。商用データベースの存在はそれ自体が非常に大きいため、外部開発ツールの供給が制限される環境ではデータベースも業務の障害になるからです。

結局、オペレーティングシステムで基本的にサポートされているデータベースとして使用可能なシステムがあるかどうか、事前調査を開始しました。

事前調査が終わる

結局、WindowsオペレーティングシステムにはWindows 2000から現在のバージョン(Windows 11)にも搭載されているESENT (ESE) データベースというものが存在することを確認しました。

これまでの経験に基づく直感と、LLM(大規模言語モデル)との協力があれば短時間で実装が可能だと思いましたが、比較的情報が少なく、思ったより時間がかかりました。

データベース機能の抽象化(カラム、スキーマ、CRUD)の実装

ESENT (ESE) データベースを使用するためのAPI実装は存在しますが、Column(カラム)、Schema(スキーマ、またはテーブル)、CRUD(作成、読み取り、更新、削除)など、実際のアプリケーションを実装する用途に適したデータベースの概念を抽象化した実装は存在しませんでした。

そこで、直接作成してみました!まずはカラムとスキーマという概念を実装してみましょう。

(注:x86互換性を維持する必要がある環境では、EsentInteropの場合、現在ライブラリサーバーで配布されているバージョンはx86用のアセンブリ(dll)を提供していないため、ソースコードを直接含めてビルドする必要がありました。)

// Column.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class Column
    {
        public string Name { get; set; }
        public JET_coltyp Type { get; set; }
        public int MaxSize { get; set; }
        public JET_CP CodePage { get; set; }
        public bool IsPrimaryKey { get; set; } = false;

public override string ToString()
        {
            return Name;
        }

public static explicit operator string(Column c)
        {
            return c.ToString();
        }

public Column(string name, JET_coltyp type, int maxSize = 0, JET_CP codePage = JET_CP.None)
        {
            Name = name;
            Type = type;
            MaxSize = maxSize;
            CodePage = codePage == JET_CP.None ?
                JET_CP.Unicode : codePage;
        }

public Column(string name, Type dotNetType, int maxSize = 0, Encoding encoding = null)
        {
            Name = name;
            Type = GetJetColtypFromType(dotNetType);
            MaxSize = maxSize;
            CodePage = GetJetCpFromEncoding(encoding ?? Encoding.Unicode);
        }
private static JET_coltyp GetJetColtypFromType(Type type)
        {
            if (type == typeof(string)) return JET_coltyp.Text;
            if (type == typeof(int)) return JET_coltyp.Long;
            if (type == typeof(long)) return JET_coltyp.Currency;
            if (type == typeof(bool)) return JET_coltyp.Bit;
            if (type == typeof(float)) return JET_coltyp.IEEESingle;
            if (type == typeof(double)) return JET_coltyp.IEEEDouble;
            if (type == typeof(DateTime)) return JET_coltyp.DateTime;
            if (type == typeof(byte[])) return JET_coltyp.LongBinary;

throw new NotSupportedException($"Unsupported .NET type: {type.FullName}");
        }

private static JET_CP GetJetCpFromEncoding(Encoding encoding)
        {
            if (encoding == Encoding.Unicode) return JET_CP.Unicode;
            if (encoding == Encoding.ASCII) return JET_CP.ASCII;
            if (encoding.CodePage == 1252) return (JET_CP)1252; // Windows-1252 / Latin1
            if (encoding.CodePage == 51949) return (JET_CP)51949; // EUC-KR
            if (encoding.CodePage == 949) return (JET_CP)949; // UHC (Windows Korean)
            if (encoding.CodePage == 932) return (JET_CP)932; // Shift-JIS (Japanese)
            if (encoding.CodePage == 936) return (JET_CP)936; // GB2312 (Simplified Chinese)
            if (encoding.CodePage == 65001) return (JET_CP)65001; // UTF-8
            if (encoding.CodePage == 28591) return (JET_CP)28591; // ISO-8859-1

throw new NotSupportedException($"Unsupported encoding: {encoding.WebName} (code page {encoding.CodePage})");
        }
    }
}

// Schema.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;

namespace WelsonJS.Esent
{
    public class Schema
    {
        public string TableName { get; set; }
        public List<Column> Columns { get; set; }
        public Column PrimaryKey
        {
            get
            {
                return Columns.Find(c => c.IsPrimaryKey) ?? null;
            }
        }

public Schema(string tableName, List<Column> columns)
        {
            TableName = tableName;
            Columns = columns ?? new List<Column>();
        }

public void SetPrimaryKey(string columnName)
        {
            Column column = Columns.Find(c => c.Name.Equals(columnName, StringComparison.OrdinalIgnoreCase));
            if (column != null)
            {
                column.IsPrimaryKey = true;
            }
            else
            {
                throw new ArgumentException($"Column '{columnName}' does not exist in schema '{TableName}'.");
            }
        }
    }
}

そして、待望のCRUDを実装します。(例の内容のうち、Loggerインターフェースの活用に関連する内容は省略しました。)

// DataStore.cs (WelsonJS.Esent)
// SPDX-License-Identifier: MIT
// SPDX-FileCopyrightText: 2025 Namhyeon Go <gnh1201@catswords.re.kr>, Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
// 
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text;
using Microsoft.Isam.Esent.Interop;

namespace WelsonJS.Esent
{
    public class EsentDatabase : IDisposable
    {
        private const string _primaryKeyindexName = "primary";
        private const string _indexNamePrefix = "idx_";
        private const string _databaseName = "metadata.edb";

private readonly ICompatibleLogger _logger;
        private static readonly object _lock = new object();
        private static bool _initialized = false;
        private static Instance _instance;
        private static string _workingDirectory;
        private static string _filePath;

private readonly Session _session;
        private readonly JET_DBID _dbid;
        private readonly Schema _schema;
        private readonly Column _primaryKey;
        private readonly Dictionary<string, JET_COLUMNID> _columnIds;

public EsentDatabase(Schema schema, string workingDirectory, ICompatibleLogger logger = null)
        {
            _logger = logger ?? new TraceLogger();

if (schema == null)
                throw new ArgumentNullException(nameof(schema));

_primaryKey = schema.PrimaryKey;

if (_primaryKey == null)
                throw new ArgumentNullException();

if (!schema.Columns.Exists(c => c == _primaryKey))
                throw new ArgumentException($"Primary key '{_primaryKey.Name}' is not in schema.");

_workingDirectory = workingDirectory;
            _schema = schema;
            _columnIds = new Dictionary<string, JET_COLUMNID>(StringComparer.OrdinalIgnoreCase);

InitializeInstance();

_session = new Session(_instance);

if (!File.Exists(_filePath))
            {
                Api.JetCreateDatabase(_session, _filePath, null, out _dbid, CreateDatabaseGrbit.None);
                CreateTable(_schema);
            }
            else
            {
                Api.JetAttachDatabase(_session, _filePath, AttachDatabaseGrbit.None);
                Api.JetOpenDatabase(_session, _filePath, null, out _dbid, OpenDatabaseGrbit.None);
            }

CacheColumns();
        }

private static void InitializeInstance()
        {
            if (_initialized) return;

lock (_lock)
            {
                if (_initialized) return;

// set the file path
                _filePath = Path.Combine(_workingDirectory, _databaseName);

// config the instance
                _instance = new Instance(typeof(EsentDatabase).Namespace);
                _instance.Parameters.SystemDirectory = _workingDirectory;
                _instance.Parameters.LogFileDirectory = _workingDirectory;
                _instance.Parameters.TempDirectory = _workingDirectory;

// initialize the instance _instance.Init(); _initialized = true; } }

private void CreateTable(Schema schema) { Api.JetBeginTransaction(_session); JET_TABLEID tableid; Api.JetCreateTable(_session, _dbid, schema.TableName, 0, 100, out tableid);

foreach (var col in schema.Columns) { var coldef = new JET_COLUMNDEF { coltyp = col.Type, cbMax = col.MaxSize, cp = col.CodePage }; Api.JetAddColumn(_session, tableid, col.Name, coldef, null, 0, out _); }

CreateIndex(tableid, new[] { _primaryKey }, CreateIndexGrbit.IndexPrimary | CreateIndexGrbit.IndexUnique);

Api.JetCloseTable(_session, tableid); Api.JetCommitTransaction(_session, CommitTransactionGrbit.None); }

public void CreateIndex(JET_TABLEID tableid, IEnumerable<Column> columns, CreateIndexGrbit grbit) { if (columns == null) throw new ArgumentNullException(nameof(columns));

var columnList = columns.ToList(); if (columnList.Count == 0) throw new ArgumentException("At least one column is required to create an index.", nameof(columns));

if (tableid == JET_TABLEID.Nil) throw new ArgumentException("Invalid table ID.", nameof(tableid));

bool isPrimaryKeyIndex = (columnList.Count == 1 && columnList[0].IsPrimaryKey);

if (isPrimaryKeyIndex && (grbit & CreateIndexGrbit.IndexPrimary) == 0) throw new ArgumentException("Primary key index must have the CreateIndexGrbit.IndexPrimary flag set.", nameof(grbit));

string indexName = isPrimaryKeyIndex ? _primaryKeyindexName : indexNamePrefix + string.Join("", columnList.Select(c => c.Name));

string key = string.Concat(columnList.Select(c => "+" + c.Name)); string keyDescription = key + "\0\0"; // double null-terminated int keyDescriptionLength = keyDescription.Length;

Api.JetCreateIndex( _session, tableid, indexName, grbit, keyDescription, keyDescriptionLength, 100 ); }

private void CacheColumns() { using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly)) { foreach (var col in _schema.Columns) { try { JET_COLUMNID colid = Api.GetTableColumnid(_session, table, col.Name); _columnIds[col.Name] = colid; } catch (EsentColumnNotFoundException) { _logger.Warn($"Column '{col.Name}' not found."); } } } }

public bool Insert(Dictionary<string, object> values, out object key)
        {
            return TrySaveRecord(values, JET_prep.Insert, expectSeek: false, out key);
        }

public bool Update(Dictionary<string, object> values)
        {
            return TrySaveRecord(values, JET_prep.Replace, expectSeek: true, out _);
        }

private bool TrySaveRecord(
            Dictionary<string, object> values,
            JET_prep prepType,
            bool expectSeek,
            out object primaryKeyValue)
        {
            primaryKeyValue = null;

if (!TryGetPrimaryKeyValue(values, out var keyValue))
                return false;

var keyType = _primaryKey.Type;

using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                try
                {
                    Api.JetBeginTransaction(_session);

Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                    MakeKeyByType(keyValue, keyType, _session, table);
                    bool found = Api.TrySeek(_session, table, SeekGrbit.SeekEQ);

if (expectSeek != found)
                    {
                        _logger.Warn($"[ESENT] Operation skipped. Seek result = {found}, expected = {expectSeek}");
                        Api.JetRollback(_session, RollbackTransactionGrbit.None);
                        return false;
                    }

Api.JetPrepareUpdate(_session, table, prepType);
                    SetAllColumns(values, table);

Api.JetUpdate(_session, table);
                    Api.JetCommitTransaction(_session, CommitTransactionGrbit.None);

if (prepType == JET_prep.Insert)
                        primaryKeyValue = keyValue;

return true;
                }
                catch (Exception ex)
                {
                    Api.JetRollback(_session, RollbackTransactionGrbit.None);
                    throw new InvalidOperationException($"[ESENT] Operation failed: {ex.Message}");
                }
            }
        }

public Dictionary<string, object> FindById(object keyValue)
        {
            var result = new Dictionary<string, object>();
            var keyType = _primaryKey.Type;

using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return null;

foreach (var col in _schema.Columns)
                {
                    if (!_columnIds.TryGetValue(col.Name, out var colid))
                        continue;

var value = RetrieveColumnByType(_session, table, colid, col.Type);
                    result[col.Name] = value;
                }
            }

return result;
        }
public List<Dictionary<string, object>> FindAll()
        {
            var results = new List<Dictionary<string, object>>();

using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.ReadOnly))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);

if (!Api.TryMoveFirst(_session, table))
                    return results;

do
                {
                    var row = new Dictionary<string, object>();
                    foreach (var col in _schema.Columns)
                    {
                        if (!_columnIds.TryGetValue(col.Name, out var colid))
                            continue;

var value = RetrieveColumnByType(_session, table, colid, col.Type);
                        row[col.Name] = value;
                    }
                    results.Add(row);
                }
                while (Api.TryMoveNext(_session, table));
            }

return results;
        }

public bool DeleteById(object keyValue)
        {
            var keyType = _primaryKey.Type;

using (var table = new Table(_session, _dbid, _schema.TableName, OpenTableGrbit.Updatable))
            {
                Api.JetSetCurrentIndex(_session, table, _primaryKeyindexName);
                MakeKeyByType(keyValue, keyType, _session, table);
                if (!Api.TrySeek(_session, table, SeekGrbit.SeekEQ))
                    return false;

Api.JetDelete(_session, table);
                return true;
            }
        }

private object RetrieveColumnByType(Session session, Table table, JET_COLUMNID columnId, JET_coltyp type)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    return Api.RetrieveColumnAsString(session, table, columnId, Encoding.Unicode);
                case JET_coltyp.Long:
                    return Api.RetrieveColumnAsInt32(session, table, columnId);
                case JET_coltyp.IEEEDouble:
                    return Api.RetrieveColumnAsDouble(session, table, columnId);
                case JET_coltyp.DateTime:
                    return Api.RetrieveColumnAsDateTime(session, table, columnId);
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    return Api.RetrieveColumn(session, table, columnId);
                default:
                    _logger.Warn($"[ESENT] Unsupported RetrieveColumn type: {type}");
                    return null;
            }
        }

private bool TryGetPrimaryKeyValue(Dictionary<string, object> values, out object keyValue)
        {
            keyValue = null;

if (!values.TryGetValue(_primaryKey.Name, out keyValue))
            {
                _logger.Warn($"[ESENT] Missing primary key '{_primaryKey.Name}'.");
                return false;
            }

if (keyValue == null)
            {
                _logger.Warn("[ESENT] Primary key value cannot be null.");
                return false;
            }

return true;
        }
private JET_coltyp GetColumnType(string columnName)
        {
            var column = _schema.Columns.FirstOrDefault(c => c.Name == columnName);
            if (column == null)
                throw new ArgumentException($"Column '{columnName}' not found in schema.");

return column.Type;
        }

private void SetAllColumns(Dictionary<string, object> values, Table table)
        {
            foreach (var kv in values)
            {
                if (!_columnIds.TryGetValue(kv.Key, out var colid))
                {
                    _logger.Warn($"[ESENT] Column '{kv.Key}' not found in cache.");
                    continue;
                }

var colType = GetColumnType(kv.Key);
                SetColumnByType(_session, table, colid, kv.Value, colType);
            }
        }

private void SetColumnByType(Session session, Table table, JET_COLUMNID columnId, object value, JET_coltyp type)
        {
            if (value == null)
                return;

switch (type)
            {
                case JET_coltyp.Text:
                    Api.SetColumn(session, table, columnId, value.ToString(), Encoding.Unicode);
                    break;
                case JET_coltyp.Long:
                    Api.SetColumn(session, table, columnId, Convert.ToInt32(value));
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.SetColumn(session, table, columnId, Convert.ToDouble(value));
                    break;
                case JET_coltyp.DateTime:
                    Api.SetColumn(session, table, columnId, Convert.ToDateTime(value));
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.SetColumn(session, table, columnId, (byte[])value);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported SetColumn type: {type}");
                    break;
            }
        }
private void MakeKeyByType(object value, JET_coltyp type, Session session, Table table)
        {
            switch (type)
            {
                case JET_coltyp.Text:
                    Api.MakeKey(session, table, value.ToString(), Encoding.Unicode, MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Long:
                    Api.MakeKey(session, table, Convert.ToInt32(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.IEEEDouble:
                    Api.MakeKey(session, table, Convert.ToDouble(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.DateTime:
                    Api.MakeKey(session, table, Convert.ToDateTime(value), MakeKeyGrbit.NewKey);
                    break;
                case JET_coltyp.Binary:
                case JET_coltyp.LongBinary:
                    Api.MakeKey(session, table, (byte[])value, MakeKeyGrbit.NewKey);
                    break;
                default:
                    _logger.Warn($"[ESENT] Unsupported MakeKey type: {type}");
                    break;
            }
        }

public void Dispose()
        {
            _session?.Dispose();
        }
    }
}

このようにして、ESENT (ESE) データベースでアプリケーション開発に適したカラム、スキーマ、およびCRUD用のメソッドを実装することができます。

活用

このように作成された実装は、次のように使用できます:

using WelsonJS.Esent;

// connect the database to manage instances
Schema schema = new Schema("Instances", new List<Column>
{
    new Column("InstanceId", typeof(string), 255),
    new Column("FirstDeployTime", typeof(DateTime), 1)
});
schema.SetPrimaryKey("InstanceId");
_db = new EsentDatabase(schema, Path.GetTempPath());

// Insert row
try
{
    _db.Insert(new Dictionary<string, object>
    {
        ["InstanceId"] = instanceId,
        ["FirstDeployTime"] = now
    }, out _);
}
catch (Exception ex)
{
    // Handle exception
}

// find all
var instances = _db.FindAll();
foreach (var instance in instances)
{
    try
    {
        string instanceId = instance["InstanceId"].ToString();
        string firstDeployTime = instance.ContainsKey("FirstDeployTime")
            ? ((DateTime)instance["FirstDeployTime"]).ToString("yyyy-MM-dd HH:mm:ss")
            : "Unknown";

Console.WriteLine($"{firstDeployTime}, {instanceId}");
    }
    catch (Exception ex)
    {
        // Handle exception
    }
}

私たちにとって比較的馴染みのある、アプリケーション開発に適したメソッドをサポートしていることが確認できます。

完全な実装

こうして新しいライブラリ「WelsonJS.Esent」が誕生しました。完全な実装は以下のリンクで確認できます。

https://github.com/gnh1201/welsonjs/tree/master/WelsonJS.Toolkit/WelsonJS.Esent

5

No comments

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/0197aaeb-842e-783a-9ac6-b52f4bc09084 on your instance and reply to it.