9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypedSql──C# の型システムをクエリエンジンとして「悪用」してみた話

Last updated at Posted at 2025-11-23

0. はじめに

TypedSql は、ある日ふと湧いた「ちょっとした不満」から始まりました。

.NET でコードを書いていると、「クエリっぽい処理」を書く場面がよくあります。

例えば、すでにメモリ上にある List<T> や配列をフィルタして、一部の列だけ取り出したいときです。

そのとき、だいたい次の 3 つの選択肢があります。

  • 素直に foreach で回す — 速くて明示的だけど、ちょっとコードがうるさい
  • LINQ(Language Integrated Query)を使う — 書き心地はいいけれど、イテレーターやデリゲートのオーバーヘッドが気になる
  • いっそデータベースに突っ込んで、本物の SQL を書く — さすがにやりすぎ感がある

「もう少しだけ“ちょうどいい”選択肢が欲しいな」と思ったところから、TypedSql の実験が始まりました。

そこで出てきたのが、こんな発想です。

C# の型システムそのものを、クエリプランとして扱ったらどうなるんだろう?

ふつうなら、

  • 実行時に式ツリー(Expression Tree)を組み立てて
  • それをデータに対して解釈していく

というスタイルになりますよね。

TypedSql は、そこをひっくり返します。

  • SQL っぽい文字列をパースして
  • その結果を「ネストしたジェネリック型」として表現し
  • あとは全部、静的メソッドだけで処理を流す

つまり、クエリの実行計画を丸ごと型に押し込めてしまう、という遊びです。

1000016101.jpg

1. 「クエリ=ネストしたジェネリック型」

TypedSql のコアにある考え方は、とてもシンプルです。

クエリは WhereSelect<TRow, …, Stop<...>> みたいな ネストしたジェネリック型の連鎖 で表せるのでは?

LINQ のように「メソッドチェーンでクエリを書く」のではなく、そのチェーンの構造そのものを「型引数」で表現してしまう、ということです。

1.1 パイプラインを丸ごと型にする

TypedSql では、コンパイルされたクエリはすべて「閉じたジェネリック型」になります。
パイプラインの部品は、例えばこんな感じです。

  • Where<TRow, TPredicate, TNext, TResult, TRoot>
  • Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>
  • WhereSelect<TRow, TPredicate, TProjection, TNext, TMiddle, TResult, TRoot>
  • Stop<TResult, TRoot>

そして、これらはみんな次のインターフェースを実装します。

internal interface IQueryNode<TRow, TResult, TRoot>
{
    static abstract void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime);

    static abstract void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime);
}
  • Run が外側のループ(全行を回す)
  • Process が 1 行ごとの処理

という役割分担です。

例えば Where ノードはこんなイメージです。

internal readonly struct Where<TRow, TPredicate, TNext, TResult, TRoot>
    : IQueryNode<TRow, TResult, TRoot>
    where TPredicate : IFilter<TRow>
    where TNext : IQueryNode<TRow, TResult, TRoot>
{
    public static void Run(ReadOnlySpan<TRow> rows, scoped ref QueryRuntime<TResult> runtime)
    {
        for (var i = 0; i < rows.Length; i++)
        {
            Process(in rows[i], ref runtime);
        }
    }

    public static void Process(in TRow row, scoped ref QueryRuntime<TResult> runtime)
    {
        if (TPredicate.Evaluate(in row))
        {
            TNext.Process(in row, ref runtime);
        }
    }
}

ここで大事なのは、

  • パイプラインの「形」はすべて型引数に閉じている
  • 各ノードは struct で、インスタンスを作らない
  • すべて static メソッド呼び出しで完結する

という点です。

JIT(Just-In-Time コンパイル)から見ると、「一度ジェネリック引数が決まってしまえば、あとはただの静的な呼び出しグラフ」です。つまり、かなり強気に最適化できる余地があります。

1.2 列(カラム)と投影を値型で統一する

クエリを動かすには、

  • どんな列があるのか
  • どんな値を取り出すのか
  • どうフィルタするのか

といった情報が必要です。
TypedSql はここも徹底的に「値型+静的メソッド」で揃えます。

行は、ユーザーが定義する任意の型(クラスでもレコードでも)です。その上に、「この行からどう値を取り出すか」を表す IColumn を乗せます。

internal interface IColumn<TRow, TValue>
{
    static abstract string Identifier { get; }

    static abstract TValue Get(in TRow row);
}

例えば Person.Name 列なら、こんな struct になります。

internal readonly struct PersonNameColumn : IColumn<Person, string>
{
    public static string Identifier => "Name";

    public static string Get(in Person row) => row.Name;
}

インスタンスはいりません。
「列名」と「値の取り出し方」を、全部 static で済ませています。

投影(SELECT 句で返す値)は IProjection です。

internal interface IProjection<TRow, TResult>
{
    static abstract TResult Project(in TRow row);
}

「ある列だけ返す」投影なら、こんな struct です。

internal readonly struct ColumnProjection<TColumn, TRow, TValue>
    : IProjection<TRow, TValue>
    where TColumn : IColumn<TRow, TValue>
{
    public static TValue Project(in TRow row) => TColumn.Get(row);
}

複数列を返したいときは、ValueTuple(値タプル)を構成する専用の projection をいくつか用意しておきます。

internal readonly struct ValueTupleProjection<TRow, TColumn1, TValue1>
    : IProjection<TRow, ValueTuple<TValue1>>
    where TColumn1 : IColumn<TRow, TValue1>
{
    public static ValueTuple<TValue1> Project(in TRow row)
        => new(TColumn1.Get(row));
}

// … 7 列まで専用型を用意し、その先は Rest で再帰

ここでもやはり、

  • すべて struct
  • すべて静的メソッド

という統一ルールです。

1.3 フィルタ(WHERE 句)もすべて型で表現する

フィルタは IFilter<TRow> です。

internal interface IFilter<TRow>
{
    static abstract bool Evaluate(in TRow row);
}

列とリテラルの比較は、例えば次のように struct で表現します。

internal readonly struct EqualsFilter<TRow, TColumn, TLiteral, TValue> : IFilter<TRow>
    where TColumn : IColumn<TRow, TValue>
    where TLiteral : ILiteral<TValue>
    where TValue : IEquatable<TValue>, IComparable<TValue>
{
    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public static bool Evaluate(in TRow row)
    {
        if (typeof(TValue).IsValueType)
        {
            return TColumn.Get(row).Equals(TLiteral.Value);
        }
        else
        {
            var left = TColumn.Get(row);
            var right = TLiteral.Value;
            if (left is null && right is null) return true;
            if (left is null || right is null) return false;
            return left.Equals(right);
        }
    }
}

ここでは TValue が値型か参照型かを分けて null を扱っています。.NET の JIT はこのパターンを認識して、値型用と参照型用に別々のコードパスを特化してくれるので、実際にはこの分岐による余計なコストはほぼ発生しません。

>, <, != なども同じノリで struct です。

AND / OR / NOT は、型レベルの論理演算子として積み上げます。

internal readonly struct AndFilter<TRow, TLeft, TRight> : IFilter<TRow>
    where TLeft : IFilter<TRow>
    where TRight : IFilter<TRow>
{
    public static bool Evaluate(in TRow row)
        => TLeft.Evaluate(in row) && TRight.Evaluate(in row);
}

internal readonly struct OrFilter<TRow, TLeft, TRight> : IFilter<TRow>
    where TLeft : IFilter<TRow>
    where TRight : IFilter<TRow>
{
    public static bool Evaluate(in TRow row)
        => TLeft.Evaluate(in row) || TRight.Evaluate(in row);
}

internal readonly struct NotFilter<TRow, TPredicate> : IFilter<TRow>
    where TPredicate : IFilter<TRow>
{
    public static bool Evaluate(in TRow row)
        => !TPredicate.Evaluate(in row);
}

結果として、WHERE 句全体が フィルタ型の木構造になります。式ツリーではなく、「型ツリー」です。

1.4 文字列だけは特別扱い──ValueString

.NET では string は参照型です。
ここが、ジェネリックの世界では少し厄介なポイントになります。

  • 参照型は「ジェネリックの共有」が行われる
  • その結果、内部で辞書を引くような形のオーバーヘッドが少しだけ乗る

「ホットパスはできるだけ値型だけで走らせたい」という欲張りな願望から、TypedSql は string を一度ラップします。

internal readonly struct ValueString(string? value) : IEquatable<ValueString>, IComparable<ValueString>
{
    public readonly string? Value = value;

    public int CompareTo(ValueString other)
        => string.Compare(Value, other.Value, StringComparison.Ordinal);

    public bool Equals(ValueString other)
        => string.Equals(Value, other.Value, StringComparison.Ordinal);

    public override string? ToString() => Value;

    public static implicit operator ValueString(string value) => new(value);

    public static implicit operator string?(ValueString value) => value.Value;
}

string 列は ValueStringColumn で自動的にこれに変換されます。

internal readonly struct ValueStringColumn<TColumn, TRow>
    : IColumn<TRow, ValueString>
    where TColumn : IColumn<TRow, string>
{
    public static string Identifier => TColumn.Identifier;

    public static ValueString Get(in TRow row)
        => new(TColumn.Get(in row));
}

こうすることで、

  • ホットパスは可能な限り値型だけで完結
  • 文字列比較は常に ordinal に統一
  • 呼び出し側の API は string のまま(暗黙変換で吸収)

という、わりとおいしいバランスが取れます。

2. 小さな SQL 方言をパースする

TypedSql は、フル機能の SQL を目指してはいません。
対象はあくまで「1 テーブル、メモリ上の配列に対するシンプルなクエリ」です。

対応している構文は、ざっくりこんな感じです。

  • SELECT * FROM $
  • SELECT col FROM $
  • SELECT col1, col2, ... FROM $
  • WHERE
    • 比較演算子: =, !=, >, <, >=, <=
    • 論理演算子: AND, OR, NOT
    • 括弧
  • リテラル
    • 整数 (42)
    • 浮動小数点数 (123.45)
    • 真偽値 (true, false)
    • シングルクォート文字列 ('Seattle''' でエスケープ)
    • null
  • 列名は大文字小文字を区別しない
  • $ は「今のソース(行の集合)」を表すプレースホルダ

パーサは次の 2 段階です。

  1. 入力 SQL をトークナイズ(単語に切り出す)
  2. 小さな抽象構文木(AST)を組み立てる
    • ParsedQuery — SELECT と optional な WHERE
    • SelectionSelectAll または列名のリスト
    • WhereExpression — 次のいずれか
      • ComparisonExpression
      • AndExpression
      • OrExpression
      • NotExpression
    • LiteralValue — 種別と実際の値
      • LiteralKind.Integer + IntValue
      • LiteralKind.Float + FloatValue
      • LiteralKind.Boolean + BoolValue
      • LiteralKind.String + StringValue
      • LiteralKind.Null

この段階では、まだ C# の型は一切出てきません。
「構文としてどういう構造をしているか」だけを表現しています。

「その列は本当に int なのか?」「そのリテラルは float に変換できるのか?」といった型の整合性チェックは、このあとコンパイルフェーズで行います。

3. リテラルを「値」ではなく「型」として表す

TypedSql でもっとも“変態的(いい意味で)”な部分は、たぶんここです。

整数・浮動小数・文字・文字列・bool といったリテラル値を、すべて「型」として表現する

すべてのリテラル型は ILiteral<T> を実装します。

internal interface ILiteral<T>
{
    static abstract T Value { get; }
}
  • 整数(int
  • 浮動小数(float
  • 文字(char
  • 真偽値(bool
  • 文字列(ValueString 経由)

などが、みんなこのインターフェースに従います。

3.1 16 進数の桁で分解する数値リテラル

数値リテラルは「16 進数の桁」に分解した型として表現されます。

まず 1 桁ぶんの IHex と、Hex0HexF の struct があります。

internal interface IHex { static abstract int Value { get; } }

internal readonly struct Hex0 : IHex { public static int Value => 0; }
// ...
internal readonly struct HexF : IHex { public static int Value => 15; }

整数リテラルは、これを 8 個並べた Int<H7, ..., H0> 型です。

internal readonly struct Int<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<int>
    where H7 : IHex
    // ...
    where H0 : IHex
{
    public static int Value
        => (H7.Value << 28)
         | (H6.Value << 24)
         | (H5.Value << 20)
         | (H4.Value << 16)
         | (H3.Value << 12)
         | (H2.Value <<  8)
         | (H1.Value <<  4)
         |  H0.Value;
}

浮動小数点数(float)も 8 桁ぶんのビットパターンを持ち、Unsafe.BitCastfloat に変換します。

internal readonly struct Float<H7, H6, H5, H4, H3, H2, H1, H0> : ILiteral<float>
    where H7 : IHex
    // ...
{
    public static float Value
        => Unsafe.BitCast<int, float>(
               (H7.Value << 28)
             | (H6.Value << 24)
             | (H5.Value << 20)
             | (H4.Value << 16)
             | (H3.Value << 12)
             | (H2.Value <<  8)
             | (H1.Value <<  4)
             |  H0.Value);
}

文字(char)は 4 桁ぶんの 16 進数です。

internal readonly struct Char<H3, H2, H1, H0> : ILiteral<char>
    where H3 : IHex
    // ...
{
    public static char Value
        => (char)((H3.Value << 12)
                | (H2.Value <<  8)
                | (H1.Value <<  4)
                |  H0.Value);
}

ここまで来ると、「型引数として渡されているビットパターン」だけでリテラルを表せるようになります。

たとえば、42 という整数リテラルは Int<Hex0, Hex0, Hex0, Hex0, Hex0, Hex0, Hex2, HexA> という型になります。そして、'A' という文字リテラルは Char<Hex0, Hex0, Hex4, Hex1> です。

3.2 文字列リテラル:型レベルのリンクトリスト

文字列は、もう一段階トリッキーです。

TypedSql は、文字列を「文字ごとのリテラル型をつなげた 型レベルのリンクトリスト」として表現します。

そのためのインターフェースが IStringNode です。

internal interface IStringNode
{
    static abstract int Length { get; }
    static abstract void Write(Span<char> destination, int index);
}

実装は 3 種類あります。

  • StringEnd — 終端(長さ 0)
  • StringNullnull を表す特別なノード(長さ -1)
  • StringNode<TChar, TNext> — 1 文字+残り
internal readonly struct StringEnd : IStringNode
{
    public static int Length => 0;
    public static void Write(Span<char> destination, int index) { }
}

internal readonly struct StringNull : IStringNode
{
    public static int Length => -1;
    public static void Write(Span<char> destination, int index) { }
}

internal readonly struct StringNode<TChar, TNext> : IStringNode
    where TChar : ILiteral<char>
    where TNext : IStringNode
{
    public static int Length => 1 + TNext.Length;

    public static void Write(Span<char> destination, int index)
    {
        destination[index] = TChar.Value;
        TNext.Write(destination, index + 1);
    }
}

この「型レベルのリスト」から、実際の ValueString を組み立てるのが StringLiteral<TString> です。

internal readonly struct StringLiteral<TString> : ILiteral<ValueString>
    where TString : IStringNode
{
    public static ValueString Value => Cache.Value;

    private static class Cache
    {
        public static readonly ValueString Value = Build();

        private static ValueString Build()
        {
            var length = TString.Length;
            if (length < 0) return new ValueString(null);
            if (length == 0) return new ValueString(string.Empty);

            var chars = new char[length];
            TString.Write(chars.AsSpan(), 0);
            return new string(chars, 0, length);
        }
    }
}

「型から 1 度だけ文字列を組み立ててキャッシュする」というイメージです。

3.2.1 'Seattle' はどう型になる?

WHERE City = 'Seattle' という SQL を例にしてみます。

  1. パーサは 'Seattle' を見つけて、
    • Kind = LiteralKind.String
    • StringValue = "Seattle"
      というリテラル情報を作ります。
  2. コンパイラは対応する列が文字列(実際には ValueString)だと判断します。
  3. LiteralTypeFactory.CreateStringLiteral("Seattle") が呼ばれます。

このメソッドの中では、ざっくり次のような処理をします。

  • 最初に type = StringEnd
  • 文字列を後ろから前に一文字ずつ見ていき、
  • 各文字を Char<...> 型に変換し、
  • StringNode<Char<'e'>, StringEnd> のようにどんどん前に継ぎ足していく

最終形は、概ねこんな感じの型になります。

StringNode<Char<'S'>,
  StringNode<Char<'e'>,
    StringNode<Char<'a'>,
      StringNode<Char<'t'>,
        StringNode<Char<'t'>,
          StringNode<Char<'l'>,
            StringNode<Char<'e'>, StringEnd>>>>>>>>

これ 1 つの閉じたジェネリック型が、「文字列リテラル 'Seattle'」そのものを表します。

EqualsFilter は、ここから TLiteral.Value を通じて ValueString("Seattle") を取り出します。

3.2.2 null 文字列も型で区別する

null もちゃんと型として扱います。

  • WHERE Team != null という条件なら、パーサは Kind = LiteralKind.Null として記録します。
  • 文字列列に対しては StringLiteral<StringNull> という型を使います。
  • StringNull.Length = -1 なので、Valuenew ValueString(null) を返します。

これで、null"" を型レベルでも実行時でもきちんと区別できます。

3.3 リテラルファクトリ

これらのリテラル型は、LiteralTypeFactory によって一括で生成されます。

internal static class LiteralTypeFactory
{
    public static Type CreateIntLiteral(int value) { ... }
    public static Type CreateFloatLiteral(float value) { ... }
    public static Type CreateBoolLiteral(bool value) { ... }
    public static Type CreateStringLiteral(string? value) { ... }
}

SQL コンパイラは、

  • 実行時の列の型(int, float, bool, ValueString など)
  • リテラルの種類(整数・浮動小数・真偽値・文字列・null)

に応じて、適切な ILiteral<T> 型をここから取得します。

結果として、WHERE 句に出てくるすべてのリテラルが、「値を型引数に埋め込んだ ILiteral<T> 型」 になります。

4. パイプライン型を組み立てる

ここまでで、次のものがそろいました。

  • パース済みクエリ(SELECT と WHERE の構造)
  • 列名から IColumn<TRow, TValue> 実装へのマッピング
  • リテラルを表す ILiteral<T>

コンパイラの仕事は、これらを使って次の 3 つを作ることです。

  • パイプライン本体 TPipelineIQueryNode<TRow, TRuntimeResult, TRoot> を実装する閉じたジェネリック型)
  • 実行時の結果型 TRuntimeResult
  • 公開する結果型 TPublicResult

4.1 SELECT の扱い

まずは SELECT からです。

SELECT *

一番シンプルな SELECT * FROM $ の場合、

  • 実行時の結果型も公開する結果型も TRow
  • パイプラインの末尾は Stop<TRow, TRow>

になります。

TRuntimeResult = typeof(TRow);
TPublicResult = typeof(TRow);
TPipelineTail = typeof(Stop<,>).MakeGenericType(TRuntimeResult, typeof(TRow));

SELECT col / SELECT col1, col2, ...

投影(列の選択)がある場合は、少しだけ手順が増えます。

  • SELECT col の場合

    • 列名から ColumnMetadata を解決
    • 実行時の値型を決める
      • string 以外ならそのまま
      • string なら ValueString に変換
    • ColumnProjection<TRuntimeColumn, TRow, TRuntimeValue> を組み立てる
  • SELECT col1, col2, ... の場合

    • 各列を解決し
    • 実行時には ValueTuple<...>(中身は ValueString など)を作る projection を作り
    • 公開型には、ユーザーの指定どおりの ValueTuple<...>(中身は string など)を使う

最終的には、どの場合も Select ノードを Stop の直前に挿入します。

Select<TRow, TProjection, Stop<...>, TMiddle, TRuntimeResult, TRoot> → Stop<...>

このノードは、Project の結果を Stop.Process に渡す役割を持ちます。

4.2 WHERE の扱い

WHERE 句は、構文木から再帰的にフィルタ型を組み立てます。

論理演算子(AND / OR / NOT)

WhereExpression の木構造を、対応するフィルタ型にマッピングします。

  • A AND BAndFilter<TRow, TA, TB>
  • A OR BOrFilter<TRow, TA, TB>
  • NOT ANotFilter<TRow, TA>
Type BuildPredicate<TRow>(WhereExpression expr)
{
    return expr switch
    {
        ComparisonExpression cmpExpr => BuildComparisonPredicate<TRow>(cmpExpr),
        AndExpression andExpr => typeof(AndFilter<,,>).MakeGenericType(
            typeof(TRow),
            BuildPredicate<TRow>(andExpr.Left),
            BuildPredicate<TRow>(andExpr.Right)),
        OrExpression orExpr => typeof(OrFilter<,,>).MakeGenericType(
            typeof(TRow),
            BuildPredicate<TRow>(orExpr.Left),
            BuildPredicate<TRow>(orExpr.Right)),
        NotExpression notExpr => typeof(NotFilter<,>).MakeGenericType(
            typeof(TRow),
            BuildPredicate<TRow>(notExpr.Expression)),
        _ => throw ...
    };
}

比較演算子(=、!=、> など)

葉ノードは「列とリテラルの比較」です。

City = 'Seattle'
Salary >= 180000
Team != null

これらは最終的に、次のような型になります(例:文字列列に対する City = 'Seattle')。

  • 実行時の列型は ValueStringColumn<PersonCityColumn, Person>
  • 実行時の値型は ValueString
  • リテラル型は StringLiteral<SomeStringNode<…>>

つまり、

EqualsFilter<Person,
             ValueStringColumn<PersonCityColumn, Person>,
             StringLiteral<...>,
             ValueString>

のような型が組み上がります。

コンパイラはこれを Where<TRow, TPredicate, TNext, TRuntimeResult, TRoot> ノードにくっつけて、パイプラインに組み込みます。

4.3 Where と Select を「融合」して 1 パスにする

ここまでで組み上がったパイプラインは、正しさという意味では問題ありませんが、少しだけ最適化の余地があります。

典型的なクエリは、だいたい次のような形をしています。

SELECT Name FROM $ WHERE City = 'Seattle'

素直に組み立てると、

Where<...> → Select<...> → Stop<...>

という 2 ステージ構成になりますが、これは 1 つにまとめることができます。

TypedSql には、小さなオプティマイザがあり、

  • Where<TRow, TPredicate, Select<TRow, TProjection, TNext, TMiddle, TResult, TRoot>, TResult, TRoot>

のようなパターンを見つけると、これを

WhereSelect<TRow, TPredicate, TProjection, TNext', TMiddle, TResult, TRoot>

に置き換えます。

WhereSelect ノードは、1 つのループの中で

  • まず TPredicate.Evaluate でフィルタし
  • 通った行だけ TProjection.Project で投影し
  • その結果を次のノードに渡す

という仕事をこなします。

結果として、SELECT Name FROM $ WHERE City = 'Seattle' のようなクエリは、

WhereSelect<...> → Stop<...>

という 1 パスのループ に落ちます。

さらに、このオプティマイザはもう少し複雑なネスト構造もある程度まで認識して、可能な限り WhereSelect をまとめてしまいます。ここでやっていることは難しい最適化アルゴリズムではなく、「既存のジェネリック型の引数をばらして、新しい融合ノードの型引数として組み立て直す」だけなので、実装自体はかなりシンプルです。

5. 実行結果を「外向きの型」に変換する

パイプライン内部で使っている型と、ユーザーが欲しい型は、必ずしも同じではありません。

例えば、

  • 内部では ValueString
  • ユーザーには string を返したい

といったズレがあります。その差を埋めるのが QueryProgram<TRow, ...> です。

internal static class QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>
    where TPipeline : IQueryNode<TRow, TRuntimeResult, TRow>
{
    public static IReadOnlyList<TPublicResult> Execute(ReadOnlySpan<TRow> rows)
    {
        var runtime = new QueryRuntime<TRuntimeResult>(rows.Length);
        TPipeline.Run(rows, ref runtime);

        return ConvertResult(ref runtime);
    }

    private static IReadOnlyList<TPublicResult> ConvertResult(ref QueryRuntime<TRuntimeResult> runtime)
    {
        if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<TPublicResult>))
        {
            return (IReadOnlyList<TPublicResult>)(object)runtime.Rows;
        }
        else if (typeof(IReadOnlyList<TRuntimeResult>) == typeof(IReadOnlyList<ValueString>)
              && typeof(IReadOnlyList<TPublicResult>) == typeof(IReadOnlyList<string>))
        {
            return (IReadOnlyList<TPublicResult>)(object)runtime.AsStringRows();
        }
        else if (RuntimeFeature.IsDynamicCodeSupported
              && typeof(TRuntimeResult).IsGenericType
              && typeof(TPublicResult).IsGenericType)
        {
            return runtime.AsValueTupleRows<TPublicResult>();
        }

        throw new InvalidOperationException(
            $"Cannot convert query result from '{typeof(TRuntimeResult)}' to '{typeof(TPublicResult)}'.");
    }
}

ざっくり分けると、ケースは 3 つです。

  1. 実行時の結果型と公開型が同じ
    → そのまま Rows を返す。
  2. 実行時が ValueString で、公開型が string
    AsStringRows でラップし、ValueString[]string? に暗黙変換して返す。
  3. どちらも ValueTuple で、形が対応している
    AsValueTupleRows<TPublicResult>() で値タプル同士をコピーする。

5.1 ValueTupleConvertHelper:動的 IL でフィールドコピー

ValueTupleConvertHelper<TPublicResult, TRuntimeResult> は、

  • 実行時の値タプルから公開用の値タプルへフィールドをコピーし
  • 必要に応じて stringValueString を相互変換し
  • ネストした Rest フィールドも再帰的に処理する

という仕事をします。

内部的には DynamicMethod を使って、型初期化時に IL を生成します。

internal static class ValueTupleConvertHelper<TPublicResult, TRuntimeResult>
{
    private delegate void CopyDelegate(ref TPublicResult dest, ref readonly TRuntimeResult source);

    private static readonly CopyDelegate _helper = default!;

    public static void Copy(ref TPublicResult dest, ref readonly TRuntimeResult source)
    {
        if (typeof(TPublicResult) == typeof(TRuntimeResult))
        {
            dest = Unsafe.As<TRuntimeResult, TPublicResult>(ref Unsafe.AsRef(in source));
        }
        else
        {
            _helper.Invoke(ref dest, in source);
        }
    }

    static ValueTupleConvertHelper()
    {
        // DynamicMethod と IL を生成して、
        // Item1〜Item7 と Rest をコピーするコードを組み立てる。
    }
}

これのおかげで、実行時には

  • 内部のタプル (ValueString, int, ValueString, …)
  • 公開用タプル (string, int, string, …)

のあいだを、余計なオーバーヘッドなくコピーできます。

ここでは DynamicMethod を使った動的 IL 生成を行っているため、AOT など動的コード生成が制限される環境では利用できません。TypedSql 側では実行環境の機能を確認し、動的コードが使える場合だけこの経路を有効にし、それ以外の環境では「実行時結果型と公開結果型を一致させる」方針にフォールバックするようにしています。

6. コンパイルと実行をつなぐ

ユーザーから見たエントリポイントは、とてもシンプルです。

var compiled = QueryEngine.Compile<Person, string>(
    "SELECT Name FROM $ WHERE City != 'Seattle'");

内部で Compile<TRow, TResult> は次のように動きます。

  1. SQL をパースして ParsedQuery(構文木)を作る。
  2. SQL コンパイラを呼び出して、
    • パイプライン型 TPipeline
    • 実行時の結果型 TRuntimeResult
    • 公開する結果型 TPublicResult
      を受け取る。
  3. TPublicResult がユーザーの指定した TResult と一致することを確認する。
  4. QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult> 型を構築する。
  5. その静的メソッド Execute(ReadOnlySpan<TRow>) を探す。
  6. デリゲートに変換して、CompiledQuery<TRow, TResult> としてラップして返す。

CompiledQuery<TRow, TResult> は、内部的には次のようなフィールドを持っています。

private readonly Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>> _entryPoint
    = executeMethod.CreateDelegate<Func<ReadOnlySpan<TRow>, IReadOnlyList<TResult>>>();

そして、公開 API はただこれだけです。

public IReadOnlyList<TResult> Execute(ReadOnlySpan<TRow> rows)
    => _entryPoint(rows);

.NET 10 では、デリゲートに対するエスケープ解析やデバーチャライゼーション(仮想呼び出しの除去)、インライン展開がかなり強化されています。
そのため、この薄いラッパー層はほぼゼロに近いオーバーヘッドしか持ちません。

JIT から見ると、Compile が一度走ってしまえば、その後の Execute はただの

  • 閉じたジェネリックメソッドへの静的呼び出し
  • その中で struct ノード・フィルタ・投影の静的メソッドが連鎖しているだけ

という構造になります。

7. 実際の使い方とワークフロー

TypedSql の典型的な使い方は、次のような流れになります。

  1. 行型を定義する。

    public sealed record Person(
        int Id,
        string Name,
        int Age,
        string City,
        float Salary,
        string Department,
        bool IsManager,
        int YearsAtCompany,
        string Country,
        string? Team,
        string Level);
    
  2. クエリで使いたい列ごとに IColumn<Person, TValue> を実装する。

  3. それらをスキーマとして登録する。

  4. SQL っぽい文字列でクエリをコンパイルし、メモリ上のデータに対して実行する。

例えば、こんなクエリを書けます。

// 一度だけコンパイル
var wellPaidManagers = QueryEngine.Compile<Person, Person>(
    "SELECT * FROM $ " +
    "WHERE Department = 'Engineering' " +
    "AND IsManager = true " +
    "AND YearsAtCompany >= 5 " +
    "AND Salary > 170000 " +
    "AND Country = 'US'");

// データセットを変えて何度でも実行
var result = wellPaidManagers.Execute(allPeople.AsSpan());

あるいは、複数の列だけ抜き出してタプルで返すクエリも、素直に書けます。

var seniorTitles = QueryEngine.Compile<Person, (string Name, string City, string Level)>(
    "SELECT Name, City, Level FROM $ " +
    "WHERE Level = 'Senior' AND City = 'Seattle'");

foreach (var (name, city, level) in seniorTitles.Execute(allPeople.AsSpan()))
{
    Console.WriteLine($"{name} in {city} [{level}]");
}

大事なのは、

  • パース
  • リテラルの型化
  • パイプライン型の組み立て

といった「重い処理」は、クエリをコンパイルするときに 1 回だけ行われる、ということです。その後の .Execute は、専用に最適化された静的パイプラインにデータを流し込むだけになります。

8. 手書きループにどこまで近づけるのか?

TypedSql を作るうえで一番気になっていたのは、次の点でした。

型システムに全振りしたクエリエンジンは、実際どれくらい速いのか?

「おもしろいからやった」で終わらせるのではなく、きちんと数字を見たかったのです。

そこで、次の 3 つを同じ条件でベンチマークしてみました。

  • TypedSql のクエリ
  • 同等の LINQ クエリ
  • 手書きの foreach ループ

ワークロードはとても素直で、

  • City == "Seattle" の行だけをフィルタして
  • その Id を返す

というものです。

TypedSql がコンパイルするパイプラインは、ざっくり言うとこういう形になります。

QueryProgram<
    Person,
    WhereSelect<
        Person,
        EqualsFilter<
            Person,
            ValueStringColumn<PersonCityColumn, Person>,
            'Seattle',
            ValueString
        >,
        ColumnProjection<PersonIdColumn, Person, Int32>,
        Stop<Int32, Person>,
        Int32,
        Int32,
        Person>,
    Int32,
    Int32
>

ここから先は、RyuJIT がこの抽象をどう「はぎ取って」いくかの話です。TypedSql が生成したこのパイプラインは、JIT の目にはただの静的なメソッド呼び出しのグラフとして見えます。そこからどこまで最適化されるのかを、実際に出てきた asm を眺めながら見ていきます。

ここで、実際に RyuJIT が吐き出した機械語を少し覗いてみましょう。

G_M000_IG01:                ; prologue
    push     r15
    push     r14
    push     rdi
    push     rsi
    push     rbp
    push     rbx
    sub      rsp, 40
    mov      rbx, rcx

G_M000_IG02:                ; 結果配列の割り当て
    mov      esi, dword ptr [rbx+0x08]
    mov      edx, esi
    mov      rcx, 0x7FFE71F29558
    call     CORINFO_HELP_NEWARR_1_VC
    mov      rdi, rax
    xor      ebp, ebp
    mov      rbx, bword ptr [rbx]
    test     esi, esi
    jle      SHORT G_M000_IG06

G_M000_IG03:                ; ループ変数の初期化
    xor      r14d, r14d

G_M000_IG04:                ; ループ本体
    lea      r15, bword ptr [rbx+r14]
    mov      rcx, gword ptr [r15+0x08]
    mov      rdx, 0x16EB0400D30
    mov      rdx, gword ptr [rdx]
    mov      rdx, gword ptr [rdx+0x08]
    cmp      rcx, rdx
    je       G_M000_IG12
    test     rcx, rcx
    je       SHORT G_M000_IG05
    test     rdx, rdx
    je       SHORT G_M000_IG05
    mov      r8d, dword ptr [rcx+0x08]
    cmp      r8d, dword ptr [rdx+0x08]
    je       SHORT G_M000_IG08

G_M000_IG05:                ; ループカウンタ更新
    add      r14, 72
    dec      esi
    jne      SHORT G_M000_IG04

G_M000_IG06:                ; 結果オブジェクトの生成
    mov      rcx, 0x7FFE72227600
    call     CORINFO_HELP_NEWSFAST
    mov      rbx, rax
    lea      rcx, bword ptr [rbx+0x08]
    mov      rdx, rdi
    call     CORINFO_HELP_ASSIGN_REF
    mov      dword ptr [rbx+0x10], ebp
    mov      rax, rbx

G_M000_IG07:                ; epilogue
    add      rsp, 40
    pop      rbx
    pop      rbp
    pop      rsi
    pop      rdi
    pop      r14
    pop      r15
    ret

G_M000_IG08:                ; 文字列長の比較
    lea      rax, bword ptr [rcx+0x0C]
    add      rdx, 12
    mov      ecx, dword ptr [rcx+0x08]
    add      ecx, ecx
    mov      r8d, ecx
    cmp      r8, 10
    je       SHORT G_M000_IG10

G_M000_IG09:                ; 文字列内容の汎用比較
    mov      rcx, rax
    call     [System.SpanHelpers:SequenceEqual(byref,byref,nuint):bool]
    jmp      SHORT G_M000_IG11

G_M000_IG10:                ; 長さ 10 向けの高速比較パス
    mov      rcx, qword ptr [rax]
    mov      rax, qword ptr [rax+0x02]
    mov      r8, qword ptr [rdx]
    xor      rcx, r8
    xor      rax, qword ptr [rdx+0x02]
    or       rcx, rax
    sete     al
    movzx    rax, al

G_M000_IG11:                ; 比較結果に基づく分岐
    test     eax, eax
    je       SHORT G_M000_IG05

G_M000_IG12:                ; マッチした Id の書き込み
    mov      ecx, dword ptr [r15+0x30]
    lea      rax, bword ptr [rdi+0x10]
    lea      edx, [rbp+0x01]
    mov      r15d, edx
    movsxd   rdx, ebp
    mov      dword ptr [rax+4*rdx], ecx
    mov      ebp, r15d
    jmp      G_M000_IG05

いくつか面白いポイントがあります。

  • G_M000_IG08cmp r8, 10 に注目すると、ここで使われている 10 はそのまま文字列リテラル 'Seattle' の長さです。JIT はリテラルの長さを即値としてコードに焼き付けていて、さらに長さが一致したときだけ G_M000_IG10 という「長さ 10 の文字列専用」の高速比較パスに飛ぶようになっています。つまり、単なる「文字列比較」ではなく「このリテラルに特化した比較コード」が生成されています。
  • G_M000_IG05add r14, 72 も興味深いところで、この 72sizeof(Person) に相当します。ループインデックスの更新で毎回 72 を足しているということは、「行サイズをそのまま定数として埋め込んだストライド付きループ」になっている、ということです。
  • 同じく dec esi によって、配列の末尾から先頭に向かって走査する「減少カウンタ」のループに変形されていて、比較命令を 1 つ減らす形で最適化されています。

これらを C# に書き戻すと、概ね次のような手書きループと同じ意味になります。

int length = elements.Length;
Span<int> values = new int[length];
int count = 0;

for (int i = length - 1; i >= 0; i--)
{
    var elem = elements[i];
    var city = elem.City;
    if (city == null)
     continue;

    if (city.Length == 10 && city == "Seattle")
    {
     values[length - 1 - count] = elem.Id;
     count++;
    }
}

return values[..count];

見ての通り、抽象の痕跡は機械語レベルではほとんど残っておらず、「City == "Seattle" を満たす行の Id を配列に詰めるだけ」の素直なループにまで落ちています。TypedSql が型レベルで積み上げた WhereSelectEqualsFilter といったノードは、JIT の最適化を通じてほぼ完全に「はぎ取られた」状態になっているわけです。

ベンチマーク結果はこんな感じになりました。

メソッド Mean Error StdDev Gen0 Code Size Allocated
TypedSql 10.953 ns 0.0250 ns 0.0195 ns 0.0051 111 B 80 B
Linq 27.030 ns 0.1277 ns 0.1067 ns 0.0148 3,943 B 232 B
Foreach 9.429 ns 0.0417 ns 0.0326 ns 0.0046 407 B 72 B

ベンチマークの数字を改めて眺めると、TypedSql は実行時間・割り当てともに手書きの foreach にかなり近い位置に張り付いており、.NET 10 で相当に最適化されている LINQ 実装をはっきりと上回っています。しかも、クエリの書き味としては SQL にかなり近い DSL(ドメイン特化言語) のままです。

9. おわりに──型システムはどこまで「エンジン」になれるか?

TypedSql は、小さなインメモリクエリエンジンの実験プロジェクトです。

やりたかったのは、C# の型システムにどこまでロジックを押し込み、その結果を .NET の JIT にどこまで最適化してもらえるか? という検証でした。

実際にやってみると、

  • 列・投影・フィルタを「静的メソッドだけを持つ struct」として表現し
  • それらを Where, Select, WhereSelect, Stop といったパイプラインノードに組み合わせ
  • 数値や文字列のリテラルまで含めて ILiteral<T> という「型」として埋め込み

という形で、「クエリ全体を型システムの上に乗せる」ことができました。

結果として、

  • JIT から見れば、ただの「強く特化されたループ」の集合体
  • 開発者から見れば、SQL っぽい文字列でクエリを書くだけ

という、ちょっと不思議なバランスのクエリエンジンが生まれました。

もう少し先の話をすると、同じアイデアはクエリエンジンに限らず、

  • DSL コンパイラ
  • ランタイム
  • あるいは「型で構造を表し、JIT に任せる」あらゆる仕組み

に応用できるはずです。

場合によっては、構築した型をソースコードとして出力して NativeAOT(.NET アプリをネイティブコードに事前コンパイルする仕組み)でビルドし、そのままネイティブバイナリにしてしまうこともできます。

C++ のテンプレートや constexpr と違って、TypedSql のアプローチは 実行時の入力にも対応できる、というのもポイントです。「型レベルで構造を固定しつつ、クエリ文字列は動的にコンパイルできる」という柔軟さがあります。

TypedSql のコードは GitHub で公開しています:https://github.com/hez2010/TypedSql

もし少しでも興味が湧いたら、ぜひ自分のデータで遊んでみてください。「型システムでここまでできるのか」という、ちょっとした驚きがあるはずです。

そして、もし気に入ったら Star してもらえると嬉しいです。

最後に、ここまで読んでくださり、ありがとうございました。

9
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?