From e1a87a08cbbb3c0fe71ae82cc6c9d01ae1ce1f70 Mon Sep 17 00:00:00 2001 From: itsmura-h Date: Sun, 29 Mar 2026 14:37:32 +0000 Subject: [PATCH 1/5] rule --- .../rules/branch/333-prepared-statement.mdc | 719 ++++++++++++++++++ 1 file changed, 719 insertions(+) create mode 100644 .cursor/rules/branch/333-prepared-statement.mdc diff --git a/.cursor/rules/branch/333-prepared-statement.mdc b/.cursor/rules/branch/333-prepared-statement.mdc new file mode 100644 index 00000000..65f72f13 --- /dev/null +++ b/.cursor/rules/branch/333-prepared-statement.mdc @@ -0,0 +1,719 @@ +--- +description: 333-prepared-statementブランチでの開発時に読み込む +alwaysApply: false +--- +333-prepared-statement ブランチでの開発内容 +=== + +このブランチで実装することは以下の通りです。 +- コネクションプールに対応した prepared statement API を SQLite・PostgreSQL・MariaDB に追加する + +## 進捗 + +- [x] SQLite の prepared statement 実装 + - [x] `sqlite_impl.nim` に再利用可能な実行関数を追加(reset/rebind パス) + - [x] `sqlite_types.nim` に `SqlitePreparedStatement` 型を追加 + - [x] `sqlite_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加(`getPlain` / `firstPlain` も追加) +- [x] PostgreSQL の prepared statement 実装 + - [x] `postgres_impl.nim` に `deallocate` 関数を追加(`prepare` は `nParams` を明示、`preparedQueryOpts` / `preparedExecOpts` で `Option[string]` 引数対応) + - [x] `postgres_types.nim` に `PostgresPreparedStatement` 型を追加 + - [x] `postgres_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加(Plain 系も) +- [x] MariaDB の prepared statement 実装 + - [x] `mariadb_rdb.nim` の MYSQL_STMT 関連 FFI を有効化 + - [x] `mariadb_impl.nim` に stmt 操作関数を追加 + - [x] `mariadb_types.nim` に `MariadbPreparedStatement` 型を追加(`PSTMT` を export) + - [x] `mariadb_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加 +- [x] `query_builder.nim` — 既存の `export *_types` / `export *_exec` で各 `*PreparedStatement` と `prepare` がそのまま利用者に公開されるため変更なし +- [x] テスト追加: `tests/sqlite/test_prepared_statement.nim`, `tests/postgres/test_prepared_statement.nim`, `tests/mariadb/test_prepared_statement.nim` + +## 参考資料 + +- SQLite prepared statement API: https://www.sqlite.org/c3ref/prepare.html + - `sqlite3_prepare_v2` / `sqlite3_reset` / `sqlite3_bind_*` / `sqlite3_step` / `sqlite3_finalize` +- PostgreSQL libpq prepared statements: https://www.postgresql.jp/document/12/html/libpq-exec.html + - `PQsendPrepare` / `PQsendQueryPrepared` / `PQexec("DEALLOCATE ...")` +- MariaDB Connector/C STMT API: https://mariadb.com/kb/en/mysql_stmt_prepare/ + - `mysql_stmt_init` / `mysql_stmt_prepare` / `mysql_stmt_bind_param` / `mysql_stmt_execute` / `mysql_stmt_close` + +## 調査結果・設計まとめ + +--- + +### 0. SQL 組み立てと値バインドの仕組み(現行実装) + +prepared statement を設計するために、まず既存の「SQL 組み立て → 値バインド → 実行」のフローを正確に理解する。 + +#### 0-1. SQL 組み立ての責務分離 + +``` +*_query.nim ← ユーザー向け API(select/table/where/...) + │ where() 等が self.query (JObject) と self.placeHolder (JArray) に情報を書き込む + ▼ +*_generator.nim ← SQL フラグメントを生成するプロシージャ群(メソッドチェーン) + │ self.queryString を順番に組み立て、値位置には必ず "?" を埋める + ▼ +*_builder.nim ← generator を組み合わせて完全な SQL 文字列を返す + │ selectBuilder() / insertValueBuilder() / updateBuilder() / ... + ▼ +*_exec.nim ← SQL 文字列と placeHolder を実際のコネクションに渡す +``` + +#### 0-2. placeHolder の構造 + +`SqliteQuery.placeHolder` / `PostgresQuery.placeHolder` は `JsonNode`(JArray)。 +**builder 用(where 値・insert 値・update 値)**はオブジェクト形式: + +```json +[ + {"key": "id", "value": 1}, + {"key": "name", "value": "alice"}, + {"key": "flag", "value": true}, + {"key": "rate", "value": 3.14}, + {"key": "memo", "value": null} +] +``` + +**raw クエリ用**はフラット配列形式: + +```json +[1, "alice", true, 3.14, null] +``` + +#### 0-3. generator が placeHolder を書き込むタイミング + +`*_generator.nim` の各プロシージャが `self.query` を読みながら `self.queryString` を組み立てる際、**値が入る位置には `"?"` を書き込み、同時に `self.placeHolder` に `{"key": col, "value": val}` を append する**。 + +例)`insertValueSql`: + +```nim +proc insertValueSql*(self: SqliteQuery, items: JsonNode): SqliteQuery = + for key, val in items.pairs: + ... + self.placeHolder.add(%*{"key": key, "value": val}) # ← 値を記録 + values.add("?") # ← SQL には ? を入れる +``` + +例)`updateValuesSql`(UPDATE の場合は SET 値を先に、WHERE 値を後ろに並べる): + +```nim +proc updateValuesSql*(self: SqliteQuery, items: JsonNode): SqliteQuery = + let placeHolder = newJArray() + for key, val in items.pairs: + placeHolder.add(%*{"key": key, "value": val}) # SET 値を先に + for row in self.placeHolder.items: + placeHolder.add(row) # WHERE 値を後ろに + self.placeHolder = placeHolder # 置き換え +``` + +`whereSql` では `self.placeHolder` への追記は `*_query.nim` 側(`where()` 呼び出し時点)で行われ、`whereSql` は `"?"` を SQL に書くだけ。 + +#### 0-4. PostgreSQL の `?` → `$n` 変換 + +generator は SQLite / PostgreSQL とも `?` で SQL を生成する。 +**PostgreSQL の `exec.nim` 公開 API 層で `questionToDaller()` を呼んで `$1, $2...` に変換してから `postgres_impl` に渡す**。 + +```nim +# postgres_exec.nim (公開 API) +proc get*(self: PostgresQuery): Future[seq[JsonNode]] {.async.} = + var sql = self.selectBuilder() # "SELECT ... WHERE "id" = ?" + sql = questionToDaller(sql) # "SELECT ... WHERE "id" = $1" + return self.getAllRows(sql).await +``` + +`questionToDaller` の動作: + +``` +入力: UPDATE "users" SET "name" = ?, "email" = ? WHERE "id" = ? +出力: UPDATE "users" SET "name" = $1, "email" = $2 WHERE "id" = $3 +``` + +raw クエリ(`RawPostgresQuery`)も同様に、private `getAllRows` / `exec` 内で `questionToDaller` を呼ぶ。 + +#### 0-5. 値のバインド方法(ドライバ別) + +**SQLite**(`sqlite_impl.exec`): + +``` +placeHolder (JArray of {"key","value"}) を受け取る + ↓ +sqlite3_prepare_v2() で PStmt を生成 + ↓ +arg["value"].kind で分岐: + JBool → bind_int64(stmt, i, 0 or 1) + JInt → bind_int64(stmt, i, val) + JFloat → bind_double(stmt, i, val) + JNull → bind_null(stmt, i) + JObject/JArray → bind_text(stmt, i, val.pretty) + JString → カラム型が BLOB なら bind_blob、それ以外は bind_text + ※ BLOB 判定は columnTypeCache (PRAGMA table_info) を使う + ↓ +step(stmt) → finalize(stmt) +``` + +`query`(SELECT系)では string に変換してから `setupQuery` でエスケープ埋め込み: + +``` +placeHolder を seq[string] に変換(JBool→"1"/"0", JInt→$int, ...) + ↓ +setupQuery(db, sql, strArgs) + └─ dbFormat(sql, strArgs) で ? をエスケープ済み文字列に置換して PStmt を生成 + ↓ +step/finalize +``` + +**PostgreSQL**(`postgres_impl`): + +``` +placeHolder (JArray) を PGParams に変換 (postgres_lib.nim) + PGParams.fromObjArray(args, columns): + JBool → "t" / "f" (format=0, length=0) + JInt → $int (format=0, length=0) + JFloat → $float (format=0, length=0) + JNull → nil (values[i] = nil → PQsendQueryParams が NULL 扱い) + JString → val (bytea カラムなら format=1 バイナリ) + allocCStringArray(values) → cstringArray + ↓ +PQsendQueryParams(db, sql_with_$n, nParams, nil, values, lengths, formats, 0) +``` + +**MariaDB**(`mariadb_impl`): + +``` +placeHolder (JArray of {"key","value"}) を seq[string] に変換 + JBool → "1" / "0" + JInt → $int + JFloat → $float + JNull → "null" + JString → val + ↓ +dbFormat(conn, sql, MariadbParams) でエスケープ後に real_query_start → real_query_cont +``` + +MariaDB は現在 MYSQL_STMT を使わず、エスケープ済み文字列を `real_query` に投げる方式。 + +#### 0-6. prepared statement との対応関係 + +準備した SQL は上記フローの **「SQLを生成する部分」と「値をバインドする部分」を分離したもの**: + +| 通常クエリ | prepared statement | +|---|---| +| 実行のたびに SQL 文字列を組み立てる | SQL は prepare 時に 1 回だけコンパイル | +| 値は SQL 文字列に埋め込む(エスケープ)or bind API | 値は毎回 bind API で渡す(SQL 再コンパイルなし) | +| `PStmt` の生成と破棄が毎回発生(SQLite) | `PStmt` はコネクションにキャッシュされ `reset` で再利用 | + +prepared statement 実行時のバインド方法は **ドライバの bind API をそのまま使う**。SQLite なら `bind_int64` / `bind_text` 等、PostgreSQL なら `pqsendQueryPrepared` にパラメータ配列を渡す。値の型変換ロジック(JBool→"t"/"f" 等)は既存コードを共通関数として切り出して再利用する。 + +--- + +### 0-7. Prepared Statement API のデザインと SQL 組み立て・値バインドの方針 + +#### 基本方針:ユーザーは SQL を直接書く + +通常の query builder はメソッドチェーンで SQL を組み立てるが、prepared statement はそれと**別の API 系統**として提供する。ユーザーは生 SQL を書き、実行時に引数を渡す。 + +```nim +# 通常クエリ(毎回 SQL を組み立てる) +let rows = await rdb.table("World").where("index", "=", index).get() + +# prepared statement(SQL は一度だけコンパイル、引数だけ変える) +let stmt = rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") +let rows = await stmt.first($index) +await stmt.close() +``` + +builder API(`.table().where().get()`)との統合は行わない。理由: +- builder は SQL 生成と値バインドを 1 回の処理に結びつけている +- prepare 時点で「クエリの形」、execute 時点で「値」を分ける設計と相性が悪い +- 生 SQL の方がユーザーの意図が明確で、prepare/execute の境界がわかりやすい + +#### API の全体像 + +```nim +# ── prepare ────────────────────────────────────────── +# SQL に ? プレースホルダを使う(全 DB 共通) +let stmt: SqlitePreparedStatement = rdb.prepare( + "SELECT \"index\", \"randomNumber\" FROM \"World\" WHERE \"index\" = ?" +) + +# ── execute(SELECT 系) ────────────────────────────── +# 引数は seq[string]。? の順番通りに渡す +let rows: seq[JsonNode] = await stmt.get(@[$index]) +let row: Option[JsonNode] = await stmt.first(@[$index]) +let plain: seq[seq[string]] = await stmt.getPlain(@[$index]) +let row1: seq[string] = await stmt.firstPlain(@[$index]) + +# ── execute(INSERT / UPDATE / DELETE 系) ──────────── +await stmt.exec(@[$number, $index]) + +# ── lifecycle ───────────────────────────────────────── +stmt.close() # 全コネクション分のステートメントを解放 +``` + +**benchmark.nim での使用イメージ**: + +```nim +# prepare は一度だけ(プールの外で) +let stmtSelect = rdb.prepare( + "SELECT \"index\" as id, \"randomNumber\" FROM \"World\" WHERE \"index\" = ?" +) +let stmtUpdate = rdb.prepare( + "UPDATE \"World\" SET \"randomNumber\" = ? WHERE \"index\" = ?" +) +defer: stmtSelect.close(); stmtUpdate.close() + +# ループ内では execute だけ(SQL の再解析なし) +for i in 1..countNum: + let index = rand(range1_10000) + let number = rand(range1_10000) + let row = await stmtSelect.first(@[$index]) + await stmtUpdate.exec(@[$number, $index]) +``` + +#### SQL の書き方ルール + +プレースホルダは **`?` を統一記法とする**。DB の内部仕様(PostgreSQL は `$n`)への変換は実装側が行う。 + +```nim +# ユーザーは常に ? で書く +rdb.prepare("SELECT * FROM \"users\" WHERE \"id\" = ?") +rdb.prepare("INSERT INTO \"users\" (\"name\", \"email\") VALUES (?, ?)") +rdb.prepare("UPDATE \"users\" SET \"name\" = ?, \"email\" = ? WHERE \"id\" = ?") +``` + +テーブル名・カラム名はユーザーが適切にクォートする(`"tableName"`)。builder のような自動クォートは行わない。 + +#### prepare 時の SQL 処理(ドライバ別) + +**SQLite**: + +``` +rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") + ↓ +コネクション初回使用時(lazy)に: + sqlite3_prepare_v2(conn, sql_with_?, len, ppStmt, nil) + → PStmt をコネクション i のスロット stmts[i] に保存 + ※ SQL を DB エンジンがコンパイル・最適化プランを生成して保持 +``` + +**PostgreSQL**: + +``` +rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") + ↓ +コネクション初回使用時(lazy)に: + questionToDaller("...WHERE \"index\" = ?") + → "...WHERE \"index\" = $1" # 変換 + nArgs = count of '?' in original sql + pqsendPrepare(conn, stmtName, sql_with_$n, nArgs, nil) + → 変換後の SQL をサーバーにコンパイルさせる + → stmtName をコネクション i のスロット stmtNames[i] に保存 +``` + +**MariaDB**: + +``` +rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") + ↓ +コネクション初回使用時(lazy)に: + mysql_stmt_prepare(stmt, sql_with_?, len) + → MYSQL_STMT をコネクション i のスロット stmts[i] に保存 +``` + +**重要**:prepare 時点では値は一切関与しない。SQL の構造だけを DB エンジンに渡す。 + +#### execute 時の値バインド(ドライバ別) + +引数は `seq[string]` で受け取る。`?` の出現順と対応する。 + +**SQLite**(`reset` + rebind パターン): + +``` +stmt.get(@["42"]) + ↓ +getFreeConn() → connI + ↓ +PStmt が stmts[connI] に存在しなければ prepare(lazy) + ↓ +reset(stmts[connI]) # 前回の実行結果・バインドをクリア +clear_bindings(stmts[connI]) # 全 bind スロットを NULL に戻す + ↓ +bind_text(stmts[connI], 1, "42") # 1-indexed で位置バインド + ↓ +step ループで行を取得 + └─ step() == SQLITE_ROW の間、column_text/column_int 等で値を読む + ↓ +finalize は呼ばない(再利用するため) + ↓ +returnConn(connI) +``` + +SQLite は文字列として `bind_text` に渡しても DB 側が列の型アフィニティに従って自動変換する("42" を INTEGER カラムに入れると整数として扱う)。`bind_text` 統一で十分。 + +**PostgreSQL**(`pqsendQueryPrepared` パターン): + +``` +stmt.get(@["42"]) + ↓ +getFreeConn() → connI + ↓ +stmtName が stmtNames[connI] に存在しなければ prepare(lazy) + ↓ +pgEnsureIdle(conn) # 前の処理が完了していることを確認 + ↓ +let arr = allocCStringArray(@["42"]) +pqsendQueryPrepared( + conn, + stmtName, # prepare 時に登録した名前 + nArgs, # プレースホルダ数 + arr, # 値の cstringArray(NULL は nil ポインタ) + nil, # lengths(text モードでは nil) + nil, # formats(0 = text) + 0 # result format (text) +) +deallocCStringArray(arr) + ↓ +pgFlushOutgoing → pgNextResult でレスポンスを受け取る + ↓ +returnConn(connI) +``` + +NULL を渡したい場合は `arr[i] = nil`(cstring nil が NULL 扱い)。 + +**MariaDB**(`mysql_stmt_execute` パターン): + +``` +stmt.exec(@["100", "42"]) + ↓ +getFreeConn() → connI + ↓ +MYSQL_STMT が stmts[connI] に存在しなければ prepare(lazy) + ↓ +MYSQL_BIND 配列を構築: + 各 args[i] を MYSQL_BIND に設定 + buffer_type = MYSQL_TYPE_STRING + buffer = args[i].cstring + length = args[i].len + ↓ +mysql_stmt_bind_param(stmt, binds) + ↓ +mysql_stmt_execute_start → mysql_stmt_execute_cont (非同期) + ↓ +SELECT 系は mysql_stmt_store_result → mysql_stmt_fetch で行取得 + ↓ +returnConn(connI) +``` + +#### NULL の扱い + +`seq[string]` に `"NULL"` という文字列を入れても DB には文字列 "NULL" として渡ってしまう。NULL を表現するには **特別な sentinel 値**か **`Option[string]`** が必要。 + +設計上の選択肢: +1. `seq[Option[string]]` を使う(`none(string)` が NULL) +2. `""` を NULL として扱う(空文字との区別ができない) +3. `"NULL"` という文字列を特別扱いする("NULL" という文字列を入れたいときに困る) + +**変更(実装)**:`get` / `first` / `exec` 等は **`macro` + `varargs[untyped]`** で展開し、呼び出し側は **`echo` と同様に任意個・任意型**を渡せる(各引数は **`toPreparedParam`** → 通常は **`$` で文字列化**)。Nim の **`echo` 専用の `varargs[typed, `$`]` はユーザー定義 proc では使えない**ため、この形が実質的な代替となる。SQL NULL は **`sqlNull()` などの専用 API は出さず**、次を **SQL NULL** として解釈する。 + +- `Option[T]` の **`none(...)`**(例: `none(string)`) +- リテラル **`nil`**(`typeof(nil)` 用オーバーロード) +- **`cstring(nil)`** +- **`JsonNode`** が `nil`、または JSON の **`null`**(`JNull`) + +```nim +await stmt.exec("alice", "alice@example.com", none(string)) +await stmt.first(42) +``` + +#### 型情報と bind_text 統一の理由 + +Prepared statement の引数はすべて `string` として受け渡す(`bind_text` / text format に統一)。通常クエリの `placeHolder` が持っていた `{"key": col, "value": JsonNode}` のような型情報は **prepared statement では持たない**。 + +理由: +- **SQLite** は型アフィニティにより文字列からの自動変換を行うため実用上問題ない +- **PostgreSQL** は `PQsendQueryPrepared` に渡す format=0(text)で正常動作する。パラメータの型は prepare 済みのプランが知っているため、text で渡してもサーバー側でキャストされる +- **MariaDB** も `MYSQL_TYPE_STRING` で渡せば適切に変換される +- BLOB 対応は将来課題とする(`bind_blob` が必要なケースは `exec` のオーバーロードで対応) + +通常クエリの `exec`(INSERT/UPDATE)が `columnTypeCache` + `PRAGMA table_info` を使って BLOB カラムを判定しているのと対照的に、prepared statement では BLOB サポートを初期スコープから外す。 + +--- + +### 1. 現在のコネクションプール仕様 + +#### 共通構造(SQLite / PostgreSQL / MariaDB) + +``` +SqliteConnections / PostgresConnections / MariadbConnections + └─ pools: Connections + ├─ conns: seq[Connection] # プールサイズ = maxConnections + ├─ timeout: int # 秒単位のタイムアウト + ├─ waiters: Deque[Future[void]] # 空き待ちキュー(async/await 対応) + └─ columnTypeCache: Table[string, ...] # テーブルごとの型情報キャッシュ + +Connection + ├─ conn: PSqlite3 | PPGconn | PMySQL # ネイティブ接続ハンドル + ├─ isBusy: bool # 使用中フラグ + └─ createdAt: int64 +``` + +- MySQL(レガシー)は `waiters` を持たず、非同期待機に非対応。MariaDB は対応済み。 + +#### コネクション取得・返却フロー + +1. `getFreeConn(self)` → `isBusy == false` の連番インデックス `connI` を返す +2. 全コネクションがビジーなら `Future[void]` を `waiters` に積んで非同期待機 +3. 実処理: `pools.conns[connI].conn` に対して `*_impl` の関数を直接呼ぶ +4. `returnConn(self, connI)` → `isBusy = false` にして `waiters` の先頭を 1 件 complete + +#### クエリ実行の全体像 + +``` +rdb.table("users").where("id","=",1).first() + │ + ├─ selectBuilder() で SQL 文字列と placeHolder (JArray) を生成 + ├─ getFreeConn() → connI + ├─ *_impl.query(pools.conns[connI].conn, sql, args, timeout) + └─ returnConn(connI) +``` + +--- + +### 2. Prepared Statement に固有の制約 + +#### ステートメントはコネクションに紐づく + +| DB | ハンドル型 | スコープ | +|---|---|---| +| SQLite | `PStmt` (`ptr TStmt`) | 特定の `PSqlite3` コネクションに紐づく | +| PostgreSQL | named statement (文字列名) | 特定の `PPGconn` セッションに紐づく | +| MariaDB | `MYSQL_STMT*` | 特定の `PMySQL` コネクションに紐づく | + +プールに N 本のコネクションがある場合、同一の SQL であっても **各コネクションに個別に prepare** する必要がある。 + +#### 現状の `*_impl.nim` に存在するプリミティブ + +| DB | prepare 関数 | execute 関数 | 再利用可否 | +|---|---|---|---| +| SQLite | `prepare(db, sql)` → `PStmt` | `preparedQuery` / `preparedExec` | **不可**(finalize で破棄) | +| PostgreSQL | `prepare(db, sql, stmtName)` → `int` (nArgs) | `preparedQuery` / `preparedExec` | **可**(DEALLOCATE まで存続) | +| MariaDB | なし | なし | — | + +**SQLite の既存 `preparedQuery` / `preparedExec` の問題点**: +- `preparedQuery` 内の `instantRows(db, dbRows, sqliteStmt)` イテレータが `finalize` を呼ぶ +- `preparedExec` も末尾で `finalize(sqliteStmt)` を呼ぶ +- これらは「一度使ったら破棄」の実装であり、ステートメントを再利用できない +- **再利用のためには `finalize` を呼ばず `reset` → rebind → `step` に変更する必要がある** + +--- + +### 3. 設計方針: Lazy Per-Connection Preparation + +#### 採用するアプローチ + +`PreparedStatement` ハンドルを返す設計とし、ステートメントは **コネクション初回使用時に遅延 prepare** する。 + +``` +let stmt = rdb.prepare("SELECT * FROM users WHERE id = ?") +# ↑ この時点では何も prepare しない。ハンドルのみ生成。 + +let row = await stmt.first("1") +# ↑ getFreeConn() で connI を取得し、stmts[connI] が nil なら prepare を実行。 +# 以降同じ connI が選ばれた場合はステートメントを reset + rebind して再利用。 + +stmt.close() +# ↑ 全 connI の PStmt を finalize / DEALLOCATE +``` + +#### 採用理由 + +- コネクション数が多くても使われないコネクションには prepare コストが発生しない +- 既存の `getFreeConn` / `returnConn` ロジックをそのまま流用できる +- API を単純にできる(ユーザーは SQL 文字列と引数だけ意識すれば良い) + +#### 不採用アプローチ + +- **Eager whole-pool prepare**: dbOpen 後に全コネクションをブロックして prepare するのはコストが高い +- **Single-connection pinning**: 並列度が maxConnections から 1 に落ちる + +--- + +### 4. 型定義の設計 + +#### SQLite + +```nim +# sqlite_types.nim に追加 +type SqlitePreparedStatement* = ref object + log*: LogSetting + pools*: Connections + sql*: string + stmts*: seq[PStmt] ## pools.conns.len と同サイズ。nil = 未 prepare + isInTransaction*: bool + transactionConn*: int +``` + +#### PostgreSQL + +PostgreSQL の named prepared statement は文字列名で管理する。 +名前は `allographer_<ハッシュ>_` のような形式にしてコリジョンを避ける。 + +```nim +# postgres_types.nim に追加 +type PostgresPreparedStatement* = ref object + log*: LogSetting + pools*: Connections + sql*: string + stmtBaseName*: string ## ユニークな基底名(prepare 時に生成) + stmtNames*: seq[string] ## pools.conns.len と同サイズ。"" = 未 prepare + nArgs*: int ## プレースホルダ($1, $2, ...)の数 + isInTransaction*: bool + transactionConn*: int +``` + +#### MariaDB + +```nim +# mariadb_types.nim に追加 +type MYSQL_STMT* = pointer # mariadb_rdb.nim で FFI 定義する + +type MariadbPreparedStatement* = ref object + log*: LogSetting + pools*: Connections + info*: ConnectionInfo + sql*: string + stmts*: seq[MYSQL_STMT] ## pools.conns.len と同サイズ。nil = 未 prepare + isInTransaction*: bool + transactionConn*: int +``` + +--- + +### 5. `*_impl.nim` への追加・変更 + +#### `sqlite_impl.nim` + +```nim +# 再利用可能な実行(finalize しない) +proc preparedQueryReuse*(db: PSqlite3, stmt: PStmt, args: seq[string], + timeout: int): Future[(seq[Row], DbRows)] {.async.} + # reset(stmt) → bind_* → step loop → clear_bindings(stmt) + # finalize は呼ばない + +proc preparedExecReuse*(db: PSqlite3, stmt: PStmt, args: seq[string], + timeout: int) {.async.} + # reset(stmt) → bind_* → step → clear_bindings(stmt) + # finalize は呼ばない + +proc finalizeStmt*(stmt: PStmt) + # sqlite3_finalize のラッパー +``` + +既存の `preparedQuery` / `preparedExec` は後方互換のため残す(ただし benchmark.nim 等で未使用であれば整理可)。 + +#### `postgres_impl.nim` + +```nim +# DEALLOCATE のラッパー(close 時に呼ぶ) +proc deallocate*(db: PPGconn, stmtName: string, timeout: int) {.async.} +``` + +既存の `prepare` / `preparedQuery` / `preparedExec` はそのまま再利用可。 + +#### `mariadb_impl.nim` + +MariaDB C client の `MYSQL_STMT` を使う非同期ラッパーを追加。 +MariaDB の非ブロッキング API には `mysql_stmt_prepare_start` / `mysql_stmt_prepare_cont` 等が存在する。 + +```nim +proc stmtPrepare*(conn: PMySQL, sql: string, deadline: MonoTime): Future[MYSQL_STMT] {.async.} +proc stmtExec*(conn: PMySQL, stmt: MYSQL_STMT, args: seq[string], deadline: MonoTime) {.async.} +proc stmtQuery*(conn: PMySQL, stmt: MYSQL_STMT, args: seq[string], + deadline: MonoTime): Future[(seq[Row], DbRows)] {.async.} +proc stmtClose*(stmt: MYSQL_STMT) {.async.} +``` + +--- + +### 6. 公開 API 設計 + +```nim +# ─── prepare ─── +# rdb が SqliteConnections / PostgresConnections / MariadbConnections のどれでも同じ形 +proc prepare*(self: SqliteConnections, sql: string): SqlitePreparedStatement +proc prepare*(self: PostgresConnections, sql: string): PostgresPreparedStatement +proc prepare*(self: MariadbConnections, sql: string): MariadbPreparedStatement + +# ─── execute ─── +# `macro` + `varargs[untyped]` — 展開時に `@[toPreparedParam(a), ...]` を組み立てる(`prepared_param.buildPreparedParamSeqExpr`) +# 呼び出しは `stmt.get(1, "x")` のように echo 風。SQL NULL は `none(...)` / `nil` / `cstring(nil)` / `JsonNode(nil|JNull)` など(`sqlNull()` は公開しない) +macro get*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped +macro first*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped +macro exec*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped +# 展開結果の型は従来どおり Future[...](内部で runPrepared* に seq[PreparedParam] を渡す) + +# ─── lifecycle ─── +proc close*(self: SqlitePreparedStatement): Future[void] {.async.} + # 全コネクション分の PStmt を finalize +``` + +PostgreSQL・MariaDB も同じマクロ形を持つ(型名だけ異なる)。`getPlain` / `firstPlain` あり。束縛値の意味論は `PreparedParam` / `toPreparedParam`(`sqlNull` は無し)。 + +#### プレースホルダ規約 + +| DB | ユーザー記法(prepare API) | +|---|---| +| SQLite | `?` | +| PostgreSQL | `?`(実装が `questionToDaller` で `$1`, `$2`, ... に変換) | +| MariaDB | `?` | + +公開 prepared API では **クエリは共通で `?`** とし、PostgreSQL のみ内部で `$n` に変換する(通常の query builder と同様)。 + +--- + +### 7. 実装ファイル変更一覧 + +| ファイル | 変更内容 | +|---|---| +| `libs/sqlite/sqlite_impl.nim` | `preparedQueryReuse` / `preparedExecReuse` / `finalizeStmt` 追加 | +| `libs/postgres/postgres_impl.nim` | `deallocate` 追加 | +| `libs/mariadb/mariadb_rdb.nim` | `MYSQL_STMT` 型・FFI 追加(要確認) | +| `libs/mariadb/mariadb_impl.nim` | `stmtPrepare` / `stmtExec` / `stmtQuery` / `stmtClose` 追加 | +| `models/sqlite/sqlite_types.nim` | `SqlitePreparedStatement` 追加 | +| `models/postgres/postgres_types.nim` | `PostgresPreparedStatement` 追加 | +| `models/mariadb/mariadb_types.nim` | `MariadbPreparedStatement` / `MYSQL_STMT` 追加 | +| `models/sqlite/sqlite_exec.nim` | `prepare` / `get` / `first` / `exec` / `close` 追加 | +| `models/postgres/postgres_exec.nim` | 同上 | +| `models/mariadb/mariadb_exec.nim` | 同上 | +| `query_builder.nim` | 各 `*PreparedStatement` 型・API をエクスポート | + +--- + +### 8. 未解決・要検討事項 + +1. **MariaDB の非ブロッキング STMT API の有無確認** + - `mariadb_rdb.nim` は `real_query_start` / `real_query_cont` を持つが、`mysql_stmt_prepare_start` / `mysql_stmt_prepare_cont` が利用できるか未確認 + - できない場合は同期的な STMT API か、クエリエスケープによる従来実装にフォールバック + +2. **PostgreSQL の `stmtBaseName` 生成方法** + - SQL 文字列のハッシュを使うか、単調増加カウンタを使うか + - コネクションを再接続した場合の旧ステートメント名との衝突回避 + +3. **MySQL(レガシー)への対応** + - MySQL ドライバは非同期未対応のため、当ブランチのスコープ外とする可能性が高い + +4. **トランザクション中の prepared statement** + - `isInTransaction = true` の場合は `transactionConn` の固定コネクションを使う(既存クエリと同じ) + - `stmts[transactionConn]` が nil なら prepare → execute の流れは通常時と同じ + +5. **コネクション再接続時のステートメント無効化** + - 現状、コネクション断後の再接続処理は各ドライバに存在しない + - `PStmt` がコネクション切断後に無効になった場合のエラーハンドリングは後続タスク + +--- + +### 実装後メモ(333 ブランチ) + +- **PostgreSQL `prepare`**: `nParams` は `postgres_lib.countQuestionMarks(sql)`(`?` の個数)で固定。旧 `query.count('$')` は `$10` 等で誤るため廃止。 +- **引数型**: 公開 API は **`macro` + `varargs[untyped]`**(echo 風の呼び出し)。展開先は `seq[PreparedParam]` + `toPreparedParam`(`$` 変換)。SQL NULL は `none` / `nil` / `cstring(nil)` / `JsonNode` の nil・JNull を `toPreparedParam` が解釈。async と varargs の相性のため、実処理は `runPrepared*` に閉じ、マクロはそれへの呼び出しに展開する。 +- **MariaDB `mysql_stmt_fetch`**: 行終端で `101` を返す前提でループ終了(コネクタの定義に依存するため、挙動が異なる環境では要確認)。 +- **`sqlite_lib`**: `preparedQueryReuse` 用に `setColumnsStaticMeta` / `setColumnsRuntimeTypes` を export。 From bbb737641553f8664963cf9ae20f608aa476b8e2 Mon Sep 17 00:00:00 2001 From: itsmura-h Date: Mon, 30 Mar 2026 06:03:28 +0000 Subject: [PATCH 2/5] Add prepared statement support and tests - Add `prepared_param` abstraction and integrate prepared-parameter handling across MySQL, MariaDB, PostgreSQL and SQLite. - Implement driver-specific prepared-statement logic, unify lifecycle ownership and reduce allocation overhead for bindings. - Cache SQLite column metadata for prepared statements and provide public API overloads (seq[string] / JsonNode); use macro+varargs entrypoints for other drivers with conversion via `toPreparedParam`. - Add cross-driver tests: `tests/*/test_prepared_statement.nim`. - Update branch rule document for prepared-statement work. --- .../rules/branch/333-prepared-statement.mdc | 247 +++++++++++++++++- .../libs/mariadb/mariadb_impl.nim | 239 +++++++++++++++++ .../libs/mariadb/mariadb_rdb.nim | 92 ++++--- .../query_builder/libs/mysql/mysql_impl.nim | 189 ++++++++++++++ .../query_builder/libs/mysql/mysql_rdb.nim | 56 ++-- .../libs/postgres/postgres_impl.nim | 62 ++++- .../query_builder/libs/sqlite/sqlite_impl.nim | 64 +++++ .../query_builder/libs/sqlite/sqlite_lib.nim | 4 +- .../models/mariadb/mariadb_exec.nim | 195 +++++++++++++- .../models/mariadb/mariadb_types.nim | 19 ++ .../query_builder/models/mysql/mysql_exec.nim | 189 +++++++++++++- .../models/mysql/mysql_types.nim | 17 ++ .../models/postgres/postgres_exec.nim | 209 ++++++++++++++- .../models/postgres/postgres_types.nim | 8 + .../models/sqlite/sqlite_exec.nim | 245 +++++++++++++---- .../models/sqlite/sqlite_types.nim | 11 + .../query_builder/prepared_param.nim | 115 ++++++++ tests/mariadb/test_prepared_statement.nim | 89 +++++++ tests/mysql/test_prepared_statement.nim | 89 +++++++ tests/postgres/test_prepared_statement.nim | 89 +++++++ tests/sqlite/test_prepared_statement.nim | 89 +++++++ 21 files changed, 2178 insertions(+), 139 deletions(-) create mode 100644 src/allographer/query_builder/prepared_param.nim create mode 100644 tests/mariadb/test_prepared_statement.nim create mode 100644 tests/mysql/test_prepared_statement.nim create mode 100644 tests/postgres/test_prepared_statement.nim create mode 100644 tests/sqlite/test_prepared_statement.nim diff --git a/.cursor/rules/branch/333-prepared-statement.mdc b/.cursor/rules/branch/333-prepared-statement.mdc index 65f72f13..ce15030d 100644 --- a/.cursor/rules/branch/333-prepared-statement.mdc +++ b/.cursor/rules/branch/333-prepared-statement.mdc @@ -23,8 +23,23 @@ alwaysApply: false - [x] `mariadb_impl.nim` に stmt 操作関数を追加 - [x] `mariadb_types.nim` に `MariadbPreparedStatement` 型を追加(`PSTMT` を export) - [x] `mariadb_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加 +- [x] MySQL の prepared statement 実装 + - [x] `mysql_rdb.nim` の MYSQL_STMT 関連 FFI を有効化 + - [x] `mysql_impl.nim` に stmt 操作関数を追加 + - [x] `mysql_types.nim` に `MysqlPreparedStatement` 型を追加(`PSTMT` を export) + - [x] `mysql_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加 - [x] `query_builder.nim` — 既存の `export *_types` / `export *_exec` で各 `*PreparedStatement` と `prepare` がそのまま利用者に公開されるため変更なし -- [x] テスト追加: `tests/sqlite/test_prepared_statement.nim`, `tests/postgres/test_prepared_statement.nim`, `tests/mariadb/test_prepared_statement.nim` +- [x] テスト追加: `tests/sqlite/test_prepared_statement.nim`, `tests/postgres/test_prepared_statement.nim`, `tests/mariadb/test_prepared_statement.nim`, `tests/mysql/test_prepared_statement.nim` +- [x] Prepared statement の public API を SQLite は proc overload、PostgreSQL / MariaDB / MySQL は private helper + proc overload に統一した +- [x] 改善候補の次フェーズ対応 + - [x] MariaDB の STMT 実行を non-blocking start/cont ベースへ移行 + - [x] MariaDB の result bind を接続単位で再利用する cache を追加 + - [x] PostgreSQL の `close()` を並列 `DEALLOCATE` に変更 + - [x] SQLite の `setColumnsStaticMeta` を prepared statement 側でキャッシュ化 + - [x] PostgreSQL の prepared 引数を unsafeAddr 依存から外した + - [x] `preparedArgsExpr` の間接呼び出しを除去した + - [x] MariaDB の `stmt.mysql` 直接参照を撤去した + - [x] PreparedStatement 型の冗長な `log` / `pools` フィールドを削除した ## 参考資料 @@ -412,7 +427,7 @@ returnConn(connI) 2. `""` を NULL として扱う(空文字との区別ができない) 3. `"NULL"` という文字列を特別扱いする("NULL" という文字列を入れたいときに困る) -**変更(実装)**:`get` / `first` / `exec` 等は **`macro` + `varargs[untyped]`** で展開し、呼び出し側は **`echo` と同様に任意個・任意型**を渡せる(各引数は **`toPreparedParam`** → 通常は **`$` で文字列化**)。Nim の **`echo` 専用の `varargs[typed, `$`]` はユーザー定義 proc では使えない**ため、この形が実質的な代替となる。SQL NULL は **`sqlNull()` などの専用 API は出さず**、次を **SQL NULL** として解釈する。 +**変更(実装)**:`get` / `first` / `exec` 等は **`macro` + `varargs[untyped]`** で展開し、呼び出し側は **`echo` と同様に任意個・任意型**を渡せる(各引数は **`toPreparedParam`** → 通常は **`$` で文字列化**)。Nim の **`echo` 専用の `varargs[typed, `$`]` はユーザー定義 proc では使えない**ため、この形が実質的な代替となる。なお **SQLite の prepared statement 公開 API は proc overload に切り替え**、`seq[string]` と `JsonNode` の両方を受ける。SQL NULL は **`sqlNull()` などの専用 API は出さず**、次を **SQL NULL** として解釈する。 - `Option[T]` の **`none(...)`**(例: `none(string)`) - リテラル **`nil`**(`typeof(nil)` 用オーバーロード) @@ -421,9 +436,11 @@ returnConn(connI) ```nim await stmt.exec("alice", "alice@example.com", none(string)) -await stmt.first(42) +await stmt.first(@[$index]) ``` +SQLite の proc overload では、文字列側は `"NULL"` / `"null"` sentinel を互換維持し、JsonNode 側は `JNull` を SQL NULL として扱う。 + #### 型情報と bind_text 統一の理由 Prepared statement の引数はすべて `string` として受け渡す(`bind_text` / text format に統一)。通常クエリの `placeHolder` が持っていた `{"key": col, "value": JsonNode}` のような型情報は **prepared statement では持たない**。 @@ -714,6 +731,228 @@ PostgreSQL・MariaDB も同じマクロ形を持つ(型名だけ異なる) ### 実装後メモ(333 ブランチ) - **PostgreSQL `prepare`**: `nParams` は `postgres_lib.countQuestionMarks(sql)`(`?` の個数)で固定。旧 `query.count('$')` は `$10` 等で誤るため廃止。 -- **引数型**: 公開 API は **`macro` + `varargs[untyped]`**(echo 風の呼び出し)。展開先は `seq[PreparedParam]` + `toPreparedParam`(`$` 変換)。SQL NULL は `none` / `nil` / `cstring(nil)` / `JsonNode` の nil・JNull を `toPreparedParam` が解釈。async と varargs の相性のため、実処理は `runPrepared*` に閉じ、マクロはそれへの呼び出しに展開する。 +- **引数型**: SQLite の prepared statement 公開 API は **`proc` overload** で `seq[string]` / `JsonNode` を受ける。文字列側は `"NULL"` / `"null"` を SQL NULL sentinel として解釈し、JsonNode 側は `JNull` / `nil` を解釈する。PostgreSQL / MariaDB の既存 prepared 公開 API は引き続き macro + varargs で、実処理は private helper に閉じ込める。 - **MariaDB `mysql_stmt_fetch`**: 行終端で `101` を返す前提でループ終了(コネクタの定義に依存するため、挙動が異なる環境では要確認)。 - **`sqlite_lib`**: `preparedQueryReuse` 用に `setColumnsStaticMeta` / `setColumnsRuntimeTypes` を export。 +- **MariaDB result bind cache**: Nim の async 状態機械では `var tuple` の捕捉が安全解析に引っかかるため、`MariadbResultBindCache` を `ref object` にして接続単位で再利用する方針にした。 + +--- + +### 9. 改善候補(次フェーズ向け) + +実装コードレビューで判明した課題を優先度順に列挙する。 + +--- + +#### 9-1. [correctness/高優先] MariaDB: `MYSQL_DATA_TRUNCATED (101)` の誤った扱い + +**問題**: `queryPreparedStmt` の fetch ループで戻り値 `101 = MYSQL_DATA_TRUNCATED` をエラーでも終了でもなく無視している。 + +```nim +let fetchRes = stmt_fetch(stmt) +if fetchRes == 100: # MYSQL_NO_DATA → ループ終了 + break +if fetchRes notin {0, 101}: # 101 を "正常" として通過させているが実は切り捨て発生中 + dbError(conn) +``` + +結果バッファ(`max(field.len, 4096)` バイト)を超えたデータは**サイレントに切り捨てられる**。 +`TEXT` / `BLOB` 型や、`utf8mb4` で `VARCHAR(255)` (= 最大 1020 バイト) を超えるデータで発生しうる。 + +**修正方針**: +1. `fetchRes == 101` の場合、`resultBinds.errorFlags[i]` が立っている列を特定する +2. 該当列に対して動的にバッファを拡大(`resultBinds.lengths[i]` が実際の長さ)し、`stmt_fetch_column` で再取得する +3. 再取得後に row データを正しく更新する + +```nim +# 修正後イメージ(列ごとの再取得) +if fetchRes in {0, 101}: + for i in 0 ..< cols: + if resultBinds.errorFlags[i]: # このカラムはバッファ不足で切り捨て + let needed = int(resultBinds.lengths[i]) + resultBinds.buffers[i] = newString(needed) + resultBinds.binds[i].buffer = cast[pointer](resultBinds.buffers[i].cstring) + resultBinds.binds[i].buffer_length = needed.culong + discard stmt_fetch_column(stmt, resultBinds.binds[i].addr, cuint(i), 0) +``` + +--- + +#### 9-2. [correctness/高優先] `close()` でエラーが起きると残りのコネクションがクリーンアップされない + +**問題**: `close()` が途中で例外を投げると、後続のコネクション分がリークする。 + +```nim +proc close*(self: PostgresPreparedStatement) {.async.} = + for i, stmtName in self.stmtNames: + await postgres_impl.deallocate(...) # ここで例外 → 残りのループが実行されない +``` + +**修正方針**: `try/except` でエラーをログに記録しながら全コネクション分をクリーンアップする。 + +```nim +proc close*(self: PostgresPreparedStatement) {.async.} = + for i, stmtName in self.stmtNames: + if stmtName.len == 0: continue + try: + await postgres_impl.deallocate(self.pools.conns[i].conn, stmtName, self.pools.timeout) + except CatchableError: + self.log.echoErrorMsg("deallocate failed for " & stmtName & ": " & getCurrentExceptionMsg()) + self.stmtNames[i] = "" +``` + +SQLite / MariaDB の `close()` も同様に `try/except` で保護する。 + +--- + +#### 9-3. [performance/高優先] MariaDB: 同期的 STMT 実行がイベントループをブロック + +**問題**: `execPreparedStmt` / `queryPreparedStmt` は `sleepAsync(0).await` を1回行った後、以降のすべての STMT 操作(`stmt_execute`, `stmt_store_result`, `stmt_fetch`)を同期ブロッキング呼び出しで実行している。これは他の MariaDB クエリと同様に、実行中は非同期イベントループ全体をブロックする。 + +**修正方針**: MariaDB Connector/C の非ブロッキング STMT API を `mariadb_rdb.nim` に追加し、同パターンで実装する。 + +追加が必要な FFI: +```nim +proc stmt_execute_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_execute_start".} +proc stmt_execute_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_execute_cont".} +proc stmt_store_result_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_store_result_start".} +proc stmt_store_result_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_store_result_cont".} +proc stmt_fetch_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_fetch_start".} +proc stmt_fetch_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_fetch_cont".} +proc stmt_prepare_start*(ret: ptr cint, stmt: PSTMT, query: cstring, len: int): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_prepare_start".} +proc stmt_prepare_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_prepare_cont".} +``` + +`prepareStmt` も非ブロッキングパターンに変更する。 + +--- + +#### 9-4. [memory/中優先] MariaDB: `bindStmtResults` が毎回 GC アロケーション + +**問題**: `queryPreparedStmt` → `bindStmtResults` が毎回 5 つの `newSeq` を確保する(`binds`, `buffers`, `lengths`, `nullFlags`, `errorFlags`)。高頻度な prepared statement 実行では GC プレッシャーになる。 + +**修正方針**: `MariadbPreparedStatement` にキャッシュフィールドを追加し、コネクションごとに一度だけ確保して再利用する。 + +```nim +type MariadbPreparedStatement* = ref object + ... + # 各コネクションの結果バインドキャッシュ(列数が変わらない限り再利用) + resultBindCache*: seq[tuple[ + binds: seq[BIND], buffers: seq[string], + lengths: seq[culong], nullFlags: seq[my_bool], errorFlags: seq[my_bool] + ]] +``` + +--- + +#### 9-5. [memory/中優先] PreparedStatement 型の冗長フィールド + +**対応済み**: `SqlitePreparedStatement`, `PostgresPreparedStatement`, `MariadbPreparedStatement` から `log` と `pools` を削除した。prepared statement 側は `owner.log` / `owner.pools` を参照する構成に統一した。 + +```nim +type SqlitePreparedStatement* = ref object + owner*: SqliteConnections + sql*: string + stmts*: seq[PStmt] + nArgs*: int + cachedColumns*: DbColumns + hasCachedColumns*: bool +``` + +**実装結果**: prepared statement のライフサイクル管理は `owner` に集約し、型定義の重複を解消した。将来的に `owner` を持たない軽量ハンドルが必要になった場合のみ再導入を検討する。 + +--- + +#### 9-6. [safety/中優先] PostgreSQL: `toPreparedCStringArray` の unsafe ポインタ + +**問題**: + +```nim +proc toPreparedCStringArray(args: seq[PreparedParam]): seq[cstring] = + result[i] = arg.value.cstring # arg.value の内部ポインタを借用 + +let valuePtr = cast[cstringArray](values[0].unsafeAddr) # unsafeAddr +pqsendQueryPrepared(..., valuePtr, ...) +``` + +`cstring` は Nim の GC 管理文字列への生ポインタ。`values` seq 内の cstring が `args` の `PreparedParam.value` 文字列を直接指すため、GC による移動(将来の ARC/ORC + 移動最適化)で dangling になりうる。現状の `pqsendQueryPrepared` 呼び出しは await なしなので実害はないが設計上fragile。 + +**修正方針**: `allocCStringArray` を使うか、`GC_ref` で各文字列を pin してから渡す。 + +```nim +# 安全な代替案 +var strbuf = newSeq[string](args.len) # 文字列を保持する所有コンテナ +var cstrs = newSeq[cstring](args.len) +for i, arg in args: + if arg.isNull: + cstrs[i] = nil + else: + strbuf[i] = arg.value # ここで文字列を「所有」 + cstrs[i] = cstrs[i].addr # strbuf[i] の参照 +``` + +--- + +#### 9-7. [performance/低優先] `close()` の逐次 DEALLOCATE → 並列化 + +**問題**: PostgreSQL の `close()` で各コネクションへの `DEALLOCATE` を逐次 `await` している。 + +**修正方針**: `asyncdispatch.all()` で全コネクションへ並列送信する。 + +```nim +proc close*(self: PostgresPreparedStatement) {.async.} = + var futs: seq[Future[void]] + for i, stmtName in self.stmtNames: + if stmtName.len > 0: + futs.add(postgres_impl.deallocate(self.pools.conns[i].conn, stmtName, self.pools.timeout)) + await all(futs) + for i in 0 ..< self.stmtNames.len: + self.stmtNames[i] = "" +``` + +※ エラーハンドリング(9-2)と組み合わせて実装する。 + +--- + +#### 9-8. [code quality/低優先] `preparedArgsExpr` 間接呼び出しの冗長除去 + +**問題**: 3 つの `*_exec.nim` それぞれに以下の wrapper proc が存在する: + +```nim +proc preparedArgsExpr(args: NimNode): NimNode = + result = buildPreparedArgsExpr(args) +``` + +`buildPreparedArgsExpr` を直接マクロ内で呼べばよく、この wrapper は不要。 + +**修正方針**: 削除して、マクロ内で `buildPreparedArgsExpr(args)` を直接呼ぶ。 + +--- + +#### 9-9. [code quality/低優先] `getPreparedRows` / `preparedGet` 系のコード重複 + +3 つの `*_exec.nim` にほぼ同一の private proc 群が重複している: +- `getPreparedRows` / `getPreparedAllRows` / `getPreparedRow` / `getPreparedAllRowsPlain` / `getPreparedRowPlain` +- `preparedGet` / `preparedFirst` / `preparedGetPlain` / `preparedFirstPlain` / `preparedExec` + +型の違いにより単純なテンプレート化は難しいが、少なくとも try/catch ロギングラッパーパターンをテンプレートで共通化できる。 + +--- + +#### 9-10. [correctness/低優先] `mariadb_rdb.nim` の `Enum_stmt_attr_type` 重複定義 + +`Enum_stmt_attr_type` が uncomment されたが、その直下に同内容のコメントアウトされた旧定義が残っており、コード上は重複している。コメントアウト側を削除して整理する。 + +--- + +#### 9-11. [performance/低優先] SQLite: `setColumnsStaticMeta` のキャッシュ化 + +`preparedQueryReuse` が呼ばれるたびに `setColumnsStaticMeta(columns, stmt)` を実行しているが、列のメタデータ(名前・宣言型)は同じステートメントに対して不変。`SqlitePreparedStatement` に `cachedColumns` を持たせ、初回のみ設定することで毎回の計算を省略できる。 + +--- + +#### 9-12. [correctness/低優先] MariaDB: `stmt.mysql` 内部フィールルアクセス + +`bindStmtParams` 内で `dbError(stmt.mysql)` を呼んでいる。`PSTMT` の内部フィールド `mysql` への直接アクセスは Connector/C のバージョン間で構造体レイアウトが変わると壊れる可能性がある。代わりに `stmt_errno(stmt)` / `stmt_error(stmt)` を使用する。 + +--- diff --git a/src/allographer/query_builder/libs/mariadb/mariadb_impl.nim b/src/allographer/query_builder/libs/mariadb/mariadb_impl.nim index 4d42d1d6..c20af556 100644 --- a/src/allographer/query_builder/libs/mariadb/mariadb_impl.nim +++ b/src/allographer/query_builder/libs/mariadb/mariadb_impl.nim @@ -4,6 +4,8 @@ import std/times import std/json import ../../error import ../../models/database_types +import ../../models/mariadb/mariadb_types +import ../../prepared_param import ./mariadb_rdb import ./mariadb_lib @@ -136,6 +138,243 @@ proc rawExec(conn: PMySQL, query: string, args: MariadbParams, timeout: int) {.a await runRealQuery(conn, q, deadline) +proc runStmtPrepare(conn: PMySQL, stmt: PSTMT, sql: string, deadline: MonoTime): Future[void] {.async.} = + var ret = 0.cint + var waitStatus = stmt_prepare_start(addr ret, stmt, sql.cstring, culong(sql.len)) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_prepare_cont(addr ret, stmt, ready) + if ret != 0: + raise newException(DbError, $stmt_error(stmt)) + + +proc runStmtReset(conn: PMySQL, stmt: PSTMT, deadline: MonoTime): Future[void] {.async.} = + var ret = false + var waitStatus = stmt_reset_start(addr ret, stmt) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_reset_cont(addr ret, stmt, ready) + if ret: + raise newException(DbError, $stmt_error(stmt)) + + +proc runStmtFreeResult(conn: PMySQL, stmt: PSTMT, deadline: MonoTime): Future[void] {.async.} = + var ret = false + var waitStatus = stmt_free_result_start(addr ret, stmt) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_free_result_cont(addr ret, stmt, ready) + if ret: + raise newException(DbError, $stmt_error(stmt)) + + +proc runStmtExecute(conn: PMySQL, stmt: PSTMT, deadline: MonoTime): Future[void] {.async.} = + var ret = 0.cint + var waitStatus = stmt_execute_start(addr ret, stmt) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_execute_cont(addr ret, stmt, ready) + if ret != 0: + raise newException(DbError, $stmt_error(stmt)) + + +proc runStmtStoreResult(conn: PMySQL, stmt: PSTMT, deadline: MonoTime): Future[void] {.async.} = + var ret = 0.cint + var waitStatus = stmt_store_result_start(addr ret, stmt) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_store_result_cont(addr ret, stmt, ready) + if ret != 0: + raise newException(DbError, $stmt_error(stmt)) + + +proc runStmtFetch(conn: PMySQL, stmt: PSTMT, deadline: MonoTime): Future[cint] {.async.} = + var ret = 0.cint + var waitStatus = stmt_fetch_start(addr ret, stmt) + while waitStatus != 0: + let ready = await waitMariadb(conn, waitStatus, deadline) + waitStatus = stmt_fetch_cont(addr ret, stmt, ready) + return ret + + +proc prepareStmt*(conn: PMySQL, sql: string, timeout: int): Future[PSTMT] {.async.} = + assert(not conn.isNil, "Database not connected.") + result = stmt_init(conn) + if result.isNil: + dbError(conn) + let deadline = makeDeadline(timeout) + try: + await runStmtPrepare(conn, result, sql, deadline) + except CatchableError: + discard stmt_close(result) + raise + + +proc bindStmtParams(stmt: PSTMT, args: seq[PreparedParam]) = + if stmt_param_count(stmt) != args.len: + raise newException(DbError, "Prepared statement parameter count mismatch.") + + if args.len == 0: + return + + var binds = newSeq[BIND](args.len) + var values = newSeq[string](args.len) + var lengths = newSeq[culong](args.len) + var nullFlags = newSeq[my_bool](args.len) + var errorFlags = newSeq[my_bool](args.len) + + for i, arg in args: + if arg.isNull: + nullFlags[i] = true + binds[i].buffer_type = TYPE_NULL + binds[i].is_null = addr nullFlags[i] + binds[i].error = addr errorFlags[i] + continue + + values[i] = arg.value + lengths[i] = values[i].len.culong + binds[i].buffer_type = TYPE_STRING + if values[i].len > 0: + binds[i].buffer = cast[pointer](values[i].cstring) + else: + binds[i].buffer = nil + binds[i].buffer_length = values[i].len.culong + binds[i].length = addr lengths[i] + binds[i].is_null = addr nullFlags[i] + binds[i].error = addr errorFlags[i] + + if stmt_bind_param(stmt, binds[0].addr): + raise newException(DbError, $stmt_error(stmt)) + + +proc bindStmtResults( + stmt: PSTMT, + metadata: PRES, + resultBinds: MariadbResultBindCache +) = + let cols = int(num_fields(metadata)) + if resultBinds.binds.len != cols: + resultBinds.binds = newSeq[BIND](cols) + resultBinds.buffers = newSeq[string](cols) + resultBinds.lengths = newSeq[culong](cols) + resultBinds.nullFlags = newSeq[my_bool](cols) + resultBinds.errorFlags = newSeq[my_bool](cols) + for i in 0 ..< cols: + let field = fetch_field_direct(metadata, cast[mariadb_rdb.cuint](i)) + var bufferLen = int(field.len) + if bufferLen < 4096: + bufferLen = 4096 + resultBinds.buffers[i] = newString(bufferLen) + else: + for i in 0 ..< cols: + resultBinds.lengths[i] = 0 + resultBinds.nullFlags[i] = false + resultBinds.errorFlags[i] = false + + for i in 0 ..< cols: + resultBinds.binds[i].buffer_type = TYPE_STRING + resultBinds.binds[i].buffer = if resultBinds.buffers[i].len > 0: cast[pointer](resultBinds.buffers[i].cstring) else: nil + resultBinds.binds[i].buffer_length = resultBinds.buffers[i].len.culong + resultBinds.binds[i].length = addr resultBinds.lengths[i] + resultBinds.binds[i].is_null = addr resultBinds.nullFlags[i] + resultBinds.binds[i].error = addr resultBinds.errorFlags[i] + + if cols > 0 and stmt_bind_result(stmt, resultBinds.binds[0].addr): + raise newException(DbError, $stmt_error(stmt)) + + +proc refetchTruncatedColumns( + stmt: PSTMT, + resultBinds: MariadbResultBindCache +) = + for i in 0 ..< resultBinds.binds.len: + if not resultBinds.errorFlags[i]: + continue + let needed = max(int(resultBinds.lengths[i]), resultBinds.buffers[i].len) + if needed <= 0: + continue + resultBinds.buffers[i] = newString(needed) + resultBinds.binds[i].buffer = cast[pointer](resultBinds.buffers[i].cstring) + resultBinds.binds[i].buffer_length = needed.culong + if stmt_fetch_column(stmt, resultBinds.binds[i].addr, cast[mariadb_rdb.cuint](i), 0) != 0: + raise newException(DbError, $stmt_error(stmt)) + + +proc execPreparedStmt*(conn: PMySQL, stmt: PSTMT, args: seq[PreparedParam], timeout: int) {.async.} = + assert(not conn.isNil, "Database not connected.") + let deadline = makeDeadline(timeout) + await runStmtReset(conn, stmt, deadline) + await runStmtFreeResult(conn, stmt, deadline) + bindStmtParams(stmt, args) + await runStmtExecute(conn, stmt, deadline) + await runStmtFreeResult(conn, stmt, deadline) + + +proc queryPreparedStmt*( + conn: PMySQL, + stmt: PSTMT, + args: seq[PreparedParam], + timeout: int, + resultBinds: MariadbResultBindCache +): Future[(seq[database_types.Row], DbRows)] {.async.} = + assert(not conn.isNil, "Database not connected.") + let deadline = makeDeadline(timeout) + await runStmtReset(conn, stmt, deadline) + await runStmtFreeResult(conn, stmt, deadline) + bindStmtParams(stmt, args) + await runStmtExecute(conn, stmt, deadline) + + var dbRows: DbRows + var rows = newSeq[seq[string]]() + let metadata = stmt_result_metadata(stmt) + if metadata.isNil: + await runStmtFreeResult(conn, stmt, deadline) + return (rows, dbRows) + + defer: + free_result(metadata) + await runStmtFreeResult(conn, stmt, deadline) + + await runStmtStoreResult(conn, stmt, deadline) + + let cols = int(num_fields(metadata)) + var baseColumns: DbColumns + setColumnInfo(baseColumns, metadata, cols) + bindStmtResults(stmt, metadata, resultBinds) + + while true: + let fetchRes = await runStmtFetch(conn, stmt, deadline) + if fetchRes == 100: + break + if fetchRes notin {0, 101}: + raise newException(DbError, $stmt_error(stmt)) + if fetchRes == 101: + refetchTruncatedColumns(stmt, resultBinds) + + var rowColumns = baseColumns + var row = newSeq[string](cols) + for i in 0 ..< cols: + if resultBinds.nullFlags[i]: + rowColumns[i].typ.kind = dbNull + row[i] = "" + else: + let length = min(int(resultBinds.lengths[i]), resultBinds.buffers[i].len) + if length <= 0: + row[i] = "" + else: + row[i] = resultBinds.buffers[i][0 ..< length] + rows.add(row) + dbRows.add(rowColumns) + + return (rows, dbRows) + + +proc closePreparedStmt*(stmt: PSTMT): void = + if stmt.isNil: + return + discard stmt_close(stmt) + + proc jsonObjValuesToStrSeq(args: JsonNode): seq[string] = result = newSeq[string](args.len) var i = 0 diff --git a/src/allographer/query_builder/libs/mariadb/mariadb_rdb.nim b/src/allographer/query_builder/libs/mariadb/mariadb_rdb.nim index 01d2cc17..ee2e845e 100644 --- a/src/allographer/query_builder/libs/mariadb/mariadb_rdb.nim +++ b/src/allographer/query_builder/libs/mariadb/mariadb_rdb.nim @@ -833,8 +833,8 @@ type STMT* = St_mysql_stmt -# Enum_stmt_attr_type* = enum -# STMT_ATTR_UPDATE_MAX_LENGTH, STMT_ATTR_CURSOR_TYPE, STMT_ATTR_PREFETCH_ROWS + Enum_stmt_attr_type* = enum + STMT_ATTR_UPDATE_MAX_LENGTH, STMT_ATTR_CURSOR_TYPE, STMT_ATTR_PREFETCH_ROWS # {.deprecated: [Tst_dynamic_array: St_dynamic_array, Tst_mysql_options: St_mysql_options, # TDYNAMIC_ARRAY: DYNAMIC_ARRAY, Tprotocol_type: Protocol_type, # Trpl_type: Rpl_type, Tcharset_info_st: Charset_info_st, @@ -1095,45 +1095,65 @@ proc store_result_start*(ret: ptr PRES, MySQL: PMySQL): cint{.stdcall, dynlib: l proc store_result_cont*(ret: ptr PRES, MySQL: PMySQL, status: cint): cint{.stdcall, dynlib: lib, importc: "mysql_store_result_cont".} proc stmt_init*(MySQL: PMySQL): PSTMT{.stdcall, dynlib: lib, importc: "mysql_stmt_init".} -# proc stmt_prepare*(stmt: PSTMT, query: cstring, len: int): cint{.stdcall, -# dynlib: lib, importc: "mysql_stmt_prepare".} -# proc stmt_execute*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_execute".} -# proc stmt_fetch*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_fetch".} -# proc stmt_fetch_column*(stmt: PSTMT, `bind`: PBIND, column: cuint, offset: int): cint{. -# stdcall, dynlib: lib, importc: "mysql_stmt_fetch_column".} -# proc stmt_store_result*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_store_result".} -# proc stmt_param_count*(stmt: PSTMT): int{.stdcall, dynlib: lib, -# importc: "mysql_stmt_param_count".} -# proc stmt_attr_set*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. -# stdcall, dynlib: lib, importc: "mysql_stmt_attr_set".} -# proc stmt_attr_get*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. -# stdcall, dynlib: lib, importc: "mysql_stmt_attr_get".} -# proc stmt_bind_param*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_bind_param".} -# proc stmt_bind_result*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_bind_result".} -# proc stmt_close*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_close".} -# proc stmt_reset*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_reset".} -# proc stmt_free_result*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_free_result".} +proc stmt_prepare_start*(ret: ptr cint, stmt: PSTMT, query: cstring, len: culong): cint{.stdcall, + dynlib: lib, importc: "mysql_stmt_prepare_start".} +proc stmt_prepare_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint{.stdcall, + dynlib: lib, importc: "mysql_stmt_prepare_cont".} +proc stmt_execute_start*(ret: ptr cint, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_execute_start".} +proc stmt_execute_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_execute_cont".} +proc stmt_fetch_start*(ret: ptr cint, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_fetch_start".} +proc stmt_fetch_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_fetch_cont".} +proc stmt_fetch_column*(stmt: PSTMT, `bind`: PBIND, column: cuint, offset: int): cint{. + stdcall, dynlib: lib, importc: "mysql_stmt_fetch_column".} +proc stmt_store_result_start*(ret: ptr cint, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_store_result_start".} +proc stmt_store_result_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_store_result_cont".} +proc stmt_param_count*(stmt: PSTMT): int{.stdcall, dynlib: lib, + importc: "mysql_stmt_param_count".} +proc stmt_attr_set*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. + stdcall, dynlib: lib, importc: "mysql_stmt_attr_set".} +proc stmt_attr_get*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. + stdcall, dynlib: lib, importc: "mysql_stmt_attr_get".} +proc stmt_bind_param*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_bind_param".} +proc stmt_bind_result*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_bind_result".} +proc stmt_close*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_close".} +proc stmt_reset*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_reset".} +proc stmt_free_result*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_free_result".} +proc stmt_reset_start*(ret: ptr my_bool, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_reset_start".} +proc stmt_reset_cont*(ret: ptr my_bool, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_reset_cont".} +proc stmt_free_result_start*(ret: ptr my_bool, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_free_result_start".} +proc stmt_free_result_cont*(ret: ptr my_bool, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_free_result_cont".} +proc stmt_close_start*(ret: ptr my_bool, stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_close_start".} +proc stmt_close_cont*(ret: ptr my_bool, stmt: PSTMT, status: cint): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_close_cont".} +proc stmt_result_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, + importc: "mysql_stmt_result_metadata".} +proc stmt_errno*(stmt: PSTMT): cuint{.stdcall, dynlib: lib, + importc: "mysql_stmt_errno".} +proc stmt_error*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, + importc: "mysql_stmt_error".} +proc stmt_sqlstate*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, + importc: "mysql_stmt_sqlstate".} # proc stmt_send_long_data*(stmt: PSTMT, param_number: cuint, data: cstring, # len: int): my_bool{.stdcall, dynlib: lib, # importc: "mysql_stmt_send_long_data".} -# proc stmt_result_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, -# importc: "mysql_stmt_result_metadata".} # proc stmt_param_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, # importc: "mysql_stmt_param_metadata".} -# proc stmt_errno*(stmt: PSTMT): cuint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_errno".} -# proc stmt_error*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, -# importc: "mysql_stmt_error".} -# proc stmt_sqlstate*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, -# importc: "mysql_stmt_sqlstate".} # proc stmt_row_seek*(stmt: PSTMT, offset: ROW_OFFSET): ROW_OFFSET{.stdcall, # dynlib: lib, importc: "mysql_stmt_row_seek".} # proc stmt_row_tell*(stmt: PSTMT): ROW_OFFSET{.stdcall, dynlib: lib, diff --git a/src/allographer/query_builder/libs/mysql/mysql_impl.nim b/src/allographer/query_builder/libs/mysql/mysql_impl.nim index f9f3c743..f4d8637e 100644 --- a/src/allographer/query_builder/libs/mysql/mysql_impl.nim +++ b/src/allographer/query_builder/libs/mysql/mysql_impl.nim @@ -5,6 +5,8 @@ import std/strformat import std/json import ../../error import ../../models/database_types +import ../../models/mysql/mysql_types +import ../../prepared_param import ./mysql_rdb import ./mysql_lib @@ -33,6 +35,193 @@ proc rawExec(conn:PMySQL, query: string, args: MysqlParams) = if realQuery(conn, q.cstring, q.len) != 0'i32: dbError(conn) +proc prepareStmt*(conn: PMySQL, sql: string, timeout: int): Future[PSTMT] {.async.} = + assert(not conn.isNil, "Database not connected.") + await sleepAsync(0) + result = mysql_rdb.stmt_init(conn) + if result.isNil: + dbError(conn) + if mysql_rdb.stmt_prepare(result, sql.cstring, sql.len) != 0: + let errmsg = $mysql_rdb.stmt_error(result) + discard mysql_rdb.stmt_close(result) + raise newException(DbError, errmsg) + + +proc bindStmtParams(stmt: PSTMT, args: seq[PreparedParam]) = + if mysql_rdb.stmt_param_count(stmt) != args.len: + raise newException(DbError, "Prepared statement parameter count mismatch.") + + if args.len == 0: + return + + var binds = newSeq[BIND](args.len) + var values = newSeq[string](args.len) + var lengths = newSeq[culong](args.len) + var nullFlags = newSeq[my_bool](args.len) + var errorFlags = newSeq[my_bool](args.len) + + for i, arg in args: + if arg.isNull: + nullFlags[i] = true + binds[i].buffer_type = TYPE_NULL + binds[i].is_null = addr nullFlags[i] + binds[i].error = addr errorFlags[i] + continue + + values[i] = arg.value + lengths[i] = values[i].len.culong + binds[i].buffer_type = TYPE_STRING + if values[i].len > 0: + binds[i].buffer = cast[pointer](values[i].cstring) + else: + binds[i].buffer = nil + binds[i].buffer_length = values[i].len.culong + binds[i].length = addr lengths[i] + binds[i].is_null = addr nullFlags[i] + binds[i].error = addr errorFlags[i] + + if mysql_rdb.stmt_bind_param(stmt, binds[0].addr): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + +proc bindStmtResults( + stmt: PSTMT, + metadata: PRES, + resultBinds: MysqlResultBindCache +) = + let cols = int(mysql_rdb.num_fields(metadata)) + if resultBinds.binds.len != cols: + resultBinds.binds = newSeq[BIND](cols) + resultBinds.buffers = newSeq[string](cols) + resultBinds.lengths = newSeq[culong](cols) + resultBinds.nullFlags = newSeq[my_bool](cols) + resultBinds.errorFlags = newSeq[my_bool](cols) + for i in 0 ..< cols: + let field = mysql_rdb.fetch_field_direct(metadata, cast[mysql_rdb.cuint](i)) + var bufferLen = int(field.len) + if bufferLen < 4096: + bufferLen = 4096 + resultBinds.buffers[i] = newString(bufferLen) + else: + for i in 0 ..< cols: + resultBinds.lengths[i] = 0 + resultBinds.nullFlags[i] = false + resultBinds.errorFlags[i] = false + + for i in 0 ..< cols: + resultBinds.binds[i].buffer_type = TYPE_STRING + resultBinds.binds[i].buffer = if resultBinds.buffers[i].len > 0: cast[pointer](resultBinds.buffers[i].cstring) else: nil + resultBinds.binds[i].buffer_length = resultBinds.buffers[i].len.culong + resultBinds.binds[i].length = addr resultBinds.lengths[i] + resultBinds.binds[i].is_null = addr resultBinds.nullFlags[i] + resultBinds.binds[i].error = addr resultBinds.errorFlags[i] + + if cols > 0 and mysql_rdb.stmt_bind_result(stmt, resultBinds.binds[0].addr): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + +proc refetchTruncatedColumns( + stmt: PSTMT, + resultBinds: MysqlResultBindCache +) = + for i in 0 ..< resultBinds.binds.len: + if not resultBinds.errorFlags[i]: + continue + let needed = max(int(resultBinds.lengths[i]), resultBinds.buffers[i].len) + if needed <= 0: + continue + resultBinds.buffers[i] = newString(needed) + resultBinds.binds[i].buffer = cast[pointer](resultBinds.buffers[i].cstring) + resultBinds.binds[i].buffer_length = needed.culong + if mysql_rdb.stmt_fetch_column(stmt, resultBinds.binds[i].addr, cast[mysql_rdb.cuint](i), 0) != 0: + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + +proc execPreparedStmt*(conn: PMySQL, stmt: PSTMT, args: seq[PreparedParam], timeout: int) {.async.} = + assert(not conn.isNil, "Database not connected.") + await sleepAsync(0) + if mysql_rdb.stmt_reset(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + if mysql_rdb.stmt_free_result(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + bindStmtParams(stmt, args) + if mysql_rdb.stmt_execute(stmt) != 0: + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + if mysql_rdb.stmt_free_result(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + +proc queryPreparedStmt*( + conn: PMySQL, + stmt: PSTMT, + args: seq[PreparedParam], + timeout: int, + resultBinds: MysqlResultBindCache +): Future[(seq[database_types.Row], DbRows)] {.async.} = + assert(not conn.isNil, "Database not connected.") + await sleepAsync(0) + if mysql_rdb.stmt_reset(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + if mysql_rdb.stmt_free_result(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + bindStmtParams(stmt, args) + if mysql_rdb.stmt_execute(stmt) != 0: + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + var dbRows: DbRows + var rows = newSeq[seq[string]]() + let metadata = mysql_rdb.stmt_result_metadata(stmt) + if metadata.isNil: + if mysql_rdb.stmt_free_result(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + return (rows, dbRows) + + defer: + mysql_rdb.free_result(metadata) + if mysql_rdb.stmt_free_result(stmt): + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + if mysql_rdb.stmt_store_result(stmt) != 0: + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + + let cols = int(mysql_rdb.num_fields(metadata)) + var baseColumns: DbColumns + setColumnInfo(baseColumns, metadata, cols) + bindStmtResults(stmt, metadata, resultBinds) + + while true: + let fetchRes = mysql_rdb.stmt_fetch(stmt) + if fetchRes == 100: + break + if fetchRes notin {0, 101}: + raise newException(DbError, $mysql_rdb.stmt_error(stmt)) + if fetchRes == 101: + refetchTruncatedColumns(stmt, resultBinds) + + var rowColumns = baseColumns + var row = newSeq[string](cols) + for i in 0 ..< cols: + if resultBinds.nullFlags[i]: + rowColumns[i].typ.kind = dbNull + row[i] = "" + else: + let length = min(int(resultBinds.lengths[i]), resultBinds.buffers[i].len) + if length <= 0: + row[i] = "" + else: + row[i] = resultBinds.buffers[i][0 ..< length] + rows.add(row) + dbRows.add(rowColumns) + + return (rows, dbRows) + + +proc closePreparedStmt*(stmt: PSTMT) = + if stmt.isNil: + return + discard mysql_rdb.stmt_close(stmt) + + proc query*(db:PMySQL, query: string, args: seq[string], timeout:int):Future[(seq[database_types.Row], DbRows)] {.async.} = assert db.ping == 0 var dbRows: DbRows diff --git a/src/allographer/query_builder/libs/mysql/mysql_rdb.nim b/src/allographer/query_builder/libs/mysql/mysql_rdb.nim index 39379123..1fbfe561 100644 --- a/src/allographer/query_builder/libs/mysql/mysql_rdb.nim +++ b/src/allographer/query_builder/libs/mysql/mysql_rdb.nim @@ -1053,43 +1053,39 @@ proc real_escape_string*(MySQL: PMySQL, fto: cstring, `from`: cstring, len: int) # proc read_query_result*(MySQL: PMySQL): my_bool{.stdcall, dynlib: lib, # importc: "mysql_read_query_result".} proc stmt_init*(MySQL: PMySQL): PSTMT{.stdcall, dynlib: lib, importc: "mysql_stmt_init".} -# proc stmt_prepare*(stmt: PSTMT, query: cstring, len: int): cint{.stdcall, -# dynlib: lib, importc: "mysql_stmt_prepare".} -# proc stmt_execute*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_execute".} -# proc stmt_fetch*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_fetch".} -# proc stmt_fetch_column*(stmt: PSTMT, `bind`: PBIND, column: cuint, offset: int): cint{. -# stdcall, dynlib: lib, importc: "mysql_stmt_fetch_column".} -# proc stmt_store_result*(stmt: PSTMT): cint{.stdcall, dynlib: lib, -# importc: "mysql_stmt_store_result".} -# proc stmt_param_count*(stmt: PSTMT): int{.stdcall, dynlib: lib, -# importc: "mysql_stmt_param_count".} -# proc stmt_attr_set*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. -# stdcall, dynlib: lib, importc: "mysql_stmt_attr_set".} -# proc stmt_attr_get*(stmt: PSTMT, attr_type: Enum_stmt_attr_type, attr: pointer): my_bool{. -# stdcall, dynlib: lib, importc: "mysql_stmt_attr_get".} -# proc stmt_bind_param*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_bind_param".} -# proc stmt_bind_result*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_bind_result".} -# proc stmt_close*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_close".} -# proc stmt_reset*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_reset".} -# proc stmt_free_result*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, -# importc: "mysql_stmt_free_result".} +proc stmt_prepare*(stmt: PSTMT, query: cstring, len: int): cint{.stdcall, + dynlib: lib, importc: "mysql_stmt_prepare".} +proc stmt_execute*(stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_execute".} +proc stmt_fetch*(stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_fetch".} +proc stmt_fetch_column*(stmt: PSTMT, `bind`: PBIND, column: cuint, offset: int): cint{. + stdcall, dynlib: lib, importc: "mysql_stmt_fetch_column".} +proc stmt_store_result*(stmt: PSTMT): cint{.stdcall, dynlib: lib, + importc: "mysql_stmt_store_result".} +proc stmt_param_count*(stmt: PSTMT): int{.stdcall, dynlib: lib, + importc: "mysql_stmt_param_count".} +proc stmt_bind_param*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_bind_param".} +proc stmt_bind_result*(stmt: PSTMT, bnd: PBIND): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_bind_result".} +proc stmt_close*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_close".} +proc stmt_reset*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_reset".} +proc stmt_free_result*(stmt: PSTMT): my_bool{.stdcall, dynlib: lib, + importc: "mysql_stmt_free_result".} # proc stmt_send_long_data*(stmt: PSTMT, param_number: cuint, data: cstring, # len: int): my_bool{.stdcall, dynlib: lib, # importc: "mysql_stmt_send_long_data".} -# proc stmt_result_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, -# importc: "mysql_stmt_result_metadata".} +proc stmt_result_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, + importc: "mysql_stmt_result_metadata".} # proc stmt_param_metadata*(stmt: PSTMT): PRES{.stdcall, dynlib: lib, # importc: "mysql_stmt_param_metadata".} # proc stmt_errno*(stmt: PSTMT): cuint{.stdcall, dynlib: lib, # importc: "mysql_stmt_errno".} -# proc stmt_error*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, -# importc: "mysql_stmt_error".} +proc stmt_error*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, + importc: "mysql_stmt_error".} # proc stmt_sqlstate*(stmt: PSTMT): cstring{.stdcall, dynlib: lib, # importc: "mysql_stmt_sqlstate".} # proc stmt_row_seek*(stmt: PSTMT, offset: ROW_OFFSET): ROW_OFFSET{.stdcall, diff --git a/src/allographer/query_builder/libs/postgres/postgres_impl.nim b/src/allographer/query_builder/libs/postgres/postgres_impl.nim index 269483b4..bc20c12a 100644 --- a/src/allographer/query_builder/libs/postgres/postgres_impl.nim +++ b/src/allographer/query_builder/libs/postgres/postgres_impl.nim @@ -7,6 +7,7 @@ import std/strutils import std/times import ../../error import ../../models/database_types +import ../../prepared_param import ./postgres_rdb import ./postgres_lib @@ -333,10 +334,9 @@ proc getColumns*(db: PPGconn, query: string, args: seq[string], timeout: int): F result.add(column.name) -proc prepare*(db: PPGconn, query: string, timeout: int, stmtName: string): Future[int] {.async.} = +proc prepare*(db: PPGconn, query: string, timeout: int, stmtName: string, nArgs: int): Future[void] {.async.} = assert db.status == CONNECTION_OK - let nArgs = query.count('$') - let success = pqsendPrepare(db, stmtName, dbFormat(query).cstring, int32(nArgs), nil) + let success = pqsendPrepare(db, stmtName, questionToDaller(query).cstring, int32(nArgs), nil) if success != 1: dbError(db) let deadline = makePgDeadline(timeout) await pgFlushOutgoing(db, deadline) @@ -346,15 +346,52 @@ proc prepare*(db: PPGconn, query: string, timeout: int, stmtName: string): Futur db.checkError() break pqclear(pqresult) - return nArgs -proc preparedQuery*(db: PPGconn, args: seq[string], nArgs: int, timeout: int, stmtName: string): Future[(seq[Row], DbRows)] {.async.} = + +proc deallocate*(db: PPGconn, stmtName: string, timeout: int): Future[void] {.async.} = + assert db.status == CONNECTION_OK + if stmtName.len == 0: + return + let success = pqsendQuery(db, ("DEALLOCATE " & stmtName).cstring) + if success != 1: + dbError(db) + let deadline = makePgDeadline(timeout) + await pgFlushOutgoing(db, deadline) + while true: + let pqresult = await pgNextResult(db, deadline) + if pqresult == nil: + db.checkError() + break + pqclear(pqresult) + +proc allocPreparedCStringArray(args: seq[PreparedParam]): cstringArray = + result = cast[cstringArray](alloc0((args.len + 1) * sizeof(cstring))) + for i, arg in args: + if arg.isNull: + continue + let cstrLen = arg.value.len + 1 + let cstr = cast[cstring](alloc0(cstrLen)) + copyMem(cstr, arg.value.cstring, arg.value.len) + result[i] = cstr + + +proc freePreparedCStringArray(values: cstringArray, n: int) = + if values.isNil: + return + for i in 0 ..< n: + if values[i] != nil: + dealloc(values[i]) + dealloc(values) + + +proc preparedQuery*(db: PPGconn, args: seq[PreparedParam], nArgs: int, timeout: int, stmtName: string): Future[(seq[Row], DbRows)] {.async.} = assert db.status == CONNECTION_OK let deadline = makePgDeadline(timeout) await pgEnsureIdle(db, deadline) - let arr = allocCStringArray(args) - let status = pqsendQueryPrepared(db, stmtName, int32(nArgs), arr, nil, nil, 0) - deallocCStringArray(arr) + let values = allocPreparedCStringArray(args) + defer: + freePreparedCStringArray(values, args.len) + let status = pqsendQueryPrepared(db, stmtName, int32(nArgs), values, nil, nil, 0) if status != 1: dbError(db) var dbRows: DbRows var rows = newSeq[Row]() @@ -376,13 +413,14 @@ proc preparedQuery*(db: PPGconn, args: seq[string], nArgs: int, timeout: int, st return (rows, dbRows) -proc preparedExec*(db: PPGconn, args: seq[string], nArgs: int, timeout: int, stmtName: string) {.async.} = +proc preparedExec*(db: PPGconn, args: seq[PreparedParam], nArgs: int, timeout: int, stmtName: string) {.async.} = assert db.status == CONNECTION_OK let deadline = makePgDeadline(timeout) await pgEnsureIdle(db, deadline) - let arr = allocCStringArray(args) - let status = pqsendQueryPrepared(db, stmtName, int32(nArgs), arr, nil, nil, 0) - deallocCStringArray(arr) + let values = allocPreparedCStringArray(args) + defer: + freePreparedCStringArray(values, args.len) + let status = pqsendQueryPrepared(db, stmtName, int32(nArgs), values, nil, nil, 0) if status != 1: dbError(db) await pgFlushOutgoing(db, deadline) while true: diff --git a/src/allographer/query_builder/libs/sqlite/sqlite_impl.nim b/src/allographer/query_builder/libs/sqlite/sqlite_impl.nim index 1d468941..14f154ec 100644 --- a/src/allographer/query_builder/libs/sqlite/sqlite_impl.nim +++ b/src/allographer/query_builder/libs/sqlite/sqlite_impl.nim @@ -2,6 +2,7 @@ import std/asyncdispatch import std/strutils import std/json import ../../models/database_types +import ../../prepared_param import ./sqlite_rdb import ./sqlite_lib @@ -164,6 +165,69 @@ proc prepare*(db:PSqlite3, query:string, timeout:int):Future[PStmt] {.async.} = dbError(db) +proc bindPreparedParams(db: PSqlite3, stmt: PStmt, args: seq[PreparedParam]) = + if reset(stmt) != SQLITE_OK: + dbError(db) + if clear_bindings(stmt) != SQLITE_OK: + dbError(db) + + for i, arg in args: + let paramIdx = i.int32 + 1 + if arg.isNull: + if bind_null(stmt, paramIdx) != SQLITE_OK: + dbError(db) + else: + if bind_text(stmt, paramIdx, arg.value.cstring, arg.value.len.int32, SQLITE_TRANSIENT) != SQLITE_OK: + dbError(db) + + +proc preparedQueryReuse*(db: PSqlite3, stmt: PStmt, args: seq[PreparedParam], timeout: int, + cachedColumns: DbColumns): Future[(seq[Row], DbRows)] {.async.} = + assert(not db.isNil, "Database not connected.") + sleepAsync(0).await + bindPreparedParams(db, stmt, args) + defer: + discard clear_bindings(stmt) + + var dbRows: DbRows + var rows = newSeq[seq[string]]() + + while true: + let stepRes = step(stmt) + if stepRes == SQLITE_ROW: + var columns = cachedColumns + setColumnsRuntimeTypes(columns, stmt) + dbRows.add(columns) + var row = newSeq[string](int(column_count(stmt))) + for i in 0 ..< row.len: + let text = column_text(stmt, i.int32) + if text.isNil: + row[i] = "" + else: + row[i] = $text + rows.add(row) + continue + if stepRes == SQLITE_DONE: + break + dbError(db) + + return (rows, dbRows) + + +proc preparedExecReuse*(db: PSqlite3, stmt: PStmt, args: seq[PreparedParam], timeout: int) {.async.} = + assert(not db.isNil, "Database not connected.") + sleepAsync(0).await + bindPreparedParams(db, stmt, args) + defer: + discard clear_bindings(stmt) + + var stepRes = step(stmt) + while stepRes == SQLITE_ROW: + stepRes = step(stmt) + if stepRes != SQLITE_DONE: + dbError(db) + + proc preparedQuery*(db:PSqlite3, args:seq[string] = @[], sqliteStmt:PStmt):Future[(seq[Row], DbRows)] {.async.} = # bind params for i, row in args: diff --git a/src/allographer/query_builder/libs/sqlite/sqlite_lib.nim b/src/allographer/query_builder/libs/sqlite/sqlite_lib.nim index 39bdd9db..07238bf0 100644 --- a/src/allographer/query_builder/libs/sqlite/sqlite_lib.nim +++ b/src/allographer/query_builder/libs/sqlite/sqlite_lib.nim @@ -79,7 +79,7 @@ proc toTypeKind(t: var DbType; x: int32) = of SQLITE_TEXT: t.kind = dbVarchar else: t.kind = dbUnknown -proc setColumnsStaticMeta(columns: var DbColumns; x: PStmt) = +proc setColumnsStaticMeta*(columns: var DbColumns; x: PStmt) = ## ステップ前でも列名・宣言型・テーブル名は取得できる(行に依存しない)。 let L = column_count(x) setLen(columns, L.int) @@ -88,7 +88,7 @@ proc setColumnsStaticMeta(columns: var DbColumns; x: PStmt) = columns[i].typ.name = $column_decltype(x, i) columns[i].tableName = $column_table_name(x, i) -proc setColumnsRuntimeTypes(columns: var DbColumns; x: PStmt) = +proc setColumnsRuntimeTypes*(columns: var DbColumns; x: PStmt) = ## 行ごとに変わりうるのは `column_type` のみ。 let L = column_count(x) for i in 0'i32 ..< L: diff --git a/src/allographer/query_builder/models/mariadb/mariadb_exec.nim b/src/allographer/query_builder/models/mariadb/mariadb_exec.nim index 6ab226ad..244a5062 100644 --- a/src/allographer/query_builder/models/mariadb/mariadb_exec.nim +++ b/src/allographer/query_builder/models/mariadb/mariadb_exec.nim @@ -8,8 +8,10 @@ import std/sequtils import std/tables import std/times import ../../libs/mariadb/mariadb_impl +import ../../libs/mariadb/mariadb_rdb except Option, cuint import ../../log import ../database_types +import ../../prepared_param import ./query/mariadb_builder import ./mariadb_types @@ -56,6 +58,22 @@ proc returnConn(self:MariadbConnections | MariadbQuery | RawMariadbQuery, i: int wakeOnePoolWaiter(self.pools) +proc prepare*(self: MariadbConnections, sql: string): MariadbPreparedStatement = + new(result) + result.owner = self + result.info = self.info + result.sql = sql + result.stmts = newSeq[PSTMT](self.pools.conns.len) + result.nArgs = countQuestionMarks(sql) + result.resultBindCache = newSeq[MariadbResultBindCache](self.pools.conns.len) + + +proc ensurePreparedStmt(self: MariadbPreparedStatement, connI: int): Future[PSTMT] {.async.} = + if self.stmts[connI].isNil: + self.stmts[connI] = await mariadb_impl.prepareStmt(self.owner.pools.conns[connI].conn, self.sql, self.owner.pools.timeout) + return self.stmts[connI] + + # ================================================================================ # toJson # ================================================================================ @@ -364,7 +382,7 @@ proc transactionStart(self:MariadbConnections) {.async.} = self.isInTransaction = true self.transactionConn = connI - mariadb_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[Row](), self.pools.timeout).await + mariadb_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[seq[string]](), self.pools.timeout).await proc transactionEnd(self:MariadbConnections, query:string) {.async.} = @@ -373,7 +391,169 @@ proc transactionEnd(self:MariadbConnections, query:string) {.async.} = self.transactionConn = 0 self.isInTransaction = false - mariadb_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[Row](), self.pools.timeout).await + mariadb_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[seq[string]](), self.pools.timeout).await + + +proc getPreparedRows(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[(seq[seq[string]], DbRows)] {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + if connI >= self.resultBindCache.len: + self.resultBindCache.setLen(connI + 1) + if self.resultBindCache[connI].isNil: + new(self.resultBindCache[connI]) + return mariadb_impl.queryPreparedStmt( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout, + self.resultBindCache[connI] + ).await + + +proc getPreparedAllRows(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[JsonNode](0) + return toJson(rows, dbRows) + + +proc getPreparedRow(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return none(JsonNode) + return toJson(rows, dbRows)[0].some() + + +proc getPreparedAllRowsPlain(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + return rows + + +proc getPreparedRowPlain(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[string](0) + return rows[0] + + +proc execPrepared(self: MariadbPreparedStatement, args: seq[PreparedParam]) {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + await mariadb_impl.execPreparedStmt( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout + ) + + +proc preparedGet(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRows(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirst(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRow(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedGetPlain(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRowsPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirstPlain(self: MariadbPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRowPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedExec(self: MariadbPreparedStatement, args: seq[PreparedParam]) {.async.} = + try: + self.owner.log.logger(self.sql) + await self.execPrepared(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc get*(self: MariadbPreparedStatement, args: seq[string]): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc get*(self: MariadbPreparedStatement, args: JsonNode): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc first*(self: MariadbPreparedStatement, args: seq[string]): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc first*(self: MariadbPreparedStatement, args: JsonNode): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc getPlain*(self: MariadbPreparedStatement, args: seq[string]): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc getPlain*(self: MariadbPreparedStatement, args: JsonNode): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc firstPlain*(self: MariadbPreparedStatement, args: seq[string]): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc firstPlain*(self: MariadbPreparedStatement, args: JsonNode): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc exec*(self: MariadbPreparedStatement, args: seq[string]) {.async.} = + await self.preparedExec(args.toPreparedParams) + + +proc exec*(self: MariadbPreparedStatement, args: JsonNode) {.async.} = + await self.preparedExec(args.toPreparedParams) # ================================================================================ @@ -663,6 +843,17 @@ proc firstPlain*(self: RawMariadbQuery):Future[seq[string]] {.async.} = return self.getRowPlain(self.queryString, self.placeHolder).await +proc close*(self: MariadbPreparedStatement) {.async.} = + for i, stmt in self.stmts: + if stmt.isNil: + continue + try: + mariadb_impl.closePreparedStmt(stmt) + except CatchableError: + self.owner.log.echoErrorMsg("close failed for prepared stmt: " & getCurrentExceptionMsg()) + self.stmts[i] = nil + + template seeder*(rdb:MariadbConnections, tableName:string, body:untyped):untyped = ## The `seeder` block allows the code in the block to work only when the table is empty. block: diff --git a/src/allographer/query_builder/models/mariadb/mariadb_types.nim b/src/allographer/query_builder/models/mariadb/mariadb_types.nim index ec729c12..c79f5623 100644 --- a/src/allographer/query_builder/models/mariadb/mariadb_types.nim +++ b/src/allographer/query_builder/models/mariadb/mariadb_types.nim @@ -65,6 +65,25 @@ type RawMariadbQuery* = ref object transactionConn*: int +type MariadbResultBindCache* = ref object + binds*: seq[BIND] + buffers*: seq[string] + lengths*: seq[culong] + nullFlags*: seq[my_bool] + errorFlags*: seq[my_bool] + + +type MariadbPreparedStatement* = ref object + owner*: MariadbConnections + info*: ConnectionInfo + sql*: string + stmts*: seq[PSTMT] + nArgs*: int + resultBindCache*: seq[MariadbResultBindCache] + + + + proc `$`*(self:MariadbConnections|MariadbQuery|RawMariadbQuery):string = return "MariaDB" diff --git a/src/allographer/query_builder/models/mysql/mysql_exec.nim b/src/allographer/query_builder/models/mysql/mysql_exec.nim index a85960cb..3aacc972 100644 --- a/src/allographer/query_builder/models/mysql/mysql_exec.nim +++ b/src/allographer/query_builder/models/mysql/mysql_exec.nim @@ -6,8 +6,10 @@ import std/strutils import std/sequtils import std/times import ../../libs/mysql/mysql_impl +import ../../libs/mysql/mysql_rdb except Option import ../../log import ../database_types +import ../../prepared_param import ./query/mysql_builder import ./mysql_types @@ -338,7 +340,7 @@ proc transactionStart(self:MysqlConnections) {.async.} = self.isInTransaction = true self.transactionConn = connI - mysql_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[Row](), self.pools.timeout).await + mysql_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[seq[string]](), self.pools.timeout).await proc transactionEnd(self:MysqlConnections, query:string) {.async.} = @@ -347,7 +349,7 @@ proc transactionEnd(self:MysqlConnections, query:string) {.async.} = self.transactionConn = 0 self.isInTransaction = false - mysql_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[Row](), self.pools.timeout).await + mysql_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[seq[string]](), self.pools.timeout).await # ================================================================================ @@ -606,6 +608,189 @@ proc commit*(self:MysqlConnections) {.async.} = self.transactionEnd("COMMIT").await +proc prepare*(self: MysqlConnections, sql: string): MysqlPreparedStatement = + new(result) + result.owner = self + result.info = self.info + result.sql = sql + result.stmts = newSeq[PSTMT](self.pools.conns.len) + result.nArgs = countQuestionMarks(sql) + result.resultBindCache = newSeq[MysqlResultBindCache](self.pools.conns.len) + + +proc ensurePreparedStmt(self: MysqlPreparedStatement, connI: int): Future[PSTMT] {.async.} = + if self.stmts[connI].isNil: + self.stmts[connI] = await mysql_impl.prepareStmt(self.owner.pools.conns[connI].conn, self.sql, self.owner.pools.timeout) + return self.stmts[connI] + + +proc getPreparedRows(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[(seq[seq[string]], DbRows)] {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + if connI >= self.resultBindCache.len: + self.resultBindCache.setLen(connI + 1) + if self.resultBindCache[connI].isNil: + new(self.resultBindCache[connI]) + return mysql_impl.queryPreparedStmt( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout, + self.resultBindCache[connI] + ).await + + +proc getPreparedAllRows(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[JsonNode](0) + return toJson(rows, dbRows) + + +proc getPreparedRow(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return none(JsonNode) + return toJson(rows, dbRows)[0].some() + + +proc getPreparedAllRowsPlain(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + return rows + + +proc getPreparedRowPlain(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[string](0) + return rows[0] + + +proc execPrepared(self: MysqlPreparedStatement, args: seq[PreparedParam]) {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + await mysql_impl.execPreparedStmt( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout + ) + + +proc preparedGet(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRows(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirst(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRow(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedGetPlain(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRowsPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirstPlain(self: MysqlPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRowPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedExec(self: MysqlPreparedStatement, args: seq[PreparedParam]) {.async.} = + try: + self.owner.log.logger(self.sql) + await self.execPrepared(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc get*(self: MysqlPreparedStatement, args: seq[string]): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc get*(self: MysqlPreparedStatement, args: JsonNode): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc first*(self: MysqlPreparedStatement, args: seq[string]): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc first*(self: MysqlPreparedStatement, args: JsonNode): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc getPlain*(self: MysqlPreparedStatement, args: seq[string]): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc getPlain*(self: MysqlPreparedStatement, args: JsonNode): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc firstPlain*(self: MysqlPreparedStatement, args: seq[string]): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc firstPlain*(self: MysqlPreparedStatement, args: JsonNode): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc exec*(self: MysqlPreparedStatement, args: seq[string]) {.async.} = + await self.preparedExec(args.toPreparedParams) + + +proc exec*(self: MysqlPreparedStatement, args: JsonNode) {.async.} = + await self.preparedExec(args.toPreparedParams) + + +proc close*(self: MysqlPreparedStatement) {.async.} = + for stmt in self.stmts: + mysql_impl.closePreparedStmt(stmt) + + proc get*(self: RawMysqlQuery):Future[seq[JsonNode]] {.async.} = ## It is only used with raw() self.log.logger(self.queryString) diff --git a/src/allographer/query_builder/models/mysql/mysql_types.nim b/src/allographer/query_builder/models/mysql/mysql_types.nim index 6b698ab9..f4e826fa 100644 --- a/src/allographer/query_builder/models/mysql/mysql_types.nim +++ b/src/allographer/query_builder/models/mysql/mysql_types.nim @@ -60,6 +60,23 @@ type RawMysqlQuery* = ref object transactionConn*: int +type MysqlResultBindCache* = ref object + binds*: seq[BIND] + buffers*: seq[string] + lengths*: seq[culong] + nullFlags*: seq[my_bool] + errorFlags*: seq[my_bool] + + +type MysqlPreparedStatement* = ref object + owner*: MysqlConnections + info*: ConnectionInfo + sql*: string + stmts*: seq[PSTMT] + nArgs*: int + resultBindCache*: seq[MysqlResultBindCache] + + proc `$`*(self:MysqlConnections|MysqlQuery|RawMysqlQuery):string = return "MySQL" diff --git a/src/allographer/query_builder/models/postgres/postgres_exec.nim b/src/allographer/query_builder/models/postgres/postgres_exec.nim index 66f833c5..70ed803c 100644 --- a/src/allographer/query_builder/models/postgres/postgres_exec.nim +++ b/src/allographer/query_builder/models/postgres/postgres_exec.nim @@ -1,5 +1,6 @@ import std/asyncdispatch import std/deques +import std/atomics import std/json import std/monotimes import std/options @@ -12,9 +13,12 @@ import ../../libs/postgres/postgres_lib import ../../libs/postgres/postgres_impl import ../../log import ../database_types +import ../../prepared_param import ./query/postgres_builder import ./postgres_types +var gPreparedStmtCounter: Atomic[int] + # ================================================================================ # connection @@ -74,6 +78,29 @@ proc returnConn(self: PostgresConnections | PostgresQuery | RawPostgresQuery, i: wakeOnePoolWaiter(self.pools) +proc prepare*(self: PostgresConnections, sql: string): PostgresPreparedStatement = + new(result) + result.owner = self + result.sql = sql + result.stmtBaseName = &"allographer_stmt_{gPreparedStmtCounter.fetchAdd(1)}" + result.stmtNames = newSeq[string](self.pools.conns.len) + result.nArgs = countQuestionMarks(sql) + + +proc ensurePreparedStmt(self: PostgresPreparedStatement, connI: int): Future[string] {.async.} = + if self.stmtNames[connI].len == 0: + let stmtName = &"{self.stmtBaseName}_{connI}" + await postgres_impl.prepare( + self.owner.pools.conns[connI].conn, + self.sql, + self.owner.pools.timeout, + stmtName, + self.nArgs + ) + self.stmtNames[connI] = stmtName + return self.stmtNames[connI] + + # ================================================================================ # toJson # ================================================================================ @@ -401,16 +428,175 @@ proc transactionStart(self:PostgresConnections|PostgresQuery) {.async.} = self.isInTransaction = true self.transactionConn = connI - postgres_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[Row](), self.pools.timeout).await + postgres_impl.exec(self.pools.conns[connI].conn, "BEGIN", newJArray(), newSeq[seq[string]](), self.pools.timeout).await proc transactionEnd(self:PostgresConnections|PostgresQuery, query:string) {.async.} = - postgres_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[Row](), self.pools.timeout).await + postgres_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), newSeq[seq[string]](), self.pools.timeout).await self.returnConn(self.transactionConn).await self.transactionConn = 0 self.isInTransaction = false +proc getPreparedRows(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[(seq[seq[string]], DbRows)] {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmtName = await self.ensurePreparedStmt(connI) + return postgres_impl.preparedQuery( + self.owner.pools.conns[connI].conn, + args, + self.nArgs, + self.owner.pools.timeout, + stmtName + ).await + + +proc getPreparedAllRows(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[JsonNode](0) + return toJson(rows, dbRows) + + +proc getPreparedRow(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return none(JsonNode) + return toJson(rows, dbRows)[0].some() + + +proc getPreparedAllRowsPlain(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + return rows + + +proc getPreparedRowPlain(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[string](0) + return rows[0] + + +proc execPrepared(self: PostgresPreparedStatement, args: seq[PreparedParam]) {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmtName = await self.ensurePreparedStmt(connI) + await postgres_impl.preparedExec( + self.owner.pools.conns[connI].conn, + args, + self.nArgs, + self.owner.pools.timeout, + stmtName + ) + + +proc preparedGet(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRows(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirst(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRow(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedGetPlain(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRowsPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirstPlain(self: PostgresPreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRowPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedExec(self: PostgresPreparedStatement, args: seq[PreparedParam]) {.async.} = + try: + self.owner.log.logger(self.sql) + await self.execPrepared(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc get*(self: PostgresPreparedStatement, args: seq[string]): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc get*(self: PostgresPreparedStatement, args: JsonNode): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc first*(self: PostgresPreparedStatement, args: seq[string]): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc first*(self: PostgresPreparedStatement, args: JsonNode): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc getPlain*(self: PostgresPreparedStatement, args: seq[string]): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc getPlain*(self: PostgresPreparedStatement, args: JsonNode): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc firstPlain*(self: PostgresPreparedStatement, args: seq[string]): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc firstPlain*(self: PostgresPreparedStatement, args: JsonNode): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc exec*(self: PostgresPreparedStatement, args: seq[string]) {.async.} = + await self.preparedExec(args.toPreparedParams) + + +proc exec*(self: PostgresPreparedStatement, args: JsonNode) {.async.} = + await self.preparedExec(args.toPreparedParams) + + # ================================================================================ # public exec # ================================================================================ @@ -725,6 +911,25 @@ proc firstPlain*(self: RawPostgresQuery):Future[seq[string]] {.async.} = return self.getRowPlain(self.queryString, self.placeHolder).await +proc deallocatePreparedStmtSafely(self: PostgresPreparedStatement, connI: int, stmtName: string): Future[void] {.async.} = + try: + await postgres_impl.deallocate(self.owner.pools.conns[connI].conn, stmtName, self.owner.pools.timeout) + except CatchableError: + self.owner.log.echoErrorMsg("deallocate failed for " & stmtName & ": " & getCurrentExceptionMsg()) + + +proc close*(self: PostgresPreparedStatement) {.async.} = + var futs: seq[Future[void]] + for i, stmtName in self.stmtNames: + if stmtName.len == 0: + continue + futs.add(self.deallocatePreparedStmtSafely(i, stmtName)) + if futs.len > 0: + await all(futs) + for i in 0 ..< self.stmtNames.len: + self.stmtNames[i] = "" + + template seeder*(rdb:PostgresConnections, tableName:string, body:untyped):untyped = ## The `seeder` block allows the code in the block to work only when the table is empty. block: diff --git a/src/allographer/query_builder/models/postgres/postgres_types.nim b/src/allographer/query_builder/models/postgres/postgres_types.nim index 9d668d9c..f7024aec 100644 --- a/src/allographer/query_builder/models/postgres/postgres_types.nim +++ b/src/allographer/query_builder/models/postgres/postgres_types.nim @@ -57,6 +57,14 @@ type RawPostgresQuery* = ref object transactionConn*: int +type PostgresPreparedStatement* = ref object + owner*: PostgresConnections + sql*: string + stmtBaseName*: string + stmtNames*: seq[string] + nArgs*: int + + proc `$`*(self:PostgresConnections|PostgresQuery|RawPostgresQuery):string = return "PostgreSQL" diff --git a/src/allographer/query_builder/models/sqlite/sqlite_exec.nim b/src/allographer/query_builder/models/sqlite/sqlite_exec.nim index d658aa4e..b7e6a999 100644 --- a/src/allographer/query_builder/models/sqlite/sqlite_exec.nim +++ b/src/allographer/query_builder/models/sqlite/sqlite_exec.nim @@ -9,8 +9,10 @@ import std/tables import std/times import ../../libs/sqlite/sqlite_impl import ../../libs/sqlite/sqlite_lib +import ../../libs/sqlite/sqlite_rdb import ../../log import ../database_types +import ../../prepared_param import ./query/sqlite_builder import ./sqlite_types @@ -73,6 +75,22 @@ proc returnConn(self: SqliteConnections | SqliteQuery | RawSqliteQuery, i: int) wakeOnePoolWaiter(self.pools) +proc prepare*(self: SqliteConnections, sql: string): SqlitePreparedStatement = + SqlitePreparedStatement( + owner: self, + sql: sql, + stmts: newSeq[PStmt](self.pools.conns.len), + nArgs: countQuestionMarks(sql) + ) + + +proc ensurePreparedStmt(self: SqlitePreparedStatement, connI: int): Future[PStmt] {.async.} = + if self.stmts[connI].isNil: + self.stmts[connI] = sqlite_impl.prepare(self.owner.pools.conns[connI].conn, self.sql, self.owner.pools.timeout).await + return self.stmts[connI] + + + # ================================================================================ # toJson # ================================================================================ @@ -509,6 +527,128 @@ proc transactionEnd(self:SqliteConnections, query:string) {.async.} = sqlite_impl.exec(self.pools.conns[self.transactionConn].conn, query, newJArray(), self.pools.timeout).await +proc getPreparedRows(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[(seq[seq[string]], DbRows)] {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + if not self.hasCachedColumns: + setColumnsStaticMeta(self.cachedColumns, stmt) + self.hasCachedColumns = true + + return sqlite_impl.preparedQueryReuse( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout, + self.cachedColumns + ).await + + +proc getPreparedAllRows(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[JsonNode](0) + return toJson(rows, dbRows) + + +proc getPreparedRow(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + let (rows, dbRows) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return none(JsonNode) + return toJson(rows, dbRows)[0].some() + + +proc getPreparedAllRowsPlain(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + return rows + + +proc getPreparedRowPlain(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + let (rows, _) = await self.getPreparedRows(args) + if rows.len == 0: + self.owner.log.echoErrorMsg(self.sql) + return newSeq[string](0) + return rows[0] + + +proc execPrepared(self: SqlitePreparedStatement, args: seq[PreparedParam]) {.async.} = + var connI = self.owner.transactionConn + if not self.owner.isInTransaction: + connI = getFreeConn(self.owner).await + defer: + if not self.owner.isInTransaction: + self.owner.returnConn(connI).await + if connI == errorConnectionNum: + return + + let stmt = await self.ensurePreparedStmt(connI) + await sqlite_impl.preparedExecReuse( + self.owner.pools.conns[connI].conn, + stmt, + args, + self.owner.pools.timeout + ) + + +proc preparedGet(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRows(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirst(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[Option[JsonNode]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRow(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedGetPlain(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[seq[string]]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedAllRowsPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedFirstPlain(self: SqlitePreparedStatement, args: seq[PreparedParam]): Future[seq[string]] {.async.} = + try: + self.owner.log.logger(self.sql) + return await self.getPreparedRowPlain(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + +proc preparedExec(self: SqlitePreparedStatement, args: seq[PreparedParam]) {.async.} = + try: + self.owner.log.logger(self.sql) + await self.execPrepared(args) + except CatchableError: + self.owner.log.echoErrorMsg(self.sql) + self.owner.log.echoErrorMsg(getCurrentExceptionMsg()) + raise getCurrentException() + + # ================================================================================ # public exec # ================================================================================ @@ -591,55 +731,6 @@ proc findPlain*(self:SqliteQuery, id: int, key="id"):Future[seq[string]] {.async return self.findPlain($id, key).await -# ==================== return Object ==================== -# proc get*[T](self: SqliteQuery, typ:typedesc[T]):Future[seq[T]] {.async.} = -# var sql = self.selectBuilder() -# try: -# self.log.logger(sql) -# let rows = self.getAllRows(sql).await -# for row in rows: -# result.add(row.to(typ)) -# except CatchableError: -# self.log.echoErrorMsg(sql) -# self.log.echoErrorMsg( getCurrentExceptionMsg() ) -# raise getCurrentException() - - -# proc first*[T](self: SqliteQuery, typ:typedesc[T]):Future[Option[T]] {.async.} = -# var sql = self.selectFirstBuilder() -# try: -# self.log.logger(sql) -# let row = self.getRow(sql).await -# if row.isSome(): -# return row.get().to(typ).some() -# else: -# return none(typ) -# except CatchableError: -# self.log.echoErrorMsg(sql) -# self.log.echoErrorMsg( getCurrentExceptionMsg() ) -# raise getCurrentException() - - -# proc find*[T](self: SqliteQuery, id:string, typ:typedesc[T], key="id"):Future[Option[T]] {.async.} = -# self.placeHolder.add(%*{"key":key, "value": id}) -# var sql = self.selectFindBuilder(key) -# try: -# self.log.logger(sql) -# let row = self.getRow(sql).await -# if row.isSome(): -# return row.get().to(typ).some() -# else: -# return none(typ) -# except CatchableError: -# self.log.echoErrorMsg(sql) -# self.log.echoErrorMsg( getCurrentExceptionMsg() ) -# raise getCurrentException() - - -# proc find*[T](self: SqliteQuery, id:int, typ:typedesc[T], key="id"):Future[Option[T]] {.async.} = -# return self.find($id, typ, key).await - - # ==================== insert JsonNode ==================== proc insert*(self:SqliteQuery, items:JsonNode) {.async.} = let sql = self.insertValueBuilder(items) @@ -836,6 +927,62 @@ proc firstPlain*(self: RawSqliteQuery):Future[seq[string]] {.async.} = return self.getRowPlain(self.queryString, self.placeHolder).await +proc close*(self: SqlitePreparedStatement) {.async.} = + for i, stmt in self.stmts: + if stmt.isNil: + continue + try: + discard finalize(stmt) + except CatchableError: + self.owner.log.echoErrorMsg("finalize failed for prepared stmt: " & getCurrentExceptionMsg()) + self.stmts[i] = nil + + +# ================================================================================ +# public prepared exec +# ================================================================================ + +proc get*(self: SqlitePreparedStatement, args: seq[string]): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc get*(self: SqlitePreparedStatement, args: JsonNode): Future[seq[JsonNode]] {.async.} = + return await self.preparedGet(args.toPreparedParams) + + +proc first*(self: SqlitePreparedStatement, args: seq[string]): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc first*(self: SqlitePreparedStatement, args: JsonNode): Future[Option[JsonNode]] {.async.} = + return await self.preparedFirst(args.toPreparedParams) + + +proc getPlain*(self: SqlitePreparedStatement, args: seq[string]): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc getPlain*(self: SqlitePreparedStatement, args: JsonNode): Future[seq[seq[string]]] {.async.} = + return await self.preparedGetPlain(args.toPreparedParams) + + +proc firstPlain*(self: SqlitePreparedStatement, args: seq[string]): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc firstPlain*(self: SqlitePreparedStatement, args: JsonNode): Future[seq[string]] {.async.} = + return await self.preparedFirstPlain(args.toPreparedParams) + + +proc exec*(self: SqlitePreparedStatement, args: seq[string]) {.async.} = + await self.preparedExec(args.toPreparedParams) + + +proc exec*(self: SqlitePreparedStatement, args: JsonNode) {.async.} = + await self.preparedExec(args.toPreparedParams) + + + template seeder*(rdb:SqliteConnections, tableName:string, body:untyped):untyped = ## The `seeder` block allows the code in the block to work only when the table is empty. block: diff --git a/src/allographer/query_builder/models/sqlite/sqlite_types.nim b/src/allographer/query_builder/models/sqlite/sqlite_types.nim index 214cea69..91a30b24 100644 --- a/src/allographer/query_builder/models/sqlite/sqlite_types.nim +++ b/src/allographer/query_builder/models/sqlite/sqlite_types.nim @@ -2,6 +2,7 @@ import std/asyncdispatch import std/deques import std/json import std/tables +import ../database_types import ../../log import ../../libs/sqlite/sqlite_rdb @@ -56,5 +57,15 @@ type RawSqliteQuery* = ref object transactionConn*: int +type SqlitePreparedStatement* = ref object + owner*: SqliteConnections + sql*: string + stmts*: seq[PStmt] + nArgs*: int + cachedColumns*: DbColumns + hasCachedColumns*: bool + + + proc `$`*(self:SqliteConnections|SqliteQuery|RawSqliteQuery):string = return "SQLite" diff --git a/src/allographer/query_builder/prepared_param.nim b/src/allographer/query_builder/prepared_param.nim new file mode 100644 index 00000000..452bb278 --- /dev/null +++ b/src/allographer/query_builder/prepared_param.nim @@ -0,0 +1,115 @@ +import std/json +import std/options +import std/macros + + +type PreparedParam* = object + value*: string + isNull*: bool + + +proc nullPreparedParam*(): PreparedParam = + result.isNull = true + + +proc toPreparedParam*(v: string): PreparedParam = + result.value = v + + +proc toPreparedParam*(v: cstring): PreparedParam = + if v.isNil: + return nullPreparedParam() + result.value = $v + + +proc toPreparedParam*(v: bool): PreparedParam = + result.value = if v: "1" else: "0" + + +proc toPreparedParam*[T: SomeInteger](v: T): PreparedParam = + result.value = $v + + +proc toPreparedParam*[T: SomeFloat](v: T): PreparedParam = + result.value = $v + + +proc toPreparedParam*(v: JsonNode): PreparedParam = + if v.isNil or v.kind == JNull: + return nullPreparedParam() + + case v.kind + of JBool: + result.value = if v.getBool: "1" else: "0" + of JInt: + result.value = $v.getInt + of JFloat: + result.value = $v.getFloat + of JString: + result.value = v.getStr + of JArray, JObject: + result.value = v.pretty + of JNull: + discard + + +proc toPreparedParam*[T](v: Option[T]): PreparedParam = + if v.isSome: + return toPreparedParam(v.get) + return nullPreparedParam() + + +proc toPreparedParam*[T](v: T): PreparedParam = + result.value = $v + + +proc toPreparedParams*(args: seq[string]): seq[PreparedParam] = + result = newSeq[PreparedParam](args.len) + for i, arg in args: + if arg == "NULL" or arg == "null": + result[i] = nullPreparedParam() + else: + result[i] = toPreparedParam(arg) + + +proc toPreparedParams*(args: JsonNode): seq[PreparedParam] = + if args.isNil or args.kind == JNull: + return + + if args.kind == JArray: + result = newSeq[PreparedParam](args.len) + for i in 0 ..< args.len: + result[i] = toPreparedParam(args[i]) + return + + result = @[toPreparedParam(args)] + + +proc preparedText*(param: PreparedParam): string = + if param.isNull: + return "NULL" + return param.value + + +proc preparedTextSeq*(args: openArray[PreparedParam]): seq[string] = + result = newSeq[string](args.len) + for i, arg in args: + result[i] = arg.preparedText + + +proc countQuestionMarks*(s: string): int = + for ch in s: + if ch == '?': + inc result + + +proc buildPreparedArgsExpr*(args: NimNode): NimNode = + let toPreparedParamSym = bindSym("toPreparedParam") + let nullPreparedParamSym = bindSym("nullPreparedParam") + var arr = nnkBracket.newTree() + for arg in args: + if arg.kind == nnkNilLit: + arr.add(newCall(nullPreparedParamSym)) + else: + arr.add(newCall(toPreparedParamSym, arg)) + result = newTree(nnkPrefix, ident("@"), arr) diff --git a/tests/mariadb/test_prepared_statement.nim b/tests/mariadb/test_prepared_statement.nim new file mode 100644 index 00000000..c49edb93 --- /dev/null +++ b/tests/mariadb/test_prepared_statement.nim @@ -0,0 +1,89 @@ +discard """ + cmd: "nim c -d:reset -d:ssl -r $file" +""" + +import std/unittest +import std/asyncdispatch +import std/json +import std/options +import std/strformat +import ../../src/allographer/schema_builder +import ../../src/allographer/query_builder +import ./connections + + +let rdb = mariadb + + +proc setup(rdb: MariadbConnections) = + rdb.create([ + table("auth", [ + Column.increments("id"), + Column.string("auth") + ]), + table("user", [ + Column.increments("id"), + Column.string("name").nullable(), + Column.string("email").nullable(), + Column.string("address").nullable(), + Column.date("submit_on").nullable(), + Column.datetime("submit_at").nullable(), + Column.foreign("auth_id").reference("id").onTable("auth").onDelete(SET_NULL).nullable() + ]) + ]) + + seeder(rdb, "auth"): + rdb.table("auth").insert(@[ + %*{"auth": "admin"}, + %*{"auth": "user"} + ]).waitFor + + seeder(rdb, "user"): + var users: seq[JsonNode] + for i in 1..10: + let authId = if i mod 2 == 0: 2 else: 1 + let month = if i > 9: $i else: &"0{i}" + users.add( + %*{ + "name": &"user{i}", + "email": &"user{i}@example.com", + "auth_id": authId, + "submit_on": &"2020-{month}-01", + "submit_at": &"2020-{month}-01 00:00:00", + } + ) + + rdb.table("user").insert(users).waitFor + + +setup(rdb) + + +suite($rdb & " prepared statement"): + test("select"): + let stmt = rdb.prepare("""SELECT `id`, `name`, `email`, `address` FROM `user` WHERE `id` = ?""") + defer: + waitFor stmt.close() + + let args = newJArray() + args.add(newJInt(1)) + + let rows = stmt.get(args).waitFor + check rows.len == 1 + check rows[0] == %*{"id": 1, "name": "user1", "email": "user1@example.com", "address": newJNull()} + let rowOpt = stmt.first(args).waitFor + let row = options.get(rowOpt) + check row["name"].getStr == "user1" + check stmt.getPlain(args).waitFor[0][1] == "user1" + check stmt.firstPlain(args).waitFor[1] == "user1" + + + test("update null"): + let stmt = rdb.prepare("""UPDATE `user` SET `address` = ? WHERE `id` = ?""") + defer: + waitFor stmt.close() + + waitFor stmt.exec(@["NULL", "1"]) + let rowOpt = rdb.table("user").find(1).waitFor + let row = options.get(rowOpt) + check row["address"].kind == JNull diff --git a/tests/mysql/test_prepared_statement.nim b/tests/mysql/test_prepared_statement.nim new file mode 100644 index 00000000..4a6b424e --- /dev/null +++ b/tests/mysql/test_prepared_statement.nim @@ -0,0 +1,89 @@ +discard """ + cmd: "nim c -d:reset -d:ssl -r $file" +""" + +import std/unittest +import std/asyncdispatch +import std/json +import std/options +import std/strformat +import ../../src/allographer/schema_builder +import ../../src/allographer/query_builder +import ./connections + + +let rdb = mysql + + +proc setup(rdb: MysqlConnections) = + rdb.create([ + table("auth", [ + Column.increments("id"), + Column.string("auth") + ]), + table("user", [ + Column.increments("id"), + Column.string("name").nullable(), + Column.string("email").nullable(), + Column.string("address").nullable(), + Column.date("submit_on").nullable(), + Column.datetime("submit_at").nullable(), + Column.foreign("auth_id").reference("id").onTable("auth").onDelete(SET_NULL).nullable() + ]) + ]) + + seeder(rdb, "auth"): + rdb.table("auth").insert(@[ + %*{"auth": "admin"}, + %*{"auth": "user"} + ]).waitFor + + seeder(rdb, "user"): + var users: seq[JsonNode] + for i in 1..10: + let authId = if i mod 2 == 0: 2 else: 1 + let month = if i > 9: $i else: &"0{i}" + users.add( + %*{ + "name": &"user{i}", + "email": &"user{i}@example.com", + "auth_id": authId, + "submit_on": &"2020-{month}-01", + "submit_at": &"2020-{month}-01 00:00:00", + } + ) + + rdb.table("user").insert(users).waitFor + + +setup(rdb) + + +suite($rdb & " prepared statement"): + test("select"): + let stmt = rdb.prepare("""SELECT `id`, `name`, `email`, `address` FROM `user` WHERE `id` = ?""") + defer: + waitFor stmt.close() + + let args = newJArray() + args.add(newJInt(1)) + + let rows = stmt.get(args).waitFor + check rows.len == 1 + check rows[0] == %*{"id": 1, "name": "user1", "email": "user1@example.com", "address": newJNull()} + let rowOpt = stmt.first(args).waitFor + let row = options.get(rowOpt) + check row["name"].getStr == "user1" + check stmt.getPlain(args).waitFor[0][1] == "user1" + check stmt.firstPlain(args).waitFor[1] == "user1" + + + test("update null"): + let stmt = rdb.prepare("""UPDATE `user` SET `address` = ? WHERE `id` = ?""") + defer: + waitFor stmt.close() + + waitFor stmt.exec(@["NULL", "1"]) + let rowOpt = rdb.table("user").find(1).waitFor + let row = options.get(rowOpt) + check row["address"].kind == JNull diff --git a/tests/postgres/test_prepared_statement.nim b/tests/postgres/test_prepared_statement.nim new file mode 100644 index 00000000..81926baa --- /dev/null +++ b/tests/postgres/test_prepared_statement.nim @@ -0,0 +1,89 @@ +discard """ + cmd: "nim c -d:reset -d:ssl -r $file" +""" + +import std/unittest +import std/asyncdispatch +import std/json +import std/options +import std/strformat +import ../../src/allographer/schema_builder +import ../../src/allographer/query_builder +import ./connections + + +let rdb = postgres + + +proc setup(rdb: PostgresConnections) = + rdb.create([ + table("auth", [ + Column.increments("id"), + Column.string("auth") + ]), + table("user", [ + Column.increments("id"), + Column.string("name").nullable(), + Column.string("email").nullable(), + Column.string("address").nullable(), + Column.date("submit_on").nullable(), + Column.datetime("submit_at").nullable(), + Column.foreign("auth_id").reference("id").onTable("auth").onDelete(SET_NULL).nullable() + ]) + ]) + + seeder(rdb, "auth"): + rdb.table("auth").insert(@[ + %*{"auth": "admin"}, + %*{"auth": "user"} + ]).waitFor + + seeder(rdb, "user"): + var users: seq[JsonNode] + for i in 1..10: + let authId = if i mod 2 == 0: 2 else: 1 + let month = if i > 9: $i else: &"0{i}" + users.add( + %*{ + "name": &"user{i}", + "email": &"user{i}@example.com", + "auth_id": authId, + "submit_on": &"2020-{month}-01", + "submit_at": &"2020-{month}-01 00:00:00", + } + ) + + rdb.table("user").insert(users).waitFor + + +setup(rdb) + + +suite($rdb & " prepared statement"): + test("select"): + let stmt = rdb.prepare("""SELECT "id", "name", "email", "address" FROM "user" WHERE "id" = ?""") + defer: + waitFor stmt.close() + + let args = newJArray() + args.add(newJInt(1)) + + let rows = stmt.get(args).waitFor + check rows.len == 1 + check rows[0] == %*{"id": 1, "name": "user1", "email": "user1@example.com", "address": newJNull()} + let rowOpt = stmt.first(args).waitFor + let row = options.get(rowOpt) + check row["name"].getStr == "user1" + check stmt.getPlain(args).waitFor[0][1] == "user1" + check stmt.firstPlain(args).waitFor[1] == "user1" + + + test("update null"): + let stmt = rdb.prepare("""UPDATE "user" SET "address" = ? WHERE "id" = ?""") + defer: + waitFor stmt.close() + + waitFor stmt.exec(@["NULL", "1"]) + let rowOpt = rdb.table("user").find(1).waitFor + let row = options.get(rowOpt) + check row["address"].kind == JNull diff --git a/tests/sqlite/test_prepared_statement.nim b/tests/sqlite/test_prepared_statement.nim new file mode 100644 index 00000000..b7d65c84 --- /dev/null +++ b/tests/sqlite/test_prepared_statement.nim @@ -0,0 +1,89 @@ +discard """ + cmd: "nim c -d:reset -d:ssl -r $file" +""" + +import std/unittest +import std/asyncdispatch +import std/json +import std/options +import std/strformat +import ../../src/allographer/schema_builder +import ../../src/allographer/query_builder +import ./connections + + +let rdb = sqlite + + +proc setup(rdb: SqliteConnections) = + rdb.create([ + table("auth", [ + Column.increments("id"), + Column.string("auth") + ]), + table("user", [ + Column.increments("id"), + Column.string("name").nullable(), + Column.string("email").nullable(), + Column.string("address").nullable(), + Column.date("submit_on").nullable(), + Column.datetime("submit_at").nullable(), + Column.foreign("auth_id").reference("id").onTable("auth").onDelete(SET_NULL).nullable() + ]) + ]) + + seeder(rdb, "auth"): + rdb.table("auth").insert(@[ + %*{"auth": "admin"}, + %*{"auth": "user"} + ]).waitFor + + seeder(rdb, "user"): + var users: seq[JsonNode] + for i in 1..10: + let authId = if i mod 2 == 0: 2 else: 1 + let month = if i > 9: $i else: &"0{i}" + users.add( + %*{ + "name": &"user{i}", + "email": &"user{i}@example.com", + "auth_id": authId, + "submit_on": &"2020-{month}-01", + "submit_at": &"2020-{month}-01 00:00:00", + } + ) + + rdb.table("user").insert(users).waitFor + + +setup(rdb) + + +suite($rdb & " prepared statement"): + test("select"): + let stmt = rdb.prepare("""SELECT "id", "name", "email", "address" FROM "user" WHERE "id" = ?""") + defer: + waitFor stmt.close() + + let args = newJArray() + args.add(newJInt(1)) + + let rows = stmt.get(args).waitFor + check rows.len == 1 + check rows[0] == %*{"id": 1, "name": "user1", "email": "user1@example.com", "address": newJNull()} + let rowOpt = stmt.first(args).waitFor + let row = options.get(rowOpt) + check row["name"].getStr == "user1" + check stmt.getPlain(args).waitFor[0][1] == "user1" + check stmt.firstPlain(args).waitFor[1] == "user1" + + + test("update null"): + let stmt = rdb.prepare("""UPDATE "user" SET "address" = ? WHERE "id" = ?""") + defer: + waitFor stmt.close() + + waitFor stmt.exec(@["NULL", "1"]) + let rowOpt = rdb.table("user").find(1).waitFor + let row = options.get(rowOpt) + check row["address"].kind == JNull From c096364ecc39404a365c8109fd97ca299f012ea7 Mon Sep 17 00:00:00 2001 From: itsmura-h Date: Mon, 30 Mar 2026 10:14:36 +0000 Subject: [PATCH 3/5] run benchmark --- .../rules/branch/333-prepared-statement.mdc | 401 ++++++++++++++++++ config.nims | 8 +- example/benchmark.nim | 50 ++- 3 files changed, 448 insertions(+), 11 deletions(-) diff --git a/.cursor/rules/branch/333-prepared-statement.mdc b/.cursor/rules/branch/333-prepared-statement.mdc index ce15030d..530dea6d 100644 --- a/.cursor/rules/branch/333-prepared-statement.mdc +++ b/.cursor/rules/branch/333-prepared-statement.mdc @@ -40,6 +40,8 @@ alwaysApply: false - [x] `preparedArgsExpr` の間接呼び出しを除去した - [x] MariaDB の `stmt.mysql` 直接参照を撤去した - [x] PreparedStatement 型の冗長な `log` / `pools` フィールドを削除した +- [x] `example/benchmark.nim` の PostgreSQL 計測で prepared update が遅く見える理由を調査し、ブランチルールへ反映した +- [x] prepared statement を高速化目的で使うための実装方針を設計書としてブランチルールへ追記した ## 参考資料 @@ -52,6 +54,405 @@ alwaysApply: false ## 調査結果・設計まとめ +### 2026-03-30 benchmark.nim(PostgreSQL) で prepared update が遅く見える理由 + +結論として、現在の `example/benchmark.nim` が計測しているのは「prepared statement を十分に warm up して再利用した steady-state の実行性能」ではなく、**prepare / lazy prepare / deallocate を含めた prepared statement のライフサイクル全体**である。そのため PostgreSQL では通常の `update` より prepared 側が遅く見えやすい。 + +#### 1. ベンチマークが `prepare` と `close()` を毎回の計測に含めている + +`benchUpdatePrepared()` は毎回の実行ごとに `selectStmt` / `updateStmt` を新規作成し、500 件の処理が終わったあと `close()` している。 + +- `example/benchmark.nim:83-105` +- `example/benchmark.nim:114-118` + +つまり `timeProcess("update prepared", ...)` の 1 回分には次がすべて含まれる。 + +- `rdb.prepare(...)` で prepared statement ハンドルを作る +- 各コネクションで必要になった時点の lazy prepare +- 500 件の `SELECT` + `UPDATE` +- `close()` による各コネクションの `DEALLOCATE` + +しかも `timeProcess` はこれを 5 回繰り返すので、prepared statement の準備・破棄コストが毎回入り直す。 + +#### 2. PostgreSQL 実装は「コネクションごと」に lazy prepare する + +`PostgresPreparedStatement` は `stmtNames` をコネクション数ぶん持ち、`ensurePreparedStmt()` がそのコネクションで未 prepare の場合だけ `postgres_impl.prepare()` を呼ぶ。 + +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:81-100` + +したがって PostgreSQL の prepared statement は「SQL ごとに 1 回」ではなく、**SQL ごと × 使用したコネクションごとに 1 回** prepare される。 + +このベンチマークは `DB_MAX_CONNECTION` のデフォルトが 95 で、500 件を並列に投げている。 + +- `example/benchmark.nim:20` +- `example/benchmark.nim:25` + +そのため `selectStmt` と `updateStmt` の 2 本は、1 回の計測中に多くのコネクションへ広く拡散して prepare されやすい。最悪に近いケースでは、1 回の計測で概ね次の追加往復が発生する。 + +- `selectStmt`: 最大 95 接続ぶんの `PREPARE` +- `updateStmt`: 最大 95 接続ぶんの `PREPARE` +- `selectStmt.close()`: 使用済み接続ぶんの `DEALLOCATE` +- `updateStmt.close()`: 使用済み接続ぶんの `DEALLOCATE` + +つまり通常経路にない追加通信が、1 回のベンチで最大 190 回の `PREPARE` と 190 回の `DEALLOCATE` ぶん乗る。 + +#### 3. `first()` と `exec()` が別々に接続を取り直すので、prepare がさらに拡散する + +prepared の `first()` と `exec()` はどちらも内部で `getFreeConn()` し、処理後に即 `returnConn()` する。トランザクションで束縛していないため、1 つの Future 内の `SELECT` と `UPDATE` が別コネクションで走ることがある。 + +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:441-458` +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:490-507` + +このため 500 件の処理は実質的に「1000 回のコネクション取得」に近い挙動になり、prepared statement がプール全体へ広がりやすい。結果として lazy prepare の発生回数が増える。 + +#### 4. 通常の `update` 側にも初回コストはあるが、prepared 側よりかなり小さい + +通常の PostgreSQL `update` は `exec()` の前に `information_schema.columns` を読んで列型をキャッシュする。ただしこれはテーブルごとに 1 回だけで、以後は `columnTypeCache` が再利用される。 + +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:147-159` +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:759-763` + +一方 prepared 側は列型キャッシュ取得を避けられても、その代わりに `PREPARE` / `DEALLOCATE` がコネクション単位で発生する。今回のベンチ条件では、この差のほうが重い。 + +#### 5. PostgreSQL の通常経路もすでに bind パラメータ実行で、prepared の利得が限定されやすい + +通常の `PostgresQuery` は文字列連結で値を埋め込んでいるわけではなく、`postgres_impl.query/exec` で `PQsendQueryParams` を使っている。つまり通常経路でもクライアント側の SQL 文字列組み立てコストは大きくなく、prepared が削減できるのは主にサーバー側の parse/plan の一部である。 + +- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:69-77` +- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:124-161` + +今回のベンチでは 1 回のステートメントを長く使い回す前に close してしまうため、その利得よりも prepare/deallocate の通信コストのほうが前面に出る。 + +#### 6. 実装ベースでの整理 + +したがって、今回の PostgreSQL 計測結果は「prepared statement が本質的に遅い」ことを示しているのではなく、次の条件が重なった結果と解釈するのが正しい。 + +- `benchmark.nim` が prepared statement を毎回作成して毎回 close している +- PostgreSQL 実装が prepared statement をコネクションごとに lazy prepare する +- `SELECT` と `UPDATE` が別々にコネクションを取り直すため、prepare 対象の接続が増えやすい +- 通常経路はすでに `PQsendQueryParams` を使っており、prepared の純粋な利得が小さい + +#### 7. 再計測時の注意点 + +prepared statement の純粋な再利用効果を見たい場合は、少なくとも次の条件に変える必要がある。 + +- `selectStmt` / `updateStmt` を `timeProcess` の外で 1 回だけ作る +- 5 回の繰り返しで同じ prepared statement を使い回す +- `close()` は最後に 1 回だけ呼ぶ +- 可能なら `DB_MAX_CONNECTION` を絞るか、同一処理内で接続を固定してコネクションごとの warm up 拡散を抑える + +現状コードのままでは、benchmark 名は `update prepared` でも、実際に測っているものは「prepared statement reuse の速さ」ではなく「prepared statement の準備・拡散・破棄まで含む総コスト」である。 + +### 2026-03-30 prepared statement を高速化目的で使うための設計書 + +#### 1. 目的 + +prepared statement を「SQL インジェクション対策の別 API」として提供するだけでなく、**同じ SQL を高頻度に繰り返し実行するワークロードで、通常経路より速くなる実装**へ寄せる。 + +このブランチで目指すべき性能改善の主眼は次の 3 つ。 + +- prepare/deallocate の往復回数を減らす +- 同一の logical operation 内で接続を固定し、prepared state の局所性を高める +- ベンチマークと利用者 API の両方で、prepared statement の warm state を継続利用できるようにする + +#### 2. 現状のボトルネック整理 + +高速化を阻害している要因は、前節の benchmark 調査結果と合わせると次の 4 点に集約できる。 + +##### 2-1. prepared state がハンドル単位で、プール単位の cache になっていない + +現状の `PostgresPreparedStatement` は `stmtBaseName` / `stmtNames` を自分自身の中に持つ。 + +- `src/allographer/query_builder/models/postgres/postgres_types.nim` +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:81-100` + +このため同じ SQL に対して `rdb.prepare(sql)` を 2 回呼ぶと、同一プール内でも statement 情報は共有されず、別の handle として prepare がやり直される。ベンチマークのように handle を短命に作る使い方では、この設計がそのまま追加コストになる。 + +##### 2-2. prepared 実行ごとに `getFreeConn()` するため、接続局所性が弱い + +`first()` と `exec()` は prepared statement でも毎回 `getFreeConn()` → 実行 → `returnConn()` になっている。 + +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:441-458` +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:490-507` + +このため「SELECT してから UPDATE」のような 1 つの業務操作の中でも接続が変わりうる。prepared の再利用に必要な「同じ接続に同じ SQL を当て続ける」性質が弱く、prepare がプール全体に拡散する。 + +##### 2-3. `close()` が論理 close ではなく物理 deallocate になっている + +現状の `close()` は、その handle が使った全接続に対して `DEALLOCATE` を送る。 + +- `src/allographer/query_builder/models/postgres/postgres_exec.nim:921-930` + +利用者が「このハンドルをもう参照しない」という意味で close しただけでも、プール内に蓄積した prepared state を物理破棄してしまう。短いスコープの prepared handle を何度も作る利用法と相性が悪い。 + +##### 2-4. 通常経路もすでに bind 実行なので、prepared の優位は薄い + +PostgreSQL の通常経路は `PQsendQueryParams` ベースであり、文字列埋め込みをしていない。 + +- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:69-77` +- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:124-161` + +したがって prepared による性能優位は「サーバー側 parse/plan の再利用」と「クライアント側の余計な周辺処理削減」に限られる。prepare/deallocate や接続取り直しが多いままでは、通常経路を上回りにくい。 + +#### 3. 設計方針 + +高速化のための設計方針は次の 3 本柱にする。 + +##### 3-1. プール単位の prepared statement cache を導入する + +`PreparedStatement` 自体に driver ハンドルを抱え込ませるのではなく、**プール (`Connections`) が SQL ごとの prepared state を保持する**設計に寄せる。 + +概念的には以下の構成にする。 + +```nim +type PreparedCacheKey = object + sql*: string + nArgs*: int + +type PostgresPreparedEntry = ref object + sql*: string + nArgs*: int + stmtBaseName*: string + stmtNames*: seq[string] ## connI ごとの物理 prepared 名 + refCount*: int ## 論理 handle 数 + lastUsedAt*: int64 + +type Connections* = ref object + conns*: seq[Connection] + timeout*: int + waiters*: Deque[Future[void]] + columnTypeCache*: Table[string, seq[Row]] + preparedCache*: Table[string, PostgresPreparedEntry] +``` + +`prepare(self, sql)` の意味も次のように変える。 + +- いま: 新しい `PostgresPreparedStatement` を毎回独立生成 +- 変更後: `preparedCache[sql]` を lookup し、なければ論理 entry を作る +- 戻り値の handle は cache entry への参照だけを持つ軽量ハンドルにする + +これにより同一 SQL を何度 `prepare()` しても、物理 prepared state はプール内で共有される。 + +##### 3-2. 「接続固定で複数回実行する」API を追加する + +高速化を狙うなら、prepared statement を単発 API として呼ぶだけでは不十分。**呼び出し側が同じ接続に複数の prepared 実行を束ねられる API** を持つべきである。 + +最小案は次のどちらか。 + +- `withConnection` / `withPreparedConnection` のような block API +- `PreparedSession` / `ConnectionLease` のような明示ハンドル API + +PostgreSQL の既存構造に最も素直なのは block API。 + +```nim +await rdb.withPreparedConnection(proc (ctx: PostgresPreparedContext): Future[void] {.async.} = + discard await selectStmt.first(ctx, @[$index]) + await updateStmt.exec(ctx, @[$number, $index]) +) +``` + +`ctx` は内部に `connI` を持ち、prepared 実行は `getFreeConn()` を呼ばず必ずその接続を使う。これにより 1 つの業務操作の中で接続がぶれなくなる。 + +この API はトランザクションとも整合させやすい。 + +- トランザクション中なら `transactionConn` を流用 +- 非トランザクション時は `withPreparedConnection` が 1 回だけ `getFreeConn()` し、block 終了時に返却 + +##### 3-3. `close()` を「論理 close」に変更し、物理破棄は別 API に分離する + +prepared statement の高速化には、プールに残った warm state を次回も再利用できることが重要。そのため `close()` は物理 `DEALLOCATE` ではなく、**論理参照の解放だけ**に変える。 + +想定する意味論は次の通り。 + +- `close()`: handle の `refCount` を減らすだけ。entry 自体は cache に残す +- `flushPrepared(sql)` または `clearPreparedCache()`: 物理 `DEALLOCATE` +- コネクション close 時: その接続に紐づく prepared state を一括破棄 + +この変更により、利用者が毎回 `prepare` / `close` しても、プール側が hot SQL を一定期間保持できる。 + +#### 4. PostgreSQL 向け具体設計 + +##### 4-1. 型変更 + +`PostgresPreparedStatement` は「物理 state の所有者」から「cache entry を参照するハンドル」へ変える。 + +```nim +type PostgresPreparedStatement* = ref object + owner*: PostgresConnections + cacheKey*: string +``` + +`stmtBaseName` / `stmtNames` / `nArgs` は `Connections.preparedCache[cacheKey]` 側へ移す。 + +##### 4-2. statement 名の決め方 + +PostgreSQL の prepared 名は cache entry に属する stable name にする。 + +候補: + +- `allographer_stmt_{hash(sql)}` +- 実際の物理名は `{base}_{connI}` + +`prepare()` のたびに `gPreparedStmtCounter` で新しい名前を振る現行方式だと cache 再利用と相性が悪い。SQL ベースで stable に決まる名前へ寄せる。 + +##### 4-3. `ensurePreparedStmt()` の役割変更 + +`ensurePreparedStmt(self, connI)` は handle ではなく cache entry を見るようにする。 + +挙動: + +- `preparedCache[cacheKey].stmtNames[connI]` が空なら `PQsendPrepare` +- すでに埋まっていればそのまま再利用 +- `lastUsedAt` を更新 + +これで同じ SQL を複数 handle が共有しても、接続ごとの physical prepare は 1 回で済む。 + +##### 4-4. evict 戦略 + +cache を無制限にすると長時間プロセスで膨らむため、最低限の eviction ポリシーを入れる。 + +初期実装は簡素でよい。 + +- `preparedCacheMaxEntries` を `Connections` に持つ +- 上限超過時は `refCount == 0` の entry だけを LRU で追い出す +- evict 時に、その entry が prepare 済みの接続へ `DEALLOCATE` + +TTL は後回しでよい。まずは `maxEntries + refCount + LRU` で十分。 + +#### 5. 接続固定 API の設計 + +##### 5-1. 新規型 + +```nim +type PostgresPreparedContext* = ref object + owner*: PostgresConnections + connI*: int +``` + +##### 5-2. 新規 API + +```nim +proc withPreparedConnection*( + self: PostgresConnections, + body: proc (ctx: PostgresPreparedContext): Future[void] +): Future[void] +``` + +必要なら戻り値付き overload も持たせる。 + +```nim +proc withPreparedConnection*[T]( + self: PostgresConnections, + body: proc (ctx: PostgresPreparedContext): Future[T] +): Future[T] +``` + +##### 5-3. prepared 実行の overload + +prepared statement に `ctx` 付き overload を足す。 + +```nim +proc first*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[Option[JsonNode]] +proc exec*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[void] +``` + +内部では `ctx.connI` をそのまま使い、`getFreeConn()` / `returnConn()` を行わない。 + +##### 5-4. 使い分け + +- 既存 API `stmt.exec(args)` は後方互換のため維持 +- 高速化を狙う経路は `withPreparedConnection` + `stmt.exec(ctx, args)` を推奨 + +これなら public API を壊さず、新しい最適化経路だけを増やせる。 + +#### 6. benchmark.nim の設計変更 + +性能検証用の example は、prepared statement の高速化効果を測れるように使い方も変える。 + +##### 6-1. statement 作成位置を `timeProcess` の外へ出す + +```nim +let selectStmt = rdb.prepare(selectSql) +let updateStmt = rdb.prepare(updateSql) + +waitFor timeProcess("update prepared", benchUpdatePrepared) + +await selectStmt.close() +await updateStmt.close() +``` + +これにより 5 回の計測中で同じ prepared state を使い回せる。 + +##### 6-2. 計測対象を 2 種類に分ける + +ベンチマーク名も分けるべき。 + +- `update prepared cold`: prepare/warmup を含む +- `update prepared warm`: warmup 済み state の再利用だけを測る + +prepared statement の利得を見るなら主対象は `warm`。 + +##### 6-3. 高速化経路では接続固定 API を使う + +```nim +await rdb.withPreparedConnection(proc (ctx: PostgresPreparedContext): Future[void] {.async.} = + discard await selectStmt.first(ctx, @[$index]) + await updateStmt.exec(ctx, @[$number, $index]) +) +``` + +これで 1 つの操作が 1 接続に閉じ、prepared locality が保たれる。 + +#### 7. 実装順序 + +この設計は段階的に入れる。 + +##### Phase 1: cache 化 + +- `Connections.preparedCache` 追加 +- `prepare()` を cache lookup ベースへ変更 +- `close()` を論理 close 化 +- `clearPreparedCache()` を追加 + +この段階だけでも「毎回 prepare / close する」使い方の無駄をかなり減らせる。 + +##### Phase 2: 接続固定 API + +- `PreparedContext` / `withPreparedConnection` を追加 +- prepared `get/first/exec` に `ctx` overload を追加 +- benchmark.nim をこの経路で更新 + +この段階で「SELECT と UPDATE が別接続に飛ぶ」問題を抑えられる。 + +##### Phase 3: eviction と運用制御 + +- `preparedCacheMaxEntries` +- LRU eviction +- 明示的 flush API +- 必要なら prewarm API + +ここは運用性の整備フェーズ。 + +#### 8. テスト方針 + +高速化系の変更でも、最低限次の挙動はテストで固定する。 + +- 同じ SQL を `prepare()` しても、同一接続では物理 prepare が 1 回しか走らないこと +- `close()` 後でも cache が残る設計なら、再 `prepare()` で物理 prepare が再実行されないこと +- `withPreparedConnection` の block 内で、同じ `connI` が再利用されること +- transaction 中は `transactionConn` が優先され、追加の `getFreeConn()` が走らないこと +- cache eviction 時に `DEALLOCATE` が正しく発行されること + +#### 9. 非目標 + +今回の高速化設計では、以下は初期スコープに含めない。 + +- PostgreSQL の generic plan / custom plan の細かい制御 +- SQL 正規化による高度な cache key 統合 +- prepared statement の driver 横断完全共通化 + +まずは PostgreSQL を基準に、**prepare/deallocate の無駄を減らし、接続固定で locality を作る**ところまでを優先する。 + --- ### 0. SQL 組み立てと値バインドの仕組み(現行実装) diff --git a/config.nims b/config.nims index ab19512a..9f41bec9 100644 --- a/config.nims +++ b/config.nims @@ -1,7 +1,7 @@ import os -putEnv("DB_SQLITE", $true) +putEnv("DB_SQLITE", $false) putEnv("DB_POSTGRES", $true) -putEnv("DB_MYSQL", $true) -putEnv("DB_MARIADB", $true) -putEnv("DB_SURREAL", $true) +putEnv("DB_MYSQL", $false) +putEnv("DB_MARIADB", $false) +putEnv("DB_SURREAL", $false) diff --git a/example/benchmark.nim b/example/benchmark.nim index e2ef1c1a..cc67b7b8 100644 --- a/example/benchmark.nim +++ b/example/benchmark.nim @@ -27,6 +27,7 @@ let sqlitePath = getEnv("SQLITE_PATH", "db.sqlite3") + mysqlUrl = getEnv("MYSQL_URL", "mysql://user:pass@mysql:3306/database") database = getEnv("DB_DATABASE", "database") user = getEnv("DB_USER", "user") password = getEnv("DB_PASSWORD", "pass") @@ -43,7 +44,7 @@ let surrealPort = getEnv("SURREAL_PORT", "8000").parseInt -template benchmarkScenario(rdb: untyped): untyped = +template benchmarkScenario(rdb: untyped, useBackticks: static[bool]): untyped = proc migrate() {.async.} = rdb.create( table("World", [ @@ -78,6 +79,31 @@ template benchmarkScenario(rdb: untyped): untyped = await all(futures) return response + proc benchUpdatePrepared(): Future[seq[JsonNode]] {.async.} = + when isExistsMariaDB or isExistsMySQL: + let selectSql = """SELECT `index` as id, `randomNumber` FROM `World` WHERE `index` = ?""" + let updateSql = """UPDATE `World` SET `randomNumber` = ? WHERE `index` = ?""" + else: + let selectSql = """SELECT "index" as id, "randomNumber" FROM "World" WHERE "index" = ?""" + let updateSql = """UPDATE "World" SET "randomNumber" = ? WHERE "index" = ?""" + + let selectStmt = rdb.prepare(selectSql) + let updateStmt = rdb.prepare(updateSql) + var response = newSeq[JsonNode](countNum) + var futures = newSeq[Future[void]](countNum) + for i in 1..countNum: + let index = rand(range1_10000) + let number = rand(range1_10000) + futures[i - 1] = (proc(): Future[void] {.async.} = + discard await selectStmt.first(@[$index]) + await updateStmt.exec(@[$number, $index]) + )() + response[i - 1] = %*{"id": index, "randomNumber": number} + await all(futures) + await selectStmt.close() + await updateStmt.close() + return response + proc timeProcess[T](name: system.string, cb: proc(): Future[T]) {.async.} = var eachTime = 0.0 var sumTime = 0.0 @@ -102,36 +128,46 @@ template benchmarkScenario(rdb: untyped): untyped = migrate().waitFor waitFor timeProcess("update", benchUpdate) + when compiles(rdb.prepare("SELECT 1")): + waitFor timeProcess("update prepared", benchUpdatePrepared) when isExistsSqlite: proc runSqlite() = echo "=== sqlite" let rdb = dbOpen(SQLite3, sqlitePath, maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) - benchmarkScenario(rdb) + benchmarkScenario(rdb, false) + +when isExistsMysql: + proc runMysql() = + echo "=== mysql" + let rdb = dbOpen(MySQL, mysqlUrl, maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) + benchmarkScenario(rdb, true) when isExistsMariadb: proc runMariadb() = echo "=== mariadb" - let rdb = dbOpen(MariaDB, database, user, password, mariaHost, mariaPort, maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) - benchmarkScenario(rdb) + let rdb = dbOpen(MariaDB, "mariadb://user:pass@mariadb:3306/database", maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) + benchmarkScenario(rdb, true) when isExistsPostgres: proc runPostgres() = echo "=== postgres" - let rdb = dbOpen(PostgreSQL, database, user, password, pgHost, pgPort, maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) - benchmarkScenario(rdb) + let rdb = dbOpen(PostgreSQL, "postgresql://user:pass@postgres:5432/database", maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) + benchmarkScenario(rdb, false) when isExistsSurrealdb: proc runSurreal() = echo "=== surrealdb" let rdb = waitFor dbOpen(SurrealDB, surrealNamespace, surrealDatabase, surrealUser, surrealPassword, surrealHost, surrealPort, maxConnections, timeout, shouldDisplayLog=shouldDisplayLog) - benchmarkScenario(rdb) + benchmarkScenario(rdb, false) proc main() = when isExistsSqlite: runSqlite() + when isExistsMysql: + runMysql() when isExistsMariadb: runMariadb() when isExistsPostgres: From ba600559e90bc080c86d6abc36c88e33827ef009 Mon Sep 17 00:00:00 2001 From: itsmura-h Date: Mon, 30 Mar 2026 10:18:31 +0000 Subject: [PATCH 4/5] update branch rule --- .../rules/branch/333-prepared-statement.mdc | 1366 ++--------------- 1 file changed, 87 insertions(+), 1279 deletions(-) diff --git a/.cursor/rules/branch/333-prepared-statement.mdc b/.cursor/rules/branch/333-prepared-statement.mdc index 530dea6d..c84330b2 100644 --- a/.cursor/rules/branch/333-prepared-statement.mdc +++ b/.cursor/rules/branch/333-prepared-statement.mdc @@ -2,219 +2,69 @@ description: 333-prepared-statementブランチでの開発時に読み込む alwaysApply: false --- -333-prepared-statement ブランチでの開発内容 +333-prepared-statement ブランチでの高速化内容 === このブランチで実装することは以下の通りです。 -- コネクションプールに対応した prepared statement API を SQLite・PostgreSQL・MariaDB に追加する +- prepared statement を、高頻度な同一 SQL 実行で通常経路より速くなるように改善する +- とくに PostgreSQL で、prepare/deallocate の無駄と接続拡散を減らす +- benchmark が prepared statement の warm 状態を正しく測れるようにする ## 進捗 -- [x] SQLite の prepared statement 実装 - - [x] `sqlite_impl.nim` に再利用可能な実行関数を追加(reset/rebind パス) - - [x] `sqlite_types.nim` に `SqlitePreparedStatement` 型を追加 - - [x] `sqlite_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加(`getPlain` / `firstPlain` も追加) -- [x] PostgreSQL の prepared statement 実装 - - [x] `postgres_impl.nim` に `deallocate` 関数を追加(`prepare` は `nParams` を明示、`preparedQueryOpts` / `preparedExecOpts` で `Option[string]` 引数対応) - - [x] `postgres_types.nim` に `PostgresPreparedStatement` 型を追加 - - [x] `postgres_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加(Plain 系も) -- [x] MariaDB の prepared statement 実装 - - [x] `mariadb_rdb.nim` の MYSQL_STMT 関連 FFI を有効化 - - [x] `mariadb_impl.nim` に stmt 操作関数を追加 - - [x] `mariadb_types.nim` に `MariadbPreparedStatement` 型を追加(`PSTMT` を export) - - [x] `mariadb_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加 -- [x] MySQL の prepared statement 実装 - - [x] `mysql_rdb.nim` の MYSQL_STMT 関連 FFI を有効化 - - [x] `mysql_impl.nim` に stmt 操作関数を追加 - - [x] `mysql_types.nim` に `MysqlPreparedStatement` 型を追加(`PSTMT` を export) - - [x] `mysql_exec.nim` に `prepare` / `get` / `first` / `exec` / `close` を追加 -- [x] `query_builder.nim` — 既存の `export *_types` / `export *_exec` で各 `*PreparedStatement` と `prepare` がそのまま利用者に公開されるため変更なし -- [x] テスト追加: `tests/sqlite/test_prepared_statement.nim`, `tests/postgres/test_prepared_statement.nim`, `tests/mariadb/test_prepared_statement.nim`, `tests/mysql/test_prepared_statement.nim` -- [x] Prepared statement の public API を SQLite は proc overload、PostgreSQL / MariaDB / MySQL は private helper + proc overload に統一した -- [x] 改善候補の次フェーズ対応 - - [x] MariaDB の STMT 実行を non-blocking start/cont ベースへ移行 - - [x] MariaDB の result bind を接続単位で再利用する cache を追加 - - [x] PostgreSQL の `close()` を並列 `DEALLOCATE` に変更 - - [x] SQLite の `setColumnsStaticMeta` を prepared statement 側でキャッシュ化 - - [x] PostgreSQL の prepared 引数を unsafeAddr 依存から外した - - [x] `preparedArgsExpr` の間接呼び出しを除去した - - [x] MariaDB の `stmt.mysql` 直接参照を撤去した - - [x] PreparedStatement 型の冗長な `log` / `pools` フィールドを削除した -- [x] `example/benchmark.nim` の PostgreSQL 計測で prepared update が遅く見える理由を調査し、ブランチルールへ反映した -- [x] prepared statement を高速化目的で使うための実装方針を設計書としてブランチルールへ追記した +- [x] `example/benchmark.nim` の PostgreSQL 計測で prepared update が遅く見える理由を調査した +- [x] prepared statement 高速化の設計方針を整理した +- [ ] PostgreSQL にプール単位の prepared statement cache を追加する +- [ ] `close()` を論理 close 化し、物理破棄は別 API に分離する +- [ ] 接続固定で prepared statement を連続実行できる API を追加する +- [ ] `example/benchmark.nim` を cold/warm 計測に分離し、warm 経路で同一 statement を再利用する +- [ ] prepared cache と接続固定 API のテストを追加する ## 参考資料 -- SQLite prepared statement API: https://www.sqlite.org/c3ref/prepare.html - - `sqlite3_prepare_v2` / `sqlite3_reset` / `sqlite3_bind_*` / `sqlite3_step` / `sqlite3_finalize` - PostgreSQL libpq prepared statements: https://www.postgresql.jp/document/12/html/libpq-exec.html - - `PQsendPrepare` / `PQsendQueryPrepared` / `PQexec("DEALLOCATE ...")` -- MariaDB Connector/C STMT API: https://mariadb.com/kb/en/mysql_stmt_prepare/ - - `mysql_stmt_init` / `mysql_stmt_prepare` / `mysql_stmt_bind_param` / `mysql_stmt_execute` / `mysql_stmt_close` + - `PQsendPrepare` + - `PQsendQueryPrepared` + - `DEALLOCATE` +- PostgreSQL 非同期 libpq: https://www.postgresql.jp/document/12/html/libpq-async.html +- 対象実装 + - `src/allographer/query_builder/models/postgres/postgres_exec.nim` + - `src/allographer/query_builder/models/postgres/postgres_types.nim` + - `src/allographer/query_builder/libs/postgres/postgres_impl.nim` + - `example/benchmark.nim` ## 調査結果・設計まとめ -### 2026-03-30 benchmark.nim(PostgreSQL) で prepared update が遅く見える理由 +### 現状の問題 -結論として、現在の `example/benchmark.nim` が計測しているのは「prepared statement を十分に warm up して再利用した steady-state の実行性能」ではなく、**prepare / lazy prepare / deallocate を含めた prepared statement のライフサイクル全体**である。そのため PostgreSQL では通常の `update` より prepared 側が遅く見えやすい。 +PostgreSQL で prepared statement が遅く見える主因は、prepared statement 自体の性質ではなく、現在の実装と benchmark の計測条件にある。 -#### 1. ベンチマークが `prepare` と `close()` を毎回の計測に含めている +- `benchmark.nim` は計測のたびに `prepare` と `close` を行っており、steady-state の再利用性能ではなくライフサイクル全体を測っている +- PostgreSQL 実装は prepared statement をコネクションごとに lazy prepare するため、プールが大きいと `PREPARE` が広く拡散する +- `first()` と `exec()` が毎回 `getFreeConn()` するため、1 つの業務操作でも接続が変わり、prepared state の局所性が弱い +- 通常経路も `PQsendQueryParams` による bind 実行なので、prepared の利得は parse/plan 再利用ぶんに限られる -`benchUpdatePrepared()` は毎回の実行ごとに `selectStmt` / `updateStmt` を新規作成し、500 件の処理が終わったあと `close()` している。 +### 高速化の基本方針 -- `example/benchmark.nim:83-105` -- `example/benchmark.nim:114-118` +prepared statement を速くするには、単発 API を追加するだけでは足りない。以下をまとめて入れる必要がある。 -つまり `timeProcess("update prepared", ...)` の 1 回分には次がすべて含まれる。 +- prepare/deallocate の回数を減らす +- 同じ logical operation を同じ接続に寄せる +- warm 済み prepared state をプール内で継続利用する -- `rdb.prepare(...)` で prepared statement ハンドルを作る -- 各コネクションで必要になった時点の lazy prepare -- 500 件の `SELECT` + `UPDATE` -- `close()` による各コネクションの `DEALLOCATE` +### 設計方針 1: プール単位の prepared cache -しかも `timeProcess` はこれを 5 回繰り返すので、prepared statement の準備・破棄コストが毎回入り直す。 +`PreparedStatement` ハンドルごとに物理 state を持つのではなく、`Connections` が SQL ごとの prepared state を保持する。 -#### 2. PostgreSQL 実装は「コネクションごと」に lazy prepare する - -`PostgresPreparedStatement` は `stmtNames` をコネクション数ぶん持ち、`ensurePreparedStmt()` がそのコネクションで未 prepare の場合だけ `postgres_impl.prepare()` を呼ぶ。 - -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:81-100` - -したがって PostgreSQL の prepared statement は「SQL ごとに 1 回」ではなく、**SQL ごと × 使用したコネクションごとに 1 回** prepare される。 - -このベンチマークは `DB_MAX_CONNECTION` のデフォルトが 95 で、500 件を並列に投げている。 - -- `example/benchmark.nim:20` -- `example/benchmark.nim:25` - -そのため `selectStmt` と `updateStmt` の 2 本は、1 回の計測中に多くのコネクションへ広く拡散して prepare されやすい。最悪に近いケースでは、1 回の計測で概ね次の追加往復が発生する。 - -- `selectStmt`: 最大 95 接続ぶんの `PREPARE` -- `updateStmt`: 最大 95 接続ぶんの `PREPARE` -- `selectStmt.close()`: 使用済み接続ぶんの `DEALLOCATE` -- `updateStmt.close()`: 使用済み接続ぶんの `DEALLOCATE` - -つまり通常経路にない追加通信が、1 回のベンチで最大 190 回の `PREPARE` と 190 回の `DEALLOCATE` ぶん乗る。 - -#### 3. `first()` と `exec()` が別々に接続を取り直すので、prepare がさらに拡散する - -prepared の `first()` と `exec()` はどちらも内部で `getFreeConn()` し、処理後に即 `returnConn()` する。トランザクションで束縛していないため、1 つの Future 内の `SELECT` と `UPDATE` が別コネクションで走ることがある。 - -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:441-458` -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:490-507` - -このため 500 件の処理は実質的に「1000 回のコネクション取得」に近い挙動になり、prepared statement がプール全体へ広がりやすい。結果として lazy prepare の発生回数が増える。 - -#### 4. 通常の `update` 側にも初回コストはあるが、prepared 側よりかなり小さい - -通常の PostgreSQL `update` は `exec()` の前に `information_schema.columns` を読んで列型をキャッシュする。ただしこれはテーブルごとに 1 回だけで、以後は `columnTypeCache` が再利用される。 - -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:147-159` -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:759-763` - -一方 prepared 側は列型キャッシュ取得を避けられても、その代わりに `PREPARE` / `DEALLOCATE` がコネクション単位で発生する。今回のベンチ条件では、この差のほうが重い。 - -#### 5. PostgreSQL の通常経路もすでに bind パラメータ実行で、prepared の利得が限定されやすい - -通常の `PostgresQuery` は文字列連結で値を埋め込んでいるわけではなく、`postgres_impl.query/exec` で `PQsendQueryParams` を使っている。つまり通常経路でもクライアント側の SQL 文字列組み立てコストは大きくなく、prepared が削減できるのは主にサーバー側の parse/plan の一部である。 - -- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:69-77` -- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:124-161` - -今回のベンチでは 1 回のステートメントを長く使い回す前に close してしまうため、その利得よりも prepare/deallocate の通信コストのほうが前面に出る。 - -#### 6. 実装ベースでの整理 - -したがって、今回の PostgreSQL 計測結果は「prepared statement が本質的に遅い」ことを示しているのではなく、次の条件が重なった結果と解釈するのが正しい。 - -- `benchmark.nim` が prepared statement を毎回作成して毎回 close している -- PostgreSQL 実装が prepared statement をコネクションごとに lazy prepare する -- `SELECT` と `UPDATE` が別々にコネクションを取り直すため、prepare 対象の接続が増えやすい -- 通常経路はすでに `PQsendQueryParams` を使っており、prepared の純粋な利得が小さい - -#### 7. 再計測時の注意点 - -prepared statement の純粋な再利用効果を見たい場合は、少なくとも次の条件に変える必要がある。 - -- `selectStmt` / `updateStmt` を `timeProcess` の外で 1 回だけ作る -- 5 回の繰り返しで同じ prepared statement を使い回す -- `close()` は最後に 1 回だけ呼ぶ -- 可能なら `DB_MAX_CONNECTION` を絞るか、同一処理内で接続を固定してコネクションごとの warm up 拡散を抑える - -現状コードのままでは、benchmark 名は `update prepared` でも、実際に測っているものは「prepared statement reuse の速さ」ではなく「prepared statement の準備・拡散・破棄まで含む総コスト」である。 - -### 2026-03-30 prepared statement を高速化目的で使うための設計書 - -#### 1. 目的 - -prepared statement を「SQL インジェクション対策の別 API」として提供するだけでなく、**同じ SQL を高頻度に繰り返し実行するワークロードで、通常経路より速くなる実装**へ寄せる。 - -このブランチで目指すべき性能改善の主眼は次の 3 つ。 - -- prepare/deallocate の往復回数を減らす -- 同一の logical operation 内で接続を固定し、prepared state の局所性を高める -- ベンチマークと利用者 API の両方で、prepared statement の warm state を継続利用できるようにする - -#### 2. 現状のボトルネック整理 - -高速化を阻害している要因は、前節の benchmark 調査結果と合わせると次の 4 点に集約できる。 - -##### 2-1. prepared state がハンドル単位で、プール単位の cache になっていない - -現状の `PostgresPreparedStatement` は `stmtBaseName` / `stmtNames` を自分自身の中に持つ。 - -- `src/allographer/query_builder/models/postgres/postgres_types.nim` -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:81-100` - -このため同じ SQL に対して `rdb.prepare(sql)` を 2 回呼ぶと、同一プール内でも statement 情報は共有されず、別の handle として prepare がやり直される。ベンチマークのように handle を短命に作る使い方では、この設計がそのまま追加コストになる。 - -##### 2-2. prepared 実行ごとに `getFreeConn()` するため、接続局所性が弱い - -`first()` と `exec()` は prepared statement でも毎回 `getFreeConn()` → 実行 → `returnConn()` になっている。 - -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:441-458` -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:490-507` - -このため「SELECT してから UPDATE」のような 1 つの業務操作の中でも接続が変わりうる。prepared の再利用に必要な「同じ接続に同じ SQL を当て続ける」性質が弱く、prepare がプール全体に拡散する。 - -##### 2-3. `close()` が論理 close ではなく物理 deallocate になっている - -現状の `close()` は、その handle が使った全接続に対して `DEALLOCATE` を送る。 - -- `src/allographer/query_builder/models/postgres/postgres_exec.nim:921-930` - -利用者が「このハンドルをもう参照しない」という意味で close しただけでも、プール内に蓄積した prepared state を物理破棄してしまう。短いスコープの prepared handle を何度も作る利用法と相性が悪い。 - -##### 2-4. 通常経路もすでに bind 実行なので、prepared の優位は薄い - -PostgreSQL の通常経路は `PQsendQueryParams` ベースであり、文字列埋め込みをしていない。 - -- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:69-77` -- `src/allographer/query_builder/libs/postgres/postgres_impl.nim:124-161` - -したがって prepared による性能優位は「サーバー側 parse/plan の再利用」と「クライアント側の余計な周辺処理削減」に限られる。prepare/deallocate や接続取り直しが多いままでは、通常経路を上回りにくい。 - -#### 3. 設計方針 - -高速化のための設計方針は次の 3 本柱にする。 - -##### 3-1. プール単位の prepared statement cache を導入する - -`PreparedStatement` 自体に driver ハンドルを抱え込ませるのではなく、**プール (`Connections`) が SQL ごとの prepared state を保持する**設計に寄せる。 - -概念的には以下の構成にする。 +概念設計: ```nim -type PreparedCacheKey = object - sql*: string - nArgs*: int - type PostgresPreparedEntry = ref object sql*: string nArgs*: int stmtBaseName*: string - stmtNames*: seq[string] ## connI ごとの物理 prepared 名 - refCount*: int ## 論理 handle 数 + stmtNames*: seq[string] ## connI ごとの物理 prepared 名 + refCount*: int lastUsedAt*: int64 type Connections* = ref object @@ -225,111 +75,30 @@ type Connections* = ref object preparedCache*: Table[string, PostgresPreparedEntry] ``` -`prepare(self, sql)` の意味も次のように変える。 +期待効果: -- いま: 新しい `PostgresPreparedStatement` を毎回独立生成 -- 変更後: `preparedCache[sql]` を lookup し、なければ論理 entry を作る -- 戻り値の handle は cache entry への参照だけを持つ軽量ハンドルにする +- 同じ SQL に対する `prepare()` の重複を避けられる +- 同一プール内では physical prepare を接続ごとに 1 回へ寄せられる +- 短命な prepared handle を繰り返し作っても warm state を失いにくくなる -これにより同一 SQL を何度 `prepare()` しても、物理 prepared state はプール内で共有される。 +### 設計方針 2: `close()` の論理 close 化 -##### 3-2. 「接続固定で複数回実行する」API を追加する +現在の `close()` は、その handle が使った接続すべてに `DEALLOCATE` を送る。高速化の観点ではこれは不利なので、意味論を分ける。 -高速化を狙うなら、prepared statement を単発 API として呼ぶだけでは不十分。**呼び出し側が同じ接続に複数の prepared 実行を束ねられる API** を持つべきである。 +- `close()`: handle の参照を閉じるだけ +- `clearPreparedCache()` または `flushPrepared(sql)`: 物理 `DEALLOCATE` +- 接続 close 時: その接続に紐づく prepared state を一括破棄 -最小案は次のどちらか。 +期待効果: -- `withConnection` / `withPreparedConnection` のような block API -- `PreparedSession` / `ConnectionLease` のような明示ハンドル API +- 利用者が毎回 `prepare` / `close` しても、プール側は hot SQL を保持できる +- benchmark の warm 計測でも、毎回の物理破棄コストを避けられる -PostgreSQL の既存構造に最も素直なのは block API。 +### 設計方針 3: 接続固定 API の追加 -```nim -await rdb.withPreparedConnection(proc (ctx: PostgresPreparedContext): Future[void] {.async.} = - discard await selectStmt.first(ctx, @[$index]) - await updateStmt.exec(ctx, @[$number, $index]) -) -``` - -`ctx` は内部に `connI` を持ち、prepared 実行は `getFreeConn()` を呼ばず必ずその接続を使う。これにより 1 つの業務操作の中で接続がぶれなくなる。 - -この API はトランザクションとも整合させやすい。 - -- トランザクション中なら `transactionConn` を流用 -- 非トランザクション時は `withPreparedConnection` が 1 回だけ `getFreeConn()` し、block 終了時に返却 - -##### 3-3. `close()` を「論理 close」に変更し、物理破棄は別 API に分離する - -prepared statement の高速化には、プールに残った warm state を次回も再利用できることが重要。そのため `close()` は物理 `DEALLOCATE` ではなく、**論理参照の解放だけ**に変える。 - -想定する意味論は次の通り。 - -- `close()`: handle の `refCount` を減らすだけ。entry 自体は cache に残す -- `flushPrepared(sql)` または `clearPreparedCache()`: 物理 `DEALLOCATE` -- コネクション close 時: その接続に紐づく prepared state を一括破棄 +prepared statement の locality を高めるため、同じ接続上で複数回の prepared 実行を束ねる API を追加する。 -この変更により、利用者が毎回 `prepare` / `close` しても、プール側が hot SQL を一定期間保持できる。 - -#### 4. PostgreSQL 向け具体設計 - -##### 4-1. 型変更 - -`PostgresPreparedStatement` は「物理 state の所有者」から「cache entry を参照するハンドル」へ変える。 - -```nim -type PostgresPreparedStatement* = ref object - owner*: PostgresConnections - cacheKey*: string -``` - -`stmtBaseName` / `stmtNames` / `nArgs` は `Connections.preparedCache[cacheKey]` 側へ移す。 - -##### 4-2. statement 名の決め方 - -PostgreSQL の prepared 名は cache entry に属する stable name にする。 - -候補: - -- `allographer_stmt_{hash(sql)}` -- 実際の物理名は `{base}_{connI}` - -`prepare()` のたびに `gPreparedStmtCounter` で新しい名前を振る現行方式だと cache 再利用と相性が悪い。SQL ベースで stable に決まる名前へ寄せる。 - -##### 4-3. `ensurePreparedStmt()` の役割変更 - -`ensurePreparedStmt(self, connI)` は handle ではなく cache entry を見るようにする。 - -挙動: - -- `preparedCache[cacheKey].stmtNames[connI]` が空なら `PQsendPrepare` -- すでに埋まっていればそのまま再利用 -- `lastUsedAt` を更新 - -これで同じ SQL を複数 handle が共有しても、接続ごとの physical prepare は 1 回で済む。 - -##### 4-4. evict 戦略 - -cache を無制限にすると長時間プロセスで膨らむため、最低限の eviction ポリシーを入れる。 - -初期実装は簡素でよい。 - -- `preparedCacheMaxEntries` を `Connections` に持つ -- 上限超過時は `refCount == 0` の entry だけを LRU で追い出す -- evict 時に、その entry が prepare 済みの接続へ `DEALLOCATE` - -TTL は後回しでよい。まずは `maxEntries + refCount + LRU` で十分。 - -#### 5. 接続固定 API の設計 - -##### 5-1. 新規型 - -```nim -type PostgresPreparedContext* = ref object - owner*: PostgresConnections - connI*: int -``` - -##### 5-2. 新規 API +候補 API: ```nim proc withPreparedConnection*( @@ -338,1022 +107,61 @@ proc withPreparedConnection*( ): Future[void] ``` -必要なら戻り値付き overload も持たせる。 - ```nim -proc withPreparedConnection*[T]( - self: PostgresConnections, - body: proc (ctx: PostgresPreparedContext): Future[T] -): Future[T] +type PostgresPreparedContext* = ref object + owner*: PostgresConnections + connI*: int ``` -##### 5-3. prepared 実行の overload - -prepared statement に `ctx` 付き overload を足す。 +prepared statement 側には `ctx` 付き overload を足す。 ```nim proc first*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[Option[JsonNode]] proc exec*(self: PostgresPreparedStatement, ctx: PostgresPreparedContext, args: seq[string]): Future[void] ``` -内部では `ctx.connI` をそのまま使い、`getFreeConn()` / `returnConn()` を行わない。 - -##### 5-4. 使い分け - -- 既存 API `stmt.exec(args)` は後方互換のため維持 -- 高速化を狙う経路は `withPreparedConnection` + `stmt.exec(ctx, args)` を推奨 - -これなら public API を壊さず、新しい最適化経路だけを増やせる。 - -#### 6. benchmark.nim の設計変更 - -性能検証用の example は、prepared statement の高速化効果を測れるように使い方も変える。 - -##### 6-1. statement 作成位置を `timeProcess` の外へ出す - -```nim -let selectStmt = rdb.prepare(selectSql) -let updateStmt = rdb.prepare(updateSql) - -waitFor timeProcess("update prepared", benchUpdatePrepared) - -await selectStmt.close() -await updateStmt.close() -``` - -これにより 5 回の計測中で同じ prepared state を使い回せる。 - -##### 6-2. 計測対象を 2 種類に分ける +期待効果: -ベンチマーク名も分けるべき。 +- `SELECT` と `UPDATE` が別接続に飛ぶのを防げる +- lazy prepare の発生先が必要な接続に限定される +- transaction 中の `transactionConn` と整合しやすい -- `update prepared cold`: prepare/warmup を含む -- `update prepared warm`: warmup 済み state の再利用だけを測る +### benchmark の変更方針 -prepared statement の利得を見るなら主対象は `warm`。 - -##### 6-3. 高速化経路では接続固定 API を使う - -```nim -await rdb.withPreparedConnection(proc (ctx: PostgresPreparedContext): Future[void] {.async.} = - discard await selectStmt.first(ctx, @[$index]) - await updateStmt.exec(ctx, @[$number, $index]) -) -``` - -これで 1 つの操作が 1 接続に閉じ、prepared locality が保たれる。 - -#### 7. 実装順序 - -この設計は段階的に入れる。 - -##### Phase 1: cache 化 - -- `Connections.preparedCache` 追加 -- `prepare()` を cache lookup ベースへ変更 -- `close()` を論理 close 化 -- `clearPreparedCache()` を追加 - -この段階だけでも「毎回 prepare / close する」使い方の無駄をかなり減らせる。 - -##### Phase 2: 接続固定 API - -- `PreparedContext` / `withPreparedConnection` を追加 -- prepared `get/first/exec` に `ctx` overload を追加 -- benchmark.nim をこの経路で更新 - -この段階で「SELECT と UPDATE が別接続に飛ぶ」問題を抑えられる。 - -##### Phase 3: eviction と運用制御 - -- `preparedCacheMaxEntries` -- LRU eviction -- 明示的 flush API -- 必要なら prewarm API - -ここは運用性の整備フェーズ。 - -#### 8. テスト方針 - -高速化系の変更でも、最低限次の挙動はテストで固定する。 - -- 同じ SQL を `prepare()` しても、同一接続では物理 prepare が 1 回しか走らないこと -- `close()` 後でも cache が残る設計なら、再 `prepare()` で物理 prepare が再実行されないこと -- `withPreparedConnection` の block 内で、同じ `connI` が再利用されること -- transaction 中は `transactionConn` が優先され、追加の `getFreeConn()` が走らないこと -- cache eviction 時に `DEALLOCATE` が正しく発行されること - -#### 9. 非目標 - -今回の高速化設計では、以下は初期スコープに含めない。 - -- PostgreSQL の generic plan / custom plan の細かい制御 -- SQL 正規化による高度な cache key 統合 -- prepared statement の driver 横断完全共通化 - -まずは PostgreSQL を基準に、**prepare/deallocate の無駄を減らし、接続固定で locality を作る**ところまでを優先する。 - ---- - -### 0. SQL 組み立てと値バインドの仕組み(現行実装) - -prepared statement を設計するために、まず既存の「SQL 組み立て → 値バインド → 実行」のフローを正確に理解する。 - -#### 0-1. SQL 組み立ての責務分離 - -``` -*_query.nim ← ユーザー向け API(select/table/where/...) - │ where() 等が self.query (JObject) と self.placeHolder (JArray) に情報を書き込む - ▼ -*_generator.nim ← SQL フラグメントを生成するプロシージャ群(メソッドチェーン) - │ self.queryString を順番に組み立て、値位置には必ず "?" を埋める - ▼ -*_builder.nim ← generator を組み合わせて完全な SQL 文字列を返す - │ selectBuilder() / insertValueBuilder() / updateBuilder() / ... - ▼ -*_exec.nim ← SQL 文字列と placeHolder を実際のコネクションに渡す -``` - -#### 0-2. placeHolder の構造 - -`SqliteQuery.placeHolder` / `PostgresQuery.placeHolder` は `JsonNode`(JArray)。 -**builder 用(where 値・insert 値・update 値)**はオブジェクト形式: - -```json -[ - {"key": "id", "value": 1}, - {"key": "name", "value": "alice"}, - {"key": "flag", "value": true}, - {"key": "rate", "value": 3.14}, - {"key": "memo", "value": null} -] -``` - -**raw クエリ用**はフラット配列形式: - -```json -[1, "alice", true, 3.14, null] -``` - -#### 0-3. generator が placeHolder を書き込むタイミング - -`*_generator.nim` の各プロシージャが `self.query` を読みながら `self.queryString` を組み立てる際、**値が入る位置には `"?"` を書き込み、同時に `self.placeHolder` に `{"key": col, "value": val}` を append する**。 - -例)`insertValueSql`: - -```nim -proc insertValueSql*(self: SqliteQuery, items: JsonNode): SqliteQuery = - for key, val in items.pairs: - ... - self.placeHolder.add(%*{"key": key, "value": val}) # ← 値を記録 - values.add("?") # ← SQL には ? を入れる -``` - -例)`updateValuesSql`(UPDATE の場合は SET 値を先に、WHERE 値を後ろに並べる): - -```nim -proc updateValuesSql*(self: SqliteQuery, items: JsonNode): SqliteQuery = - let placeHolder = newJArray() - for key, val in items.pairs: - placeHolder.add(%*{"key": key, "value": val}) # SET 値を先に - for row in self.placeHolder.items: - placeHolder.add(row) # WHERE 値を後ろに - self.placeHolder = placeHolder # 置き換え -``` - -`whereSql` では `self.placeHolder` への追記は `*_query.nim` 側(`where()` 呼び出し時点)で行われ、`whereSql` は `"?"` を SQL に書くだけ。 - -#### 0-4. PostgreSQL の `?` → `$n` 変換 - -generator は SQLite / PostgreSQL とも `?` で SQL を生成する。 -**PostgreSQL の `exec.nim` 公開 API 層で `questionToDaller()` を呼んで `$1, $2...` に変換してから `postgres_impl` に渡す**。 - -```nim -# postgres_exec.nim (公開 API) -proc get*(self: PostgresQuery): Future[seq[JsonNode]] {.async.} = - var sql = self.selectBuilder() # "SELECT ... WHERE "id" = ?" - sql = questionToDaller(sql) # "SELECT ... WHERE "id" = $1" - return self.getAllRows(sql).await -``` - -`questionToDaller` の動作: - -``` -入力: UPDATE "users" SET "name" = ?, "email" = ? WHERE "id" = ? -出力: UPDATE "users" SET "name" = $1, "email" = $2 WHERE "id" = $3 -``` - -raw クエリ(`RawPostgresQuery`)も同様に、private `getAllRows` / `exec` 内で `questionToDaller` を呼ぶ。 - -#### 0-5. 値のバインド方法(ドライバ別) - -**SQLite**(`sqlite_impl.exec`): - -``` -placeHolder (JArray of {"key","value"}) を受け取る - ↓ -sqlite3_prepare_v2() で PStmt を生成 - ↓ -arg["value"].kind で分岐: - JBool → bind_int64(stmt, i, 0 or 1) - JInt → bind_int64(stmt, i, val) - JFloat → bind_double(stmt, i, val) - JNull → bind_null(stmt, i) - JObject/JArray → bind_text(stmt, i, val.pretty) - JString → カラム型が BLOB なら bind_blob、それ以外は bind_text - ※ BLOB 判定は columnTypeCache (PRAGMA table_info) を使う - ↓ -step(stmt) → finalize(stmt) -``` - -`query`(SELECT系)では string に変換してから `setupQuery` でエスケープ埋め込み: - -``` -placeHolder を seq[string] に変換(JBool→"1"/"0", JInt→$int, ...) - ↓ -setupQuery(db, sql, strArgs) - └─ dbFormat(sql, strArgs) で ? をエスケープ済み文字列に置換して PStmt を生成 - ↓ -step/finalize -``` - -**PostgreSQL**(`postgres_impl`): - -``` -placeHolder (JArray) を PGParams に変換 (postgres_lib.nim) - PGParams.fromObjArray(args, columns): - JBool → "t" / "f" (format=0, length=0) - JInt → $int (format=0, length=0) - JFloat → $float (format=0, length=0) - JNull → nil (values[i] = nil → PQsendQueryParams が NULL 扱い) - JString → val (bytea カラムなら format=1 バイナリ) - allocCStringArray(values) → cstringArray - ↓ -PQsendQueryParams(db, sql_with_$n, nParams, nil, values, lengths, formats, 0) -``` - -**MariaDB**(`mariadb_impl`): - -``` -placeHolder (JArray of {"key","value"}) を seq[string] に変換 - JBool → "1" / "0" - JInt → $int - JFloat → $float - JNull → "null" - JString → val - ↓ -dbFormat(conn, sql, MariadbParams) でエスケープ後に real_query_start → real_query_cont -``` - -MariaDB は現在 MYSQL_STMT を使わず、エスケープ済み文字列を `real_query` に投げる方式。 - -#### 0-6. prepared statement との対応関係 - -準備した SQL は上記フローの **「SQLを生成する部分」と「値をバインドする部分」を分離したもの**: - -| 通常クエリ | prepared statement | -|---|---| -| 実行のたびに SQL 文字列を組み立てる | SQL は prepare 時に 1 回だけコンパイル | -| 値は SQL 文字列に埋め込む(エスケープ)or bind API | 値は毎回 bind API で渡す(SQL 再コンパイルなし) | -| `PStmt` の生成と破棄が毎回発生(SQLite) | `PStmt` はコネクションにキャッシュされ `reset` で再利用 | - -prepared statement 実行時のバインド方法は **ドライバの bind API をそのまま使う**。SQLite なら `bind_int64` / `bind_text` 等、PostgreSQL なら `pqsendQueryPrepared` にパラメータ配列を渡す。値の型変換ロジック(JBool→"t"/"f" 等)は既存コードを共通関数として切り出して再利用する。 - ---- - -### 0-7. Prepared Statement API のデザインと SQL 組み立て・値バインドの方針 - -#### 基本方針:ユーザーは SQL を直接書く - -通常の query builder はメソッドチェーンで SQL を組み立てるが、prepared statement はそれと**別の API 系統**として提供する。ユーザーは生 SQL を書き、実行時に引数を渡す。 - -```nim -# 通常クエリ(毎回 SQL を組み立てる) -let rows = await rdb.table("World").where("index", "=", index).get() - -# prepared statement(SQL は一度だけコンパイル、引数だけ変える) -let stmt = rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") -let rows = await stmt.first($index) -await stmt.close() -``` - -builder API(`.table().where().get()`)との統合は行わない。理由: -- builder は SQL 生成と値バインドを 1 回の処理に結びつけている -- prepare 時点で「クエリの形」、execute 時点で「値」を分ける設計と相性が悪い -- 生 SQL の方がユーザーの意図が明確で、prepare/execute の境界がわかりやすい - -#### API の全体像 - -```nim -# ── prepare ────────────────────────────────────────── -# SQL に ? プレースホルダを使う(全 DB 共通) -let stmt: SqlitePreparedStatement = rdb.prepare( - "SELECT \"index\", \"randomNumber\" FROM \"World\" WHERE \"index\" = ?" -) - -# ── execute(SELECT 系) ────────────────────────────── -# 引数は seq[string]。? の順番通りに渡す -let rows: seq[JsonNode] = await stmt.get(@[$index]) -let row: Option[JsonNode] = await stmt.first(@[$index]) -let plain: seq[seq[string]] = await stmt.getPlain(@[$index]) -let row1: seq[string] = await stmt.firstPlain(@[$index]) - -# ── execute(INSERT / UPDATE / DELETE 系) ──────────── -await stmt.exec(@[$number, $index]) - -# ── lifecycle ───────────────────────────────────────── -stmt.close() # 全コネクション分のステートメントを解放 -``` - -**benchmark.nim での使用イメージ**: - -```nim -# prepare は一度だけ(プールの外で) -let stmtSelect = rdb.prepare( - "SELECT \"index\" as id, \"randomNumber\" FROM \"World\" WHERE \"index\" = ?" -) -let stmtUpdate = rdb.prepare( - "UPDATE \"World\" SET \"randomNumber\" = ? WHERE \"index\" = ?" -) -defer: stmtSelect.close(); stmtUpdate.close() - -# ループ内では execute だけ(SQL の再解析なし) -for i in 1..countNum: - let index = rand(range1_10000) - let number = rand(range1_10000) - let row = await stmtSelect.first(@[$index]) - await stmtUpdate.exec(@[$number, $index]) -``` - -#### SQL の書き方ルール - -プレースホルダは **`?` を統一記法とする**。DB の内部仕様(PostgreSQL は `$n`)への変換は実装側が行う。 - -```nim -# ユーザーは常に ? で書く -rdb.prepare("SELECT * FROM \"users\" WHERE \"id\" = ?") -rdb.prepare("INSERT INTO \"users\" (\"name\", \"email\") VALUES (?, ?)") -rdb.prepare("UPDATE \"users\" SET \"name\" = ?, \"email\" = ? WHERE \"id\" = ?") -``` - -テーブル名・カラム名はユーザーが適切にクォートする(`"tableName"`)。builder のような自動クォートは行わない。 - -#### prepare 時の SQL 処理(ドライバ別) - -**SQLite**: - -``` -rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") - ↓ -コネクション初回使用時(lazy)に: - sqlite3_prepare_v2(conn, sql_with_?, len, ppStmt, nil) - → PStmt をコネクション i のスロット stmts[i] に保存 - ※ SQL を DB エンジンがコンパイル・最適化プランを生成して保持 -``` - -**PostgreSQL**: - -``` -rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") - ↓ -コネクション初回使用時(lazy)に: - questionToDaller("...WHERE \"index\" = ?") - → "...WHERE \"index\" = $1" # 変換 - nArgs = count of '?' in original sql - pqsendPrepare(conn, stmtName, sql_with_$n, nArgs, nil) - → 変換後の SQL をサーバーにコンパイルさせる - → stmtName をコネクション i のスロット stmtNames[i] に保存 -``` +`example/benchmark.nim` は prepared statement の高速化効果を正しく測れるように変更する。 -**MariaDB**: - -``` -rdb.prepare("SELECT * FROM \"World\" WHERE \"index\" = ?") - ↓ -コネクション初回使用時(lazy)に: - mysql_stmt_prepare(stmt, sql_with_?, len) - → MYSQL_STMT をコネクション i のスロット stmts[i] に保存 -``` - -**重要**:prepare 時点では値は一切関与しない。SQL の構造だけを DB エンジンに渡す。 - -#### execute 時の値バインド(ドライバ別) - -引数は `seq[string]` で受け取る。`?` の出現順と対応する。 - -**SQLite**(`reset` + rebind パターン): - -``` -stmt.get(@["42"]) - ↓ -getFreeConn() → connI - ↓ -PStmt が stmts[connI] に存在しなければ prepare(lazy) - ↓ -reset(stmts[connI]) # 前回の実行結果・バインドをクリア -clear_bindings(stmts[connI]) # 全 bind スロットを NULL に戻す - ↓ -bind_text(stmts[connI], 1, "42") # 1-indexed で位置バインド - ↓ -step ループで行を取得 - └─ step() == SQLITE_ROW の間、column_text/column_int 等で値を読む - ↓ -finalize は呼ばない(再利用するため) - ↓ -returnConn(connI) -``` - -SQLite は文字列として `bind_text` に渡しても DB 側が列の型アフィニティに従って自動変換する("42" を INTEGER カラムに入れると整数として扱う)。`bind_text` 統一で十分。 - -**PostgreSQL**(`pqsendQueryPrepared` パターン): - -``` -stmt.get(@["42"]) - ↓ -getFreeConn() → connI - ↓ -stmtName が stmtNames[connI] に存在しなければ prepare(lazy) - ↓ -pgEnsureIdle(conn) # 前の処理が完了していることを確認 - ↓ -let arr = allocCStringArray(@["42"]) -pqsendQueryPrepared( - conn, - stmtName, # prepare 時に登録した名前 - nArgs, # プレースホルダ数 - arr, # 値の cstringArray(NULL は nil ポインタ) - nil, # lengths(text モードでは nil) - nil, # formats(0 = text) - 0 # result format (text) -) -deallocCStringArray(arr) - ↓ -pgFlushOutgoing → pgNextResult でレスポンスを受け取る - ↓ -returnConn(connI) -``` - -NULL を渡したい場合は `arr[i] = nil`(cstring nil が NULL 扱い)。 - -**MariaDB**(`mysql_stmt_execute` パターン): - -``` -stmt.exec(@["100", "42"]) - ↓ -getFreeConn() → connI - ↓ -MYSQL_STMT が stmts[connI] に存在しなければ prepare(lazy) - ↓ -MYSQL_BIND 配列を構築: - 各 args[i] を MYSQL_BIND に設定 - buffer_type = MYSQL_TYPE_STRING - buffer = args[i].cstring - length = args[i].len - ↓ -mysql_stmt_bind_param(stmt, binds) - ↓ -mysql_stmt_execute_start → mysql_stmt_execute_cont (非同期) - ↓ -SELECT 系は mysql_stmt_store_result → mysql_stmt_fetch で行取得 - ↓ -returnConn(connI) -``` - -#### NULL の扱い - -`seq[string]` に `"NULL"` という文字列を入れても DB には文字列 "NULL" として渡ってしまう。NULL を表現するには **特別な sentinel 値**か **`Option[string]`** が必要。 - -設計上の選択肢: -1. `seq[Option[string]]` を使う(`none(string)` が NULL) -2. `""` を NULL として扱う(空文字との区別ができない) -3. `"NULL"` という文字列を特別扱いする("NULL" という文字列を入れたいときに困る) - -**変更(実装)**:`get` / `first` / `exec` 等は **`macro` + `varargs[untyped]`** で展開し、呼び出し側は **`echo` と同様に任意個・任意型**を渡せる(各引数は **`toPreparedParam`** → 通常は **`$` で文字列化**)。Nim の **`echo` 専用の `varargs[typed, `$`]` はユーザー定義 proc では使えない**ため、この形が実質的な代替となる。なお **SQLite の prepared statement 公開 API は proc overload に切り替え**、`seq[string]` と `JsonNode` の両方を受ける。SQL NULL は **`sqlNull()` などの専用 API は出さず**、次を **SQL NULL** として解釈する。 - -- `Option[T]` の **`none(...)`**(例: `none(string)`) -- リテラル **`nil`**(`typeof(nil)` 用オーバーロード) -- **`cstring(nil)`** -- **`JsonNode`** が `nil`、または JSON の **`null`**(`JNull`) - -```nim -await stmt.exec("alice", "alice@example.com", none(string)) -await stmt.first(@[$index]) -``` - -SQLite の proc overload では、文字列側は `"NULL"` / `"null"` sentinel を互換維持し、JsonNode 側は `JNull` を SQL NULL として扱う。 - -#### 型情報と bind_text 統一の理由 - -Prepared statement の引数はすべて `string` として受け渡す(`bind_text` / text format に統一)。通常クエリの `placeHolder` が持っていた `{"key": col, "value": JsonNode}` のような型情報は **prepared statement では持たない**。 - -理由: -- **SQLite** は型アフィニティにより文字列からの自動変換を行うため実用上問題ない -- **PostgreSQL** は `PQsendQueryPrepared` に渡す format=0(text)で正常動作する。パラメータの型は prepare 済みのプランが知っているため、text で渡してもサーバー側でキャストされる -- **MariaDB** も `MYSQL_TYPE_STRING` で渡せば適切に変換される -- BLOB 対応は将来課題とする(`bind_blob` が必要なケースは `exec` のオーバーロードで対応) - -通常クエリの `exec`(INSERT/UPDATE)が `columnTypeCache` + `PRAGMA table_info` を使って BLOB カラムを判定しているのと対照的に、prepared statement では BLOB サポートを初期スコープから外す。 - ---- - -### 1. 現在のコネクションプール仕様 - -#### 共通構造(SQLite / PostgreSQL / MariaDB) - -``` -SqliteConnections / PostgresConnections / MariadbConnections - └─ pools: Connections - ├─ conns: seq[Connection] # プールサイズ = maxConnections - ├─ timeout: int # 秒単位のタイムアウト - ├─ waiters: Deque[Future[void]] # 空き待ちキュー(async/await 対応) - └─ columnTypeCache: Table[string, ...] # テーブルごとの型情報キャッシュ - -Connection - ├─ conn: PSqlite3 | PPGconn | PMySQL # ネイティブ接続ハンドル - ├─ isBusy: bool # 使用中フラグ - └─ createdAt: int64 -``` - -- MySQL(レガシー)は `waiters` を持たず、非同期待機に非対応。MariaDB は対応済み。 - -#### コネクション取得・返却フロー - -1. `getFreeConn(self)` → `isBusy == false` の連番インデックス `connI` を返す -2. 全コネクションがビジーなら `Future[void]` を `waiters` に積んで非同期待機 -3. 実処理: `pools.conns[connI].conn` に対して `*_impl` の関数を直接呼ぶ -4. `returnConn(self, connI)` → `isBusy = false` にして `waiters` の先頭を 1 件 complete - -#### クエリ実行の全体像 - -``` -rdb.table("users").where("id","=",1).first() - │ - ├─ selectBuilder() で SQL 文字列と placeHolder (JArray) を生成 - ├─ getFreeConn() → connI - ├─ *_impl.query(pools.conns[connI].conn, sql, args, timeout) - └─ returnConn(connI) -``` - ---- - -### 2. Prepared Statement に固有の制約 - -#### ステートメントはコネクションに紐づく - -| DB | ハンドル型 | スコープ | -|---|---|---| -| SQLite | `PStmt` (`ptr TStmt`) | 特定の `PSqlite3` コネクションに紐づく | -| PostgreSQL | named statement (文字列名) | 特定の `PPGconn` セッションに紐づく | -| MariaDB | `MYSQL_STMT*` | 特定の `PMySQL` コネクションに紐づく | - -プールに N 本のコネクションがある場合、同一の SQL であっても **各コネクションに個別に prepare** する必要がある。 - -#### 現状の `*_impl.nim` に存在するプリミティブ - -| DB | prepare 関数 | execute 関数 | 再利用可否 | -|---|---|---|---| -| SQLite | `prepare(db, sql)` → `PStmt` | `preparedQuery` / `preparedExec` | **不可**(finalize で破棄) | -| PostgreSQL | `prepare(db, sql, stmtName)` → `int` (nArgs) | `preparedQuery` / `preparedExec` | **可**(DEALLOCATE まで存続) | -| MariaDB | なし | なし | — | - -**SQLite の既存 `preparedQuery` / `preparedExec` の問題点**: -- `preparedQuery` 内の `instantRows(db, dbRows, sqliteStmt)` イテレータが `finalize` を呼ぶ -- `preparedExec` も末尾で `finalize(sqliteStmt)` を呼ぶ -- これらは「一度使ったら破棄」の実装であり、ステートメントを再利用できない -- **再利用のためには `finalize` を呼ばず `reset` → rebind → `step` に変更する必要がある** - ---- - -### 3. 設計方針: Lazy Per-Connection Preparation - -#### 採用するアプローチ - -`PreparedStatement` ハンドルを返す設計とし、ステートメントは **コネクション初回使用時に遅延 prepare** する。 - -``` -let stmt = rdb.prepare("SELECT * FROM users WHERE id = ?") -# ↑ この時点では何も prepare しない。ハンドルのみ生成。 - -let row = await stmt.first("1") -# ↑ getFreeConn() で connI を取得し、stmts[connI] が nil なら prepare を実行。 -# 以降同じ connI が選ばれた場合はステートメントを reset + rebind して再利用。 - -stmt.close() -# ↑ 全 connI の PStmt を finalize / DEALLOCATE -``` - -#### 採用理由 - -- コネクション数が多くても使われないコネクションには prepare コストが発生しない -- 既存の `getFreeConn` / `returnConn` ロジックをそのまま流用できる -- API を単純にできる(ユーザーは SQL 文字列と引数だけ意識すれば良い) - -#### 不採用アプローチ - -- **Eager whole-pool prepare**: dbOpen 後に全コネクションをブロックして prepare するのはコストが高い -- **Single-connection pinning**: 並列度が maxConnections から 1 に落ちる - ---- - -### 4. 型定義の設計 - -#### SQLite - -```nim -# sqlite_types.nim に追加 -type SqlitePreparedStatement* = ref object - log*: LogSetting - pools*: Connections - sql*: string - stmts*: seq[PStmt] ## pools.conns.len と同サイズ。nil = 未 prepare - isInTransaction*: bool - transactionConn*: int -``` - -#### PostgreSQL - -PostgreSQL の named prepared statement は文字列名で管理する。 -名前は `allographer_<ハッシュ>_` のような形式にしてコリジョンを避ける。 - -```nim -# postgres_types.nim に追加 -type PostgresPreparedStatement* = ref object - log*: LogSetting - pools*: Connections - sql*: string - stmtBaseName*: string ## ユニークな基底名(prepare 時に生成) - stmtNames*: seq[string] ## pools.conns.len と同サイズ。"" = 未 prepare - nArgs*: int ## プレースホルダ($1, $2, ...)の数 - isInTransaction*: bool - transactionConn*: int -``` - -#### MariaDB - -```nim -# mariadb_types.nim に追加 -type MYSQL_STMT* = pointer # mariadb_rdb.nim で FFI 定義する - -type MariadbPreparedStatement* = ref object - log*: LogSetting - pools*: Connections - info*: ConnectionInfo - sql*: string - stmts*: seq[MYSQL_STMT] ## pools.conns.len と同サイズ。nil = 未 prepare - isInTransaction*: bool - transactionConn*: int -``` - ---- - -### 5. `*_impl.nim` への追加・変更 - -#### `sqlite_impl.nim` - -```nim -# 再利用可能な実行(finalize しない) -proc preparedQueryReuse*(db: PSqlite3, stmt: PStmt, args: seq[string], - timeout: int): Future[(seq[Row], DbRows)] {.async.} - # reset(stmt) → bind_* → step loop → clear_bindings(stmt) - # finalize は呼ばない - -proc preparedExecReuse*(db: PSqlite3, stmt: PStmt, args: seq[string], - timeout: int) {.async.} - # reset(stmt) → bind_* → step → clear_bindings(stmt) - # finalize は呼ばない - -proc finalizeStmt*(stmt: PStmt) - # sqlite3_finalize のラッパー -``` - -既存の `preparedQuery` / `preparedExec` は後方互換のため残す(ただし benchmark.nim 等で未使用であれば整理可)。 - -#### `postgres_impl.nim` - -```nim -# DEALLOCATE のラッパー(close 時に呼ぶ) -proc deallocate*(db: PPGconn, stmtName: string, timeout: int) {.async.} -``` - -既存の `prepare` / `preparedQuery` / `preparedExec` はそのまま再利用可。 - -#### `mariadb_impl.nim` - -MariaDB C client の `MYSQL_STMT` を使う非同期ラッパーを追加。 -MariaDB の非ブロッキング API には `mysql_stmt_prepare_start` / `mysql_stmt_prepare_cont` 等が存在する。 - -```nim -proc stmtPrepare*(conn: PMySQL, sql: string, deadline: MonoTime): Future[MYSQL_STMT] {.async.} -proc stmtExec*(conn: PMySQL, stmt: MYSQL_STMT, args: seq[string], deadline: MonoTime) {.async.} -proc stmtQuery*(conn: PMySQL, stmt: MYSQL_STMT, args: seq[string], - deadline: MonoTime): Future[(seq[Row], DbRows)] {.async.} -proc stmtClose*(stmt: MYSQL_STMT) {.async.} -``` - ---- - -### 6. 公開 API 設計 - -```nim -# ─── prepare ─── -# rdb が SqliteConnections / PostgresConnections / MariadbConnections のどれでも同じ形 -proc prepare*(self: SqliteConnections, sql: string): SqlitePreparedStatement -proc prepare*(self: PostgresConnections, sql: string): PostgresPreparedStatement -proc prepare*(self: MariadbConnections, sql: string): MariadbPreparedStatement - -# ─── execute ─── -# `macro` + `varargs[untyped]` — 展開時に `@[toPreparedParam(a), ...]` を組み立てる(`prepared_param.buildPreparedParamSeqExpr`) -# 呼び出しは `stmt.get(1, "x")` のように echo 風。SQL NULL は `none(...)` / `nil` / `cstring(nil)` / `JsonNode(nil|JNull)` など(`sqlNull()` は公開しない) -macro get*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped -macro first*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped -macro exec*(self: SqlitePreparedStatement, args: varargs[untyped]): untyped -# 展開結果の型は従来どおり Future[...](内部で runPrepared* に seq[PreparedParam] を渡す) - -# ─── lifecycle ─── -proc close*(self: SqlitePreparedStatement): Future[void] {.async.} - # 全コネクション分の PStmt を finalize -``` - -PostgreSQL・MariaDB も同じマクロ形を持つ(型名だけ異なる)。`getPlain` / `firstPlain` あり。束縛値の意味論は `PreparedParam` / `toPreparedParam`(`sqlNull` は無し)。 - -#### プレースホルダ規約 - -| DB | ユーザー記法(prepare API) | -|---|---| -| SQLite | `?` | -| PostgreSQL | `?`(実装が `questionToDaller` で `$1`, `$2`, ... に変換) | -| MariaDB | `?` | - -公開 prepared API では **クエリは共通で `?`** とし、PostgreSQL のみ内部で `$n` に変換する(通常の query builder と同様)。 - ---- - -### 7. 実装ファイル変更一覧 - -| ファイル | 変更内容 | -|---|---| -| `libs/sqlite/sqlite_impl.nim` | `preparedQueryReuse` / `preparedExecReuse` / `finalizeStmt` 追加 | -| `libs/postgres/postgres_impl.nim` | `deallocate` 追加 | -| `libs/mariadb/mariadb_rdb.nim` | `MYSQL_STMT` 型・FFI 追加(要確認) | -| `libs/mariadb/mariadb_impl.nim` | `stmtPrepare` / `stmtExec` / `stmtQuery` / `stmtClose` 追加 | -| `models/sqlite/sqlite_types.nim` | `SqlitePreparedStatement` 追加 | -| `models/postgres/postgres_types.nim` | `PostgresPreparedStatement` 追加 | -| `models/mariadb/mariadb_types.nim` | `MariadbPreparedStatement` / `MYSQL_STMT` 追加 | -| `models/sqlite/sqlite_exec.nim` | `prepare` / `get` / `first` / `exec` / `close` 追加 | -| `models/postgres/postgres_exec.nim` | 同上 | -| `models/mariadb/mariadb_exec.nim` | 同上 | -| `query_builder.nim` | 各 `*PreparedStatement` 型・API をエクスポート | - ---- - -### 8. 未解決・要検討事項 - -1. **MariaDB の非ブロッキング STMT API の有無確認** - - `mariadb_rdb.nim` は `real_query_start` / `real_query_cont` を持つが、`mysql_stmt_prepare_start` / `mysql_stmt_prepare_cont` が利用できるか未確認 - - できない場合は同期的な STMT API か、クエリエスケープによる従来実装にフォールバック - -2. **PostgreSQL の `stmtBaseName` 生成方法** - - SQL 文字列のハッシュを使うか、単調増加カウンタを使うか - - コネクションを再接続した場合の旧ステートメント名との衝突回避 - -3. **MySQL(レガシー)への対応** - - MySQL ドライバは非同期未対応のため、当ブランチのスコープ外とする可能性が高い - -4. **トランザクション中の prepared statement** - - `isInTransaction = true` の場合は `transactionConn` の固定コネクションを使う(既存クエリと同じ) - - `stmts[transactionConn]` が nil なら prepare → execute の流れは通常時と同じ - -5. **コネクション再接続時のステートメント無効化** - - 現状、コネクション断後の再接続処理は各ドライバに存在しない - - `PStmt` がコネクション切断後に無効になった場合のエラーハンドリングは後続タスク - ---- - -### 実装後メモ(333 ブランチ) - -- **PostgreSQL `prepare`**: `nParams` は `postgres_lib.countQuestionMarks(sql)`(`?` の個数)で固定。旧 `query.count('$')` は `$10` 等で誤るため廃止。 -- **引数型**: SQLite の prepared statement 公開 API は **`proc` overload** で `seq[string]` / `JsonNode` を受ける。文字列側は `"NULL"` / `"null"` を SQL NULL sentinel として解釈し、JsonNode 側は `JNull` / `nil` を解釈する。PostgreSQL / MariaDB の既存 prepared 公開 API は引き続き macro + varargs で、実処理は private helper に閉じ込める。 -- **MariaDB `mysql_stmt_fetch`**: 行終端で `101` を返す前提でループ終了(コネクタの定義に依存するため、挙動が異なる環境では要確認)。 -- **`sqlite_lib`**: `preparedQueryReuse` 用に `setColumnsStaticMeta` / `setColumnsRuntimeTypes` を export。 -- **MariaDB result bind cache**: Nim の async 状態機械では `var tuple` の捕捉が安全解析に引っかかるため、`MariadbResultBindCache` を `ref object` にして接続単位で再利用する方針にした。 - ---- - -### 9. 改善候補(次フェーズ向け) - -実装コードレビューで判明した課題を優先度順に列挙する。 - ---- - -#### 9-1. [correctness/高優先] MariaDB: `MYSQL_DATA_TRUNCATED (101)` の誤った扱い - -**問題**: `queryPreparedStmt` の fetch ループで戻り値 `101 = MYSQL_DATA_TRUNCATED` をエラーでも終了でもなく無視している。 - -```nim -let fetchRes = stmt_fetch(stmt) -if fetchRes == 100: # MYSQL_NO_DATA → ループ終了 - break -if fetchRes notin {0, 101}: # 101 を "正常" として通過させているが実は切り捨て発生中 - dbError(conn) -``` - -結果バッファ(`max(field.len, 4096)` バイト)を超えたデータは**サイレントに切り捨てられる**。 -`TEXT` / `BLOB` 型や、`utf8mb4` で `VARCHAR(255)` (= 最大 1020 バイト) を超えるデータで発生しうる。 - -**修正方針**: -1. `fetchRes == 101` の場合、`resultBinds.errorFlags[i]` が立っている列を特定する -2. 該当列に対して動的にバッファを拡大(`resultBinds.lengths[i]` が実際の長さ)し、`stmt_fetch_column` で再取得する -3. 再取得後に row データを正しく更新する - -```nim -# 修正後イメージ(列ごとの再取得) -if fetchRes in {0, 101}: - for i in 0 ..< cols: - if resultBinds.errorFlags[i]: # このカラムはバッファ不足で切り捨て - let needed = int(resultBinds.lengths[i]) - resultBinds.buffers[i] = newString(needed) - resultBinds.binds[i].buffer = cast[pointer](resultBinds.buffers[i].cstring) - resultBinds.binds[i].buffer_length = needed.culong - discard stmt_fetch_column(stmt, resultBinds.binds[i].addr, cuint(i), 0) -``` - ---- - -#### 9-2. [correctness/高優先] `close()` でエラーが起きると残りのコネクションがクリーンアップされない - -**問題**: `close()` が途中で例外を投げると、後続のコネクション分がリークする。 - -```nim -proc close*(self: PostgresPreparedStatement) {.async.} = - for i, stmtName in self.stmtNames: - await postgres_impl.deallocate(...) # ここで例外 → 残りのループが実行されない -``` - -**修正方針**: `try/except` でエラーをログに記録しながら全コネクション分をクリーンアップする。 - -```nim -proc close*(self: PostgresPreparedStatement) {.async.} = - for i, stmtName in self.stmtNames: - if stmtName.len == 0: continue - try: - await postgres_impl.deallocate(self.pools.conns[i].conn, stmtName, self.pools.timeout) - except CatchableError: - self.log.echoErrorMsg("deallocate failed for " & stmtName & ": " & getCurrentExceptionMsg()) - self.stmtNames[i] = "" -``` - -SQLite / MariaDB の `close()` も同様に `try/except` で保護する。 - ---- - -#### 9-3. [performance/高優先] MariaDB: 同期的 STMT 実行がイベントループをブロック - -**問題**: `execPreparedStmt` / `queryPreparedStmt` は `sleepAsync(0).await` を1回行った後、以降のすべての STMT 操作(`stmt_execute`, `stmt_store_result`, `stmt_fetch`)を同期ブロッキング呼び出しで実行している。これは他の MariaDB クエリと同様に、実行中は非同期イベントループ全体をブロックする。 - -**修正方針**: MariaDB Connector/C の非ブロッキング STMT API を `mariadb_rdb.nim` に追加し、同パターンで実装する。 - -追加が必要な FFI: -```nim -proc stmt_execute_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_execute_start".} -proc stmt_execute_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_execute_cont".} -proc stmt_store_result_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_store_result_start".} -proc stmt_store_result_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_store_result_cont".} -proc stmt_fetch_start*(ret: ptr cint, stmt: PSTMT): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_fetch_start".} -proc stmt_fetch_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_fetch_cont".} -proc stmt_prepare_start*(ret: ptr cint, stmt: PSTMT, query: cstring, len: int): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_prepare_start".} -proc stmt_prepare_cont*(ret: ptr cint, stmt: PSTMT, status: cint): cint {.stdcall, dynlib: lib, importc: "mysql_stmt_prepare_cont".} -``` - -`prepareStmt` も非ブロッキングパターンに変更する。 - ---- - -#### 9-4. [memory/中優先] MariaDB: `bindStmtResults` が毎回 GC アロケーション - -**問題**: `queryPreparedStmt` → `bindStmtResults` が毎回 5 つの `newSeq` を確保する(`binds`, `buffers`, `lengths`, `nullFlags`, `errorFlags`)。高頻度な prepared statement 実行では GC プレッシャーになる。 - -**修正方針**: `MariadbPreparedStatement` にキャッシュフィールドを追加し、コネクションごとに一度だけ確保して再利用する。 - -```nim -type MariadbPreparedStatement* = ref object - ... - # 各コネクションの結果バインドキャッシュ(列数が変わらない限り再利用) - resultBindCache*: seq[tuple[ - binds: seq[BIND], buffers: seq[string], - lengths: seq[culong], nullFlags: seq[my_bool], errorFlags: seq[my_bool] - ]] -``` - ---- - -#### 9-5. [memory/中優先] PreparedStatement 型の冗長フィールド - -**対応済み**: `SqlitePreparedStatement`, `PostgresPreparedStatement`, `MariadbPreparedStatement` から `log` と `pools` を削除した。prepared statement 側は `owner.log` / `owner.pools` を参照する構成に統一した。 - -```nim -type SqlitePreparedStatement* = ref object - owner*: SqliteConnections - sql*: string - stmts*: seq[PStmt] - nArgs*: int - cachedColumns*: DbColumns - hasCachedColumns*: bool -``` - -**実装結果**: prepared statement のライフサイクル管理は `owner` に集約し、型定義の重複を解消した。将来的に `owner` を持たない軽量ハンドルが必要になった場合のみ再導入を検討する。 - ---- - -#### 9-6. [safety/中優先] PostgreSQL: `toPreparedCStringArray` の unsafe ポインタ - -**問題**: - -```nim -proc toPreparedCStringArray(args: seq[PreparedParam]): seq[cstring] = - result[i] = arg.value.cstring # arg.value の内部ポインタを借用 - -let valuePtr = cast[cstringArray](values[0].unsafeAddr) # unsafeAddr -pqsendQueryPrepared(..., valuePtr, ...) -``` - -`cstring` は Nim の GC 管理文字列への生ポインタ。`values` seq 内の cstring が `args` の `PreparedParam.value` 文字列を直接指すため、GC による移動(将来の ARC/ORC + 移動最適化)で dangling になりうる。現状の `pqsendQueryPrepared` 呼び出しは await なしなので実害はないが設計上fragile。 - -**修正方針**: `allocCStringArray` を使うか、`GC_ref` で各文字列を pin してから渡す。 - -```nim -# 安全な代替案 -var strbuf = newSeq[string](args.len) # 文字列を保持する所有コンテナ -var cstrs = newSeq[cstring](args.len) -for i, arg in args: - if arg.isNull: - cstrs[i] = nil - else: - strbuf[i] = arg.value # ここで文字列を「所有」 - cstrs[i] = cstrs[i].addr # strbuf[i] の参照 -``` - ---- - -#### 9-7. [performance/低優先] `close()` の逐次 DEALLOCATE → 並列化 - -**問題**: PostgreSQL の `close()` で各コネクションへの `DEALLOCATE` を逐次 `await` している。 - -**修正方針**: `asyncdispatch.all()` で全コネクションへ並列送信する。 - -```nim -proc close*(self: PostgresPreparedStatement) {.async.} = - var futs: seq[Future[void]] - for i, stmtName in self.stmtNames: - if stmtName.len > 0: - futs.add(postgres_impl.deallocate(self.pools.conns[i].conn, stmtName, self.pools.timeout)) - await all(futs) - for i in 0 ..< self.stmtNames.len: - self.stmtNames[i] = "" -``` - -※ エラーハンドリング(9-2)と組み合わせて実装する。 - ---- - -#### 9-8. [code quality/低優先] `preparedArgsExpr` 間接呼び出しの冗長除去 - -**問題**: 3 つの `*_exec.nim` それぞれに以下の wrapper proc が存在する: - -```nim -proc preparedArgsExpr(args: NimNode): NimNode = - result = buildPreparedArgsExpr(args) -``` - -`buildPreparedArgsExpr` を直接マクロ内で呼べばよく、この wrapper は不要。 - -**修正方針**: 削除して、マクロ内で `buildPreparedArgsExpr(args)` を直接呼ぶ。 - ---- - -#### 9-9. [code quality/低優先] `getPreparedRows` / `preparedGet` 系のコード重複 - -3 つの `*_exec.nim` にほぼ同一の private proc 群が重複している: -- `getPreparedRows` / `getPreparedAllRows` / `getPreparedRow` / `getPreparedAllRowsPlain` / `getPreparedRowPlain` -- `preparedGet` / `preparedFirst` / `preparedGetPlain` / `preparedFirstPlain` / `preparedExec` +- `selectStmt` / `updateStmt` を `timeProcess` の外で 1 回だけ作る +- 繰り返し計測では同じ prepared statement を再利用する +- `close()` は全計測の最後に 1 回だけ呼ぶ +- benchmark 名を `cold` と `warm` に分ける +- warm 計測では `withPreparedConnection` を使って接続固定経路を測る -型の違いにより単純なテンプレート化は難しいが、少なくとも try/catch ロギングラッパーパターンをテンプレートで共通化できる。 +### 実装順序 ---- +#### Phase 1 -#### 9-10. [correctness/低優先] `mariadb_rdb.nim` の `Enum_stmt_attr_type` 重複定義 +- `Connections.preparedCache` を追加する +- `prepare()` を cache lookup ベースに変更する +- `close()` を論理 close 化する +- `clearPreparedCache()` を追加する -`Enum_stmt_attr_type` が uncomment されたが、その直下に同内容のコメントアウトされた旧定義が残っており、コード上は重複している。コメントアウト側を削除して整理する。 +#### Phase 2 ---- +- `PostgresPreparedContext` を追加する +- `withPreparedConnection` を追加する +- prepared `get/first/exec` に `ctx` overload を追加する +- `benchmark.nim` を warm/cold 計測へ更新する -#### 9-11. [performance/低優先] SQLite: `setColumnsStaticMeta` のキャッシュ化 +#### Phase 3 -`preparedQueryReuse` が呼ばれるたびに `setColumnsStaticMeta(columns, stmt)` を実行しているが、列のメタデータ(名前・宣言型)は同じステートメントに対して不変。`SqlitePreparedStatement` に `cachedColumns` を持たせ、初回のみ設定することで毎回の計算を省略できる。 +- `preparedCacheMaxEntries` を追加する +- `refCount == 0` の entry を対象に LRU eviction を入れる +- eviction 時に `DEALLOCATE` を発行する ---- +### テスト方針 -#### 9-12. [correctness/低優先] MariaDB: `stmt.mysql` 内部フィールルアクセス - -`bindStmtParams` 内で `dbError(stmt.mysql)` を呼んでいる。`PSTMT` の内部フィールド `mysql` への直接アクセスは Connector/C のバージョン間で構造体レイアウトが変わると壊れる可能性がある。代わりに `stmt_errno(stmt)` / `stmt_error(stmt)` を使用する。 - ---- +- 同じ SQL を複数回 `prepare()` しても、同一接続で physical prepare が 1 回に収まること +- `close()` 後に再 `prepare()` しても、cache が残る設計なら physical prepare が増えないこと +- `withPreparedConnection` の block 内で同じ `connI` が使われること +- transaction 中は `transactionConn` が優先されること +- cache eviction 時に `DEALLOCATE` が正しく走ること From a73cffffc4963428ec6da1a1688ba0601d25feae Mon Sep 17 00:00:00 2001 From: itsmura-h Date: Mon, 30 Mar 2026 10:52:23 +0000 Subject: [PATCH 5/5] fix run test --- config.nims | 8 ++++---- tests/config.nims | 10 +++++----- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/config.nims b/config.nims index 9f41bec9..ab19512a 100644 --- a/config.nims +++ b/config.nims @@ -1,7 +1,7 @@ import os -putEnv("DB_SQLITE", $false) +putEnv("DB_SQLITE", $true) putEnv("DB_POSTGRES", $true) -putEnv("DB_MYSQL", $false) -putEnv("DB_MARIADB", $false) -putEnv("DB_SURREAL", $false) +putEnv("DB_MYSQL", $true) +putEnv("DB_MARIADB", $true) +putEnv("DB_SURREAL", $true) diff --git a/tests/config.nims b/tests/config.nims index ef7ea0c4..66addc57 100644 --- a/tests/config.nims +++ b/tests/config.nims @@ -1,8 +1,8 @@ import os switch("path", "$projectDir/../src") -# putEnv("DB_SQLITE", $true) -# putEnv("DB_POSTGRES", $true) -# putEnv("DB_MYSQL", $true) -# putEnv("DB_MARIADB", $true) -# putEnv("DB_SURREAL", $true) +putEnv("DB_SQLITE", $true) +putEnv("DB_POSTGRES", $true) +putEnv("DB_MYSQL", $true) +putEnv("DB_MARIADB", $true) +putEnv("DB_SURREAL", $true)