0. はじめに
TypedSql は、ある日ふと湧いた「ちょっとした不満」から始まりました。
.NET でコードを書いていると、「クエリっぽい処理」を書く場面がよくあります。
例えば、すでにメモリ上にある List<T> や配列をフィルタして、一部の列だけ取り出したいときです。
そのとき、だいたい次の 3 つの選択肢があります。
- 素直に
foreachで回す — 速くて明示的だけど、ちょっとコードがうるさい - LINQ(Language Integrated Query)を使う — 書き心地はいいけれど、イテレーターやデリゲートのオーバーヘッドが気になる
- いっそデータベースに突っ込んで、本物の SQL を書く — さすがにやりすぎ感がある
「もう少しだけ“ちょうどいい”選択肢が欲しいな」と思ったところから、TypedSql の実験が始まりました。
そこで出てきたのが、こんな発想です。
C# の型システムそのものを、クエリプランとして扱ったらどうなるんだろう?
ふつうなら、
- 実行時に式ツリー(Expression Tree)を組み立てて
- それをデータに対して解釈していく
というスタイルになりますよね。
TypedSql は、そこをひっくり返します。
- SQL っぽい文字列をパースして
- その結果を「ネストしたジェネリック型」として表現し
- あとは全部、静的メソッドだけで処理を流す
つまり、クエリの実行計画を丸ごと型に押し込めてしまう、という遊びです。
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 段階です。
- 入力 SQL をトークナイズ(単語に切り出す)
- 小さな抽象構文木(AST)を組み立てる
-
ParsedQuery— SELECT と optional な WHERE -
Selection—SelectAllまたは列名のリスト -
WhereExpression— 次のいずれかComparisonExpressionAndExpressionOrExpressionNotExpression
-
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 と、Hex0〜HexF の 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.BitCast で float に変換します。
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) -
StringNull—nullを表す特別なノード(長さ -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 を例にしてみます。
- パーサは
'Seattle'を見つけて、Kind = LiteralKind.String-
StringValue = "Seattle"
というリテラル情報を作ります。
- コンパイラは対応する列が文字列(実際には
ValueString)だと判断します。 -
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なので、Valueはnew 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 つを作ることです。
- パイプライン本体
TPipeline(IQueryNode<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 B→AndFilter<TRow, TA, TB> -
A OR B→OrFilter<TRow, TA, TB> -
NOT A→NotFilter<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 パスのループ に落ちます。
さらに、このオプティマイザはもう少し複雑なネスト構造もある程度まで認識して、可能な限り Where と Select をまとめてしまいます。ここでやっていることは難しい最適化アルゴリズムではなく、「既存のジェネリック型の引数をばらして、新しい融合ノードの型引数として組み立て直す」だけなので、実装自体はかなりシンプルです。
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 つです。
-
実行時の結果型と公開型が同じ
→ そのままRowsを返す。 -
実行時が
ValueStringで、公開型がstring
→AsStringRowsでラップし、ValueString[]をstring?に暗黙変換して返す。 -
どちらも
ValueTupleで、形が対応している
→AsValueTupleRows<TPublicResult>()で値タプル同士をコピーする。
5.1 ValueTupleConvertHelper:動的 IL でフィールドコピー
ValueTupleConvertHelper<TPublicResult, TRuntimeResult> は、
- 実行時の値タプルから公開用の値タプルへフィールドをコピーし
- 必要に応じて
stringとValueStringを相互変換し - ネストした
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> は次のように動きます。
- SQL をパースして
ParsedQuery(構文木)を作る。 - SQL コンパイラを呼び出して、
- パイプライン型
TPipeline - 実行時の結果型
TRuntimeResult - 公開する結果型
TPublicResult
を受け取る。
- パイプライン型
-
TPublicResultがユーザーの指定したTResultと一致することを確認する。 -
QueryProgram<TRow, TPipeline, TRuntimeResult, TPublicResult>型を構築する。 - その静的メソッド
Execute(ReadOnlySpan<TRow>)を探す。 - デリゲートに変換して、
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 の典型的な使い方は、次のような流れになります。
-
行型を定義する。
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); -
クエリで使いたい列ごとに
IColumn<Person, TValue>を実装する。 -
それらをスキーマとして登録する。
-
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_IG08のcmp r8, 10に注目すると、ここで使われている10はそのまま文字列リテラル'Seattle'の長さです。JIT はリテラルの長さを即値としてコードに焼き付けていて、さらに長さが一致したときだけG_M000_IG10という「長さ 10 の文字列専用」の高速比較パスに飛ぶようになっています。つまり、単なる「文字列比較」ではなく「このリテラルに特化した比較コード」が生成されています。 -
G_M000_IG05のadd r14, 72も興味深いところで、この72はsizeof(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 が型レベルで積み上げた WhereSelect や EqualsFilter といったノードは、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 してもらえると嬉しいです。
最後に、ここまで読んでくださり、ありがとうございました。
