DynamoDB のためのローカル SQL エンジンをどう構築したか(JOIN のないデータベースで)
DynamoDB は 100 GB のテーブルをエクスポートできますが、それを集計することはできません。JOIN も、GROUP BY も、COUNT(*) もありません — それらの遅い版があるのでもなく、何もない のです — そして AWS 自身の SQL 風の表面である ですら、それらを追加しません。だからどのチームも、結局は同じ使い捨てのスクリプトを書くはめになります — をページネートし、行をマップに畳み込み、答えを出力し、スクリプトを削除する、というものです。
DynoTable の Workbench は、そのスクリプトに対する私たちの答えです — 1 つの本物の SELECT(複数テーブルの JOIN … ON、WHERE、GROUP BY、HAVING、DISTINCT、CASE、集計)を、そのどれもサポートしないテーブルに対して書けるタブです。この記事は、それが内部でどう動くのか、私たちに静かに嘘をついたパーサー、そして後にエンジンのメモリモデルを引き剥がした理由についてです。
クエリエンジンを作るな
核となる決定は、リレーショナルな実行エンジンを書くことを拒むことでした。DynamoDB のページは埋め込みの SQLite データベースにストリーミングされ、地球上で最もテストされたクエリエンジンの 1 つである SQLite が、リレーショナルな仕事をこなします:
各 DynamoDB アイテムは、単一の item カラムの中に、生の型付きエンベロープ({platform: {S: "ios"}})として SQLite に着地します。小さなユーザー定義関数がクエリ時に属性をアンラップするので、あなたの SELECT platform は attr(item, 'platform', …) の呼び出しにコンパイルされます。その関数の中の 1 つのディテールが耐力を担っています — それは JSON のパースを catch で包んでいます。なぜなら SQLite のバインディングは、1 つの不正な形式の行のパースエラーを、プリペアドステートメント全体 の中断に変えてしまうからです。catch がなければ、1 つの壊れた行がクエリを殺します。
私たちが守った正直な制約 — DynamoDB のアクセスパターンの物理法則はやはり適用される ということです。JOIN の to 側はプライマリキーか のパーティションキーでなければなりません — エンジンは結合を、クロス積のスキャンではなく、ストリームに対するキー参照で解決します。非キー属性に対する任意の結合が必要なら、それはクエリエンジンの機能ではなく、モデリングの議論です。

私たちに嘘をついたパーサー
最初のバージョンは、人気の既製 SQL パーサーを使っていました。数週間のあいだに、4 つの独立した正しさのバグが、それに行き着きました:
SELECT DISTINCTが静かに落とされた。 パーサーはそれを受け付け、エミッターはそれを無視しました — すべての重複行が返ってきたのです。あるユーザーのバグ報告はこう書かれていました — 「カウンターではなく、すべての値の表が表示される」。- 扱われないものはすべて、リテラルの SQL
NULLになった。CASE、CAST、スカラーサブクエリ — 未知の構文ノードに対するエミッターのフォールバックはreturn 'NULL'でした。クエリは実行され、自信たっぷりに誤った答えを返し、エラーは何も出しませんでした。 - 識別子の大文字小文字が SQL のように振る舞わなかった。
SELECT PLATFORM FROM tは、属性がplatformのときに null を返しました — あらゆる SQL データベースが期待するよう仕込むことの、正反対です。 - オートコンプリートがリゾルバーと食い違い、コンパイラーがその後に解決に失敗する名前を提案しました。
置き換えとなった sql-parser-cst は、調査が他のどこにも見つけられなかった 1 つのプリミティブで勝りました — その木は、すべての識別子の 生のテキスト と 正規化された名前 の両方を保持するのです — だからコンパイラーは platform と "PLATFORM" を区別できます。その 1 つの区別が、1 つの文法に適切な ANSI のセマンティクスを持たせます — 引用符なしの識別子は大文字小文字を区別せずに解決し、引用符つきのものは大文字小文字を区別する抜け道になる、ちょうど Postgres や SQLite のように。それはまた、すべてのノードにソース範囲を持たせるので、診断はステートメント全体ではなく、問題のある構文に下線を引きます。
そして私たちは return 'NULL' のフォールバックを、今ではポリシーとして扱うルールに置き換えました — 静かな NULL は、決してなし。 エミッターのデフォルトのアームは、範囲が正確なエラーです。ウィンドウ関数は、ただの「ノー」ではなく、理由を述べるメッセージを受け取ります:
Window functions are not supported in Workbench.
They have undefined semantics on the joined-row materialization.私たちが受け入れたトレードオフ — 1.0 前の依存関係で、正確なバージョンに固定し、テストスイートをゲートとして手作業でのみアップグレードする、というものです。私たちは、古いパーサーにパッチを当てること(並列のプレトークナイザーが必要で、あらゆる入力を 2 回パースする)、エディターの構文木を使うこと(トークンレベルで、句の構造がない)、そして手書きすること(SQLite パーサーのほとんどで、際限のないメンテナンス)を検討しました。4 通りの正しさの失敗は、それらの懸念のすべてに勝ります。
何をブロックしないかを知る
ある診断は、あえてあなたを 止めません。フィルターが、schema では大文字小文字を確認できない属性を参照している場合、私たちは正規の大文字小文字を知りようがありません — DynamoDB のサーバー側のフィルターは大文字小文字を区別し、その schema はキー属性しか宣言しないからです。それは警告を出し、警告が Run を無効にすることは決してありません。私たちはこの種の教訓を、バリデーターの取り組みで身をもって学びました — 最も一般的な正規のクエリ形状を拒否するゲートは、ゲートがないよりも悪いのです。
上限は間違った形だった
最初の集計エンジンは、スキャンした行をサイズ上限つきの メモリ内 SQLite にバッファしました — ここでは 64 MB、あそこでは 250 MB、そして大きなテーブルでアプリを OOM させかねない上限のない経路が 1 つ。上限に達すると、オーバーフローフラグつきの部分的な答えが返ってきました。
再設計は、ある捉え直しから始まりました — 競合とは、開発者が代わりに書くであろう使い捨てのスクリプトである ということです。そして有能なスクリプトはバッファしてから上限をかけたりはしません — それは ストリーミング し、各ページを限られたメモリで走行合計に畳み込みます。メモリ内の上限は小さすぎたのではなく、形が間違っていたのです。さらに悪いことに、部分的な集計は「不完全」なのではなく — 間違っている のです。それを具体的にした瞬間 — 私たち自身の AI デモが、部分的な「支出上位者」のランキングを、事実として自信たっぷりに語ったのです。
作り直された Compute エンジンは、スクリプトが動くのと同じように動き、そこにスクリプトが決して気にかけないすべてを加えます:
- プランナーがあらゆるクエリを分類する — 3 つのレーンのいずれかに。ストリーム畳み込み(素朴な代数的集計 —
COUNT/SUM/AVG/MIN/MAX— をメインプロセスの外でページごとに畳み込む。サイズが畳み込み可能なクエリをゲートすることは決してなく、ただ時間がかかるだけ)、マテリアライズ(ソート、キーで境界づけられた結合 — ディスクに支えられた SQLite に書き出し、メモリ上限なし)、あるいは、私たちが応えられない末端に対して、正しい重量級のツール(DynamoDB の S3 エクスポートと Athena)を勧める正直な 拒否 です。 - 畳み込みの分類はあえて狭くしてある。
HAVING、ORDER BY、集計の隣の裸のカラム — そのいずれもがマテリアライズのレーンを強制します。句を無視しながらなお「正確」と報告する畳み込みは、まさにこのプランナーが防ぐために存在する、間違っているのに自信たっぷりなバグそのものになるでしょう。 - 正確さは結果の一部である。 集計カラムは、ページがまだストリーミングされているあいだは partial バッジを表示し、スキャンが尽きたときにのみそれを外します。算術の正直さすら追跡されます — 整数の合計は 2⁵³ を超えても正確なまま(64 ビット整数として保存)ですが、浮動小数点数の仮数部を超える小数の合計は、静かに丸めるのではなく、自らに
partial: precisionのフラグを立てます。 - 機械にはポップアップの代わりにヒューズを。 インタラクティブなアプリは、500 万アイテムのスキャンの前に尋ねます。AI アシスタントと MCP の呼び出し元は、代わりにスキャン予算とページ境界での中断を得ます。そしてモデルが目にする結果のエンベロープは、あえて切り詰められています — コストクラスとサイズの見積もりは伏せられます。古くなった推測を見せると、モデルがそれを事実として語ってしまうからです。
社内で人々を驚かせたコストの注記が 1 つ — 一度きりの集計では、ライブスキャンは DynamoDB→S3 エクスポートを生成するよりもおよそ 6 倍安い のです — だからこそスクリプト型のレーンがデフォルトで、データウェアハウスは反射的な選択ではなく、繰り返される重い分析のための推奨なのです。(料金計算ツールは、あなた自身のテーブルのフルパスの料金を見積もります。)
本番に SQL を向けるのは安全なのか?
Workbench は構造的に読み取り専用です。ローカルのエンジンに到達する最終的な SQL は、単一の SELECT でないものをトークン化して拒否する多層防御のゲートを通ります — ユーザーエラーとしてではなく、契約違反 として。なぜならコンパイラーはそれを決して生成すべきではないからです。SQLite の基盤は危険な組み込み機能を無効にして動き、DynamoDB への書き込みはアプリ全体でちょうど 1 つの経路しか持ちません — SQL が到達できない、レビュー可能なステージングエリアです。
あなたが 1 つ構築しているなら、何が応用できるか
- クエリエンジンを書くな。すでに存在するものへの忠実な コンパイラー を書け。あなたのバグは、結合アルゴリズムではなく、継ぎ目(識別子の大文字小文字、型エンベロープ、壊れた行)に潜む。
- パーサーに引用符の由来を要求せよ。それが
nameと"NAME"を区別できないなら、SQL の大文字小文字のセマンティクスを実装することはできず、ユーザーはあなたより先にそれに気づく。-「扱われない構文」を、デフォルト値ではなく、大声のエラーにせよ。静かなNULLこそ、自信たっぷりな誤った答えが出荷される道だ。 - あなたの設計を、ユーザーが書くであろう使い捨てのスクリプトに照らしてベンチマークせよ。スクリプトがストリーミングしていてあなたがバッファしているなら、間違った形を作ったのだ。
- 部分的な集計を、部分点ではなく、ラベル付けが必要な誤った答えとして扱え。
Workbench のドキュメントは日々の機能の表面を扱い、DynamoDB のための SQLはエコシステム全体で何が動くのかを地図にします。あるいは単にDynoTable をダウンロードして、⌘⌥Q で Workbench のタブを開き、それを持たないデータベースに対して JOIN を実行してみてください。


