tobymao

Stars and mentions

Last 30 days, Oct 5 – Nov 3

GitHub stars70
mentions6
036912Oct 5Oct 19Nov 3
UTC days · repositories tobymao owns1 repositories

Package downloads

Cumulative recorded downloads · since 2026-04-08

PyPI507.9M
0127M254M380.9M507.9MOct 5Oct 19Nov 3
Oct 27PyPI: 2/2 packages observed

Across 2 known packages in repositories tobymao owns. Cumulative totals include recorded downloads before the selected range. Missing observations leave gaps; totals may be incomplete. PyPI excludes known mirrors.

Top repositories

tobymao’s repositories ranked by new stars, as on trending.

Loading

Contributes to

Repositories outside tobymao’s account where tobymao opened or reviewed pull requests in the past 365 days.

Who noticed tobymao

Everyone who starred or forked tobymao’s repositories since the archive began in 2011.

Notable accounts

Ordered by the stars their own repositories have received.

Biggest fans

Accounts that starred the most of these repositories.

Breakout weeks

Each repository’s best week of new stars, with the release published around it.

Mentions

6posts
Nov 40 posts
  1. 【既存のSQL, PL/SQLをAIを用いて移行する】第3回:ANTLR grammar で PL/SQL を解析し、中間表現に落とす 概要 この連載では、Oracle / MySQL / PostgreSQL 向けに書かれた既存の SQL と PL/SQL を ScalarDB へ移すために試作した仕組み(wfukatsu/sql-migration)を、6 回に分けて解説します。 この連載で説明する仕組みのコード(SQL の変換ツール、PL/SQL の解析と Java の生成、生成した Java が使うランタイム、スキル、文書)は、次のリポジトリに置いてあります。記事の題材(会員ポイントの SQL と package pkg_points)、実 DB で比べるためのハーネス、テストは開発用の非公開リポジトリにあり、公開のリポジトリには入っていません。手元で試せる小さな題材は、公開のリポジトリの docs/quickstart/ にあります。 wfukatsu/sql-migration SQLGlot で Oracle / MySQL / PostgreSQL の SQL を ScalarDB SQL に変換する 変換できない SQL を H2 と組み合わせて動かす ANTLR grammar で PL/SQL を解析し、中間表現に落とす(この記事) 中間表現から Java + ScalarDB を生成し、AUTO / REVIEW / REDESIGN を判定する Claude Code / Codex のスキルから使う migrate-flow: 承認をはさんで、仕様の調査からテストまで進める 全体の流れと各回の位置づけです。色を付けた所がこの記事です。 第 1 回・第 2 回は SQL 文の話でした。変換ツールは PL/SQL のブロックに出会うと PLSQL_BLOCK の ERROR を出して止まります。ストアドプロシージャや package、trigger は SQL 文ではなく、制御構造・変数・例外・トランザクション制御を持つプログラムだからです。 ここからの 2 回は、その PL/SQL を Java + ScalarDB に移す系統です。この記事は前半で、PL/SQL を ANTLR の grammar で解析し、意味を保った中間表現(IR)に落とすまで を解説します。 なぜ PL/SQL を Java に逐語変換してはいけないのか ANTLR と SQLGlot の責務をどこで切るか grammars-v4 の PL/SQL grammar を固定して取り込み、Python から使う方法 前処理、速い段から試す解析、記号表、IR への lowering IR の中の SQL 断片だけを SQLGlot に渡す「SQL ブリッジ」 対象読者 Oracle の PL/SQL 資産を ScalarDB + Java に移すことを検討しているエンジニア ANTLR で既存言語のパーサを組み込んだ経験がある、またはこれから組み込む方 「変換ツールが何を見て、何を見ていないか」を確かめたいアーキテクト 背景・目的 PL/SQL の routine を Java に移すとき、最初に決めたのは 逐語変換をしない ことです。SELECT ... INTO を JDBC に、IF を if に、RAISE_APPLICATION_ERROR を throw に、と 1 対 1 で置き換えれば動くように見えますが、PL/SQL には Java の文に写らない意味がいくつもあります。 SELECT ... INTO は、行が 0 件なら NO_DATA_FOUND、2 件以上なら TOO_MANY_ROWS を投げる。「1 行ちょうど」という 件数の期待 が文に埋め込まれている FOR UPDATE は行ロックであり、ScalarDB のトランザクション(楽観的並行性制御)には同じものが無い routine の中の COMMIT はトランザクション境界であり、呼び出し側の設計に関わる %TYPE / %ROWTYPE は表の定義に依存し、表が変われば型が変わる これらは、構文木を Java に書き写す途中で失われます。そこで、ANTLR で PL/SQL 全体を解析し、型・制御フロー・SQL・トランザクション・例外・副作用を明示的な属性として保持する中間表現(Migration IR) に一度落とし、判定と生成はすべて IR の上で行うことにしました。Oracle 固有の意味は消さず、属性として残します。 ANTLR と SQLGlot の責務境界 第 1 回の SQL 変換ツール(SQLGlot ベース)は、PL/SQL の中の SQL にもそのまま使いたい。一方で SQLGlot は PL/SQL を解析できません。責務は次のように切りました。 項目 ANTLR 側 SQLGlot 側 package / procedure / function / trigger 構文解析する 対象外 制御構造・例外 構文解析し IR 化する 対象外 変数スコープ・package 状態 記号表で解決する 対象外 SQL の切り出し ソース位置と PL/SQL 変数を保持して抽出する SQL の構文木へ解析する 表・列の参照 PL/SQL 側の参照文脈を付与する 構文木と表定義から抽出する SQL の方言変換 行わない Oracle から ScalarDB SQL へ変換する ScalarDB で実行できるかの判定 ルールエンジンへ渡す 構文木の特徴を供給する 動的 SQL 文字列を組み立てる式を IR 化する 定数に畳めた SQL だけ解析する コード生成 IR の生成まで SQL 文字列の生成まで 全体の流れ 以下、この流れを順に追います。題材は第 1 回・第 2 回と同じ会員ポイントの package pkg_points です。 pkg_points.pkb(抜粋) FUNCTION get_balance(p_member_id IN members.member_id%TYPE) RETURN NUMBER IS v_balance members.balance%TYPE; BEGIN SELECT balance INTO v_balance FROM members WHERE member_id = p_member_id; RETURN v_balance; EXCEPTION WHEN NO_DATA_FOUND THEN RAISE_APPLICATION_ERROR(-20101, '会員が見つかりません'); END get_balance; grammar を固定して取り込む PL/SQL の grammar は自作せず、antlr/grammars-v4 の sql/plsql を使います。Lexer が約 2,600 行、Parser が約 10,000 行ある大きな grammar です。 取り込み方には方針があります。上流の更新に自動追従せず、fixture で検証した commit を固定して vendor 化する ことです。grammar が変わると解析結果が変わり、判定と生成物の再現性が崩れるためです。取り込んだ commit、日付、ANTLR のバージョン、ランタイムのバージョンを 1 つのファイルに記録し、パッチを当てる場合はパッチファイルを残します(現時点では未改変です)。 項目 値 上流 antlr/grammars-v4 sql/plsql ANTLR ツール 4.13.2 Python ランタイム antlr4-python3-runtime 4.13.2 生成先の言語 Python 3 ライセンス Apache License 2.0。本文(LICENSE)と著作権表示(NOTICE)を grammar と同じディレクトリに置いている 生成は 3 段階 パーサの生成は Makefile で 3 段階にしています。 grammar に埋め込まれたアクションと述語は Java 向けに書かれている(this.isSolidusSeparator() など)ので、上流が用意している変換スクリプトで this. を self. に、&& / || を and / or に書き換える java -jar antlr-4.13.2-complete.jar -Dlanguage=Python3 -visitor で Lexer / Parser / Listener / Visitor を生成する 生成物をパッケージに配置し、基底クラスの内部にある from PlSqlLexer import を相対 import に直す(ANTLR 4.13 はここをパッケージ修飾しません) 生成したパーサはリポジトリに commit しています(Parser だけで 6.5 MB)。利用者は Java も ANTLR ツールも要らず、pip install -r requirements.txt だけで PL/SQL を解析できます。make で再生成した結果が commit 済みの生成物とバイト一致することを、受け入れ条件にしました。 この grammar の特徴 / の扱い: 行に / だけがあるときだけ unit の終端とみなす述語(isSolidusSeparator)が grammar に入っています キーワードを識別子として使える: Oracle の膨大な非予約キーワードを識別子として受ける規則(regular_id)があります。便利な一方で、grammar が曖昧になる原因の 1 つです 曖昧で、解析が重い: 上流の README が明記しているとおりで、package body 15 行程度でも、新しいプロセスでの初回の解析に約 2 秒かかります(後述) 前処理: SQL*Plus のスクリプトを unit に分ける 現場の PL/SQL は、たいてい SQL*Plus のスクリプトとして保存されています。SET SERVEROUTPUT ON、SPOOL、PROMPT、@other.sql といった命令が混ざり、unit の終わりは / だけの行です。grammar に渡す前に、これを整えます。 方針は 3 つです。 行ごと消す。列をずらさない。 SQL*Plus の命令は 1 行を占めるので、行単位で落とせば、出力の 1 行が入力のちょうど 1 行に対応し、列番号がずれません。ソースマップは「出力の何行目が入力の何行目か」の配列だけで済みます 大文字小文字を畳まない。 grammar はキーワードを大文字小文字を区別せずに受けるので、大文字化する必要がありません。大文字化すると文字列リテラルの中身が壊れます コメントは消さない。 / と ; の位置を探す走査のためだけに、コメントと文字列リテラルを同じ長さの空白に置き換えます。ANTLR に渡すのは元の行です 落とし穴も 2 つありました。EXEC は SQL*Plus の命令ですが EXECUTE IMMEDIATE は PL/SQL の文です。複数行の UPDATE の継続行は SET で始まりますが、命令ではありません。どちらも unit の外にある行だけを命令として扱うことで回避しています。@file の取り込みと &var の置換変数は、黙って処理せずに WARN として出します。 解析: 速い段から試し、失敗したら次の段へ ANTLR の予測モードには、速いが一部の文法で失敗する SLL と、常に正しいが遅い LL があります。この grammar は曖昧なので、LL だけで解析すると時間がかかります。そこで、速い段から順に 3 段で解析します。 plsql/frontend.py(抜粋) def parse_unit(unit: Unit) -> ParsedUnit: """Parse one unit. Never raises: a failure comes back as an ERROR issue.""" excluded: set[tuple[str, int]] = set() # shortcuts this unit does not take (_Shortcuts) stage 1: SLL tree = _bail(unit, PredictionMode.SLL, excluded) if tree is not None: return ParsedUnit(unit=unit, tree=tree) stage 2: the full strategy, still bailing: a unit SLL cannot take, without the plain lookahead of stage 3 tree = _bail(unit, PredictionMode.LL, excluded) if tree is not None: return ParsedUnit(unit=unit, tree=tree, used_fallback=True) stage 3: the full strategy with the plain prediction, collecting diagnostics collector = _Collector(unit) parser = _new_parser(unit.text, collector) try: tree = parser.sql_script() except RecursionError: return ParsedUnit(unit=unit, tree=None, used_fallback=True, issues=[Issue( "ERROR", "PARSE", "the grammar recursed too deeply on this unit", unit.range)]) ... return ParsedUnit(unit=unit, tree=tree, issues=collector.issues, used_fallback=True) 1 段目は SLL、2 段目は LL で、どちらも BailErrorStrategy を使い、最初の問題で即座に諦めます。このときのエラーは診断ではなく、次の段へ進む合図です。3 段目は素の LL で、エラーリスナーが構文エラーを集めます。何が解析できないかを決めて報告するのは、いつも最後の段です。合成 corpus(PL/SQL 46 ファイル)では、1 段目で通らずに先へ進むのは 3 ファイルだけです。 1 段目と 2 段目には、もう 1 つ工夫があります(2026-09-29 に追加)。NVL(a, b) は、grammar の 2 つの選択肢(組み込み関数と、任意の名前の呼び出し)のどちらにも字句ごとに一致します。ANTLR は閉じ括弧まで先読みしないとどちらかを決められず、組み込み関数が入れ子になっていると、内側でも同じ分岐が起きます。その結果、初めて見る形の解析時間が、入れ子が 1 段深くなるごとに膨らみます。 そこで速い段では、「組み込み関数の名前のあとの (」と「引数リストを始める (」の 2 つの判断を、先読みなしで決めます。どちらも、成り立つかぎり ANTLR 自身が選ぶ選択肢なので、この近道で解析が通れば、結果は通常の予測と同じ木です。近道の中で解析が失敗したら、その名前(またはその ()だけを通常の予測に戻して、同じ段をやり直します。組み込み関数を 5 段に入れ子にした代入 1 文で測ると、新しいプロセスでの初回の解析は 1.3 秒で、素の LL だけなら 3.1 秒でした。 構文エラーは例外ではなくデータです。リスナーは ANTLR の行(1 始まり)と列(0 始まり)をソースマップで元ファイルの位置に戻し、PARSE の ERROR として記録します。1 つの unit の失敗がファイル全体を止めることはありません。 解析時間と、並列化の注意 解析時間には特徴があります。ANTLR の Python ランタイムは、プロセスごとに ATN(grammar の状態機械)を復元し、予測のキャッシュを遅延で作ります。同じプロセスで同じ corpus を 2 回解析すると、こうなります。 import 0.22s first pass 12.27s second pass 0.22s files=46 parsed=46 units=46 fallback=['pkg_bulk_load.pkb', 'trg_orders_audit.trg', 'trg_orders_seq.trg'] 1 回目の 12 秒はほぼウォームアップです。同じ corpus を素の LL だけで解析しても初回は 15.5 秒で、差は大きくありません。初回の時間のほとんどは、段の選び方ではなくウォームアップが決めています。routine ごとにプロセスを fork して並列化すると、毎回この 12 秒を払うことになり、かえって遅くなります。 並列化するならワーカーを再利用する前提が要ります。 ! ANTLR はエラーから回復して、それらしい木を返します 構文エラーがあっても、ANTLR は問題の字句を読み飛ばすか別のものとして読み直し、木を返してきます。その木の上でルールを動かすと、壊れた routine が「きれいな routine」に見え、AUTO と判定されました。構文エラーから回復した unit には、先頭に PARSE_RECOVERED の印を付け、AUTO にならないようにしています。 Visitor は使わない ANTLR は Visitor と Listener を生成しますが、この実装ではどちらも使っていません。grammar の規則が 1,000 を超え、Visitor を継承すると使わないメソッドで埋まるためです。代わりに、木を子ノードごとに辿り、コンテキストのクラス名 で目的のノードを拾います。 plsql/symbols.py(抜粋) ROUTINE_BODIES = {"Procedure_bodyContext", "Function_bodyContext", "Create_procedure_bodyContext", "Create_function_bodyContext"} def _descend(context, names: set[str], stop: set[str] | None = None) -> list: """Matching contexts in source order, not descending into a match or into a stop context.""" ... name = type(child).name if name in names: out.append(child) continue if stop and name in stop: continue visit(child) grammar を固定しているからこそ、名前で拾う書き方が保守できます。もう 1 つ、ノードの文字列は getText() で取りません。getText() は字句を隙間なく連結するため、SELECT balance INTO が SELECTbalanceINTO になります。字句列の開始と終了の位置で、入力ストリームから元の文字列を切り出しています。 記号表: 名前と型を解決する parse tree から IR に落とす前に、記号表を作ります。記録するのは引数、変数、定数、cursor、例外、型、routine で、スコープは module(package)→ routine の 2 段です。package の仕様部(.pks)を先に歩き、そこで宣言された RECORD 型や定数が本体から見えるようにします。 %TYPE と %ROWTYPE は表の定義から解決します。 表の DDL(schema.sql)を SQLGlot で解析したスナップショットを持ち、members.balance%TYPE は NUMBER(10) に、members%ROWTYPE は RECORD(member_id NUMBER(10), ...) になります。解決した型は、どの DDL のどの版から来たかを覚えています(schema.sql@2a70a9f3 のような指紋)。解決できない名前は 推測せず、UNRESOLVED_TYPE として記録します。 オーバーロードは、同名の routine を宣言順の番号で区別します(pkg.put、pkg.put~2)。シグネチャで区別しないのは、引数の型が 1 つでも解決できないと、実行のたびにシグネチャと id が変わってしまうからです。 lowering: parse tree から IR へ IR は JSON Schema(draft-07)で固定し、schemaVersion を持ちます。スキーマはモデルのコードから機械的に生成し、additionalProperties: false で余計な属性を禁じています。設計上の決めごとは 2 つです。 IR は SQLGlot にも ANTLR にも依存しない。 SqlOperation が持つのは SQL の文字列と、そこから分かったことの安定した記述だけで、ライブラリのオブジェクトは持たない 黙って落とさない。 IR に写せない構文は Unsupported ノードとして残し、UNSUPPORTED_CONSTRUCT の WARN を付ける 主なノードの種類です。 種類 内容 Program / Module / Routine corpus 全体 / package・procedure・function・trigger / routine(引数、戻り型、宣言、本体、例外ハンドラ、transactionEffects、externalEffects) Assignment / If / Case / Loop / Block / Return / Exit ... 制御構造。Loop は basic / while / for / cursor-for / forall を区別し、cursor-for は query に SqlOperation を持つ SqlOperation 静的 SQL。originalSql、binds、intoTargets、cardinality、lockingMode、readSet / writeSet、targetStatus、targetSql DynamicSql EXECUTE IMMEDIATE。定数に畳めれば constantSql Raise / Commit / Rollback / Savepoint / OpenCursor / Fetch / CloseCursor 例外・トランザクション制御・cursor 操作 Unsupported 写せなかった構文 すべてのノードが安定した id(pkg_points.get_balance#stmt-1 のような形)と、元ファイルの位置(sourceRange)を持ちます。第 4 回で見る生成後の Java には、この位置が // pkg_points.pkb:16 のようなコメントとして残ります。 実際の IR 先ほどの get_balance の IR です(sourceRange の一部と、既定値のままの属性を省いています)。 program.ir.json(抜粋) { "id": "pkg_points.get_balance", "kind": "Routine", "sourceRange": {"file": "pkg_points.pkb", "startLine": 13, "endLine": 21, "startColumn": 3, "endColumn": 18}, "name": "get_balance", "routineKind": "function", "parameters": [ { "id": "pkg_points.get_balance#param-1", "kind": "Parameter", "type": {"oracle": "members.member_id%TYPE", "resolved": "NUMBER(10)", "origin": "column-type", "schemaSnapshot": "schema.sql@2a70a9f3"}, "name": "p_member_id", "direction": "IN" } ], "returnType": {"oracle": "NUMBER", "resolved": "NUMBER", "origin": "declared"}, "declarations": [ { "id": "pkg_points.get_balance#decl-1", "kind": "Declaration", "type": {"oracle": "members.balance%TYPE", "resolved": "NUMBER(10)", "origin": "column-type", "schemaSnapshot": "schema.sql@2a70a9f3"}, "name": "v_balance", "declarationKind": "variable" } ], "body": [ { "id": "pkg_points.get_balance#stmt-1", "kind": "SqlOperation", "sourceRange": {"file": "pkg_points.pkb", "startLine": 16, "endLine": 16, "startColumn": 5, "endColumn": 66}, "diagnostics": [ {"severity": "INFO", "code": "ACCESS", "message": "SELECT: full primary key specified -> GET (single record)"} ], "readSet": ["members"], "sqlKind": "SELECT", "originalSql": "SELECT balance INTO v_balance FROM members WHERE member_id = p_member_id", "binds": [ {"name": "p_member_id", "direction": "IN", "oracleType": "members.member_id%TYPE", "plsqlVariable": "p_member_id", "column": "member_id", "scalardbType": "BIGINT", "columnOracleType": "NUMBER(10)"} ], "intoTargets": ["v_balance"], "intoColumns": ["balance"], "intoTypes": ["BIGINT"], "intoOracleTypes": ["NUMBER(10)"], "cardinality": "EXACTLY_ONE", "targetStatus": "OK", "targetSql": ["SELECT balance FROM members WHERE member_id = :p_member_id"] }, {"id": "pkg_points.get_balance#stmt-2", "kind": "Return", "expression": "v_balance"} ], "exceptionHandlers": [ { "id": "pkg_points.get_balance#handler-1", "kind": "ExceptionHandler", "exceptions": ["NO_DATA_FOUND"], "body": [ {"id": "pkg_points.get_balance#stmt-3", "kind": "Raise", "errorCode": -20101, "message": "'会員が見つかりません'"} ] } ], "visibility": "public", "transactionEffects": {"commits": 0, "rollbacks": 0, "savepoints": 0, "autonomous": false}, "externalEffects": {"dynamicSql": false} } IR を読むと、逐語変換で失われる意味が属性として残っていることが分かります。 cardinality: "EXACTLY_ONE": SELECT ... INTO は 1 行ちょうどを期待する。0 行なら NO_DATA_FOUND、2 行以上なら TOO_MANY_ROWS を再現する必要がある intoTypes と intoOracleTypes: 代入先の ScalarDB の型と Oracle の型の両方。NUMBER(10) を BIGINT に入れるときの桁の検査に使う transactionEffects: この routine は COMMIT も ROLLBACK もしない。呼び出し側がトランザクション境界を持てる もう 1 つ、use_points の行ロックの文です。 program.ir.json(抜粋) { "id": "pkg_points.use_points#stmt-3", "kind": "SqlOperation", "diagnostics": [ {"severity": "WARN", "code": "ROW_LOCK", "message": "FOR UPDATE is row locking; the target has to provide the same guarantee another way"}, {"severity": "WARN", "code": "LOCK", "message": "FOR UPDATE / locking clause dropped (ScalarDB transactions handle isolation)"}, {"severity": "INFO", "code": "ACCESS", "message": "SELECT: full primary key specified -> GET (single record)"} ], "originalSql": "SELECT balance, last_seq INTO v_balance, v_seq\n FROM members\n WHERE member_id = p_member_id\n FOR UPDATE", "intoTargets": ["v_balance", "v_seq"], "cardinality": "EXACTLY_ONE", "lockingMode": "FOR UPDATE", "targetStatus": "WARN", "targetSql": ["SELECT balance, last_seq FROM members WHERE member_id = :p_member_id"] } lockingMode: "FOR UPDATE" が残り、ROW_LOCK の WARN が付いています。ScalarDB SQL には FOR UPDATE が無いので targetSql からは落ちていますが、「行ロックに頼っていた」という事実は IR に残り、第 4 回のルール判定が REDESIGN を出す根拠になります。 SQL ブリッジ: IR の中の SQL だけを SQLGlot に渡す IR の SqlOperation が持つ originalSql は、PL/SQL の文としての SQL です。これを第 1 回の変換ツールに渡すには、PL/SQL 固有の要素を剥がして「SQL パーサが読める文」にする必要があります。この橋渡しをするのが SQL ブリッジで、変換ツールそのものは 1 行も再実装していません。 ブリッジが正しく扱わなければならないことは 3 つです。 1. SELECT ... INTO は PL/SQL であって SQL ではない SQLGlot は INTO を文に付けたまま解析しますが、代入先が 1 つのとき(Into.this)と複数のとき(Into.expressions)で形が違います。どちらも剥がして intoTargets に移し、変換ツールには素の SELECT を見せます。剥がさずに渡すと、INTO v_balance が 表 として読まれ、readSet に v_balance が現れます。 plsql/sqlbridge.py(抜粋) def strip_into(tree: exp.Expression) -> list[str]: into = tree.args.get("into") if isinstance(tree, exp.Select) else None if into is None: return [] targets: list[str] = [] if into.expressions: targets = [_target_name(e) for e in into.expressions] elif into.this is not None: targets = [_target_name(into.this)] tree.set("into", None) return targets 2. PL/SQL の変数は列ではない WHERE member_id = p_member_id は、SQL パーサには列どうしの比較に見えます。記号表が判定します。修飾されていない識別子がスコープ内の変数か引数に解決できれば、名前つきのバインド変数に置き換えます。 plsql/sqlbridge.py(抜粋) for column in list(tree.find_all(exp.Column)): if column.table: fields = loop_fields.get(column.table.lower()) if fields is None: continue # qualified: it is a column of that table, not a variable ... name = column.name symbol = symbols.resolve(scope, name) if symbol is None or symbol.kind not in BIND_KINDS: continue if name.lower() in columns and not column.find_ancestor(exp.Values): Oracle resolves a bare name in SQL to a column of the statement's tables first ... shadowed.append(name) continue placeholder = _unique(name, found) found[placeholder] = BindVariable(name=placeholder, direction=symbol.direction or "IN", oracle_type=symbol.type.oracle if symbol.type else None, plsql_variable=name) column.replace(exp.Placeholder(this=placeholder)) Oracle には「裸の名前は、まず文の表の列として解決し、無ければ PL/SQL 変数」という規則があります。変数と同名の列がある場合は列として扱い、BIND_SHADOWED の WARN を出します。 3. バインドは ? ではなく名前で 第 2 回の decomposer と Java ランタイムは、匿名の ? を 1 つの文字列に畳んで解決するため、1 つの取得に ? が 2 つあると同じ値になってしまいます。変数ごとに一意な名前を付け、:p_member_id の形で出します。 このほか、* の展開、件数の期待(INTO があれば EXACTLY_ONE、FETCH FIRST 1 ROWS ONLY や集約だけの射影なら AT_MOST_ONE)、式の持ち上げ を行います。式の持ち上げとは、VALUES (..., -p_points, ...) や SET balance = v_balance - p_points のように、変数・リテラル・ランタイムが実装している関数だけでできた式を 1 つのバインド(:expr6)に置き換え、値は Java 側で計算することです。列を含む式は持ち上げません(第 1 回の RMW と同じ理由です)。 ここまで整えた木を、第 1 回の StatementConverter に渡します。返ってくるのは ScalarDB SQL、判定(OK / WARN / PLANNED / ERROR)、アクセスパス、必要なら実行計画で、それを IR の targetSql、targetStatus、binds、readSet / writeSet に書き戻します。ブリッジの入出力は JSON にできる素のデータ構造で、将来ここをプロセス境界にできるようにしてあります。 詰まったポイント・Tips コンテキストのクラス名は、1 文字違いで意味が変わる Function_bodyContext と Create_function_bodyContext(小文字の f)を取り違え、単独の function が procedure として lowering されていました。Sql_statement、Transaction_control_statements はグループの規則で葉ではなく、葉として扱うと corpus の COMMIT がすべて Unsupported になりました。入れ子の BEGIN ... END は、DECLARE があれば Block、無ければ素の Body として解析されます。名前で拾う設計は、grammar を固定していることと、golden の IR で回帰を検出していることが前提です。 MERGE には SELECT が含まれる 文の種類は、どの ANTLR コンテキストが存在するかで決めますが、MERGE の中には SELECT があり、INSERT ... SELECT にも SELECT があります。外側から順に、MERGE → INSERT → UPDATE / DELETE → SELECT の順で判定しないと、MERGE が SELECT になります。 「解析できた」は「Oracle でコンパイルできる」ではない ANTLR が受け付けても Oracle が拒むファイルが、32 ファイル中 2 つありました。parse 率はあくまで「grammar が構文に届いた」割合です。この試作では、実 Oracle への配備を差分テストの一部にすることで、この差を検出しています(第 4 回)。 SQLGlot が SQL として読んでしまう PL/SQL INTO が表になる話のほかにも、FETCH FIRST p_batch ROWS ONLY の件数は Column ではなく Identifier として解析される、trigger の :NEW.x は Placeholder と Dot の組になる、seq.NEXTVAL は修飾された列になる、SYSDATE と USER は裸の列になる、といった形の違いがあります。ブリッジはこれらを 1 つずつ拾っています。 ! 設計からの変更点 当初の設計はフロントエンドを Java 21 + ANTLR、SQL 解析を Python + SQLGlot の別サービスとしていました。実装では PoC の到達速度を優先し、フロントエンドも Python(antlr4-python3-runtime)にして、第 1 回の変換ツールを同一プロセスで関数呼び出ししています。逃げ道は IR の JSON Schema と SQL ブリッジの JSON の 2 か所だけで、ここを境界に言語を分けられます。生成物(ターゲットコード)は Java のままです。 まとめ 成果: grammars-v4 の PL/SQL grammar を commit 固定で vendor 化し、生成したパーサを commit することで、pip install だけで PL/SQL を解析できる Python のフロントエンドができました。解析は SLL → LL → 診断つきの LL の 3 段で、入れ子の組み込み関数で初回の解析時間が膨らむ 2 つの判断は、先読みなしで決めています 成果: 型・件数の期待・行ロック・トランザクション制御・副作用を属性として持つ IR に落とし、SQL 断片だけを第 1 回の変換ツールに渡す構成にしました。合成 corpus(PL/SQL 46 ファイル)の parse 率と型解決率は 100% です 課題: parse 率が 100% でも、Oracle でコンパイルできる保証にはなりません。動的 SQL は定数に畳めるものしか解析できません。数値は合成 corpus 上のもので、実案件の PL/SQL に対する耐性を示すものではありません 次回は後半です。この IR からルールで AUTO / REVIEW / REDESIGN を判定し、Java + ScalarDB のコードを生成し、実 Oracle と実 ScalarDB の差分テストを AUTO の証拠にする仕組みを解説します。 最後にお読みいただきありがとうございました。この記事が同じ悩みを持つ方の参考になれば幸いです。 参考 wfukatsu/sql-migration github.com/antlr/grammars-v4/tree/master/sql/plsql github.com/antlr/antlr4/blob/master/doc/python-ta… tobymao/sqlglot 執筆者紹介 執筆者 深津 航 株式会社Scalar 代表取締役CEO、Co-Founder 最近はAIを使った開発プロセスの改善に取り組んでいます。 会社情報 Scalar, Incは、「データマネジメントの未来を創る」を掲げ、データベースミドルウェアの研究開発・販売を行う日本発のグローバルスタートアップ企業です。複数の異なるデータベース(RDB、NoSQLなど)を仮想的に統合し、トランザクション処理や分析クエリを実現するデータベースミドルウェアである ScalarDB 、データの改ざん検知を行う ScalarDL を主な製品としています。是非下記の弊社サイトもチェックをお願いします! scalar-labs.com medium.com scalar-labs.com

  2. 【既存のSQL, PL/SQLをAIを用いて移行する】第2回:変換できない SQL を H2 と組み合わせて動かす 概要 この連載では、Oracle / MySQL / PostgreSQL 向けに書かれた既存の SQL と PL/SQL を ScalarDB へ移すために試作した仕組み(wfukatsu/sql-migration)を、6 回に分けて解説します。 この連載で説明する仕組みのコード(SQL の変換ツール、PL/SQL の解析と Java の生成、生成した Java が使うランタイム、スキル、文書)は、次のリポジトリに置いてあります。記事の題材(会員ポイントの SQL と package pkg_points)、実 DB で比べるためのハーネス、テストは開発用の非公開リポジトリにあり、公開のリポジトリには入っていません。手元で試せる小さな題材は、公開のリポジトリの docs/quickstart/ にあります。 wfukatsu/sql-migration SQLGlot で Oracle / MySQL / PostgreSQL の SQL を ScalarDB SQL に変換する 変換できない SQL を H2 と組み合わせて動かす(この記事) ANTLR grammar で PL/SQL を解析し、中間表現に落とす 中間表現から Java + ScalarDB を生成し、AUTO / REVIEW / REDESIGN を判定する Claude Code / Codex のスキルから使う migrate-flow: 承認をはさんで、仕様の調査からテストまで進める 全体の流れと各回の位置づけです。色を付けた所がこの記事です。 第 1 回では、SQL を 1 文ずつ ScalarDB SQL に変換し、OK / WARN / PLANNED / ERROR の 4 つに振り分けるところまでを説明しました。この記事は、そのうち PLANNED の中身です。ScalarDB SQL の文法に収まらない読み取りクエリを、「ScalarDB からの取得」と「インメモリの H2 Database で元の SQL を実行する」の 2 段に分解して動かす方法を解説します。 なぜ「元の SQL をそのまま H2 で実行する」という方針を選んだのか 分解の手順と、実行計画 JSON の中身 Java のランタイムがその計画をどう実行するか ガードレールと、実測した性能 分解しても動かないものと、その対処 対象読者 Oracle / MySQL / PostgreSQL から ScalarDB への移行を検討しているエンジニア ScalarDB SQL に書き直せない SELECT が残っていて、アプリケーションの書き換え量を見積もりたい方 「取得してからアプリ側で計算する」設計の落とし穴を知りたい方 背景・目的 ScalarDB SQL の文法は意図的に絞られています。射影に書けるのは列と集約関数だけ、WHERE の右辺はリテラルだけ、サブクエリ・CTE(Common Table Expression: WITH句)・UNION・ウィンドウ関数・DISTINCT・OFFSET は書けません。既存アプリケーションの SELECT には、このどれかが入っているものが少なくありません。 しかし、こうした構文はいずれも 取得した行の上での計算 です。行さえ手元にあれば、元の SQL は元の意味のまま実行できます。第 1 回の設計方針の 1 つ、「読み取りは元の SQL をそのまま実行する」はここから来ています。NULL の扱い、丸め、並び順といった意味を書き直すと結果が変わりやすいため、書き直さないのです。 そこで、ScalarDB SQL に収まらない読み取り文を次の 2 段に分けます。 取得: 表ごとに、ScalarDB で絞れるだけ絞って、文が使う列だけを読む 残余処理: 取得した行を、リクエストごとに作るインメモリの H2 に投入し、元の SQL を移行元の方言の互換モードで実行する ScalarDB のバックエンドに直接つなぐものは存在しません。H2 が扱うのは、ScalarDB がトランザクションの中で返した行だけです。 仕組み 分解の考え方 例として、第 1 回と同じ会員ポイントの表(members、point_history)に対する、Oracle の外部結合の文を使います。 SELECT m.member_id, m.name, h.seq_no, h.points FROM members m, point_history h WHERE m.member_id = h.member_id(+) AND m.member_id = 1; この文は ScalarDB SQL になりません。(+) は LEFT JOIN に書き換えられますが、結合条件が point_history の主キー全体(member_id, seq_no)を覆っていないため、ScalarDB Cluster が DB-SQL-10067 で断ります(第 1 回の JOIN_KEY)。読み取り専用なので、変換ツールはこれを実行計画に分解します。 分解は次の手順で行います。 WHERE 句を CNF(AND の OR)に正規化し、「その表の列 演算子 リテラル」だけからなる連言を、その表の取得条件として 押し下げる。WITH の中の日付リテラルも押し下げの対象です 文が参照する列だけを取得列にする。射影・WHERE・GROUP BY・ORDER BY・結合条件に出てくる列を集めます 表ごとに ScalarDB SQL の SELECT を組み立て、アクセスパス(GET / パーティション SCAN / インデックス SCAN / クロスパーティション SCAN)を判定する 元の SQL を、H2 が受け付ける形に最小限だけ直す。(+) は LEFT JOIN に、ORDER BY には移行元の NULL の並び位置を明示します 押し下げは取得行数を減らすための最適化であって、正しさの条件ではありません。H2 側で完全な述語を再適用するため、取得結果は元の条件の上位集合であれば十分です。外部結合される側の IS NULL を押し下げないのは、押し下げると外部結合の意味が変わるからです。 実行計画 JSON 変換ツールが書き出した、上の文の実行計画です(--plan-dir を付けると <名前>.<文番号>.plan.json に出ます)。読みやすさのために、表の全列の型(column_types)、Python の参照実装用の残余 SQL(residual.python)、recommended_config の一部などを省いています。 points.17.plan.json(抜粋) { "pattern": "P8", "source_dialect": "oracle", "source_sql": "SELECT m.member_id, m.name, h.seq_no, h.points\n FROM members m, point_history h\n WHERE m.member_id = h.member_id(+)\n AND m.member_id = 1", "fetch": [ { "table": "members", "alias": "m", "columns": ["member_id", "name"], "predicates": [{"column": "member_id", "op": "=", "value": 1}], "scalardb_sql": "SELECT member_id, name FROM members WHERE member_id = 1", "access_path": "GET", "max_rows": 10000, "index_columns": [["member_id"]] }, { "table": "point_history", "alias": "h", "columns": ["member_id", "seq_no", "points"], "predicates": [], "scalardb_sql": "SELECT member_id, seq_no, points FROM point_history", "access_path": "CROSS_PARTITION", "max_rows": 10000, "index_columns": [["member_id", "seq_no"]] } ], "residual": { "java": { "engine": "h2", "mode": "Oracle", "sql": "SELECT m.member_id, m.name, h.seq_no, h.points FROM members m LEFT JOIN point_history h ON m.member_id = h.member_id WHERE m.member_id = 1", "build_indexes": false } }, "guardrails": {"requires_cross_partition_scan": true, "row_limit": 10000}, "notes": [ "Oracle (+) outer join rewritten as LEFT/RIGHT OUTER JOIN (H2 does not support (+))", "members: pushdown member_id = 1" ], "transaction": {"read_only": true}, "recommended_config": { "scalar.db.scan_fetch_size": 1000, "scalar.db.cross_partition_scan.enabled": true } } 読み方を 3 点だけ挙げます。 fetch が表ごとの取得です。 members は主キーが揃っているので GET、point_history は述語が無いのでクロスパーティション SCAN です。m.member_id = 1 と結合条件から h.member_id = 1 を推論して押し下げることは、この試作ではしていません。計画を読んで「この取得は全表走査だ」と気づけることが大事です residual.java.sql が H2 で実行する SQL です。 元の SQL とほぼ同じで、変わっているのは (+) を LEFT JOIN にしたところだけです guardrails と recommended_config は、実行時と設定の注意です。 クロスパーティション走査が要ること、1 表あたりの行数上限、読み取り専用トランザクションで動かすこと、scan_fetch_size の推奨値が書かれています pattern は、ScalarDB SQL に収まらなかった理由の分類です。 パターン 理由 パターン 理由 P1 射影の式・関数 P7 集約 P2 WHERE の式・列どうしの比較 P8 結合(キーを覆わない ON、外部結合など) P3 DISTINCT P13 ストレージが実行できない ORDER BY P4 OFFSET P14 キーの OR / IN P5 副問合せ P15 クロスパーティション走査を使えない P6 CTE・集合演算 もう 1 つ、ウィンドウ関数の例です。パーティションキーが揃っているので、取得はパーティション SCAN の 1 つだけになります。 points.19.plan.json(抜粋) { "pattern": "P1", "fetch": [ { "table": "point_history", "columns": ["member_id", "seq_no", "points"], "scalardb_sql": "SELECT member_id, seq_no, points FROM point_history WHERE member_id = 1", "access_path": "PARTITION_SCAN" } ], "residual": { "java": { "engine": "h2", "mode": "Oracle", "sql": "SELECT member_id, points, SUM(points) OVER (PARTITION BY member_id ORDER BY seq_no NULLS LAST) AS running_total FROM point_history WHERE member_id = 1" } }, "notes": ["java: Oracle semantics made explicit for H2 (date functions, NULL ordering)"] } ORDER BY seq_no NULLS LAST が足されています。理由は後述の「詰まったポイント」で説明します。 実行時の構成(runtime-java) 計画を実行するのは Java のランタイムです。クラス構成は次のとおりです。 Fetcher 経路 ライセンス 用途 CoreFetcher ScalarDB Core API(Scan、読み取り専用トランザクション) 不要 開発・差分テスト JdbcFetcher ScalarDB SQL の JDBC(ScalarDB Cluster) 要 本番想定の経路・ベンチマーク 実行の流れです。 設計上のポイントは 4 つです。 1. 取得は 1 つのトランザクションにまとめる 1 つの計画の取得は、すべて 1 つの読み取り専用トランザクションで行います。表ごとの結果が同じ時点のデータになり、複数表をまたぐ残余 SQL が矛盾した行を見ることがありません。 2. H2 はリクエストごとに作って捨てる H2 はインメモリで作成し、問合せが終わると閉じます。永続化はしません。ScalarDB の型から H2 の型へ写像して表を作り、取得した行を投入します。 3. 元の SQL を、移行元の方言の互換モードで実行する H2 の互換モード(MODE=Oracle / PostgreSQL / MySQL)を使うため、NVL、DECODE、SUBSTR、TO_CHAR、ウィンドウ関数などがそのまま動きます。H2 に無い Oracle 関数は、OracleFunctions が Java の関数として登録して補います。 4. 行数の上限で止める 取得の行数が max_rows を超えると例外で止めます。上限に引っかかる計画は、その SQL を設計から見直す対象です。 H2 の索引はオプション 計画の各取得には、H2 に作ると結合が速くなる索引の列(index_columns: 主キーと、結合・相関・IN 副問合せの列)が付きます。索引を作るかどうかはオプションで、既定はオフです。 索引なし(既定) 索引あり 向く処理 オンラインの小さな要求、1 表だけの集約・並べ替え 数万行以上の表を結合するバッチ処理 3 表結合(注文 2 万行・明細 5 万行) 26〜28 秒 1.7〜1.9 秒 1 表の集約(100 万行規模) 基準 索引の構築の分だけ +19〜50% H2 のメモリ 基準 約 1.6 倍 結合が入れ子ループの総当たりから索引の参照になるため、大きな表の結合では効果が大きい一方、1 表だけの計画や小さな要求では、索引を作る時間(投入と同程度)とメモリの分だけ遅くなります。 ガードレール 「取得してから計算する」設計は、データ量が増えた瞬間に破綻する危険と隣り合わせです。変換ツールは計画を作る時点で、次の指摘を出します。 コード 重要度 意味 PLAN_CROSS_PARTITION WARN 計画の取得にクロスパーティション走査が要る COST INFO 取得コストの見積もり。走査 1 行あたり約 25 µs、キー指定で約 5 ms を基準にする ROW_LIMIT WARN 計画の行数上限(既定 1 万行)を超える見込み COST_DEADLINE WARN 見積もりが ScalarDB Cluster の gRPC の期限(既定 60 秒)を超える CONFIG INFO 読み取り専用トランザクション、scan_fetch_size、クロスパーティション走査の設定、分離レベルの影響 --expected-rows 表=行数 で本番の行数を渡すと、COST が具体的な見積もりになります。 ! 行数が読めない本番の表で、全表取得の計画をそのまま使わないこと 全表を H2 に載せる計画は、テストデータの規模では問題なく動いてしまいます。上限は事故を防ぐための仕掛けであり、PLAN_CROSS_PARTITION の WARN が出た計画は、本番の行数で見積もってから採用してください。 もう 1 つ、バックエンドの種類による制約があります。クロスパーティション走査を ScalarDB が受け付けるのは JDBC(RDBMS)バックエンドのときだけです。Cassandra をバックエンドにする場合は --storage cassandra を付けます。すると、キーでもインデックスでも取得できない表は FULL_SCAN の ERROR になり、計画は作られません。同じ会員ポイントの SQL を --storage cassandra で変換すると、PLANNED は 4 文から 2 文(主キーとパーティションキーで取得できるもの)に減り、外部結合の文にはこう出ます。 ERROR FULL_SCAN: point_history: no key or index condition to fetch by; a scan of every partition is not used on cassandra (cross-partition scans are for JDBC backends only) -- fetch by key instead: read members first, then point_history with one partition scan per members.member_id value (WHERE member_id = ?) 「キーで読める表から順に読む」という、アプリ側の書き方の提案です。Cassandra では、取得してから計算する方式そのものが使えないのではなく、取得をキーで駆動できる形にアプリが組み立て直す ことが要ります。 分解しても H2 で動かないもの H2 に構文が無いものは、計画にしても実行時に失敗します。変換ツールはこれを変換時に RESIDUAL_H2 の ERROR として報告し、計画を作りません。 機能 アプリ側での書き方 CONNECT BY / SYS_CONNECT_BY_PATH 木をたどる補助クラス Hierarchy で展開する ROLLUP / CUBE / GROUPING SETS 集約レベルごとの UNION ALL に展開する KEEP (DENSE_RANK FIRST / LAST) ウィンドウ関数に書き換える PIVOT / UNPIVOT CASE 式に展開する ここは「取得 + アプリ側の Java」で書き直します。書き直しでは Oracle の動きを再現する補助クラスを用意し、Oracle で一度取った正解(golden)と結果を突き合わせます。 補助クラス 再現する Oracle の動き Hierarchy START WITH / CONNECT BY、LEVEL、SYS_CONNECT_BY_PATH、循環の検出 Windows LAG / LEAD、移動平均、RANK / DENSE_RANK OracleNumbers NUMBER の算術、0 除算のエラー、0 から遠いほうへの丸め、NULL を除く集約 OracleOrdering NULL の並び位置、NLS_SORT=BINARY の文字列順 OracleDates ADD_MONTHS の月末、TO_CHAR の年月 実例として、CONNECT BY で 3 段の組織の階層を展開してから月次で集計する分析クエリ(月次集計の CTE と、LAG・移動平均・順位のウィンドウ関数つき)を、アプリ側 Java で実装して Oracle と比べました。2,400 行すべてが値まで一致しています。 性能 いずれも検証環境(単一クライアント・単一ノード)での実測で、規模に依存する数字です。 押し下げの効果 WITH の中の日付範囲を取得に押し下げる前後の比較です(emp 表 20,018 行、ScalarDB SQL JDBC 経由、p50)。 文 取得行数(前) ScalarDB p50(前) 取得行数(後) ScalarDB p50(後) Oracle 直接 日付範囲 + ウィンドウ関数 20,018 566.4 ms 1,409 46.9 ms 4.8 ms 日付範囲 + 月次集計 20,018 566.9 ms 2,430 69.2 ms 2.1 ms 時間のほとんどは取得で、H2 での残余処理は 2〜15 ms でした。応答時間は読む行数で決まります。 押し下げで読む行数を減らせるかが、この方式の性能のほぼすべてです。 全表を読む処理 CONNECT BY の分析クエリ(組織 214 行 + 明細 29,426 行を 1 トランザクションで取得)は、p50 で 813.6 ms でした。内訳は取得 637.1 ms、Java での処理 36.5 ms で、取得 1 行あたり約 22 µs です。Oracle 直接の 14.0 ms に対して 58 倍です。 この CONNECT BY の数値と、前の節の「2,400 行が一致」は、同じ形をした別の題材で 2026-09 に測ったものです。リポジトリの今の題材(合成の「部署別の月次受注集計」)では測り直していません。 ScalarDB の背後が Oracle であっても、全行を ScalarDB に読み込む処理は Oracle 直接の数十倍から数百倍かかります。この方式は「動かす」ことはできますが、「速くする」ことはできません。速くするには、月次集計表のように読む行数を減らす設計が要ります。 詰まったポイント・Tips ORDER BY の NULL の位置が食い違う 最も見つけにくかった差です。Oracle は昇順で NULL を末尾に置きますが、H2 は先頭に置きます。そして H2 の Oracle 互換モードはこの規則を再現しません。 方言 昇順のときの NULL 降順のときの NULL Oracle 末尾 先頭 PostgreSQL 末尾 先頭 MySQL 先頭 末尾 H2 先頭 末尾 NULL を含まないデータでテストしていると気づきません。対処として、残余 SQL を生成するときに移行元の方言の NULL 順序を NULLS FIRST / NULLS LAST として明示するようにしました。先ほどの計画に ORDER BY seq_no NULLS LAST が入っていたのはこのためです。 ! 互換モードは「だいたい同じ」であって「同じ」ではありません 関数名が通ることと、ソート順や NULL の扱いまで一致することは別問題です。移行の検証では、NULL を含むデータと ORDER BY の組み合わせを必ずテストケースに入れてください。 外部結合の (+) は書き換えられるが、押し下げに注意 a.x = b.y(+) は LEFT JOIN に書き換えられます。ただし書き換え後、結合の種類が構文木の別の属性に入るため、分解処理が外部結合と認識できず、外部結合される側の IS NULL を取得条件に押し下げてしまう不具合がありました。 -- d.dname IS NULL は「結合相手が見つからなかった行」を意味する -- これを dept の取得条件に押し下げると、結果が変わる SELECT e.ename, d.dname FROM emp e, dept d WHERE e.deptno = d.deptno(+) AND (d.dname LIKE 'S%' OR d.dname IS NULL); この種の不具合は単体テストでは見つかりません。移行元データベースと ScalarDB で同じ SQL を実行し、結果集合を突き合わせる差分テストを用意したことで表面化しました。 「ScalarDB が断る」と言いながら WARN にしていた 第 1 回の会員ポイントの SQL を実 DB で流したとき、外部結合の文がツールでは WARN なのに、ScalarDB Cluster では DB-SQL-10067 で断られました。結合が相手の表の主キー全体を覆っていないことをツールは検出していたのに、判定を WARN にしていたのです。JOIN_KEY を ERROR にし、読み取りなら実行計画(P8)に回るよう直しました。この記事の最初の例は、直したあとの結果です。 ! 変換ツールの判定は、実 DB で確かめて初めて信用できます ツールが「動く」と言った文が動かないケースは、実 DB の差分テストでしか見つかりません。移行のパイプラインには、変換結果を実際の ScalarDB Cluster で流す工程を必ず入れてください。 書き込みには同じ手が使えない 読み取りは「行を取ってきてから計算すればよい」ため、この方式が効きます。書き込みは、どの行をどう変えるかをデータベース側で決める構文が多く、同じ手は使えません。UPDATE ... SET qty = qty - 5 のような読み書きは、同じトランザクションの中で「読む → 計算する → リテラルで書く」に分解します。読み取りの計画を書き込みに広げる「書き込み計画」は設計だけあり、未実装です。 まとめ 成果: ScalarDB SQL の文法に収まらない読み取りクエリを、ScalarDB からの取得とインメモリ H2 での残余処理に分解することで、元の SQL をほぼそのまま実行できました。射影の式、サブクエリ、ウィンドウ関数、DISTINCT、UNION、キーを覆わない結合が対象です 成果: 押し下げによって読む行数を減らすと、応答時間はそれに比例して短くなりました(20,018 行 → 1,409 行で 566 ms → 47 ms)。応答時間は読む行数で決まります 課題: 全表を読む処理は、バックエンドが Oracle でも Oracle 直接の数十倍から数百倍かかります。動かすことと速くすることは別で、速くするには読む行数を減らす設計が要ります 課題: CONNECT BY、ROLLUP / CUBE、PIVOT は H2 に構文が無く、アプリ側の Java で書き直します。書き込みへの拡張は未実装です 次回は PL/SQL に進みます。ANTLR の grammar で PL/SQL を解析し、制御構造・例外・SQL を保った中間表現に落とし、SQL の断片だけを SQLGlot に渡す仕組みを解説します。 最後にお読みいただきありがとうございました。この記事が同じ悩みを持つ方の参考になれば幸いです。 参考 wfukatsu/sql-migration scalardb.scalar-labs.com h2database.com tobymao/sqlglot 執筆者紹介 執筆者 深津 航 株式会社Scalar 代表取締役CEO、Co-Founder 最近はAIを使った開発プロセスの改善に取り組んでいます。 会社情報 Scalar, Incは、「データマネジメントの未来を創る」を掲げ、データベースミドルウェアの研究開発・販売を行う日本発のグローバルスタートアップ企業です。複数の異なるデータベース(RDB、NoSQLなど)を仮想的に統合し、トランザクション処理や分析クエリを実現するデータベースミドルウェアである ScalarDB 、データの改ざん検知を行う ScalarDL を主な製品としています。是非下記の弊社サイトもチェックをお願いします! scalar-labs.com medium.com scalar-labs.com

  3. 【既存のSQL, PL/SQLをAIを用いて移行する】第1回:SQLGlot で既存の SQL を ScalarDB SQL に変換する 概要 既存のアプリケーションが持つ SQL と PL/SQL は、長年の運用で育った資産です。ScalarDB へ移行するとき、数百から数千の SQL を人手で書き直すのは現実的ではありません。 この連載では、Oracle / MySQL / PostgreSQL 向けに書かれた既存の SQL と PL/SQL を ScalarDB へ移すために試作した仕組み(wfukatsu/sql-migration)を、6 回に分けて解説します。 この連載で説明する仕組みのコード(SQL の変換ツール、PL/SQL の解析と Java の生成、生成した Java が使うランタイム、スキル、文書)は、次のリポジトリに置いてあります。記事の題材(会員ポイントの SQL と package pkg_points)、実 DB で比べるためのハーネス、テストは開発用の非公開リポジトリにあり、公開のリポジトリには入っていません。手元で試せる小さな題材は、公開のリポジトリの docs/quickstart/ にあります。 wfukatsu/sql-migration SQLGlot で Oracle / MySQL / PostgreSQL の SQL を ScalarDB SQL に変換する(この記事) 変換できない SQL を H2 と組み合わせて動かす ANTLR grammar で PL/SQL を解析し、中間表現に落とす 中間表現から Java + ScalarDB を生成し、AUTO / REVIEW / REDESIGN を判定する Claude Code / Codex のスキルから使う migrate-flow: 承認をはさんで、仕様の調査からテストまで進める 全体の流れと各回の位置づけです。色を付けた所がこの記事です。 第 1 回のこの記事では、SQL 文を 1 文ずつ解析して ScalarDB SQL に変換する仕組みを説明します。 文字列置換ではなく、SQLGlot の構文木で変換する理由 1 文が OK / WARN / PLANNED / ERROR のどれかに振り分けられるまでの流れ 3 つの方言それぞれで、前処理が吸収している差 「変換できた」と「同じ結果になる」は別、という実例 対象読者 Oracle / MySQL / PostgreSQL から ScalarDB への移行を検討しているエンジニア ScalarDB SQL の文法制約でつまずいている方 SQLGlot を使った SQL の方言変換に興味がある方 背景・目的 ScalarDB は複数のデータベースを仮想的に統合し、それらをまたいだトランザクションを実現するミドルウェアです。ScalarDB SQL を使えば SQL でアクセスできますが、その文法は汎用の RDBMS と比べて意図的に絞られています。 理由は明確で、ScalarDB は背後にある複数のストレージへ確実に処理を振り分けられる範囲だけを引き受けるからです。任意のサブクエリや任意の結合を許すと、どのストレージにどの処理を投げるかが決まらなくなります。主な制約は次のとおりです。 射影に書けるのは列と集約関数(COUNT / SUM / AVG / MIN / MAX)だけ。式や関数は書けない WHERE の右辺はリテラルだけ。列どうしの比較は JOIN の ON でしか書けない WHERE は DNF(OR の AND)か CNF(AND の OR)で書く必要がある JOIN の結合条件は、相手の表の主キーかセカンダリインデックスを覆う必要がある サブクエリ、CTE(Common Table Expression: WITH句)、UNION、ウィンドウ関数、DISTINCT、OFFSET、CASE は書けない UPDATE SET col = col + 1 のような、列を参照する式での更新はできない そこで、次の問いに答えるための試作を行いました。 既存 SQL のうち、どれだけが機械的に変換できるのか 変換できない SQL は、アプリケーションをどこまで書き換えれば動くのか その境界はどこにあるのか 設計方針 試作にあたって決めた方針です。以降の説明はすべてこの表に戻ってきます。 方針 理由 構文木で変換する 文字列置換では、コメント・文字列リテラル・入れ子の構文を正しく扱えない ScalarDB に無い構文は出さない 変換漏れを「黙って通る SQL」ではなく「エラー」として見えるようにする 変換できない理由を全部挙げる 移行の見積もりには、最初の 1 つではなく作業の全量が要る 読み取りは元の SQL をそのまま実行する 意味(NULL の扱い、丸め、並び順)を書き直すと結果が変わりやすい(第 2 回) ScalarDB のバックエンドに直接つながない ScalarDB のトランザクションとメタデータを迂回しない 実データで確かめる 変換ツールの判定だけでは、型・リテラル・照合順序の差を見逃す 仕組みの全体像 変換はビルド時に 1 度だけ行い、実行時に SQL を解析し直すことはありません。 1 文が判定に至るまで 変換ツールは SQL スクリプトを文に分割し、1 文ずつ次の流れで処理します。 判定 意味 次にやること OK ScalarDB SQL に変換できた そのまま使う WARN 変換できたが、意味や性能に注意がある 指摘を確認する PLANNED ScalarDB SQL にはできないが、実行計画(取得 → H2)で動かせる 行数と応答時間を確認する ERROR 自動では移行できない レポートの対応案に沿ってアプリ側で実装する なぜ SQLGlot か、そして SQLGlot に任せきれないこと SQLGlot は Python 製の SQL パーサ・トランスパイラで、Oracle / MySQL / PostgreSQL を含む多くの方言を解析し、構文木を別の方言で書き出せます。文字列置換と違い、コメントや文字列リテラルの中を壊さず、入れ子の副問合せや CTE も構造として扱えます。この試作はその構文木の上で書き換えを行います。 ただし、SQLGlot は バリデータではありません。移行元の構文を移行先が持っていなくても、例外を出さずにそのまま出力することがあります。以下は、SQLGlot 30.18 で実測した例です。 入力 変換 素の sqlglot.transpile の出力 何が起きるか WHERE ROWNUM <= 5 Oracle → PostgreSQL そのまま 実行時に列が無いエラー WHERE e.deptno = d.deptno(+) Oracle → PostgreSQL WHERE e.deptno = d.deptno 外部結合が内部結合になり、結果が静かに変わる TRUNC(hiredate, 'MM') Oracle → MySQL DATE(hiredate) 月初めでなく日単位の切り捨てになり、結果が静かに変わる empno DIV 4 MySQL → Oracle CAST(empno / 4 AS INT) 切り捨てが四捨五入になる emp MySQL → Oracle "emp" 大文字小文字を区別する名前になり、表が見つからない (+) と TRUNC(…, 'MM') は unsupported_level=ErrorLevel.RAISE を指定すれば例外になりますが、既定の設定では警告のログが出るだけで、出力は上の表のとおりです。残りの 3 つは SQLGlot が未対応と認識していないため、どのエラーレベルでも検出されません。 そこで、この試作では 出力側の方言を厳格にしました。SQLGlot の Generator を継承した ScalarDB 方言の生成器が、ScalarDB SQL に存在しない構文(サブクエリ、CASE 式、関数呼び出し、OFFSET、CAST など)を出力しようとすると例外を出します。前処理で直せるものは直し、直せないものは指摘コードつきで報告します。 ! 変換漏れは必ずビルド時に表面化します 出力側で弾いているため、変換しきれなかった SQL が「それらしい SQL」として出力され、実行時に初めて壊れることはありません。必ず ERROR か PLANNED として手元に返ってきます。 変換ルール 述語と結合の書き換え Oracle / MySQL / PostgreSQL と ScalarDB SQL の差の多くは、意味を変えずに書き換えられます。 移行元の構文 ScalarDB SQL IN (a, b, c) / NOT IN (col = a OR col = b OR col = c) / col <> a AND col <> b NOT (a = 1) a <> 1(比較演算子を反転して押し下げ。IS NOT NULL / NOT LIKE はそのまま) 任意の AND / OR のネスト DNF か CNF の短いほうに正規化し、括弧を付与 10 < col col > 10(右辺をリテラルに揃える) ROWNUM <= n / FETCH FIRST n ROWS ONLY / LIMIT n LIMIT n FROM a, b WHERE a.x = b.y INNER JOIN b ON a.x = b.y Oracle 外部結合 a.x = b.y(+) LEFT JOIN b ON a.x = b.y JOIN ... USING (c) JOIN ... ON a.c = b.c ON CONFLICT DO UPDATE / ON DUPLICATE KEY UPDATE / REPLACE INTO / 定数ソースの MERGE UPSERT INTO(意味の差を WARN) 型と制約の変換 ScalarDB の型は 11 種(BOOLEAN / INT / BIGINT / FLOAT / DOUBLE / TEXT / BLOB / DATE / TIME / TIMESTAMP / TIMESTAMPTZ)です。 型は scalardb_migrate/types.py の map_type() が決めます。対応のたびに重要度(INFO / WARN / ERROR)と理由が付き、ERROR は文全体を ERROR にします。 移行元の型 ScalarDB 重要度 理由・注意 TINYINT / SMALLINT / INT(MySQL, PostgreSQL) INT INFO MySQL の TINYINT(1) は真偽値に使われることが多いので WARN INTEGER / INT / SMALLINT(Oracle) BIGINT WARN Oracle の整数型は NUMBER(38) の別名。NUMBER(p) で宣言すると正確に対応する BIGINT BIGINT INFO 符号なし 64 ビット(BIGINT UNSIGNED)は範囲を超えるので WARN NUMBER(p) / DECIMAL(p, 0)、p ≤ 9 INT INFO 桁数だけで決める。正確に収まる 同上、p ≤ 18 BIGINT INFO 同上、p > 18 BIGINT WARN 64 ビットを超える値はあふれる NUMBER(p, s) / DECIMAL(p, s)、s > 0 DOUBLE WARN DECIMAL が無い。金額は 10^s 倍した整数を BIGINT に、と提案する。第 2 回の実行計画(H2)ではこの列を NUMERIC(p, s) として扱う 精度なしの NUMBER / NUMERIC DOUBLE WARN 桁数も小数も無制限なので正確には保てない。MySQL の DECIMAL だけ既定の (10, 0) として読む Oracle の FLOAT / REAL DOUBLE WARN 最大 38 桁の 10 進 NUMBER(REAL は FLOAT(63))。DOUBLE は約 15 桁 BINARY_FLOAT、MySQL の FLOAT、PostgreSQL の REAL / FLOAT(p)(p ≤ 24) FLOAT INFO 単精度 DOUBLE / DOUBLE PRECISION / BINARY_DOUBLE、PostgreSQL の精度なし FLOAT DOUBLE INFO PostgreSQL の FLOAT は、精度なしか 25 以上なら倍精度 PostgreSQL の MONEY DOUBLE WARN 通貨の固定小数点。セント単位などの整数を BIGINT に持つことを提案する CHAR(n) / NCHAR(n)、n > 1 TEXT WARN 空白詰めで空白を無視する比較が、厳密な比較に変わる。移行時に trim しないと一致しなくなる VARCHAR2(n) / VARCHAR(n) / CLOB / TEXT TEXT INFO 長さの上限は強制されない Oracle の LONG TEXT WARN 古い文字列の型(最大 2 GB)で、整数ではない。Oracle では WHERE や索引に使えない RAW / BLOB / BINARY / BYTEA BLOB INFO Oracle DATE DATE WARN 時刻を持つ。時刻を使うなら TIMESTAMP にする TIME TIME INFO TIME WITH TIME ZONE はオフセットが落ちるので WARN TIMESTAMP(p) / DATETIME、p ≤ 3 TIMESTAMP INFO TIMESTAMP(p) / DATETIME、p ≥ 4(Oracle・PostgreSQL の既定は 6) TIMESTAMP WARN ScalarDB はミリ秒まで。MySQL だけは精度なしを 0 として読む TIMESTAMP WITH (LOCAL) TIME ZONE / TIMESTAMPTZ TIMESTAMPTZ INFO UTC で保存、ミリ秒まで(精度 4 以上は WARN)。リテラルは 'YYYY-MM-DD HH:MM:SS Z' の形に UTC 変換して書く MySQL の TIMESTAMP TIMESTAMPTZ INFO セッションの時間帯から UTC に直して持つ、瞬間の型。時差を持たない DATETIME は TIMESTAMP BOOLEAN / BIT(1) BOOLEAN INFO BIT(n)、n > 1 は BLOB + WARN JSON / UUID / ENUM / SET / XML TEXT WARN 文字列として入る。JSON の演算子や列挙の検査は使えない SERIAL 系 INT / BIGINT ERROR 自動採番は無い。ID はアプリで生成する INTERVAL、配列、ユーザ定義型など なし ERROR 対応する型が無い 制約は次のように扱います。 移行元の制約 ScalarDB 重要度 NOT NULL / DEFAULT / UNIQUE / FOREIGN KEY / CHECK 削除して INFO または WARN アプリで守る 複合主キー 先頭列をパーティションキー、残りをクラスタリングキー(--keys で変更) INFO ! 金額の列は DOUBLE にしないでください ScalarDB に DECIMAL 型が無いため、NUMBER(12, 2) は DOUBLE になり、誤差が出ます。金額のように誤差が許されない列は、スケール済みの整数(円なら 1 倍、ドルなら 100 倍)として BIGINT に持たせる設計を推奨します。変換ツールはこの点を WARN として報告します。 実際に変換する 会員のポイント残高(members)とその増減の履歴(point_history)を扱う、Oracle の SQL 20 文を変換します。DDL、書き込み、読み取りがひととおり入っています。 python -m scalardb_migrate.cli points.sql --source oracle --out-dir out --plan-dir out/plans 20 statements: OK=7 WARN=6 PLANNED=4 ERROR=3 ! Claude Code / Codex からは、スキルが同じスクリプトを回します 「この SQL を Oracle から ScalarDB 用に変換して」と頼むと、sql-transpile スキルがこのコマンドを実行し、判定ごとの次の手を添えて報告します。モデル自身は SQL を変換しません。仕組みは第 5 回で解説します。 DDL 変換前(Oracle) CREATE TABLE point_history ( member_id NUMBER(10) NOT NULL, seq_no NUMBER(6) NOT NULL, points NUMBER(10) NOT NULL, reason VARCHAR2(100), created_at DATE NOT NULL, CONSTRAINT pk_point_history PRIMARY KEY (member_id, seq_no) ); 変換後(ScalarDB SQL) CREATE TABLE point_history ( member_id BIGINT, seq_no INT, points BIGINT, reason TEXT, created_at DATE, PRIMARY KEY (member_id, seq_no) ); レポートには、列ごとに何が起きたかが並びます。 [ 2] WARN CREATE CREATE TABLE point_history ( INFO TYPE: column member_id: NUMBER(10) -> BIGINT (exact, fits 64-bit) INFO NOT_NULL: column member_id: NOT NULL dropped (ScalarDB columns are nullable; enforce in the application) INFO TYPE: column seq_no: NUMBER(6) -> INT (exact, fits 32-bit) INFO TYPE: column reason: VARCHAR2(100): length limit is not enforced by ScalarDB TEXT WARN TYPE: column created_at: Oracle DATE carries a time-of-day component; use TIMESTAMP if the time part is used INFO KEYS: primary key ['member_id', 'seq_no']: first column 'member_id' used as partition key, ['seq_no'] as clustering key(s). Review with --keys if a different split is needed 複合主キーの先頭列がパーティションキー、残りがクラスタリングキーになりました。この分割がアクセスパスを決めるので、KEYS の INFO は必ず読みます。 書き込み [ 7] OK UPDATE UPDATE members SET rank = 'GOLD', updated_at = DATE '2026-02-01' WHERE member_id = 1 INFO DATE_LIT: SET updated_at: TO_DATE('2026-02-01', 'YYYY-MM-DD') written as the plain literal '2026-02-01' INFO ACCESS: UPDATE: full primary key specified -> GET (single record) => UPDATE members SET rank = 'GOLD', updated_at = '2026-02-01' WHERE member_id = 1 [ 8] ERROR UPDATE UPDATE members SET balance = balance + 100 WHERE member_id = 1 ERROR RMW: SET balance = balance + 100: expressions referencing columns are not allowed; do SELECT -> compute -> UPDATE with a literal inside one ScalarDB transaction [ 10] WARN MERGE MERGE INTO members m USING (SELECT 2 AS member_id, 'Tanaka' AS name FROM dual) s ... WARN MERGE: MERGE rewritten as UPSERT: on an existing row this also overwrites ['rank', 'balance', 'last_seq'], which WHEN MATCHED does not set (it sets ['name']) => UPSERT INTO members (member_id, name, rank, balance, last_seq) VALUES (2, 'Tanaka', 'REGULAR', 0, 0) 3 文で 3 通りの結果です。主キー指定の UPDATE は OK、列を参照する式の UPDATE は ERROR で「同じトランザクションの中で読む → 計算する → リテラルで書く」という対応案が付き、MERGE は UPSERT に書き換えられますが、WHEN MATCHED が触らない列まで上書きされる差が WARN で出ています。 読み取り [ 12] OK SELECT SELECT seq_no, points, reason FROM point_history WHERE member_id = 1 AND seq_no >= 10 ORDER BY seq_no DESC INFO ACCESS: SELECT: full partition key specified -> partition SCAN [ 13] OK SELECT SELECT member_id, name FROM members WHERE rank = 'GOLD' INFO ACCESS: SELECT: equality on secondary index -> index SCAN [ 14] WARN SELECT SELECT member_id, name, balance FROM members WHERE ROWNUM <= 3 ORDER BY balance DESC WARN ROWNUM: 'ROWNUM <= 3' rewritten to LIMIT 3. Note: Oracle applies ROWNUM before ORDER BY, ScalarDB LIMIT applies after ORDER BY WARN CROSS_PARTITION: SELECT: predicates do not cover the partition key ['member_id'] of members -> cross-partition SCAN => SELECT member_id, name, balance FROM members ORDER BY balance DESC LIMIT 3 [ 16] PLANNED SELECT SELECT member_id, NVL(name, '(no name)') AS name, balance FROM members WHERE member_id = 1 ERROR PROJECTION: main query: expressions in the select list (NVL(name, '(no name)')) -- compute them in the application INFO PLAN_FETCH: GET: SELECT member_id, name, balance FROM members WHERE member_id = 1 INFO PLAN_RESIDUAL: H2 Oracle mode runs the original SQL (pattern P1, H2 indexes off) NVL の文は ScalarDB SQL にできませんが、読み取り専用なので実行計画に分解され、PLANNED になりました。この計画の中身と動かし方が第 2 回の主題です。 20 文の内訳 判定 文 何が起きたか OK(7) 主キーの SELECT、パーティション内の範囲 + ORDER BY、索引列の検索、値がリテラルの INSERT / UPDATE / DELETE そのまま ScalarDB SQL になった WARN(6) CREATE TABLE、MERGE、ROWNUM、パーティションをまたぐ集計と FETCH FIRST 変換できたが、意味か性能の注意がある PLANNED(4) NVL、外部結合 (+)、SUM(...) OVER (...)、WHERE balance > (SELECT AVG ...) 実行計画で動かす ERROR(3) CREATE SEQUENCE、member_seq.NEXTVAL、SET balance = balance + 100 アプリで採番する / 読んでから書く アクセスパス分析 表定義(入力の DDL か --schema で渡す Schema Loader の JSON)が分かると、SELECT / UPDATE / DELETE ごとに ScalarDB がどう読むかを判定し、ACCESS の INFO か CROSS_PARTITION の WARN で報告します。 結合は、ON の条件が相手の表の主キーかセカンダリインデックスを覆っているかも検査します。覆っていなければ ScalarDB Cluster が DB-SQL-10067 で断るため、JOIN_KEY の ERROR にし、読み取りなら実行計画に回します。 クロスパーティション SCAN と判定された文は表全体を走査します。テストデータの規模では問題なく動いてしまうため、移行対象の SQL 一覧に対して WARN の件数を数え、本番でどれだけの行を走査するのかを確認する工程を、移行計画に組み込むことを推奨します。 MySQL と PostgreSQL で違うところ 解析は SQLGlot が方言ごとに行うので、変換ルールの大部分は 3 方言で共通です。方言ごとに違うのは、前処理で吸収する構文と、意味の差の指摘です。 MySQL 変換前(MySQL) CREATE TABLE items ( item_id INT NOT NULL, name VARCHAR(100), price DECIMAL(10,2), created_at DATETIME, PRIMARY KEY (item_id) ); INSERT INTO items (item_id, name, price) VALUES (1, 'pen', 1.50) ON DUPLICATE KEY UPDATE name = 'pen', price = 1.50; REPLACE INTO items (item_id, name, price) VALUES (2, 'ink', 3.00); SELECT item_id, name FROM items WHERE item_id = 1 LIMIT 10; [ 1] WARN CREATE CREATE TABLE items ( WARN TYPE: column price: DECIMAL(10, 2): ScalarDB has no DECIMAL type; mapped to DOUBLE (precision loss). For money, store a scaled integer (x10^2) in BIGINT instead => CREATE TABLE items (item_id INT PRIMARY KEY, name TEXT, price DOUBLE, created_at TIMESTAMP) [ 2] ERROR INSERT INSERT INTO items ... ON DUPLICATE KEY UPDATE name = 'pen', price = 1.50 ERROR UPSERT: ON CONFLICT/DUPLICATE KEY update 'name = 'pen'' is not a plain 'col = EXCLUDED.col / VALUES(col)'; UPSERT cannot express it (read-modify-write inside a transaction instead) [ 3] WARN INSERT REPLACE INTO items (item_id, name, price) VALUES (2, 'ink', 3.00) WARN REPLACE: REPLACE INTO converted to UPSERT: REPLACE resets unlisted columns to NULL, UPSERT keeps their existing values => UPSERT INTO items (item_id, name, price) VALUES (2, 'ink', 3.00) [ 4] OK SELECT SELECT item_id, name FROM items WHERE item_id = 1 LIMIT 10 => SELECT item_id, name FROM items WHERE item_id = 1 LIMIT 10 バッククォートは外れます。ScalarDB SQL ではバッククォートは引用符ではありません DATETIME は TIMESTAMP に、DECIMAL(10,2) は DOUBLE になり、金額の注意が付きます ON DUPLICATE KEY UPDATE は、col = VALUES(col) の形なら UPSERT に変換できます(OK)。上の例のように更新値を別に書いている形は、UPSERT では表現できないので ERROR です REPLACE INTO は UPSERT に変換されますが、意味が違います。REPLACE は列リストに無い列を NULL に戻し、UPSERT は既存の値を残します。この差は WARN で出ます MySQL の既定の照合順序は大文字小文字を区別しませんが、ScalarDB は厳密に比べます。文字列リテラルとの = / LIKE / IN には INFO が付きます PostgreSQL 変換前(PostgreSQL) INSERT INTO accounts (acct_id, owner, balance) VALUES (1, 'bob', 100.00) ON CONFLICT (acct_id) DO UPDATE SET owner = 'bob', balance = 100.00; SELECT acct_id, owner FROM accounts WHERE acct_id = 1 FETCH FIRST 5 ROWS ONLY; SELECT a.acct_id, o.email FROM accounts a, owners o WHERE a.owner = o.owner AND a.acct_id = 1; [ 3] ERROR INSERT INSERT INTO accounts ... ON CONFLICT (acct_id) DO UPDATE SET owner = 'bob', balance = 100.00 ERROR UPSERT: ON CONFLICT/DUPLICATE KEY update 'owner = 'bob'' is not a plain 'col = EXCLUDED.col / VALUES(col)'; UPSERT cannot express it (read-modify-write inside a transaction instead) [ 4] OK SELECT SELECT acct_id, owner FROM accounts WHERE acct_id = 1 FETCH FIRST 5 ROWS ONLY INFO LIMIT: FETCH FIRST 5 ROWS ONLY rewritten to LIMIT 5 INFO ACCESS: SELECT: full primary key specified -> GET (single record) => SELECT acct_id, owner FROM accounts WHERE acct_id = 1 LIMIT 5 [ 5] WARN SELECT SELECT a.acct_id, o.email FROM accounts a, owners o WHERE a.owner = o.owner AND a.acct_id = 1 WARN COMMA_JOIN: implicit comma join rewritten as INNER JOIN owners ON a.owner = o.owner INFO ACCESS: SELECT: full primary key specified -> GET (single record) => SELECT a.acct_id, o.email FROM accounts AS a INNER JOIN owners AS o ON a.owner = o.owner WHERE a.acct_id = 1 ON CONFLICT DO UPDATE も MySQL と同じ規則で、SET owner = EXCLUDED.owner の形なら UPSERT に変換できます FETCH FIRST n ROWS ONLY は LIMIT n になります。Oracle の ROWNUM と違って ORDER BY より後に効くので、意味の注意は付きません カンマ結合は INNER JOIN ... ON に書き換えます。結合先の owners は主キー owner で結合しているので、ScalarDB の JOIN の条件を満たします NUMERIC を精度なしで書いた列は DOUBLE になります。PostgreSQL の精度なし NUMERIC は桁数無制限で小数を持てるためで、WARN が付きます TIMESTAMPTZ は同名の型がありますが、精度を書かないと 6 桁(マイクロ秒)になる PostgreSQL に対して、ScalarDB はミリ秒までです 方言差の全体は、3 方言とも「前処理で直せるものは直し、直せないものはコードつきで報告する」の一言に尽きます。 実 DB で確かめる 判定が実際の動作と合うかを、移行元の Oracle と ScalarDB Cluster で同じデータに同じ問い合わせを流して比べました。 PASS [4] scalardb-sql SELECT member_id, name, balance FROM members WHERE member_id = 1 PASS [5] scalardb-sql SELECT seq_no, points, reason FROM point_history WHERE member_id = 1 A… PASS [6] scalardb-sql SELECT member_id, name FROM members WHERE rank = 'GOLD' FAIL [7] scalardb-sql SELECT member_id, name, balance FROM members WHERE ROWNUM <= 3 ORDER B… PASS [8] scalardb-sql SELECT member_id, name, balance FROM members ORDER BY balance DESC FET… PASS [9] plan P1 fetched=1 SELECT member_id, NVL(name, '(no name)') AS name, balance FROM members PASS [10] plan P8 fetched=7 SELECT m.member_id, m.name, h.seq_no, h.points FROM members m, point… PASS [11] scalardb-sql SELECT rank, COUNT(*) AS members, SUM(balance) AS total FROM members G… PASS [12] plan P1 fetched=4 SELECT member_id, points, SUM(points) OVER (PARTITION BY member… PASS [13] plan P5 fetched=4 SELECT member_id, name, balance FROM members WHERE balance > (SELECT A… PASS=9 FAIL=1 FAIL の 1 件は、WARN が言っていたとおりの差です。WHERE ROWNUM <= 3 ORDER BY balance DESC は、Oracle では「任意の 3 行を取ってから並べる」、変換後は「並べてから 3 行取る」です。「残高の多い 3 人」のつもりなら、Oracle の側でも ORDER BY ... FETCH FIRST 3 ROWS ONLY と書くべきで、そちらの文は一致しています。 ! WARN は「変換できた」であって「同じ結果になる」ではありません 変換ツールの WARN は、ScalarDB SQL の文法には収まったが、意味が変わりうる箇所を指しています。読まずに通すと、実 DB で初めて差が出ます。 詰まったポイント・Tips Oracle の '' は NULL Oracle は空文字 '' を NULL として保存し、= '' は決して真になりません。ScalarDB は空文字のまま持ちます。変換できた文にも SEMANTICS の WARN を出すようにしましたが、ツールが直せる差ではありません。移行時のデータ変換と、アプリの条件式の両方で決めが要ります。 Oracle の DATE は時刻を持つ Oracle の DATE 型は秒までの時刻を持ちます。ScalarDB の DATE は日付だけです。TYPE の WARN が出た列は、時刻を使っているなら TIMESTAMP に変えます。会員ポイントの例では updated_at と created_at がこれに当たります。 MERGE を UPSERT にすると、触らない列も上書きされる WHEN MATCHED THEN UPDATE SET m.name = s.name は名前だけを更新しますが、UPSERT は行全体を書きます。既存行がありうるなら、読んでから INSERT と UPDATE を書き分けます。 ScalarDB SQL では、文法の語がすべて予約語 公式の文法ページには書かれていませんが、ScalarDB Cluster 3.19.1 で確かめたところ、ScalarDB SQL の文法に出てくる語はすべて予約語でした。type、date、data、user、mode、info といった、業務の表でよく使う列名も、そのままでは構文エラー(DB-SQL-10026)になります。二重引用符で囲めば名前として通ります。バッククォートは引用符ではなく、名前の中の二重引用符はエスケープできません。 変換ツールは、予約語(141 語)に当たる識別子を二重引用符つきで出し、IDENT の INFO で知らせます。逆に、ORM が付ける "status" のような引用符は、要らなければ外します。引用符の位置は、移行元が付けた所ではなく ScalarDB が要る所に付け直すということです。 もう 1 つ、大文字小文字の扱いが違います。移行元は引用していない識別子を畳みます(Oracle は大文字に、PostgreSQL と MySQL は小文字に)が、ScalarDB は畳みません。同じ表が customers と Customers の 2 通りに書かれていると、移行元では 1 つの表、ScalarDB では 2 つの名前です。変換ツールは 1 つのスクリプトの中で綴りが揺れている表に WARN を出します。 予約語の一覧は、ScalarDB Cluster のノードイメージに入っている SQL パーサの字句規則から取りました。ScalarDB のバージョンが変わったら取り直す前提です。 まとめ 成果: SQLGlot の構文木の上で書き換え、出力側の ScalarDB 方言で文法外の構文を例外にすることで、変換漏れが「黙って通る SQL」にならない変換ツールができました 成果: 会員ポイントの Oracle SQL 20 文は、OK 7 / WARN 6 / PLANNED 4 / ERROR 3 でした。読み取り 10 文は、PLANNED の 4 文を含めてすべてアプリの変更なしで動きます。ERROR の 3 文は採番と、列を参照する式の UPDATE です 課題: 変換ツールの判定は、実 DB で確かめて初めて信用できます。WARN のとおりに結果が変わった文が 1 つ、ツールの判定が甘かった文が 1 つ、実 DB の比較で見つかりました 次回は、PLANNED の中身です。ScalarDB SQL にならない読み取りクエリを、ScalarDB からの取得とインメモリの H2 に分解して動かす仕組みを解説します。 最後にお読みいただきありがとうございました。この記事が同じ悩みを持つ方の参考になれば幸いです。 参考 wfukatsu/sql-migration scalardb.scalar-labs.com tobymao/sqlglot h2database.com 執筆者紹介 執筆者 深津 航 株式会社Scalar 代表取締役CEO、Co-Founder 最近はAIを使った開発プロセスの改善に取り組んでいます。 会社情報 Scalar, Incは、「データマネジメントの未来を創る」を掲げ、データベースミドルウェアの研究開発・販売を行う日本発のグローバルスタートアップ企業です。複数の異なるデータベース(RDB、NoSQLなど)を仮想的に統合し、トランザクション処理や分析クエリを実現するデータベースミドルウェアである ScalarDB 、データの改ざん検知を行う ScalarDL を主な製品としています。是非下記の弊社サイトもチェックをお願いします! scalar-labs.com medium.com scalar-labs.com

  4. A read-only SQL guard for LLM-generated queries with sqlglot --- title: "A read-only SQL guard for LLM-generated queries with sqlglot" published: true description: "Regexes and 'starts with SELECT' checks won't save your warehouse. Parse the AST instead: reject DDL/DML, cap rows, estimate cost, and measure execution accuracy." tags: python, sql, ai, security --- The fastest way to learn that "the model would never do that" is a false statement is to give an LLM a database connection. A text-to-SQL agent will, sooner or later, emit a DROP, a DELETE without a WHERE, or a cartesian join over your biggest table. Sometimes because a user asked for it, sometimes because a prompt injection hid in a column value, and sometimes just because it felt creative. The usual first defence is if sql.strip().lower().startswith("select"). It fails in at least three fun ways: SELECT 1; DROP TABLE orders, a CTE that hides an INSERT, and (in DuckDB) SELECT * FROM read_csv('/etc/passwd'), which is technically a SELECT that reads your filesystem. The robust fix: parse the SQL, then reason about the tree. sqlglot gives you a dialect-aware AST in pure Python. Here is the shape of the guard I use, with a DuckDB dialect. Step 1: exactly one statement, and its root must be a SELECT import sqlglot from sqlglot import exp from sqlglot.errors import SqlglotError DIALECT = "duckdb" class GuardError(ValueError): def __init__(self, code: str, message: str) -> None: super().__init__(f"{code}: {message}") self.code = code # machine-readable, so you can eval on it def validate_sql(sql: str, max_rows: int = 1000) -> str: if not sql or not sql.strip(): raise GuardError("empty", "no SQL provided") try: stmts = [s for s in sqlglot.parse(sql, read=DIALECT) if s is not None] except SqlglotError as e: raise GuardError("parse_error", str(e).splitlines()[0]) from e if len(stmts) != 1: raise GuardError("multiple_statements", f"expected 1 statement, got {len(stmts)}") tree = stmts[0] if not isinstance(tree, (exp.Select, exp.SetOperation, exp.Subquery)): raise GuardError("not_select", f"only SELECT is allowed, got {type(tree).__name__}") ... Note the error code. You will want it later: a good guard is also a source of labelled failure data. Unparseable SQL is rejected too. That is a feature: if sqlglot cannot understand it, you do not want to run it. Step 2: walk the whole tree, not just the root A root SELECT is not enough, because data-modifying CTEs and SELECT INTO exist. So we walk every node and refuse anything on a forbidden list: _FORBIDDEN = tuple( getattr(exp, n) for n in ( "Insert", "Update", "Delete", "Merge", "Create", "Drop", "Alter", "TruncateTable", "Command", "Copy", "Set", "Use", "Pragma", "Attach", "Detach", "Transaction", "Commit", "Rollback", "Grant", "Revoke", "Install", "LoadData", "Export", ) if hasattr(exp, n) # class names differ between sqlglot versions ) for node in tree.walk(): if isinstance(node, _FORBIDDEN): raise GuardError("forbidden_statement", f"{type(node).__name__} is not allowed") if isinstance(node, exp.Into): raise GuardError("forbidden_statement", "SELECT INTO is not allowed") The hasattr filter is not decoration. sqlglot renames node classes across releases, so pin the version you tested against and re-run your guard tests when you bump it. Step 3: block file and network readers DuckDB table functions like read_csv, read_parquet and glob are the sneaky ones: pure SELECTs with filesystem side channels. Block them by name, and also block path-like table references such as FROM 'data.csv': _BLOCKED_FUNCS = {"read_csv", "read_csv_auto", "read_parquet", "read_json", "read_text", "read_blob", "glob", "parquet_scan", "query"} # inside the tree walk if isinstance(node, (exp.Anonymous, exp.Func)): name = (node.name or node.sql_name() or "").lower() if name in _BLOCKED_FUNCS: raise GuardError("forbidden_function", f"{name}() is not allowed") if isinstance(node, exp.Table) and isinstance(node.this, exp.Literal): raise GuardError("forbidden_function", "file-path table references are not allowed") If you target Postgres or Snowflake, your blocklist will differ (pg_read_file, COPY, stages, and so on). The principle holds: enumerate side-effecting entry points for your engine, and prefer an allowlist of tables on top. Step 4: table allowlist, with CTE awareness allowed = {t.lower() for t in allowed_tables} ctes = {c.alias.lower() for c in tree.find_all(exp.CTE)} for t in tree.find_all(exp.Table): if t.name.lower() not in allowed and t.name.lower() not in ctes: raise GuardError("table_not_allowed", f"table '{t.name}' is not in the allowlist") CTE names look like tables to the tree, so exclude them or you will reject every WITH query. Step 5: a row cap the model cannot raise Do not append LIMIT to the user's SQL; they may already have one, and LIMIT 10000000 would beat yours. Wrap instead: def _cap_rows(tree: exp.Expression, max_rows: int) -> str: inner = tree.sql(dialect=DIALECT) return f"SELECT * FROM ({inner}) AS _capped LIMIT {int(max_rows)}" The inner query can lower the cap, never raise it. Executing the regenerated SQL (tree.sql()), not the original string, also means you run what you validated. Step 6: a cost proxy that catches cartesian joins You cannot always trust the engine's EXPLAIN to be cheap, comparable or available. A deterministic proxy is surprisingly effective: count rows in each referenced table, add them up, and multiply whenever a join has no ON or USING. def table_refs(sql): tree = sqlglot.parse_one(sql, read=DIALECT) ctes = {c.alias.lower() for c in tree.find_all(exp.CTE)} out = [] for t in tree.find_all(exp.Table): if t.name.lower() in ctes: continue j = t.parent if isinstance(t.parent, exp.Join) else None out.append((t.name, bool(j and not j.args.get("on") and not j.args.get("using")))) return out total = 0.0 for table, cartesian in table_refs(sql): n = con.execute(f'SELECT count(*) FROM "{table}"').fetchone()[0] total = total * max(n, 1) if cartesian else total + n if max_cost is not None and total > max_cost: raise GuardError("cost_cap", f"estimated cost {total:.0f} exceeds cap {max_cost:.0f}") It is crude by design. It is not a query planner, it will over-estimate some queries and under-estimate others, but it turns "join everything to everything" into a refusal rather than an outage. Step 7: measure it, with execution accuracy A guard stops damage; it does not tell you whether the agent is right. For that, compare result sets, not SQL text. Two very different queries can both be correct: def result_sets_equal(a, b, ordered=False): norm = lambda rows: [tuple(round(v, 6) if isinstance(v, float) else v for v in r) for r in rows] na, nb = norm(a), norm(b) return na == nb if ordered else sorted(na, key=repr) == sorted(nb, key=repr) A golden case is just a question plus a reference query (and ordered: true when order matters): cases: - id: customer_count question: how many customers do we have golden_sql: "SELECT count(*) FROM customers" - id: top_products question: top 5 products by revenue golden_sql: "SELECT p.name, sum(o.amount) FROM orders o JOIN products p ON o.product_id=p.product_id GROUP BY p.name ORDER BY 2 DESC LIMIT 5" ordered: true Run the agent, run the golden SQL through the same guarded warehouse, compare. Guard rejections show up as failures with their code, so you can tell "model wrote DDL" from "model got the join wrong". Floats get rounded before comparison because otherwise you will chase 1e-12 ghosts. What a guard does not do Be honest about limits: it is no substitute for a read-only database role. Use both. It does not understand row-level security, it cannot tell that a legitimate-looking query reveals data the user should not see, and a cost proxy is not a planner. Treat the guard as one layer, and add a tiny test file of known-bad queries (; chains, CTE-wrapped inserts, read_csv) that you run on every sqlglot upgrade. The packaged version I built this into a Python kit, the Data Analyst Agent Kit: the guard above (with the full forbidden list), a DuckDB demo warehouse, Vega-Lite chart output, a deterministic mock LLM so tests run offline, and the execution-accuracy eval harness. It is one of the Agent Forge Pro kits ($147; the core runtime is $297, the SDR and support kits are $197 each, doc-review is $247, and there are $497 and $597 bundles). MIT licensed for your own use (no resale), with a 30-day money-back guarantee. There is also a free sample if you want to look before buying: free sample. Store: Agent Forge Pro If you only want the idea, everything above is enough to build your own in an afternoon. Pin sqlglot, write the bad-query tests first, and keep the read-only DB role.

  5. 너는 AI 생성 SQL에서 잘못된 테이블 참조와 DB 문법 차이 중 어디서 더 자주 막혀? 공식 저장소 tobymao/sqlglot 이런 AI 도구 계속 받아보고 싶으면 여기로 와👇 discord.gg

    6 views
  6. Mitigating Temporary Table Collisions and Namespace Pollution in Concurrent Batch Pipelines Mitigating Temporary Table Collisions and Namespace Pollution in Concurrent Batch Pipelines: A Post-Mortem and Architectural Analysis In large-scale data engineering and modern batch processing architectures, scaling throughput often requires dispatching concurrent worker processes against a shared relational database. However, naive implementations involving temporary tables frequently introduce subtle race conditions, silent data corruption, and cascading resource leaks. This article examines the root causes of namespace pollution caused by temporary tables in concurrent workloads, details a static analysis and dynamic rewriting approach implemented with Python and Pydantic v2, and provides an unvarnished post-mortem of an operational failure where a syntax-breaking artifact bypassed pre-flight validation. --- 1. Architectural Challenge: Concurrency Hazards of Unscoped Temporary Tables In high-throughput ETL architectures, batch workers frequently stage intermediate results using temporary or transient staging tables. When queries execute without deterministic worker isolation or session-unique prefixes (e.g., executing a raw CREATE TABLE temp_user ...), the database engine is exposed to three major failure modes: flowchart TD subgraph Workers ["Concurrent Batch Workers"] W1["Worker 1 (Session A)"] W2["Worker 2 (Session B)"] end subgraph Database ["Shared Database Instance"] T["Table: temp_user (Global Namespace)"] end W1 -- "1. CREATE TABLE temp_user" --> T W2 -- "2. CREATE or INSERT temp_user" --> T T -. "3. Race condition / Data overwrite" .-> W1 1.1 Global Namespace Collisions Unlike databases with strict process-isolated temporary scopes (such as PostgreSQL's transaction/session-scoped temporary tables or SQL Server's #temp tables), many production workflows either rely on shared intermediate physical tables or connect via pooling layers (e.g., PgBouncer in transaction pooling mode). In pooled or persistent schemas: - Sequential workers collide on identically named tables. - A trailing worker may overwrite or truncate data being actively processed by a leading worker, leading to silent, non-deterministic data corruption. 1.2 Lock Contention and Execution Spikes When multiple workers execute DDL or DML statements against identically named tables simultaneously: - Metadata lock (MDL) queues stall the connection pool. - Row- and page-level exclusive locks escalate to table-level locks, causing query latency spikes and cascading worker timeouts. 1.3 Cleanup Omission and Ephemeral Leakage In decoupled distributed pipelines, abnormal terminations, unhandled worker exceptions, or OOM events frequently bypass teardown hooks (such as DROP TABLE statements). Over time: - Orphaned physical tables exhaust database storage quotas. - Lingering definitions trigger TableAlreadyExists exceptions in subsequent pipeline schedules, halting downstream DAGs. --- 2. Engineering Solution: Static Query Inspection and Dynamic Rewriting To mitigate these risks prior to database submission, we engineered a pre-execution verification layer designed to statically parse query payloads, evaluate namespace isolation, and rewrite unsafe DDL statements dynamically. flowchart LR subgraph Ingestion ["Ingestion & Validation"] CFG["Raw BatchJobConfig"] -- "Pydantic v2 Parsing" --> VAL["TemporaryTableNamespaceValidator"] end subgraph Sanitization ["Static Inspection & Rewriting"] VAL -- "Regex Pattern Match" --> DETECT["Detect Unprefixed Tables"] DETECT -- "Dynamic Injection" --> REWRITE["Prepend Worker/Session ID"] end subgraph Execution ["Target Environment"] REWRITE -- "Isolated Queries" --> DB[("Database Engine")] end 2.1 Schema Definition via Pydantic v2 We enforced strict runtime typings on batch job configurations using Pydantic v2. This guarantees that all worker payloads contain verified metadata (worker IDs, session UUIDs, and SQL execution lists) before reaching the execution engine: from typing import List, Optional from uuid import UUID, uuid4 from pydantic import BaseModel, Field, field_validator class BatchJobConfig(BaseModel): worker_id: str = Field(..., description="Unique identifier for the worker process") session_id: UUID = Field(default_factory=uuid4, description="Session UUID for table scoping") queries: List[str] = Field(..., description="Ordered sequence of SQL queries to execute") @field_validator("queries") def validate_non_empty_queries(cls, queries: List[str]) -> List[str]: if not queries: raise ValueError("Query payload must contain at least one SQL statement.") return [q.strip() for q in queries if q.strip()] 2.2 Namespace Sanitizer Implementation The TemporaryTableNamespaceValidator intercepts CREATE TABLE and DROP TABLE statements, checks whether table names adhere to an isolated naming convention (tmp_<worker_id>_<session_id>_<table_name>), and rewrites them when a bare identifier is detected: import re from typing import Tuple, List # Optimized JSON serialization fallback pattern try: import orjson as json_lib except ImportError: import json as json_lib class TemporaryTableNamespaceValidator: # Captures target table identifiers in CREATE/DROP statements CREATE_PATTERN = re.compile( r"(?i)\bCREATE\s+(?:TEMPORARY\s+|TEMP\s+)?TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?([a-zA-Z_][a-zA-Z0-9_]*)", ) DROP_PATTERN = re.compile( r"(?i)\bDROP\s+TABLE\s+(?:IF\s+EXISTS\s+)?([a-zA-Z_][a-zA-Z0-9_]*)", ) def __init__(self, config: BatchJobConfig): self.config = config self.prefix = f"tmp_{self.config.worker_id}_{str(self.config.session_id).replace('-', '_')}_" def inspect_and_sanitize(self) -> Tuple[List[str], List[str]]: sanitized_queries: List[str] = [] registered_tables: List[str] = [] for query in self.config.queries: current_query = query # Inspect and rewrite CREATE statements create_match = self.CREATE_PATTERN.search(current_query) if create_match: table_name = create_match.group(1) if not table_name.startswith("tmp_"): isolated_name = f"{self.prefix}{table_name}" current_query = self.CREATE_PATTERN.sub( rf"CREATE TEMPORARY TABLE \1".replace(table_name, isolated_name), current_query, ) registered_tables.append(isolated_name) # Inspect and rewrite DROP statements drop_match = self.DROP_PATTERN.search(current_query) if drop_match: table_name = drop_match.group(1) if not table_name.startswith("tmp_"): isolated_name = f"{self.prefix}{table_name}" current_query = self.DROP_PATTERN.sub( rf"DROP TABLE \1".replace(table_name, isolated_name), current_query, ) sanitized_queries.append(current_query) return sanitized_queries, registered_tables def export_telemetry_report(self, registered_tables: List[str]) -> str: report = { "worker_id": self.config.worker_id, "session_id": str(self.config.session_id), "isolated_tables_count": len(registered_tables), "tracked_tables": registered_tables, } if hasattr(json_lib, "dumps"): # Supports both orjson and standard json modules data = json_lib.dumps(report) return data.decode("utf-8") if isinstance(data, bytes) else data return "{}" > 💡 For immediate deployment: The complete source code suite (ZIP) for this architecture is available on Gumroad for $0+ (Pay What You Want). --- 3. Post-Mortem: Anatomical Analysis of a Deployment Failure Despite passing architectural review and satisfying theoretical functional specifications, the initial implementation suffered a fatal failure during automated testing. The failure was not caused by an algorithmic flaw in the regex logic or a schema mismatch in Pydantic. Instead, it was caused by unfiltered conversational metadata leakage during code synthesis and an insufficient pre-flight verification gate. 3.1 The Incident: Unparseable Artifact When the validation harness executed against the target file, the CPython interpreter terminated immediately with a SyntaxError: File "..._test.py", line 1 ` タグ)を厳守して再提出いたします。 ^ SyntaxError: invalid character ')' (U+FF09) The artifact contained raw Japanese conversational framing text (...タグ)を厳守して再提出いたします。, meaning "Resubmitting while strictly complying with the ... tags") and unescaped markdown backticks injected directly into line 1 of the executable module. 3.2 Root-Cause Breakdown flowchart TD subgraph Synthesis ["Code Synthesis Layer"] A["LLM Output Generation"] -- "Inadvertent Markdown / Metadata" --> B["Polluted Code Stream"] end subgraph Review ["Review & Deployment Gap"] B -- "Passed Review Without Compilation" --> C["Target Environment (QA/CI)"] end subgraph Failure ["Execution Phase"] C -- "python3 execution" --> D["SyntaxError (Fatal Abort)"] end 1. Prompt Framing Demarcation Breakdown: During automated code synthesis, the generative model concatenated its conversational framing into the source stream rather than isolating it within a standardized block. The extraction tool failed to strip non-code prefix runes. 2. Omission of the Primitive Smoke Gate: The review process focused on high-level logical verification (checking regex boundary conditions, Pydantic field definitions, and fallback compatibility with orjson). However, basic zero-overhead verification—specifically executing python3 -m py_compile <file>—was skipped prior to staging. As a result, a syntactically invalid file was submitted directly to the testing harness. --- 4. Key Engineering Takeaways and Architecture Hardening This incident illustrates an essential systems engineering principle: No matter how sophisticated an architecture, data schema, or runtime fallback strategy is, a violation of the most primitive invariant—syntactic validity—reduces system utility to zero. To prevent recurrence in automated data processing systems and CI pipelines, the following guardrails must be established: 4.1 Enforcing Deterministic AST/Syntax Compilation in Pre-Commit Hooks Relying on manual code review or high-level test runners (which may fail to boot if imported modules are broken) is insufficient. Every pipeline step incorporating synthesized or dynamically assembled scripts must enforce compiler checks at the earliest boundary: # Explicit syntax verification step python3 -m py_compile path/to/script.py || exit 1 In pre-commit workflows or container build steps, this prevents unparseable source files from traversing further down the pipeline. 4.2 Moving Beyond Regex: Abstract Syntax Tree (AST) Parsing for SQL While regular expressions provide a lightweight first line of defense for detecting table creation statements, they are brittle when handling real-world SQL semantics: - Table declarations inside nested Common Table Expressions (CTEs) or subqueries. - Vendor-specific syntax variations (CREATE OR REPLACE TEMP TABLE, quoted identifiers with special characters, dialects like BigQuery/Snowflake vs. PostgreSQL). - Procedural SQL blocks with dynamic table name evaluation. For mission-critical production pipelines, this architecture should be upgraded from regex matching to an AST-driven parser (such as sqlglot): import sqlglot from sqlglot import exp def rewrite_ast_isolated_tables(sql_str: str, prefix: str, dialect: str = "postgres") -> str: expression = sqlglot.parse_one(sql_str, read=dialect) for create_expr in expression.find_all(exp.Create): table_ident = create_expr.find(exp.Table) if table_ident and not table_ident.name.startswith("tmp_"): table_ident.set("this", exp.to_identifier(f"{prefix}{table_ident.name}")) return expression.sql(dialect=dialect) AST traversal guarantees deterministic schema rewrites without risk of invalid string substitutions or regex boundary escapes. --- 5. Summary Operating high-concurrency batch pipelines against shared databases requires strict namespace isolation to prevent race conditions and cascading deadlocks. Implementing deterministic table prefixes, runtime schema validation via Pydantic, and resilient serialization fallbacks establishes a robust baseline for multi-tenant batch workers. Equally critical, however, is the operational lesson from our post-mortem: Verification must always validate the most basic assumptions before verifying complex logic. Incorporating mandatory compilation gates (py_compile) and evolving regex-based parsing toward full AST manipulation ensures both operational resilience and syntactic integrity in production workloads. --- If this engineering log saved your production server (and your sanity), consider supporting our architecture on GitHub Sponsors. ![Sponsor on GitHub](sponsors/PhenoX-AI-Alliance)

Achievements

Awards earned by the repositories tobymao owns. Select an award to embed its badge.

No awards yet

Repositories earn awards by reaching the top three in a completed trending day or week.

A repository earns an award for finishing first, second or third in a completed trending day or week, ranked by new stars, new forks or pull request authors. Awards follow a repository through renames and transfers and count toward its current owner. Weeks run Monday to Sunday, UTC.