exwiw 0.1.7 → 0.1.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +12 -0
- data/README.md +7 -0
- data/docs/plans/2026-05-15-insert-000-schema-file.md +151 -0
- data/docs/plans/2026-05-16-mongodb-from-clean-scenario.md +76 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +41 -0
- data/lib/exwiw/adapter/mysql2_adapter.rb +51 -0
- data/lib/exwiw/adapter/postgresql_adapter.rb +75 -0
- data/lib/exwiw/adapter/sqlite3_adapter.rb +41 -0
- data/lib/exwiw/adapter.rb +24 -0
- data/lib/exwiw/ddl_postprocessor.rb +61 -0
- data/lib/exwiw/runner.rb +7 -9
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +1 -0
- data/mise.toml +6 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 7c5e29a492af74dfbfa0e778fcb527a218d3a33507024646b2fe88b495581c2f
|
|
4
|
+
data.tar.gz: bd66516a56f40e4147e76e3fc662c98cd3c0261eb54a310ef7af49bcc5373cf0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b63d52ce0abd624695b73d782c64a5d4dd861e701f7c5989e977dd506d0178f4e2d394e9ae57e1106bf83898b622bd93681c60bdfbbde7dcd72bf1796cd4847
|
|
7
|
+
data.tar.gz: 36ea50859424c45eb3bd83ffc84fb148eb4080345e7dfc012ff81b32003fa27e83f43324c4e7fa296c4d35ee8de6f89754a9e9f1449bd69d3d1b4066015e51bc
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.8] - 2026-05-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Emit a leading `insert-000-schema.{sql,js}` file alongside the per-table `insert-*` files so the generated dump can be applied to an empty database in one go. ([#14](https://github.com/heyinc/exwiw/pull/14))
|
|
10
|
+
- SQL adapters (`mysql2`, `postgresql`, `sqlite3`) write idempotent `CREATE TABLE IF NOT EXISTS` (and `CREATE INDEX IF NOT EXISTS` where the engine supports it) by shelling out to `mysqldump` / `pg_dump` / reading `sqlite_master`. PostgreSQL `ALTER TABLE ... ADD CONSTRAINT` is wrapped in a `DO $$ EXCEPTION WHEN duplicate_object` block.
|
|
11
|
+
- MongoDB adapter writes `insert-000-schema.js` containing `db.createCollection(...)` (wrapped in `try/catch` for `NamespaceExists`) and `db.<col>.createIndex(...)` calls for every top-level collection. Apply with `mongosh < dump/insert-000-schema.js`.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- PostgreSQL adapter now appends a `setval` for each table's sequence at the end of the `insert-*.sql` file, transcribing the source DB's `last_value` so `nextval` after restore does not collide with imported IDs. ([#19](https://github.com/heyinc/exwiw/pull/19))
|
|
16
|
+
|
|
5
17
|
## [0.1.7] - 2026-05-14
|
|
6
18
|
|
|
7
19
|
### Added
|
data/README.md
CHANGED
|
@@ -64,12 +64,15 @@ exwiw \
|
|
|
64
64
|
|
|
65
65
|
This command will generate sql files in the `dump` directory.
|
|
66
66
|
|
|
67
|
+
- `dump/insert-000-schema.sql` — idempotent `CREATE TABLE IF NOT EXISTS ...` for every table in scope. Apply this first to provision an empty database.
|
|
67
68
|
- `dump/insert-{idx}-{table_name}.sql`
|
|
68
69
|
- `dump/delete-{idx}-{table_name}.sql`
|
|
69
70
|
|
|
70
71
|
idx means the order of the dump. bigger idx might depend on smaller idx,
|
|
71
72
|
so you should import the dump in order.
|
|
72
73
|
|
|
74
|
+
`insert-000-schema.sql` is generated by shelling out to the database client tools (`mysqldump` for `mysql2`, `pg_dump` for `postgresql`, and the sqlite3 driver for `sqlite3`), so the corresponding client must be available on PATH when running exwiw. The output is post-processed to make it idempotent: `CREATE TABLE IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS` (where the engine supports it), and PostgreSQL's `ALTER TABLE ... ADD CONSTRAINT` statements are wrapped in `DO $$ ... EXCEPTION WHEN duplicate_object`.
|
|
75
|
+
|
|
73
76
|
you need to delete the records before importing the dump,
|
|
74
77
|
`delete-{idx}-{table_name}.sql` will help you to do that.
|
|
75
78
|
This sql will delete "all" related records to the extract targets.
|
|
@@ -182,6 +185,10 @@ The MongoDB adapter is experimental. To use it:
|
|
|
182
185
|
```bash
|
|
183
186
|
mongoimport --db app_dev --collection users --file dump/insert-002-users.jsonl
|
|
184
187
|
```
|
|
188
|
+
- The leading `dump/insert-000-schema.js` contains `db.createCollection(...)` and `db.<col>.createIndex(...)` calls for every top-level collection (indexes are introspected from the source via `listIndexes`; the auto-created `_id_` index is skipped). Apply it with mongosh **before** running `mongoimport`:
|
|
189
|
+
```bash
|
|
190
|
+
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
191
|
+
```
|
|
185
192
|
- Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
|
|
186
193
|
- `raw_sql` is not supported (the `MongodbField` schema does not declare it; any `raw_sql` keys in scenario JSON are silently dropped on load). Use `replace_with` for masking.
|
|
187
194
|
- The MongoDB adapter does not support the collection-level `filter` field (it raises `NotImplementedError` if set, since the SQL-string filter cannot be applied to MongoDB).
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Plan: `insert-000-schema.{sql,js}` を dump 出力に追加する
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
現在 `exwiw` の dump 出力 (`dump/`) には `insert-NNN-{table}.sql` と `delete-NNN-{table}.sql` のみが書かれ、`CREATE TABLE` などのスキーマ定義は別管理になっている。そのため取得した dump を別環境にインポートしようとすると、利用者側で別途スキーマを用意する必要があり、空 DB に流すと失敗する/idempotent に流せない。
|
|
6
|
+
|
|
7
|
+
ゴール: 既存の per-table insert ファイルの前段として、
|
|
8
|
+
|
|
9
|
+
- **SQL adapters** (`mysql2` / `postgresql` / `sqlite3`): `insert-000-schema.sql` に `CREATE TABLE IF NOT EXISTS ...` などをまとめて出力する
|
|
10
|
+
- **MongoDB adapter**: `insert-000-schema.js` に `db.createCollection(...)` と `db.<col>.createIndex(...)` をまとめて出力する
|
|
11
|
+
|
|
12
|
+
これにより、空 DB に対しても `insert-000-schema.*` → `insert-001-...` → ... の順で適用するだけで dump が完結する。
|
|
13
|
+
|
|
14
|
+
## Design
|
|
15
|
+
|
|
16
|
+
### 採用した方針 (ユーザ確認済み)
|
|
17
|
+
- DDL の取得元は **外部コマンドのシェルアウト** (SQL 系は `mysqldump` / `pg_dump` / `sqlite3 .schema`)。MongoDB は CLI に相当するものがないので、既に require 済みの `mongo` Ruby ドライバで `listCollections` / `listIndexes` を使う (mongosh を追加要件にしない)。
|
|
18
|
+
- 出力は **1 ファイルに統合**: `insert-000-schema.{sql,js}`
|
|
19
|
+
- スコープは **DB 上の完全定義をそのまま転写**。config の `columns` / `fields` で絞り込まない。
|
|
20
|
+
- Mongo は **mongosh で流せる .js** を出力。
|
|
21
|
+
|
|
22
|
+
### アーキテクチャ
|
|
23
|
+
1. `Adapter::Base` に新規メソッドを追加 (デフォルトは no-op):
|
|
24
|
+
- `dump_schema(ordered_tables, output_path, logger)` — `output_path` (ファイル絶対パス) にスキーマ DDL を書く
|
|
25
|
+
- `schema_output_extension` — デフォルト `'sql'`、Mongo は override で `'js'`
|
|
26
|
+
2. `Runner#run` の `mkdir_p` 直後、per-table ループの前で 1 回だけ呼び出す:
|
|
27
|
+
```ruby
|
|
28
|
+
ordered_tables = ordered_table_names.map { |n| table_by_name.fetch(n) }
|
|
29
|
+
schema_path = File.join(@output_dir, "insert-000-schema.#{adapter.schema_output_extension}")
|
|
30
|
+
adapter.dump_schema(ordered_tables, schema_path, @logger)
|
|
31
|
+
```
|
|
32
|
+
- `ordered_table_names` は依存順に並んでいる (`DetermineTableProcessingOrder`) ので、FK 制約付き DDL もそのまま流せる。
|
|
33
|
+
3. 各 adapter の `dump_schema` 実装:
|
|
34
|
+
|
|
35
|
+
**Sqlite3Adapter** — 既に require 済みの `sqlite3` gem の connection を使い `SELECT type, sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 ELSE 2 END` を流す。`sqlite_sequence` 等の自動テーブル (`sql` が NULL) は除外される。各行の `sql` を後処理して:
|
|
36
|
+
- `CREATE TABLE ` → `CREATE TABLE IF NOT EXISTS `
|
|
37
|
+
- `CREATE INDEX ` → `CREATE INDEX IF NOT EXISTS ` (SQLite はサポート)
|
|
38
|
+
- `CREATE UNIQUE INDEX ` → 同上
|
|
39
|
+
- `CREATE TRIGGER ` / `CREATE VIEW ` も同様に IF NOT EXISTS を付与
|
|
40
|
+
|
|
41
|
+
設計上の選択: 「シェルアウト」をユーザは選んだが、SQLite については既存接続を再利用する方が安全 (外部 `sqlite3` バイナリの有無に依存しない)。シェルアウトに統一したい場合はオプションBへ。
|
|
42
|
+
|
|
43
|
+
**Mysql2Adapter** — `mysqldump` をシェルアウト:
|
|
44
|
+
```
|
|
45
|
+
MYSQL_PWD=$password mysqldump \
|
|
46
|
+
--host={host} --port={port} --user={user} \
|
|
47
|
+
--no-data --skip-add-drop-table --skip-comments \
|
|
48
|
+
--skip-extended-insert --skip-set-charset \
|
|
49
|
+
--compact \
|
|
50
|
+
{database}
|
|
51
|
+
```
|
|
52
|
+
- パスワードはコマンドライン引数ではなく `MYSQL_PWD` env 経由 (プロセス一覧に出さない)
|
|
53
|
+
- stdout を後処理して `CREATE TABLE ` → `CREATE TABLE IF NOT EXISTS `。MySQL は `CREATE INDEX IF NOT EXISTS` を **サポートしない** が、mysqldump はインデックスを `CREATE TABLE` 内にインラインで含めるため通常問題なし
|
|
54
|
+
- `/*!40101 ...*/` のような実行時 SET 文はそのまま残す (mysqldump が import 互換のために出すもの)
|
|
55
|
+
|
|
56
|
+
**PostgresqlAdapter** — `pg_dump` をシェルアウト:
|
|
57
|
+
```
|
|
58
|
+
PGPASSWORD=$password pg_dump \
|
|
59
|
+
--host={host} --port={port} --username={user} \
|
|
60
|
+
--schema-only --no-owner --no-acl --no-comments \
|
|
61
|
+
{database}
|
|
62
|
+
```
|
|
63
|
+
- stdout を後処理して `CREATE TABLE ` → `CREATE TABLE IF NOT EXISTS `、`CREATE INDEX ` → `CREATE INDEX IF NOT EXISTS `、`CREATE UNIQUE INDEX ` → `CREATE UNIQUE INDEX ... IF NOT EXISTS` (PG は両方サポート)
|
|
64
|
+
- `ALTER TABLE ... ADD CONSTRAINT` は重複適用で失敗する。**`information_schema.table_constraints` で存在チェックする `DO $$` ブロックにラップする** か、よりシンプルには `ALTER TABLE ONLY ... ADD CONSTRAINT` 行を検出して `IF NOT EXISTS` 版 (PG 9.6+ の `ALTER TABLE ADD CONSTRAINT` には IF NOT EXISTS はないので) **DO ブロックでラップ** する。実装方針: 各 `ALTER TABLE ... ADD CONSTRAINT "name" ...;` を:
|
|
65
|
+
```sql
|
|
66
|
+
DO $$ BEGIN
|
|
67
|
+
ALTER TABLE ... ADD CONSTRAINT "name" ...;
|
|
68
|
+
EXCEPTION WHEN duplicate_object THEN NULL; END $$;
|
|
69
|
+
```
|
|
70
|
+
でラップ。
|
|
71
|
+
- `SET ...` / `SELECT pg_catalog.set_config(...)` 行はそのまま残す。
|
|
72
|
+
- `CREATE SCHEMA public` は素の pg_dump では出ないが、出力に含まれた場合は `CREATE SCHEMA IF NOT EXISTS` に書き換える。
|
|
73
|
+
|
|
74
|
+
**MongodbAdapter** — 既に require 済みの `mongo` ドライバを使い、`tables.reject(&:embedded?)` をループ:
|
|
75
|
+
```ruby
|
|
76
|
+
db.list_collections.each { |c| existing_collections << c['name'] } # for skip-creation idempotency hint
|
|
77
|
+
ordered_tables.reject(&:embedded?).each do |config|
|
|
78
|
+
indexes = db[config.name].indexes.to_a.reject { |idx| idx['name'] == '_id_' }
|
|
79
|
+
# emit JS lines
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
出力 JS のサンプル:
|
|
83
|
+
```js
|
|
84
|
+
// Auto-generated by exwiw. Apply with: mongosh < insert-000-schema.js
|
|
85
|
+
try { db.createCollection("users"); } catch (e) { if (e.code !== 48) throw e; } // 48 = NamespaceExists
|
|
86
|
+
db.users.createIndex({"shop_id":1}, {"name":"index_users_on_shop_id"});
|
|
87
|
+
try { db.createCollection("shops"); } catch (e) { if (e.code !== 48) throw e; }
|
|
88
|
+
```
|
|
89
|
+
- `_id_` index は MongoDB が自動作成するので除外
|
|
90
|
+
- `createIndex` はキー仕様とオプションが一致していれば idempotent (重複作成は no-op)
|
|
91
|
+
- index doc には `v` / `ns` など driver 由来のメタが入るので、`key` と `name` 以外で出力に含めるのは `unique` / `sparse` / `partialFilterExpression` / `expireAfterSeconds` / `collation` のみ (allowlist) にする
|
|
92
|
+
- `MongodbAdapter#schema_output_extension` で `'js'` を返す
|
|
93
|
+
- 埋め込み (embedded_in) は親 collection に内包されるので index 不要 → スキップ
|
|
94
|
+
|
|
95
|
+
### 後処理ユーティリティ
|
|
96
|
+
新規ファイル `lib/exwiw/ddl_postprocessor.rb` を作って各 SQL adapter から呼ぶ:
|
|
97
|
+
- `.add_if_not_exists_to_create_table(sql)` — 行頭の `CREATE TABLE ` → `CREATE TABLE IF NOT EXISTS ` (既に IF NOT EXISTS が付いている場合はスキップ)
|
|
98
|
+
- `.add_if_not_exists_to_create_index(sql)` — `CREATE [UNIQUE] INDEX ` 系
|
|
99
|
+
- `.wrap_add_constraint_in_do_block(sql)` — PG 専用
|
|
100
|
+
|
|
101
|
+
### CLI 側の挙動
|
|
102
|
+
特に CLI フラグの追加はしない。常に `insert-000-schema.*` を出力する (ユーザの選択にあった通り)。ただし、外部コマンドが PATH に無い場合は明確なエラーメッセージで停止する:
|
|
103
|
+
```
|
|
104
|
+
Error: `pg_dump` not found in PATH. exwiw needs pg_dump to generate insert-000-schema.sql for the postgresql adapter.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Files to modify / add
|
|
108
|
+
|
|
109
|
+
| パス | 変更 |
|
|
110
|
+
|---|---|
|
|
111
|
+
| `lib/exwiw/adapter.rb` | `Base` に `dump_schema(tables, path, logger)` と `schema_output_extension` を追加 |
|
|
112
|
+
| `lib/exwiw/runner.rb` | `mkdir_p` 直後で `adapter.dump_schema(...)` を呼ぶ |
|
|
113
|
+
| `lib/exwiw/adapter/sqlite3_adapter.rb` | `dump_schema` 実装 (既存 connection を使い `sqlite_master` 経由) |
|
|
114
|
+
| `lib/exwiw/adapter/mysql2_adapter.rb` | `dump_schema` 実装 (`mysqldump` シェルアウト) |
|
|
115
|
+
| `lib/exwiw/adapter/postgresql_adapter.rb` | `dump_schema` 実装 (`pg_dump` シェルアウト, ADD CONSTRAINT DO ブロック化) |
|
|
116
|
+
| `lib/exwiw/adapter/mongodb_adapter.rb` | `dump_schema` 実装、`schema_output_extension` を override |
|
|
117
|
+
| `lib/exwiw/ddl_postprocessor.rb` (新規) | `IF NOT EXISTS` 書き換え / DO ブロックラップ |
|
|
118
|
+
| `lib/exwiw.rb` | 新規ファイルの require |
|
|
119
|
+
| `README.md` | `dump/` の出力に `insert-000-schema.{sql,js}` を追記、import 手順を更新 |
|
|
120
|
+
| `spec/adapter/sqlite3_adapter_spec.rb` | `dump_schema` 統合テスト (`scenario/initdb/init.sqlite3` に対して実行し、出力が `CREATE TABLE IF NOT EXISTS` を含むことを assert) |
|
|
121
|
+
| `spec/adapter/mongodb_adapter_spec.rb` | `dump_schema` テスト (db スタブで `listIndexes` を返し、出力 JS を assert) |
|
|
122
|
+
| `spec/runner_spec.rb` | `insert-000-schema.sql` が `output_dir` に書かれることを assert (Sqlite3 経由で実際に流れることを確認) |
|
|
123
|
+
|
|
124
|
+
## 再利用する既存コード
|
|
125
|
+
- `lib/exwiw/connection_config.rb` (host / port / user / password / database_name) — シェルアウトの引数組み立てに使う
|
|
126
|
+
- `lib/exwiw/determine_table_processing_order.rb` — schema dump も依存順で並べるためそのまま使う
|
|
127
|
+
- `MongodbCollectionConfig#embedded?` (lib/exwiw/mongodb_collection_config.rb:33) — 埋め込み collection をスキップ
|
|
128
|
+
- `MongodbAdapter#db` (lib/exwiw/adapter/mongodb_adapter.rb:197) — Mongo クライアントを取り出す既存 lazy getter
|
|
129
|
+
|
|
130
|
+
## Verification
|
|
131
|
+
|
|
132
|
+
### ユニット / 統合テスト
|
|
133
|
+
1. `bundle exec rspec spec/runner_spec.rb spec/adapter/sqlite3_adapter_spec.rb` — sqlite3 経路で `insert-000-schema.sql` が生成され、内容に `CREATE TABLE IF NOT EXISTS "shops"` が含まれることを確認。
|
|
134
|
+
2. `bundle exec rspec spec/adapter/mongodb_adapter_spec.rb` — mongo クライアントをスタブして JS 出力に `db.createCollection("users")` と該当 collection の `createIndex(...)` が含まれることを確認。
|
|
135
|
+
|
|
136
|
+
### E2E (scenario スクリプト経由)
|
|
137
|
+
3. `scenario/test_with_sqlite3.sh` を実行し、`dump/insert-000-schema.sql` が生成されることと、空 DB に対して `sqlite3 empty.db < dump/insert-000-schema.sql && for f in dump/insert-*.sql; do sqlite3 empty.db < $f; done` が成功することを確認する。
|
|
138
|
+
4. `scenario/test_with_mysql2.sh`, `scenario/test_with_postgresql.sh` も同様に、`mysql empty_db < dump/insert-000-schema.sql` / `psql empty_db -f dump/insert-000-schema.sql` が成功 → 続けて insert ファイル群が流せることを確認。**`mysqldump` / `pg_dump` を docker compose のコンテナ内 (`compose.yml` で起動する DB コンテナ) で実行する必要がある場合は、scenario スクリプトを更新する。**
|
|
139
|
+
5. `scenario/test_with_mongodb.sh` を実行し、`dump/insert-000-schema.js` が出力されることと、空 DB に対して `mongosh "mongodb://localhost/empty_db" < dump/insert-000-schema.js` が成功すること、続いて `mongoimport` で各 jsonl が流せることを確認。
|
|
140
|
+
6. **idempotency 確認**: 同じ schema ファイルを 2 回流してもエラーにならないこと (`IF NOT EXISTS` / `DO $$ EXCEPTION WHEN duplicate_object` / `try/catch on createCollection` が効いている)。
|
|
141
|
+
|
|
142
|
+
### 手動確認のチェックポイント
|
|
143
|
+
- `mysqldump` / `pg_dump` が PATH にない環境で実行した場合、わかりやすいエラーで止まる
|
|
144
|
+
- `DATABASE_PASSWORD` env が無い場合に外部コマンドが認証エラーで落ちないこと (シェルアウト時にも `MYSQL_PWD` / `PGPASSWORD` を渡す)
|
|
145
|
+
- `CHANGELOG.md` への追記
|
|
146
|
+
|
|
147
|
+
## 留意点 / 既知のリスク
|
|
148
|
+
- **MySQL の `CREATE INDEX IF NOT EXISTS` 非対応**: mysqldump はインデックスをテーブル定義内にインラインで吐くので通常問題にならないが、`--no-create-info --no-data --routines --triggers` などのオプションで吐き分ける場合は別途対応が必要。今回はデフォルト挙動のみサポート。
|
|
149
|
+
- **PG の COMMENT ON / GRANT / SEQUENCE OWNED BY**: `--no-owner --no-acl --no-comments` で削減できる。SEQUENCE 自体の `CREATE SEQUENCE` は `IF NOT EXISTS` 書き換え対象に含める。
|
|
150
|
+
- **mongosh 依存**: 出力 JS を流すこと自体には mongosh が必要 (README に明記)。exwiw 本体はあくまで Ruby driver のみで生成するので、exwiw 実行ホストには mongosh は不要。
|
|
151
|
+
- **巨大 schema**: pg_dump / mysqldump の出力をメモリに乗せて後処理するので、超巨大スキーマだとメモリ使用量が増える。実用上は問題にならない見込み。
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Plan: MongoDB の `insert-000-schema.js` を scenario で end-to-end 検証する
|
|
2
|
+
|
|
3
|
+
## Context
|
|
4
|
+
|
|
5
|
+
`lib/exwiw/adapter/mongodb_adapter.rb#dump_schema` は `insert-000-schema.js` に
|
|
6
|
+
`createCollection` / `createIndex` を書き出す実装を既に持っているが、scenario 側で
|
|
7
|
+
これを apply するパスが無く、CI でも検証できていなかった。具体的なギャップ:
|
|
8
|
+
|
|
9
|
+
1. `scenario/setup_with_mongodb.rb` は seed を `insert_many` で流すだけで、index を一切作っていない
|
|
10
|
+
2. その結果 `tmp/mongodb/insert-000-schema.js` は `createCollection` 行のみで `createIndex` が 0 行
|
|
11
|
+
3. `scenario/import_with_mongodb.rb` は `insert-*.jsonl` だけを glob して処理しており、`insert-000-schema.js` を一切実行しない
|
|
12
|
+
|
|
13
|
+
sqlite3 / mysql2 / postgresql で導入済みの「from clean DB から立ち上げる」流れと
|
|
14
|
+
MongoDB の `insert-000-schema.js` が連動していない状態だった (issue #16)。
|
|
15
|
+
|
|
16
|
+
## ゴール
|
|
17
|
+
|
|
18
|
+
- 空の target DB に対して `mongosh insert-000-schema.js` → `insert-*.jsonl` の順で適用する scenario を CI に乗せる
|
|
19
|
+
- source DB に代表的な index を作り、`dump_schema` が `createIndex` 行を実際に吐く状態を作る
|
|
20
|
+
- 生成された createIndex 行が mongosh で実際に通ること、target 側で index が round-trip することを検証
|
|
21
|
+
- 既存の snapshot test (`spec/insert_output_snapshot_spec.rb`) でも createIndex 行を固定化
|
|
22
|
+
|
|
23
|
+
## 変更内容
|
|
24
|
+
|
|
25
|
+
### scenario 層
|
|
26
|
+
| パス | 変更 |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `scenario/setup_with_mongodb.rb` | seed 流し込みの後に 3 種類の代表的 index を作る (unique `shops.name` / plain `users.email` / 複合 `orders.shop_id+user_id`) |
|
|
29
|
+
| `scenario/import_with_mongodb.rb` | `--no-drop` と `--input-dir DIR` フラグを追加。from-clean は drop すると schema.js が作った index ごと消えてしまうため |
|
|
30
|
+
| `scenario/verify_with_mongodb.rb` | `--with-indexes` で target collection の index を assert (default scenario では import 時に drop されるのでスキップ) |
|
|
31
|
+
| `scenario/test_with_mongodb_from_clean.sh` (新規) | `mongosh dropDatabase` → exwiw 実行 → `mongosh insert-000-schema.js` → `import --no-drop --input-dir tmp/mongodb-clean` → `verify --with-indexes` |
|
|
32
|
+
| `.github/workflows/scenario.yml` | with_mongodb job に `mongodb-mongosh` install ステップと `test_with_mongodb_from_clean.sh` 実行ステップを追加。apt repo の codename は `jammy` 固定 (ubuntu-latest が noble に上がる前提) |
|
|
33
|
+
|
|
34
|
+
### snapshot test 層
|
|
35
|
+
| パス | 変更 |
|
|
36
|
+
|---|---|
|
|
37
|
+
| `spec/support/bootstrap_databases.rb` | scenario と同じ 3 index を bootstrap で作る |
|
|
38
|
+
| `spec/insert_output_snapshots/mongodb/insert-000-schema.js` | 3 つの `db.getCollection(...).createIndex(...)` 行が追加される形で再生成 |
|
|
39
|
+
|
|
40
|
+
## 設計上の判断
|
|
41
|
+
|
|
42
|
+
- **unique index は `users.email` ではなく `shops.name` に貼る**: seed の `users.email`
|
|
43
|
+
は `user1@example.com` が 5 shop に重複するので unique にできない。`shops.name`
|
|
44
|
+
("Shop 1".."Shop 5") は seed 上一意なので unique 可。
|
|
45
|
+
- **既存 scenario への副作用を最小化**: `import_with_mongodb.rb` のデフォルト挙動は変えず
|
|
46
|
+
`--no-drop` フラグで opt-in。既存 `test_with_mongodb.sh` は無修正で動く。
|
|
47
|
+
- **verify を 2 用途で兼用**: `--with-indexes` 切り替えで from-clean のみ index を見る。
|
|
48
|
+
既存 scenario は drop→insert で index が無くなるため index 検証はスキップ。
|
|
49
|
+
- **CI への mongosh install**: `mongo:7` service container には mongosh があるが、
|
|
50
|
+
ubuntu-latest 上の `mongosh` コマンドは別。MongoDB の apt repo (`mongodb-mongosh`
|
|
51
|
+
パッケージ) を入れる。codename は `jammy` 固定 (MongoDB 7.0 repo が noble を
|
|
52
|
+
carry していない時期があるため)。
|
|
53
|
+
- **snapshot fixture を indexes 入りに更新**: bootstrap_databases.rb と
|
|
54
|
+
setup_with_mongodb.rb で同じ index を作るので、snapshot test と scenario test の
|
|
55
|
+
期待値が分岐しない。
|
|
56
|
+
|
|
57
|
+
## Verification
|
|
58
|
+
|
|
59
|
+
- `bash scenario/test_with_mongodb.sh` 既存 scenario 維持を確認 ✓
|
|
60
|
+
- `bash scenario/test_with_mongodb_from_clean.sh` 新規 scenario 通過を確認
|
|
61
|
+
(indexes round-trip OK) ✓
|
|
62
|
+
- `bundle exec rspec` 全 153 examples / 0 failures ✓
|
|
63
|
+
- `tmp/mongodb-clean/insert-000-schema.js` を目視で確認:
|
|
64
|
+
```js
|
|
65
|
+
db.getCollection("shops").createIndex({"name":1}, {"unique":true,"name":"idx_shops_name"});
|
|
66
|
+
db.getCollection("users").createIndex({"email":1}, {"name":"idx_users_email"});
|
|
67
|
+
db.getCollection("orders").createIndex({"shop_id":1,"user_id":1}, {"name":"idx_orders_shop_user"});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 留意点
|
|
71
|
+
|
|
72
|
+
- `import_with_mongodb.rb` のフラグ解析は手書きの ARGV パース。引数が増えるなら
|
|
73
|
+
OptionParser 化を検討する余地あり (現状は 2 フラグなので過剰)。
|
|
74
|
+
- ubuntu-latest が将来 codename を変えても apt repo の `jammy` 指定は壊れない想定だが、
|
|
75
|
+
MongoDB 8.x へ移行する際は repo URL の `7.0` も更新が必要。
|
|
76
|
+
- 既存 issue #16 のスコープは MongoDB のみ。SQL 系の from_clean は別 PR で導入済み。
|
|
@@ -101,6 +101,47 @@ module Exwiw
|
|
|
101
101
|
'jsonl'
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
def schema_output_extension
|
|
105
|
+
'js'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Index options copied through to the emitted createIndex call. Anything
|
|
109
|
+
# else (`v`, `ns`, server-internal fields) is dropped — they would either
|
|
110
|
+
# be rejected by createIndex or are not portable across mongod versions.
|
|
111
|
+
INDEX_OPTION_ALLOWLIST = %w[
|
|
112
|
+
unique sparse hidden expireAfterSeconds collation
|
|
113
|
+
partialFilterExpression wildcardProjection
|
|
114
|
+
].freeze
|
|
115
|
+
|
|
116
|
+
def dump_schema(ordered_tables, output_path)
|
|
117
|
+
require 'json'
|
|
118
|
+
|
|
119
|
+
collections = ordered_tables.reject(&:embedded?)
|
|
120
|
+
|
|
121
|
+
File.open(output_path, 'w') do |file|
|
|
122
|
+
file.puts("// Auto-generated by exwiw. Apply with: mongosh \"$MONGODB_URI\" #{File.basename(output_path)}")
|
|
123
|
+
file.puts
|
|
124
|
+
|
|
125
|
+
collections.each do |config|
|
|
126
|
+
name = config.name
|
|
127
|
+
file.puts(%(try { db.createCollection(#{JSON.generate(name)}); } catch (e) { if (e.code !== 48) throw e; }))
|
|
128
|
+
end
|
|
129
|
+
file.puts
|
|
130
|
+
|
|
131
|
+
collections.each do |config|
|
|
132
|
+
name = config.name
|
|
133
|
+
indexes = db[name].indexes.to_a.reject { |idx| idx['name'] == '_id_' }
|
|
134
|
+
indexes.each do |idx|
|
|
135
|
+
key = idx['key']
|
|
136
|
+
opts = idx.slice(*INDEX_OPTION_ALLOWLIST)
|
|
137
|
+
opts['name'] = idx['name'] if idx['name']
|
|
138
|
+
file.puts(%(db.getCollection(#{JSON.generate(name)}).createIndex(#{JSON.generate(key)}, #{JSON.generate(opts)});))
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
@logger.info(" Wrote schema for #{collections.size} collection(s) to #{output_path}.")
|
|
143
|
+
end
|
|
144
|
+
|
|
104
145
|
def supports_bulk_delete?
|
|
105
146
|
false
|
|
106
147
|
end
|
|
@@ -14,6 +14,57 @@ module Exwiw
|
|
|
14
14
|
connection.query(sql, cast: false, as: :array).to_a
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def dump_schema(ordered_tables, output_path)
|
|
18
|
+
require 'open3'
|
|
19
|
+
|
|
20
|
+
table_names = ordered_tables.map(&:name)
|
|
21
|
+
if table_names.empty?
|
|
22
|
+
File.write(output_path, "-- Auto-generated by exwiw. No tables in scope.\n")
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
cmd = [
|
|
27
|
+
'mysqldump',
|
|
28
|
+
"--host=#{@connection_config.host}",
|
|
29
|
+
"--port=#{@connection_config.port}",
|
|
30
|
+
"--user=#{@connection_config.user}",
|
|
31
|
+
'--no-data',
|
|
32
|
+
'--skip-add-drop-table',
|
|
33
|
+
# `--skip-comments` only suppresses the dump's header lines
|
|
34
|
+
# (e.g. `-- MySQL dump ...`, server version banner). Column and
|
|
35
|
+
# table `COMMENT '...'` clauses are emitted inline inside
|
|
36
|
+
# CREATE TABLE statements and are NOT affected, so this flag is
|
|
37
|
+
# purely about reducing noise in the generated file.
|
|
38
|
+
'--skip-comments',
|
|
39
|
+
'--skip-set-charset',
|
|
40
|
+
# Suppress `SET @@GLOBAL.GTID_PURGED=...` from the dump. It is intended
|
|
41
|
+
# for replication setup and breaks when the target already has GTIDs
|
|
42
|
+
# (ERROR 3546: added gtid set must not overlap with @@GLOBAL.GTID_EXECUTED).
|
|
43
|
+
'--set-gtid-purged=OFF',
|
|
44
|
+
'--compact',
|
|
45
|
+
@connection_config.database_name,
|
|
46
|
+
*table_names,
|
|
47
|
+
]
|
|
48
|
+
env = { 'MYSQL_PWD' => @connection_config.password.to_s }
|
|
49
|
+
|
|
50
|
+
@logger.debug(" Running mysqldump for #{table_names.size} table(s)...")
|
|
51
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
52
|
+
unless status.success?
|
|
53
|
+
if stderr.include?('command not found') || stderr.empty?
|
|
54
|
+
raise "Failed to run `mysqldump`. Ensure the mysql client is installed and on PATH. stderr: #{stderr}"
|
|
55
|
+
end
|
|
56
|
+
raise "mysqldump failed (exit #{status.exitstatus}): #{stderr}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(stdout)
|
|
60
|
+
|
|
61
|
+
File.open(output_path, 'w') do |file|
|
|
62
|
+
file.puts("-- Auto-generated by exwiw via mysqldump. Idempotent CREATE TABLE statements for mysql.")
|
|
63
|
+
file.write(idempotent)
|
|
64
|
+
end
|
|
65
|
+
@logger.info(" Wrote schema for #{table_names.size} table(s) to #{output_path}.")
|
|
66
|
+
end
|
|
67
|
+
|
|
17
68
|
def to_bulk_insert(results, table)
|
|
18
69
|
table_name = table.name
|
|
19
70
|
|
|
@@ -14,6 +14,51 @@ module Exwiw
|
|
|
14
14
|
connection.exec(sql).values
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def dump_schema(ordered_tables, output_path)
|
|
18
|
+
require 'open3'
|
|
19
|
+
|
|
20
|
+
table_names = ordered_tables.map(&:name)
|
|
21
|
+
if table_names.empty?
|
|
22
|
+
File.write(output_path, "-- Auto-generated by exwiw. No tables in scope.\n")
|
|
23
|
+
return
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
cmd = [
|
|
27
|
+
'pg_dump',
|
|
28
|
+
"--host=#{@connection_config.host}",
|
|
29
|
+
"--port=#{@connection_config.port}",
|
|
30
|
+
"--username=#{@connection_config.user}",
|
|
31
|
+
'--schema-only',
|
|
32
|
+
'--no-owner',
|
|
33
|
+
'--no-acl',
|
|
34
|
+
*table_names.flat_map { |t| ['--table', t] },
|
|
35
|
+
@connection_config.database_name,
|
|
36
|
+
]
|
|
37
|
+
env = { 'PGPASSWORD' => @connection_config.password.to_s }
|
|
38
|
+
|
|
39
|
+
@logger.debug(" Running pg_dump for #{table_names.size} table(s)...")
|
|
40
|
+
stdout, stderr, status = Open3.capture3(env, *cmd)
|
|
41
|
+
unless status.success?
|
|
42
|
+
if stderr.include?('command not found') || stderr.empty?
|
|
43
|
+
raise "Failed to run `pg_dump`. Ensure the postgresql client is installed and on PATH. stderr: #{stderr}"
|
|
44
|
+
end
|
|
45
|
+
raise "pg_dump failed (exit #{status.exitstatus}): #{stderr}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
idempotent = stdout
|
|
49
|
+
idempotent = DdlPostprocessor.add_if_not_exists_to_create_schema(idempotent)
|
|
50
|
+
idempotent = DdlPostprocessor.add_if_not_exists_to_create_sequence(idempotent)
|
|
51
|
+
idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(idempotent)
|
|
52
|
+
idempotent = DdlPostprocessor.add_if_not_exists_to_create_index(idempotent)
|
|
53
|
+
idempotent = DdlPostprocessor.wrap_add_constraint_in_do_block(idempotent)
|
|
54
|
+
|
|
55
|
+
File.open(output_path, 'w') do |file|
|
|
56
|
+
file.puts("-- Auto-generated by exwiw via pg_dump. Idempotent DDL for postgresql.")
|
|
57
|
+
file.write(idempotent)
|
|
58
|
+
end
|
|
59
|
+
@logger.info(" Wrote schema for #{table_names.size} table(s) to #{output_path}.")
|
|
60
|
+
end
|
|
61
|
+
|
|
17
62
|
def to_bulk_insert(results, table)
|
|
18
63
|
table_name = table.name
|
|
19
64
|
|
|
@@ -29,6 +74,36 @@ module Exwiw
|
|
|
29
74
|
"INSERT INTO #{table_name} (#{column_names}) VALUES\n#{values};"
|
|
30
75
|
end
|
|
31
76
|
|
|
77
|
+
# Transcribe the FROM-side sequence cursor backing `table.primary_key`
|
|
78
|
+
# onto the import target. Without this, importing into a clean DB leaves
|
|
79
|
+
# the sequence at 1 while the inserted rows occupy higher IDs, so the
|
|
80
|
+
# next default-PK INSERT collides. We query FROM's `last_value` /
|
|
81
|
+
# `is_called` directly (matching what pg_dump emits) rather than using
|
|
82
|
+
# MAX(pk), so a subsetted dump still preserves the source's "next id".
|
|
83
|
+
# Returns nil for non-auto-increment PKs (pg_get_serial_sequence -> NULL).
|
|
84
|
+
#
|
|
85
|
+
# Scope: ONLY the sequence attached to the primary key is synced. If a
|
|
86
|
+
# table has additional auto-increment columns (e.g. a non-PK SERIAL),
|
|
87
|
+
# those sequences are NOT transcribed and a subsequent default-value
|
|
88
|
+
# INSERT on them can collide. Rails-managed schemas don't hit this
|
|
89
|
+
# because only `id` is auto-increment, but bare PostgreSQL schemas may.
|
|
90
|
+
def post_insert_sql(table)
|
|
91
|
+
pk = table.primary_key
|
|
92
|
+
return nil if pk.nil? || pk.empty?
|
|
93
|
+
|
|
94
|
+
seq_name = connection
|
|
95
|
+
.exec_params("SELECT pg_get_serial_sequence($1, $2)", [table.name, pk])
|
|
96
|
+
.values.dig(0, 0)
|
|
97
|
+
return nil if seq_name.nil?
|
|
98
|
+
|
|
99
|
+
last_value, is_called = connection
|
|
100
|
+
.exec("SELECT last_value, is_called FROM #{seq_name}")
|
|
101
|
+
.values.first
|
|
102
|
+
is_called_sql = (is_called == 't' || is_called == true) ? 'true' : 'false'
|
|
103
|
+
|
|
104
|
+
"SELECT pg_catalog.setval('#{escape_single_quote(seq_name)}', #{last_value}, #{is_called_sql});"
|
|
105
|
+
end
|
|
106
|
+
|
|
32
107
|
def to_bulk_delete(select_query_ast, table)
|
|
33
108
|
raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
34
109
|
|
|
@@ -14,6 +14,47 @@ module Exwiw
|
|
|
14
14
|
connection.execute(sql)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
+
def dump_schema(ordered_tables, output_path)
|
|
18
|
+
@logger.debug(" Reading schema from sqlite_master...")
|
|
19
|
+
target_names = ordered_tables.map(&:name)
|
|
20
|
+
# `sqlite_master` row order preserves table creation order, which is also
|
|
21
|
+
# the dependency order produced by ActiveRecord-style migrations. To respect
|
|
22
|
+
# the caller-provided order, we partition tables / their owned indexes by
|
|
23
|
+
# ordered_tables.
|
|
24
|
+
all = connection.execute(<<~SQL)
|
|
25
|
+
SELECT type, name, tbl_name, sql FROM sqlite_master
|
|
26
|
+
WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'
|
|
27
|
+
SQL
|
|
28
|
+
|
|
29
|
+
tables_by_name = all.select { |type, _, _, _| type == 'table' }.to_h { |_, name, _, sql| [name, sql] }
|
|
30
|
+
indexes_by_owner = all.select { |type, _, _, _| type == 'index' }.group_by { |_, _, tbl, _| tbl }
|
|
31
|
+
triggers_by_owner = all.select { |type, _, _, _| type == 'trigger' }.group_by { |_, _, tbl, _| tbl }
|
|
32
|
+
|
|
33
|
+
statements = []
|
|
34
|
+
target_names.each do |name|
|
|
35
|
+
table_sql = tables_by_name[name]
|
|
36
|
+
next unless table_sql
|
|
37
|
+
|
|
38
|
+
statements << finalize_stmt(DdlPostprocessor.add_if_not_exists_to_create_table(table_sql.strip))
|
|
39
|
+
(indexes_by_owner[name] || []).each do |_, _, _, idx_sql|
|
|
40
|
+
statements << finalize_stmt(DdlPostprocessor.add_if_not_exists_to_create_index(idx_sql.strip))
|
|
41
|
+
end
|
|
42
|
+
(triggers_by_owner[name] || []).each do |_, _, _, trg_sql|
|
|
43
|
+
statements << finalize_stmt(trg_sql.strip)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
File.open(output_path, 'w') do |file|
|
|
48
|
+
file.puts("-- Auto-generated by exwiw. Idempotent CREATE statements for sqlite3.")
|
|
49
|
+
file.puts(statements.join("\n"))
|
|
50
|
+
end
|
|
51
|
+
@logger.info(" Wrote #{statements.size} schema statement(s) to #{output_path}.")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private def finalize_stmt(stmt)
|
|
55
|
+
stmt.end_with?(';') ? stmt : "#{stmt};"
|
|
56
|
+
end
|
|
57
|
+
|
|
17
58
|
def to_bulk_insert(results, table)
|
|
18
59
|
table_name = table.name
|
|
19
60
|
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -30,6 +30,22 @@ module Exwiw
|
|
|
30
30
|
'sql'
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
# File extension used for the leading `insert-000-schema.*` file.
|
|
34
|
+
# SQL adapters emit `.sql` (CREATE TABLE IF NOT EXISTS ...);
|
|
35
|
+
# MongodbAdapter overrides to `.js` (mongosh-runnable createCollection / createIndex).
|
|
36
|
+
def schema_output_extension
|
|
37
|
+
'sql'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write the leading schema-creation file for this adapter to `output_path`.
|
|
41
|
+
# Default is a no-op; subclasses override to emit idempotent DDL so the
|
|
42
|
+
# generated dump can be applied to an empty database.
|
|
43
|
+
#
|
|
44
|
+
# @param ordered_tables [Array] table configs in dependency order
|
|
45
|
+
# @param output_path [String] absolute path to write to
|
|
46
|
+
def dump_schema(ordered_tables, output_path)
|
|
47
|
+
end
|
|
48
|
+
|
|
33
49
|
# Whether this adapter emits delete-NNN-*.sql files.
|
|
34
50
|
def supports_bulk_delete?
|
|
35
51
|
true
|
|
@@ -46,6 +62,14 @@ module Exwiw
|
|
|
46
62
|
# dump_target. Default: nothing to validate.
|
|
47
63
|
def validate_as_dump_target!(_config)
|
|
48
64
|
end
|
|
65
|
+
|
|
66
|
+
# Optional SQL appended to the per-table insert-NNN-<table>.* file after
|
|
67
|
+
# the bulk INSERT statements. Use to bring side-state in sync with the
|
|
68
|
+
# explicit IDs that were just inserted (e.g. PostgreSQL sequences).
|
|
69
|
+
# Default: nil (nothing appended).
|
|
70
|
+
def post_insert_sql(_table)
|
|
71
|
+
nil
|
|
72
|
+
end
|
|
49
73
|
end
|
|
50
74
|
|
|
51
75
|
# @params [Exwiw::QueryAst] query_ast
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exwiw
|
|
4
|
+
# Rewrites raw CREATE statements emitted by mysqldump / pg_dump /
|
|
5
|
+
# sqlite_master.sql into idempotent forms so the generated
|
|
6
|
+
# `insert-000-schema.sql` file can be re-applied without error.
|
|
7
|
+
module DdlPostprocessor
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# `CREATE TABLE [name]` → `CREATE TABLE IF NOT EXISTS [name]`.
|
|
11
|
+
# `TEMP` / `TEMPORARY` variants and already-IF-NOT-EXISTS lines are skipped.
|
|
12
|
+
def add_if_not_exists_to_create_table(sql)
|
|
13
|
+
sql.gsub(/\bCREATE\s+TABLE\b(?!\s+IF\s+NOT\s+EXISTS)/i) do |m|
|
|
14
|
+
"#{m} IF NOT EXISTS"
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# `CREATE [UNIQUE] INDEX [name]` → `CREATE [UNIQUE] INDEX IF NOT EXISTS [name]`.
|
|
19
|
+
# Use only for databases that support it (PostgreSQL, SQLite). MySQL does NOT
|
|
20
|
+
# support `CREATE INDEX IF NOT EXISTS` — do not call from the MySQL adapter.
|
|
21
|
+
def add_if_not_exists_to_create_index(sql)
|
|
22
|
+
sql.gsub(/\bCREATE(\s+UNIQUE)?\s+INDEX\b(?!\s+IF\s+NOT\s+EXISTS)/i) do
|
|
23
|
+
unique = Regexp.last_match(1) || ""
|
|
24
|
+
"CREATE#{unique} INDEX IF NOT EXISTS"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# `CREATE SCHEMA [name]` → `CREATE SCHEMA IF NOT EXISTS [name]`.
|
|
29
|
+
def add_if_not_exists_to_create_schema(sql)
|
|
30
|
+
sql.gsub(/\bCREATE\s+SCHEMA\b(?!\s+IF\s+NOT\s+EXISTS)/i) do |m|
|
|
31
|
+
"#{m} IF NOT EXISTS"
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `CREATE SEQUENCE [name]` → `CREATE SEQUENCE IF NOT EXISTS [name]`.
|
|
36
|
+
def add_if_not_exists_to_create_sequence(sql)
|
|
37
|
+
sql.gsub(/\bCREATE\s+SEQUENCE\b(?!\s+IF\s+NOT\s+EXISTS)/i) do |m|
|
|
38
|
+
"#{m} IF NOT EXISTS"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# `ALTER TABLE ... ADD CONSTRAINT ...;` is not idempotent on its own.
|
|
43
|
+
# PostgreSQL's PL/pgSQL has no IF-NOT-EXISTS clause for ADD CONSTRAINT, so wrap
|
|
44
|
+
# each statement in a DO block that swallows `duplicate_object`.
|
|
45
|
+
# Matches only statements whose ALTER TABLE clause leads directly into ADD CONSTRAINT
|
|
46
|
+
# (no intervening ALTER COLUMN / DROP / etc) so that unrelated ALTER TABLE statements
|
|
47
|
+
# in the same dump are not absorbed.
|
|
48
|
+
ADD_CONSTRAINT_RE = /^[ \t]*ALTER\s+TABLE\s+(?:ONLY\s+)?[^\s;,]+\s+(?:\n[ \t]*)?ADD\s+CONSTRAINT\b[^;]*;/m.freeze
|
|
49
|
+
|
|
50
|
+
def wrap_add_constraint_in_do_block(sql)
|
|
51
|
+
sql.gsub(ADD_CONSTRAINT_RE) do |stmt|
|
|
52
|
+
<<~SQL.chomp
|
|
53
|
+
DO $exwiw$ BEGIN
|
|
54
|
+
#{stmt.strip}
|
|
55
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
56
|
+
END $exwiw$;
|
|
57
|
+
SQL
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -34,6 +34,11 @@ module Exwiw
|
|
|
34
34
|
FileUtils.mkdir_p(@output_dir)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
ordered_tables = ordered_table_names.map { |n| table_by_name.fetch(n) }
|
|
38
|
+
schema_path = File.join(@output_dir, "insert-000-schema.#{adapter.schema_output_extension}")
|
|
39
|
+
@logger.info("Writing schema to #{schema_path}...")
|
|
40
|
+
adapter.dump_schema(ordered_tables, schema_path)
|
|
41
|
+
|
|
37
42
|
total_size = ordered_table_names.size
|
|
38
43
|
ordered_table_names.each_with_index do |table_name, idx|
|
|
39
44
|
@logger.info("Processing table '#{table_name}'... (#{idx + 1}/#{total_size})")
|
|
@@ -57,6 +62,8 @@ module Exwiw
|
|
|
57
62
|
insert_idx = (idx + 1).to_s.rjust(3, '0')
|
|
58
63
|
File.open(File.join(@output_dir, "insert-#{insert_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
|
|
59
64
|
file.puts(insert_sql)
|
|
65
|
+
post = adapter.post_insert_sql(table)
|
|
66
|
+
file.puts(post) if post
|
|
60
67
|
end
|
|
61
68
|
|
|
62
69
|
if adapter.supports_bulk_delete?
|
|
@@ -81,14 +88,5 @@ module Exwiw
|
|
|
81
88
|
klass.from(json)
|
|
82
89
|
end
|
|
83
90
|
end
|
|
84
|
-
|
|
85
|
-
private def build_adapter
|
|
86
|
-
case @connection_config["adapter"]
|
|
87
|
-
when "sqlite3"
|
|
88
|
-
Sqlite3Adapter.new(@connection_config)
|
|
89
|
-
else
|
|
90
|
-
raise "Unsupported adapter"
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
91
|
end
|
|
94
92
|
end
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -11,6 +11,7 @@ require_relative "exwiw/table_config"
|
|
|
11
11
|
require_relative "exwiw/embedded_in"
|
|
12
12
|
require_relative "exwiw/mongodb_field"
|
|
13
13
|
require_relative "exwiw/mongodb_collection_config"
|
|
14
|
+
require_relative "exwiw/ddl_postprocessor"
|
|
14
15
|
require_relative "exwiw/adapter"
|
|
15
16
|
require_relative "exwiw/adapter/sqlite3_adapter"
|
|
16
17
|
require_relative "exwiw/adapter/mysql2_adapter"
|
data/mise.toml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
[env]
|
|
2
|
+
# Prepend scenario/bin so `pg_dump` resolves to the wrapper that delegates to
|
|
3
|
+
# the postgres container (compose.yml). exwiw's PostgreSQL adapter shells out
|
|
4
|
+
# to pg_dump, which requires a server/client major-version match — the dev DB
|
|
5
|
+
# is postgres:17 while host clients are often older (e.g. Homebrew pg14).
|
|
6
|
+
_.path = ["./scenario/bin"]
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exwiw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -35,6 +35,8 @@ files:
|
|
|
35
35
|
- CHANGELOG.md
|
|
36
36
|
- LICENSE.txt
|
|
37
37
|
- README.md
|
|
38
|
+
- docs/plans/2026-05-15-insert-000-schema-file.md
|
|
39
|
+
- docs/plans/2026-05-16-mongodb-from-clean-scenario.md
|
|
38
40
|
- exe/exwiw
|
|
39
41
|
- lib/exwiw.rb
|
|
40
42
|
- lib/exwiw/adapter.rb
|
|
@@ -44,6 +46,7 @@ files:
|
|
|
44
46
|
- lib/exwiw/adapter/sqlite3_adapter.rb
|
|
45
47
|
- lib/exwiw/belongs_to.rb
|
|
46
48
|
- lib/exwiw/cli.rb
|
|
49
|
+
- lib/exwiw/ddl_postprocessor.rb
|
|
47
50
|
- lib/exwiw/determine_table_processing_order.rb
|
|
48
51
|
- lib/exwiw/embedded_in.rb
|
|
49
52
|
- lib/exwiw/mongo_query.rb
|
|
@@ -58,6 +61,7 @@ files:
|
|
|
58
61
|
- lib/exwiw/table_config.rb
|
|
59
62
|
- lib/exwiw/version.rb
|
|
60
63
|
- lib/tasks/exwiw.rake
|
|
64
|
+
- mise.toml
|
|
61
65
|
homepage: https://github.com/riseshia/exwiw
|
|
62
66
|
licenses:
|
|
63
67
|
- MIT
|
|
@@ -79,7 +83,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
79
83
|
- !ruby/object:Gem::Version
|
|
80
84
|
version: '0'
|
|
81
85
|
requirements: []
|
|
82
|
-
rubygems_version:
|
|
86
|
+
rubygems_version: 4.0.10
|
|
83
87
|
specification_version: 4
|
|
84
88
|
summary: Ruby gem that allows you to export records from a database to a dump file.
|
|
85
89
|
test_files: []
|