exwiw 0.9.24 → 1.0.0
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 +16 -0
- data/README.md +11 -23
- data/docs/mongodb.md +1 -1
- data/lib/exwiw/adapter/identifier_quoting.rb +2 -2
- data/lib/exwiw/adapter/mongodb_adapter.rb +0 -8
- data/lib/exwiw/adapter/mysql_adapter.rb +1 -92
- data/lib/exwiw/adapter/postgresql_adapter.rb +48 -70
- data/lib/exwiw/adapter/sqlite_adapter.rb +0 -58
- data/lib/exwiw/adapter.rb +0 -11
- data/lib/exwiw/cli.rb +14 -10
- data/lib/exwiw/ddl_postprocessor.rb +44 -4
- data/lib/exwiw/runner.rb +2 -19
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ecbb0b978dfd64c1e48c396e600662a9f7d4bb9c2c8f0e0050440987b53c815d
|
|
4
|
+
data.tar.gz: eff38c81ce04abb165f0edcad7316e1820d44abd54c6596ce450357c11a9c201
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2b119cb478ae9e6bb019bb2d0d94272ed84f7bb20ac3013236260b8c98433c9c97f37b3d4dafc132a70736d0d6eb4a20e4fc0e44e473dfc5d1a91f7dbda560a0
|
|
7
|
+
data.tar.gz: 17268aa8ec3ad2959521cc4b16d453e86480935ae98ba43e4f38fb4545dada3af23980fc79b8f3751268fa79e078e87c7e7388e8bab7ce9fefbef9e6344c9453
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.0.0] - 2026-09-01
|
|
6
|
+
|
|
7
|
+
### Removed
|
|
8
|
+
|
|
9
|
+
- **BREAKING — delete SQL support is removed ([#166](https://github.com/heyinc/exwiw/issues/166)): `export` no longer generates `delete-NNN-<table>.sql` files.** They existed to clear an already-populated import target before re-inserting; the supported workflow is now to import into an empty database (`insert-000-schema.sql` provisions one) or to clear the target yourself. Every export now behaves as `--insert-only` always did, so the `--insert-only` flag and the `insert_only:` config key have nothing left to toggle — both are still **accepted but ignored**, with a warning naming them obsolete, so an existing invocation or committed config file keeps working across the upgrade; remove them at your leisure (a future release will reject them). For the adapter API, `to_bulk_delete`, `supports_bulk_delete?`, and `pre_delete_sql` are removed.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **BREAKING — PostgreSQL: `insert-000-schema.sql` now contains the source's triggers, wrapped in `DO $exwiw$ ... EXCEPTION WHEN duplicate_object`.** They used to be deleted from the dump, so a restored database silently ran none of them: an audit trigger recorded nothing, a timestamp trigger never fired, and the dump gave whoever restored it no way to add them back. The deletion existed because a `--table` dump emitted `CREATE TRIGGER` without the `CREATE FUNCTION` it references, failing the restore with `PG::UndefinedFunction`; the dump has since become a whole-database one that carries the functions too, so it was a leftover workaround rather than intended behavior — `mysqldump` and sqlite's `sqlite_master` have always emitted triggers. It is nonetheless a breaking change: dumps grow the trigger statements. They do not fire during the load, because each `insert-*.sql` now opens with the `session_replication_role = 'replica'` block described below. The DO block keeps the schema re-appliable, which a bare `CREATE TRIGGER` is not (`CREATE OR REPLACE TRIGGER` is PostgreSQL 14+ only and `pg_dump` never emits it).
|
|
14
|
+
|
|
15
|
+
- **PostgreSQL: each `insert-NNN-<table>.sql` now opens with a `session_replication_role = 'replica'` block, so triggers and foreign keys do not fire while the target is loaded.** With the source's triggers now in the schema, an unguarded load would run every one of them once per inserted row — over rows that already carry the values those triggers produced on the source — and would enforce FKs against a target that is only complete once every `insert-*.sql` has been applied. This is the counterpart of the `FOREIGN_KEY_CHECKS=0` that `MysqlAdapter#pre_insert_sql` has always emitted. Setting the parameter requires superuser (`rds_superuser` on RDS); when the restoring role lacks it the block catches `insufficient_privilege` and downgrades to a `WARNING`, so the load still runs — with triggers firing, as it did before this release. The setting is connection-scoped (`set_config(..., false)`) and is not reset at the end of the file — every file re-arms it itself, so concatenating them works regardless, but sourcing them into a session that goes on to do other work leaves that session in replica mode.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **PostgreSQL: a `CREATE TRIGGER` inside a function body is no longer rewritten.** The removed `DdlPostprocessor.strip_triggers` matched `^CREATE TRIGGER` anywhere, so it also deleted such a line from a dollar-quoted body, leaving a function that still compiled but no longer installed its trigger. Its replacement, `wrap_create_trigger_in_do_block`, anchors on the `-- Name: <table> <trigger>; Type: TRIGGER` header `pg_dump` writes before each trigger, which never appears inside a body. `CREATE CONSTRAINT TRIGGER` is handled, and a `;` inside a quoted trigger argument no longer truncates the statement.
|
|
20
|
+
|
|
5
21
|
## [0.9.24] - 2026-08-17
|
|
6
22
|
|
|
7
23
|
### Fixed
|
data/README.md
CHANGED
|
@@ -102,12 +102,15 @@ The output dir is emptied before each export so it never mixes files from a prev
|
|
|
102
102
|
|
|
103
103
|
- `dump/insert-000-schema.sql` — idempotent `CREATE TABLE IF NOT EXISTS ...` for every table in scope. Apply this first to provision an empty database.
|
|
104
104
|
- `dump/insert-{idx}-{table_name}.sql`
|
|
105
|
-
- `dump/delete-{idx}-{table_name}.sql`
|
|
106
105
|
|
|
107
106
|
idx means the order of the dump. bigger idx might depend on smaller idx,
|
|
108
107
|
so you should import the dump in order.
|
|
109
108
|
|
|
110
|
-
|
|
109
|
+
exwiw generates INSERT statements only — it does not generate DELETE statements. Import into an empty database (`insert-000-schema.sql` provisions one), or clear the target's rows yourself before importing.
|
|
110
|
+
|
|
111
|
+
`insert-000-schema.sql` is generated by shelling out to the database client tools (`mysqldump` for `mysql`, `pg_dump` for `postgresql`, and the sqlite3 driver for `sqlite`), so the corresponding client must be available on PATH when running exwiw. For `mysql`, set `EXWIW_MYSQLDUMP` to point at a specific `mysqldump` binary when the one on PATH is incompatible with the server (e.g. a MySQL 9.x `mysqldump` cannot load `mysql_native_password` against a server still using that auth plugin — `EXWIW_MYSQLDUMP=/path/to/mysql@8.0/bin/mysqldump`). 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` and `CREATE TRIGGER` statements are wrapped in `DO $$ ... EXCEPTION WHEN duplicate_object`. For `mysql`, the source server's `DEFINER=user@host` stamp on views and triggers is stripped too, so restoring into a managed MySQL instance (which usually can't grant the privilege to recreate someone else's `DEFINER`) does not fail.
|
|
112
|
+
|
|
113
|
+
The schema file carries the source's triggers. They are suppressed while the target is loaded: each `insert-NNN-<table>.sql` opens with a block that sets `session_replication_role = 'replica'` for the connection, which turns off both user triggers and foreign-key enforcement for the statements that follow (the PostgreSQL counterpart of the `FOREIGN_KEY_CHECKS=0` `mysql` dumps already carry). Setting it requires superuser (`rds_superuser` on RDS); if the restoring role lacks the privilege the block reports a `WARNING` and the load proceeds with triggers firing. The setting applies to the connection and is not reset at the end of each file — every file re-arms it itself, so `psql -f` per file and `cat insert-*.sql | psql` both work, but a session that sources these files and then goes on to do other work stays in replica mode; reset it yourself (`SET session_replication_role = 'origin'`) in that case. `sqlite` has no equivalent and loads with its triggers active.
|
|
111
114
|
|
|
112
115
|
For `postgresql`, the extensions a managed platform installs to run the source instance itself are treated as out of target and left out of the dump entirely — currently `google_vacuum_mgmt` (Cloud SQL / AlloyDB adaptive autovacuum), `google_columnar_engine` and `google_db_advisor` (AlloyDB). They serve the source instance's operation (vacuum tuning, the in-memory columnar cache, index advice), hold no application data, are referenced by nothing in the application's own schema, and ship only with the managed platform, so a restore target outside it can never create them. Their schemas are dropped via `pg_dump --exclude-schema` and their `CREATE EXTENSION` / `COMMENT ON EXTENSION` statements — which are not schema-qualified, so no `pg_dump` filter reaches them — are removed from the output; whatever was excluded is named in the run's log.
|
|
113
116
|
|
|
@@ -116,11 +119,6 @@ The list is exact names, not a `google_*` prefix match: those prefixes are not r
|
|
|
116
119
|
- a third-party extension pulled in as a dependency of an excluded one (`google_db_advisor` requires `hypopg`), since that one *is* installable on a plain PostgreSQL, and
|
|
117
120
|
- an application-facing platform extension (`google_ml_integration`, `alloydb_scann`, `alloydb_ai_nl`), which the application's own SQL and DDL can name (a ScaNN index is `USING scann`) — removing its `CREATE` would strand whatever refers to it, so it warns and skips instead.
|
|
118
121
|
|
|
119
|
-
you need to delete the records before importing the dump,
|
|
120
|
-
`delete-{idx}-{table_name}.sql` will help you to do that.
|
|
121
|
-
This sql will delete "all" related records to the extract targets.
|
|
122
|
-
idx meaning is the same as insert sql.
|
|
123
|
-
|
|
124
122
|
### `exwiw explain`
|
|
125
123
|
|
|
126
124
|
Print the query each `export` would run together with its `EXPLAIN` output, to stdout. For the SQL adapters (`mysql`, `postgresql`, `sqlite`) this is the compiled SELECT plus its `EXPLAIN` (estimate-only; `EXPLAIN QUERY PLAN` on SQLite) — no SELECT is executed. For `mongodb` it is the `find` description plus the server's explain document as JSON.
|
|
@@ -135,7 +133,7 @@ exwiw explain \
|
|
|
135
133
|
--target-table=shops --ids=1
|
|
136
134
|
```
|
|
137
135
|
|
|
138
|
-
The `--output-dir`, `--output-format`,
|
|
136
|
+
The `--output-dir`, `--output-format`, and `--after-insert-hook` options are dump-specific and rejected when used with `explain`.
|
|
139
137
|
|
|
140
138
|
MongoDB-specific explain behavior — the configurable verbosity (`queryPlanner` / `executionStats` / `allPlansExecution`) and how scoped collections are shown — is described in [MongoDB support](docs/mongodb.md#exwiw-explain-verbosity).
|
|
141
139
|
|
|
@@ -307,7 +305,6 @@ adapter: postgresql
|
|
|
307
305
|
schema_dir: exwiw/schema
|
|
308
306
|
output_dir: dump
|
|
309
307
|
output_format: insert # insert | copy
|
|
310
|
-
insert_only: false
|
|
311
308
|
after_insert_hook: hooks/seed.rb
|
|
312
309
|
log_level: info # debug | info
|
|
313
310
|
# target_table / ids / ids_field / scope_column may also be set here
|
|
@@ -326,8 +323,8 @@ Notes:
|
|
|
326
323
|
|
|
327
324
|
- **Database connection settings stay on the CLI/environment.** `host`, `port`, `user`, `database`, `uri`, and `password` are **rejected** in the config file (exwiw exits with an error). `adapter` is the one connection-related key that *is* allowed in the file.
|
|
328
325
|
- **Relative paths in the config (`schema_dir`, `output_dir`, `after_insert_hook`) are resolved relative to the config file's own directory**, not the current working directory. So with the config at the project root, `schema_dir: exwiw/schema` reads naturally, and an absolute `--config=/path/to/exwiw.yml` works no matter where you run from. (CLI path flags remain relative to the current directory — each source resolves relative to where it is written.) Absolute paths are used as-is.
|
|
329
|
-
- Unknown keys are rejected so a typo surfaces immediately.
|
|
330
|
-
- Export-only keys (`output_dir`, `output_format`, `
|
|
326
|
+
- Unknown keys are rejected so a typo surfaces immediately. (`insert_only`, whose behavior was removed, is the one grandfathered key: accepted and ignored with a warning.)
|
|
327
|
+
- Export-only keys (`output_dir`, `output_format`, `after_insert_hook`) are ignored when running `explain` or `schema`, so a single config file can be shared by every subcommand.
|
|
331
328
|
- `explain_verbosity` sets the mongodb `explain` verbosity (`queryPlanner` | `executionStats` | `allPlansExecution`, default `queryPlanner`); the `EXWIW_MONGODB_EXPLAIN_VERBOSITY` env var overrides it. Ignored by the SQL adapters and by `export`. See [MongoDB support](docs/mongodb.md#exwiw-explain-verbosity).
|
|
332
329
|
- `mongodb_query_timeout_ms` sets the global, server-enforced query timeout (mongodb only); the `--mongodb-query-timeout-ms` CLI flag overrides it. Ignored by the SQL adapters. See [MongoDB support](docs/mongodb.md).
|
|
333
330
|
|
|
@@ -618,13 +615,9 @@ psql -d app_dev -f dump/insert-001-shops.sql
|
|
|
618
615
|
|
|
619
616
|
`--output-format=copy` is only supported with the `postgresql` adapter.
|
|
620
617
|
|
|
621
|
-
### Skip DELETE SQL output
|
|
622
|
-
|
|
623
|
-
By default, exwiw generates `delete-*.sql` files alongside the `insert-*.sql` files so that an existing dataset can be cleared before re-inserting. Pass `--insert-only` when you only need the insert files.
|
|
624
|
-
|
|
625
618
|
### After-insert hook
|
|
626
619
|
|
|
627
|
-
`--after-insert-hook=PATH` runs a post-processing hook **after** all per-table insert
|
|
620
|
+
`--after-insert-hook=PATH` runs a post-processing hook **after** all per-table insert files have been written. The hook can be either a Ruby file (`.rb`) or any executable script (e.g. `.sh`).
|
|
628
621
|
|
|
629
622
|
**Ruby hook (`.rb`)**: provides a tiny DSL with these builtins:
|
|
630
623
|
|
|
@@ -666,7 +659,7 @@ Note: Ruby hooks are evaluated via `instance_eval` inside the exwiw process —
|
|
|
666
659
|
|
|
667
660
|
### Ignore a table
|
|
668
661
|
|
|
669
|
-
Set `"ignore": true` on a table's config JSON to exclude it from data extraction. The table's DDL is still emitted into `insert-000-schema.{sql,js}` so the schema stays consistent, but no `insert-*`
|
|
662
|
+
Set `"ignore": true` on a table's config JSON to exclude it from data extraction. The table's DDL is still emitted into `insert-000-schema.{sql,js}` so the schema stays consistent, but no `insert-*` files are generated for it and the table is never queried.
|
|
670
663
|
|
|
671
664
|
```json
|
|
672
665
|
{
|
|
@@ -772,7 +765,7 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
|
|
|
772
765
|
AND reviews.reviewable_type = 'Product'
|
|
773
766
|
```
|
|
774
767
|
|
|
775
|
-
The same type filter is applied on the join path
|
|
768
|
+
The same type filter is applied on the join path when the polymorphic table is an intermediate hop rather than the directly-dumped table.
|
|
776
769
|
|
|
777
770
|
#### Every arm is extracted (scope-column mode)
|
|
778
771
|
|
|
@@ -912,7 +905,6 @@ Behavior at dump time:
|
|
|
912
905
|
|
|
913
906
|
- Extraction uses `SELECT *` so the dump is robust against Rails-side column additions.
|
|
914
907
|
- `INSERT` statements omit the column list (`INSERT INTO schema_migrations VALUES (...)`). For PostgreSQL `--output-format=copy`, the `COPY` header similarly omits the column list (`COPY schema_migrations FROM stdin;`).
|
|
915
|
-
- No `delete-*.sql` file is generated for rails-managed tables, to avoid wiping migration history on the import target.
|
|
916
908
|
|
|
917
909
|
Constraints:
|
|
918
910
|
|
|
@@ -982,7 +974,6 @@ An explicit id list of that size is exactly estimated and selective, so the fore
|
|
|
982
974
|
- The batch table's ids come from **its own extraction query**, so it is narrowed by exactly the filter it would carry in the unbatched query. They are held in memory for the extraction: one scope's worth of primary keys, orders of magnitude smaller than the table being batched.
|
|
983
975
|
- The batch table may be **any number of hops up** the path — a table two hops below it (`activity_orders → activities → customers`) names `customers` too, and the batch ids are applied where the path meets the scope, bounding the whole join chain.
|
|
984
976
|
- A table that **carries the scope column itself** batches by naming itself; each batch then filters `WHERE <pk> IN (<ids>)` directly. Note that the id-set query is then the same scope predicate over the same table, so this shape only avoids the scan when the scope column is indexed (ideally index-only) — the join shape above is the one that genuinely removes the planner's choice.
|
|
985
|
-
- `delete-*.sql` is unaffected (it is generated from the unbatched query).
|
|
986
977
|
- `bulk_insert_chunk_size` is independent: batches are query boundaries, chunks are `INSERT` statement boundaries.
|
|
987
978
|
- With `--output-format=copy`, batching bounds each query's cost but not memory: COPY builds the whole table's body in memory, so all batches' rows are resident at once. Use the default INSERT format (which streams) when the kept rows themselves are huge.
|
|
988
979
|
|
|
@@ -1186,9 +1177,6 @@ exwiw can export MongoDB databases too (`--adapter=mongodb`): JSONL output impor
|
|
|
1186
1177
|
- Generate the full list of INSERT sql based on the specified conditions.
|
|
1187
1178
|
- If the processing table has no relation with target tables, then dump all records.
|
|
1188
1179
|
- If the processing table has relation with target tables, then dump the records which are related to the target tables.
|
|
1189
|
-
- Generate the full list of DELETE sql based on the specified conditions.
|
|
1190
|
-
- If the processing table has no relation with target tables, then delete all records.
|
|
1191
|
-
- If the processing table has relation with target tables, then delete the records which are related to the target tables.
|
|
1192
1180
|
|
|
1193
1181
|
## Development
|
|
1194
1182
|
|
data/docs/mongodb.md
CHANGED
|
@@ -32,7 +32,7 @@ exwiw can export a MongoDB database with `--adapter=mongodb`. This document coll
|
|
|
32
32
|
```bash
|
|
33
33
|
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
34
34
|
```
|
|
35
|
-
-
|
|
35
|
+
- exwiw emits no delete files; drop the database / collection yourself before importing if needed.
|
|
36
36
|
|
|
37
37
|
## Masking
|
|
38
38
|
|
|
@@ -170,8 +170,8 @@ module Exwiw
|
|
|
170
170
|
|
|
171
171
|
# The subset of SQLite keywords (https://sqlite.org/lang_keywords.html)
|
|
172
172
|
# that actually fail to parse as bare identifiers in the positions exwiw
|
|
173
|
-
# emits (qualified column, INSERT column list, FROM/
|
|
174
|
-
#
|
|
173
|
+
# emits (qualified column, INSERT column list, FROM/JOIN table name,
|
|
174
|
+
# CASE masking, derived-table scope JOIN). SQLite's parser accepts
|
|
175
175
|
# the other ~half of its keywords as identifiers via fallback (e.g.
|
|
176
176
|
# `key`, `temp`, `row`), and those are deliberately NOT quoted so output
|
|
177
177
|
# for such names stays byte-identical with previous releases — a name in
|
|
@@ -217,10 +217,6 @@ module Exwiw
|
|
|
217
217
|
end.join("\n")
|
|
218
218
|
end
|
|
219
219
|
|
|
220
|
-
def to_bulk_delete(_query, _config)
|
|
221
|
-
raise NotImplementedError, "MongodbAdapter does not support bulk delete"
|
|
222
|
-
end
|
|
223
|
-
|
|
224
220
|
# Default explain verbosity. `queryPlanner` asks the server to PLAN the
|
|
225
221
|
# query without executing it, so it is safe to run against a production
|
|
226
222
|
# source — no documents are scanned or returned. `executionStats` and
|
|
@@ -325,10 +321,6 @@ module Exwiw
|
|
|
325
321
|
@logger.info(" Wrote schema for #{collections.size} collection(s) to #{output_path}.")
|
|
326
322
|
end
|
|
327
323
|
|
|
328
|
-
def supports_bulk_delete?
|
|
329
|
-
false
|
|
330
|
-
end
|
|
331
|
-
|
|
332
324
|
# `--ids` from the CLI arrives as Strings. Mongo compares types strictly,
|
|
333
325
|
# so the textual ids must be coerced to the type actually stored in `_id`:
|
|
334
326
|
#
|
|
@@ -26,7 +26,7 @@ module Exwiw
|
|
|
26
26
|
# so the row count is independent of the projected columns.
|
|
27
27
|
# - the stream ties up the connection until fully drained. The Runner
|
|
28
28
|
# always drains it (write_inserts) before any further query
|
|
29
|
-
# (post_insert_sql
|
|
29
|
+
# (post_insert_sql), and MysqlClient#stream_rows drains the
|
|
30
30
|
# remainder if iteration is abandoned, so the connection stays usable.
|
|
31
31
|
class StreamingResult
|
|
32
32
|
include Enumerable
|
|
@@ -171,64 +171,6 @@ module Exwiw
|
|
|
171
171
|
end
|
|
172
172
|
end
|
|
173
173
|
|
|
174
|
-
def to_bulk_delete(select_query_ast, table)
|
|
175
|
-
raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
176
|
-
|
|
177
|
-
sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
|
|
178
|
-
|
|
179
|
-
if select_query_ast.join_clauses.empty?
|
|
180
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
181
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
182
|
-
compiled_where_conditions = select_query_ast.
|
|
183
|
-
where_clauses.
|
|
184
|
-
select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
|
|
185
|
-
map do |where|
|
|
186
|
-
compile_delete_where_condition(where, select_query_ast.from_table_name)
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
if compiled_where_conditions.size > 0
|
|
190
|
-
sql += "\nWHERE "
|
|
191
|
-
sql += compiled_where_conditions.join(' AND ')
|
|
192
|
-
end
|
|
193
|
-
sql += ";"
|
|
194
|
-
|
|
195
|
-
return sql
|
|
196
|
-
end
|
|
197
|
-
|
|
198
|
-
subquery_ast = Exwiw::QueryAst::Select.new
|
|
199
|
-
first_join = select_query_ast.join_clauses.first.clone
|
|
200
|
-
|
|
201
|
-
subquery_ast.from(first_join.join_table_name)
|
|
202
|
-
primay_key_col = table.columns.find { |col| col.name == table.primary_key }
|
|
203
|
-
subquery_ast.select([primay_key_col])
|
|
204
|
-
select_query_ast.join_clauses[1..].each do |join|
|
|
205
|
-
subquery_ast.join(join)
|
|
206
|
-
end
|
|
207
|
-
first_join.where_clauses.each do |where|
|
|
208
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
209
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
210
|
-
subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
211
|
-
end
|
|
212
|
-
|
|
213
|
-
foreign_key = first_join.foreign_key
|
|
214
|
-
subquery_sql = compile_ast(subquery_ast)
|
|
215
|
-
sql += "\nWHERE #{qualified_name(select_query_ast.from_table_name, foreign_key)} IN (#{subquery_sql})"
|
|
216
|
-
|
|
217
|
-
# first_join.base_where_clauses holds conditions on the outer
|
|
218
|
-
# delete-target table (from_table_name), such as a polymorphic type
|
|
219
|
-
# column. They are not part of the subquery, so add them to the outer
|
|
220
|
-
# WHERE. This prevents deleting rows that belong to a different
|
|
221
|
-
# polymorphic type.
|
|
222
|
-
first_join.base_where_clauses.each do |where|
|
|
223
|
-
next unless where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
224
|
-
|
|
225
|
-
sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
|
|
226
|
-
end
|
|
227
|
-
sql += ";"
|
|
228
|
-
|
|
229
|
-
sql
|
|
230
|
-
end
|
|
231
|
-
|
|
232
174
|
# @param count_only [Boolean] emit `SELECT COUNT(*)` instead of the
|
|
233
175
|
# projected columns (used by StreamingResult#size). Safe because exwiw's
|
|
234
176
|
# extraction queries have no DISTINCT/GROUP BY/LIMIT, so the count does
|
|
@@ -377,39 +319,6 @@ module Exwiw
|
|
|
377
319
|
name
|
|
378
320
|
end
|
|
379
321
|
|
|
380
|
-
# A WHERE condition for the DELETE statement.
|
|
381
|
-
#
|
|
382
|
-
# MySQL refuses a subquery that reads the table being deleted from
|
|
383
|
-
# ("You can't specify target table 'x' for update in FROM clause"), and a
|
|
384
|
-
# polymorphic multi-arm scope produces exactly that: each arm selects the
|
|
385
|
-
# join table's own primary key, so the delete's `pk IN (…)` reads the
|
|
386
|
-
# delete target. Wrapping the subquery in a derived table lifts the
|
|
387
|
-
# restriction — MySQL materializes the derived table before the DELETE
|
|
388
|
-
# runs, so the rows deleted are the ones the SELECT matched.
|
|
389
|
-
#
|
|
390
|
-
# Only that self-referencing shape is wrapped; every other subquery
|
|
391
|
-
# (a scope id-set projected from *another* table, the ids_field probe)
|
|
392
|
-
# compiles exactly as before.
|
|
393
|
-
private def compile_delete_where_condition(where_clause, table_name)
|
|
394
|
-
if where_clause.operator == :in_subquery && delete_target_self_reference?(where_clause.value, table_name)
|
|
395
|
-
key = qualified_name(table_name, where_clause.column_name)
|
|
396
|
-
return "#{key} IN (SELECT * FROM (#{compile_subquery(where_clause.value)}) AS exwiw_delete_src)"
|
|
397
|
-
end
|
|
398
|
-
|
|
399
|
-
compile_where_condition(where_clause, table_name)
|
|
400
|
-
end
|
|
401
|
-
|
|
402
|
-
private def delete_target_self_reference?(subquery, table_name)
|
|
403
|
-
case subquery
|
|
404
|
-
when Exwiw::QueryAst::SelectSubquery
|
|
405
|
-
Exwiw::QueryAst.reads_table?(subquery.query, table_name)
|
|
406
|
-
when Exwiw::QueryAst::UnionSubquery
|
|
407
|
-
subquery.queries.any? { |query| Exwiw::QueryAst.reads_table?(query, table_name) }
|
|
408
|
-
else
|
|
409
|
-
false
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
|
|
413
322
|
private def compile_where_condition(where_clause, table_name)
|
|
414
323
|
# Use as it is if it's a raw query
|
|
415
324
|
return where_clause if where_clause.is_a?(String)
|
|
@@ -24,8 +24,8 @@ module Exwiw
|
|
|
24
24
|
# unchanged, so MongoDB and the other SQL adapters are untouched.
|
|
25
25
|
# - the streaming pass ties up the connection until fully drained. The
|
|
26
26
|
# Runner always drains it (write_inserts) before issuing any further
|
|
27
|
-
# query (post_insert_sql
|
|
28
|
-
#
|
|
27
|
+
# query (post_insert_sql) on the same connection, so the ordering
|
|
28
|
+
# invariant holds.
|
|
29
29
|
class StreamingResult
|
|
30
30
|
include Enumerable
|
|
31
31
|
|
|
@@ -186,6 +186,13 @@ module Exwiw
|
|
|
186
186
|
# EXTENSION that pg_dump emits alongside is likewise wrapped to swallow
|
|
187
187
|
# undefined_object, so a skipped extension's trailing comment does not
|
|
188
188
|
# abort the restore either.
|
|
189
|
+
# Triggers are wrapped the same way. They used to be stripped instead,
|
|
190
|
+
# because a `--table` dump emitted CREATE TRIGGER without the
|
|
191
|
+
# CREATE FUNCTION it referenced; a whole-database dump carries both, so a
|
|
192
|
+
# target can have the source's triggers instead of silently running none
|
|
193
|
+
# (mysqldump and sqlite_master always emitted theirs). They do fire while
|
|
194
|
+
# the `insert-*.sql` files are applied, so a load that must not fire them
|
|
195
|
+
# disables them for the session, as a mysql restore already had to.
|
|
189
196
|
# Platform-managed extensions are stripped first: the wrapping passes below
|
|
190
197
|
# rewrite the bare CREATE/COMMENT statements this removes.
|
|
191
198
|
idempotent = strip_platform_managed_extensions(stdout)
|
|
@@ -197,7 +204,7 @@ module Exwiw
|
|
|
197
204
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_table(idempotent)
|
|
198
205
|
idempotent = DdlPostprocessor.add_if_not_exists_to_create_index(idempotent)
|
|
199
206
|
idempotent = DdlPostprocessor.wrap_add_constraint_in_do_block(idempotent)
|
|
200
|
-
idempotent = DdlPostprocessor.
|
|
207
|
+
idempotent = DdlPostprocessor.wrap_create_trigger_in_do_block(idempotent)
|
|
201
208
|
|
|
202
209
|
File.open(output_path, 'w') do |file|
|
|
203
210
|
file.puts("-- Auto-generated by exwiw via pg_dump. Idempotent DDL for postgresql.")
|
|
@@ -266,6 +273,44 @@ module Exwiw
|
|
|
266
273
|
lines.join("\n")
|
|
267
274
|
end
|
|
268
275
|
|
|
276
|
+
def pre_insert_sql(_table)
|
|
277
|
+
suppress_triggers_sql
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Suppress trigger and foreign-key enforcement for the statements that
|
|
281
|
+
# follow. insert-000-schema.sql carries the source's triggers now, so an
|
|
282
|
+
# unguarded pass would fire every one of them per row it touches — on the
|
|
283
|
+
# insert side over rows that already carry the values those triggers
|
|
284
|
+
# produced on the source, and in an order that is only guaranteed to
|
|
285
|
+
# satisfy FKs once every insert-*.sql has been applied. `replica` mode
|
|
286
|
+
# turns off both user and system (RI) triggers, matching what MysqlAdapter
|
|
287
|
+
# does with FOREIGN_KEY_CHECKS.
|
|
288
|
+
#
|
|
289
|
+
# Setting the parameter needs superuser (or a grant on it), which a
|
|
290
|
+
# restore role may not have; the pass itself is still valid without it,
|
|
291
|
+
# so a failure is downgraded to a WARNING rather than aborting the file.
|
|
292
|
+
#
|
|
293
|
+
# Unlike MysqlAdapter — and unlike `pg_dump --disable-triggers`, which
|
|
294
|
+
# pairs each table's DISABLE TRIGGER ALL with an ENABLE — no counterpart
|
|
295
|
+
# reset is emitted, so the file is NOT self-contained in session state:
|
|
296
|
+
# sourcing these files into a session that goes on to do other work
|
|
297
|
+
# leaves that session in replica mode. `is_local = false` still bounds it
|
|
298
|
+
# to the connection, and every file re-arms the setting itself, so
|
|
299
|
+
# concatenating the files works either way — the reset would only buy
|
|
300
|
+
# tidiness, at the cost of emitting it unconditionally (post_insert_sql
|
|
301
|
+
# returns nil for a table with no serial PK) inside a second exception
|
|
302
|
+
# handler (a bare reset re-raises insufficient_privilege on the
|
|
303
|
+
# unprivileged path and would abort the file).
|
|
304
|
+
private def suppress_triggers_sql
|
|
305
|
+
<<~SQL.chomp
|
|
306
|
+
DO $exwiw$ BEGIN
|
|
307
|
+
PERFORM set_config('session_replication_role', 'replica', false);
|
|
308
|
+
EXCEPTION WHEN insufficient_privilege THEN
|
|
309
|
+
RAISE WARNING 'exwiw: could not disable triggers for the load (%): %', SQLSTATE, SQLERRM;
|
|
310
|
+
END $exwiw$;
|
|
311
|
+
SQL
|
|
312
|
+
end
|
|
313
|
+
|
|
269
314
|
# Transcribe the FROM-side sequence cursor backing `table.primary_key`
|
|
270
315
|
# onto the import target. Without this, importing into a clean DB leaves
|
|
271
316
|
# the sequence at 1 while the inserted rows occupy higher IDs, so the
|
|
@@ -302,73 +347,6 @@ module Exwiw
|
|
|
302
347
|
"SELECT pg_catalog.setval('#{escape_single_quote(seq_name)}', #{last_value}, #{is_called_sql});"
|
|
303
348
|
end
|
|
304
349
|
|
|
305
|
-
def to_bulk_delete(select_query_ast, table)
|
|
306
|
-
raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
307
|
-
|
|
308
|
-
sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
|
|
309
|
-
|
|
310
|
-
if select_query_ast.join_clauses.empty?
|
|
311
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
312
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
313
|
-
compiled_where_conditions = select_query_ast.
|
|
314
|
-
where_clauses.
|
|
315
|
-
select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
|
|
316
|
-
map do |where|
|
|
317
|
-
compile_where_condition(where, select_query_ast.from_table_name)
|
|
318
|
-
end
|
|
319
|
-
|
|
320
|
-
if compiled_where_conditions.size > 0
|
|
321
|
-
sql += "\nWHERE "
|
|
322
|
-
sql += compiled_where_conditions.join(' AND ')
|
|
323
|
-
end
|
|
324
|
-
sql += ";"
|
|
325
|
-
|
|
326
|
-
return sql
|
|
327
|
-
end
|
|
328
|
-
|
|
329
|
-
subquery_ast = Exwiw::QueryAst::Select.new
|
|
330
|
-
first_join = select_query_ast.join_clauses.first.clone
|
|
331
|
-
|
|
332
|
-
subquery_ast.from(first_join.join_table_name)
|
|
333
|
-
primay_key_col = table.columns.find { |col| col.name == table.primary_key }
|
|
334
|
-
subquery_ast.select([primay_key_col])
|
|
335
|
-
select_query_ast.join_clauses[1..].each do |join|
|
|
336
|
-
subquery_ast.join(join)
|
|
337
|
-
end
|
|
338
|
-
first_join.where_clauses.each do |where|
|
|
339
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
340
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
341
|
-
subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
342
|
-
end
|
|
343
|
-
|
|
344
|
-
foreign_key = first_join.foreign_key
|
|
345
|
-
outer_table = select_query_ast.from_table_name
|
|
346
|
-
inner_table = first_join.join_table_name
|
|
347
|
-
inner_column = first_join.primary_key
|
|
348
|
-
cast_to = types_need_cast?(
|
|
349
|
-
column_pg_type(outer_table, foreign_key),
|
|
350
|
-
column_pg_type(inner_table, inner_column)
|
|
351
|
-
) ? 'text' : nil
|
|
352
|
-
subquery_sql = compile_ast(subquery_ast, select_cast_to: cast_to)
|
|
353
|
-
outer_expr = qualified_name(outer_table, foreign_key)
|
|
354
|
-
outer_expr = "#{outer_expr}::text" if cast_to
|
|
355
|
-
sql += "\nWHERE #{outer_expr} IN (#{subquery_sql})"
|
|
356
|
-
|
|
357
|
-
# first_join.base_where_clauses holds conditions on the outer
|
|
358
|
-
# delete-target table (from_table_name), such as a polymorphic type
|
|
359
|
-
# column. They are not part of the subquery, so add them to the outer
|
|
360
|
-
# WHERE. This prevents deleting rows that belong to a different
|
|
361
|
-
# polymorphic type.
|
|
362
|
-
first_join.base_where_clauses.each do |where|
|
|
363
|
-
next unless where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
364
|
-
|
|
365
|
-
sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
|
|
366
|
-
end
|
|
367
|
-
sql += ";"
|
|
368
|
-
|
|
369
|
-
sql
|
|
370
|
-
end
|
|
371
|
-
|
|
372
350
|
def compile_ast(query_ast, select_cast_to: nil)
|
|
373
351
|
raise NotImplementedError unless query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
374
352
|
|
|
@@ -137,64 +137,6 @@ module Exwiw
|
|
|
137
137
|
end
|
|
138
138
|
end
|
|
139
139
|
|
|
140
|
-
def to_bulk_delete(select_query_ast, table)
|
|
141
|
-
raise NotImplementedError unless select_query_ast.is_a?(Exwiw::QueryAst::Select)
|
|
142
|
-
|
|
143
|
-
sql = "DELETE FROM #{quote_table_name(select_query_ast.from_table_name)}"
|
|
144
|
-
|
|
145
|
-
if select_query_ast.join_clauses.empty?
|
|
146
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
147
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
148
|
-
compiled_where_conditions = select_query_ast.
|
|
149
|
-
where_clauses.
|
|
150
|
-
select { |where| where.is_a?(Exwiw::QueryAst::WhereClause) }.
|
|
151
|
-
map do |where|
|
|
152
|
-
compile_where_condition(where, select_query_ast.from_table_name)
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
if compiled_where_conditions.size > 0
|
|
156
|
-
sql += "\nWHERE "
|
|
157
|
-
sql += compiled_where_conditions.join(' AND ')
|
|
158
|
-
end
|
|
159
|
-
sql += ";"
|
|
160
|
-
|
|
161
|
-
return sql
|
|
162
|
-
end
|
|
163
|
-
|
|
164
|
-
subquery_ast = Exwiw::QueryAst::Select.new
|
|
165
|
-
first_join = select_query_ast.join_clauses.first.clone
|
|
166
|
-
|
|
167
|
-
subquery_ast.from(first_join.join_table_name)
|
|
168
|
-
primay_key_col = table.columns.find { |col| col.name == table.primary_key }
|
|
169
|
-
subquery_ast.select([primay_key_col])
|
|
170
|
-
select_query_ast.join_clauses[1..].each do |join|
|
|
171
|
-
subquery_ast.join(join)
|
|
172
|
-
end
|
|
173
|
-
first_join.where_clauses.each do |where|
|
|
174
|
-
# Ignore filter option, because bulk delete is for cleaning before import,
|
|
175
|
-
# so it should delete all records to avoid foreign key violation & data consistancy.
|
|
176
|
-
subquery_ast.where(where) if where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
foreign_key = first_join.foreign_key
|
|
180
|
-
subquery_sql = compile_ast(subquery_ast)
|
|
181
|
-
sql += "\nWHERE #{qualified_name(select_query_ast.from_table_name, foreign_key)} IN (#{subquery_sql})"
|
|
182
|
-
|
|
183
|
-
# first_join.base_where_clauses holds conditions on the outer
|
|
184
|
-
# delete-target table (from_table_name), such as a polymorphic type
|
|
185
|
-
# column. They are not part of the subquery, so add them to the outer
|
|
186
|
-
# WHERE. This prevents deleting rows that belong to a different
|
|
187
|
-
# polymorphic type.
|
|
188
|
-
first_join.base_where_clauses.each do |where|
|
|
189
|
-
next unless where.is_a?(Exwiw::QueryAst::WhereClause)
|
|
190
|
-
|
|
191
|
-
sql += " AND #{compile_where_condition(where, select_query_ast.from_table_name)}"
|
|
192
|
-
end
|
|
193
|
-
sql += ";"
|
|
194
|
-
|
|
195
|
-
sql
|
|
196
|
-
end
|
|
197
|
-
|
|
198
140
|
# @param count_only [Boolean] emit `SELECT COUNT(*)` instead of the
|
|
199
141
|
# projected columns (used by StreamingResult#size). Safe because exwiw's
|
|
200
142
|
# extraction queries have no DISTINCT/GROUP BY/LIMIT, so the count does
|
data/lib/exwiw/adapter.rb
CHANGED
|
@@ -82,11 +82,6 @@ module Exwiw
|
|
|
82
82
|
def dump_schema(ordered_tables, output_path)
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# Whether this adapter emits delete-NNN-*.sql files.
|
|
86
|
-
def supports_bulk_delete?
|
|
87
|
-
true
|
|
88
|
-
end
|
|
89
|
-
|
|
90
85
|
# Whether the given config produces its own dump output and needs an
|
|
91
86
|
# independent processing pass. SQL adapters always do; non-SQL adapters
|
|
92
87
|
# may exclude e.g. embedded subdocument configs.
|
|
@@ -339,12 +334,6 @@ module Exwiw
|
|
|
339
334
|
raise NotImplementedError
|
|
340
335
|
end
|
|
341
336
|
|
|
342
|
-
# @params [Exwiw::QueryAst] select_query_ast
|
|
343
|
-
# @params [Exwiw::TableConfig] table
|
|
344
|
-
def to_bulk_delete(select_query_ast, table)
|
|
345
|
-
raise NotImplementedError
|
|
346
|
-
end
|
|
347
|
-
|
|
348
337
|
def self.build(connection_config, logger)
|
|
349
338
|
case normalize_name(connection_config.adapter)
|
|
350
339
|
when 'sqlite'
|
data/lib/exwiw/cli.rb
CHANGED
|
@@ -39,7 +39,6 @@ module Exwiw
|
|
|
39
39
|
schema_dir
|
|
40
40
|
output_dir
|
|
41
41
|
output_format
|
|
42
|
-
insert_only
|
|
43
42
|
after_insert_hook
|
|
44
43
|
log_level
|
|
45
44
|
target_table
|
|
@@ -58,6 +57,11 @@ module Exwiw
|
|
|
58
57
|
EXPLAIN_VERBOSITIES = %w[queryPlanner executionStats allPlansExecution].freeze
|
|
59
58
|
DEFAULT_EXPLAIN_VERBOSITY = "queryPlanner"
|
|
60
59
|
|
|
60
|
+
# Keys that configured behavior that no longer exists (insert_only toggled
|
|
61
|
+
# the removed delete-*.sql generation). Accepted so a committed config file
|
|
62
|
+
# keeps working across the upgrade, warned about so it gets cleaned up.
|
|
63
|
+
OBSOLETE_CONFIG_KEYS = %w[insert_only].freeze
|
|
64
|
+
|
|
61
65
|
# Database connection settings are environment-specific (and sometimes
|
|
62
66
|
# secret-adjacent), so they must be passed via CLI/env, never the committed
|
|
63
67
|
# config file. `adapter` is the one connection-ish key allowed in config.
|
|
@@ -66,7 +70,7 @@ module Exwiw
|
|
|
66
70
|
# Keys that only make sense for `export`. They are skipped when merging config
|
|
67
71
|
# for `explain` so a shared config file does not trip validate_explain_only!,
|
|
68
72
|
# and for `schema`, which performs no export at all.
|
|
69
|
-
EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format
|
|
73
|
+
EXPORT_ONLY_CONFIG_KEYS = %w[output_dir output_format after_insert_hook parallel_workers].freeze
|
|
70
74
|
|
|
71
75
|
def self.start(argv)
|
|
72
76
|
new(argv).run
|
|
@@ -111,7 +115,6 @@ module Exwiw
|
|
|
111
115
|
@ids_field = nil
|
|
112
116
|
@scope_column = nil
|
|
113
117
|
@output_format = nil
|
|
114
|
-
@insert_only = nil
|
|
115
118
|
@after_insert_hook_path = nil
|
|
116
119
|
@parallel_workers = nil
|
|
117
120
|
@mongodb_query_timeout_ms = nil
|
|
@@ -160,7 +163,6 @@ module Exwiw
|
|
|
160
163
|
schema_dir: @schema_dir,
|
|
161
164
|
dump_target: dump_target,
|
|
162
165
|
output_format: @output_format,
|
|
163
|
-
insert_only: @insert_only,
|
|
164
166
|
after_insert_hook_path: @after_insert_hook_path,
|
|
165
167
|
parallel_workers: @parallel_workers,
|
|
166
168
|
cli_options: build_cli_options_hash,
|
|
@@ -333,7 +335,6 @@ module Exwiw
|
|
|
333
335
|
if @subcommand == "export"
|
|
334
336
|
@output_dir ||= "dump"
|
|
335
337
|
@output_format ||= "insert"
|
|
336
|
-
@insert_only = @insert_only ? true : false
|
|
337
338
|
|
|
338
339
|
valid_output_formats = ["insert", "copy"]
|
|
339
340
|
unless valid_output_formats.include?(@output_format)
|
|
@@ -426,6 +427,10 @@ module Exwiw
|
|
|
426
427
|
$stderr.puts "'#{key}' is a database connection setting and must be passed via the CLI/environment, not the config file (#{path})"
|
|
427
428
|
exit 1
|
|
428
429
|
end
|
|
430
|
+
if OBSOLETE_CONFIG_KEYS.include?(key)
|
|
431
|
+
$stderr.puts "warning: config key '#{key}' in #{path} is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove it from the config file"
|
|
432
|
+
next
|
|
433
|
+
end
|
|
429
434
|
unless ALLOWED_CONFIG_KEYS.include?(key)
|
|
430
435
|
$stderr.puts "Unknown config key '#{key}' in #{path}. Allowed keys: #{ALLOWED_CONFIG_KEYS.join(', ')}"
|
|
431
436
|
exit 1
|
|
@@ -443,7 +448,6 @@ module Exwiw
|
|
|
443
448
|
@output_dir ||= expand_dir(config["output_dir"], base)
|
|
444
449
|
@after_insert_hook_path ||= (File.expand_path(config["after_insert_hook"], base) if config["after_insert_hook"])
|
|
445
450
|
@output_format ||= config["output_format"]
|
|
446
|
-
@insert_only = config["insert_only"] if @insert_only.nil? && config.key?("insert_only")
|
|
447
451
|
@log_level ||= config["log_level"]&.to_sym
|
|
448
452
|
@target_table_name ||= config["target_table"]
|
|
449
453
|
@target_collection_name ||= config["target_collection"]
|
|
@@ -679,7 +683,6 @@ module Exwiw
|
|
|
679
683
|
rejected = []
|
|
680
684
|
rejected << "--output-dir" unless @output_dir.nil?
|
|
681
685
|
rejected << "--output-format" unless @output_format.nil?
|
|
682
|
-
rejected << "--insert-only" unless @insert_only.nil?
|
|
683
686
|
rejected << "--after-insert-hook" unless @after_insert_hook_path.nil?
|
|
684
687
|
rejected << "--parallel-workers" unless @parallel_workers.nil?
|
|
685
688
|
|
|
@@ -744,7 +747,6 @@ module Exwiw
|
|
|
744
747
|
ids_field: @ids_field,
|
|
745
748
|
scope_column: @scope_column,
|
|
746
749
|
output_format: @output_format,
|
|
747
|
-
insert_only: @insert_only,
|
|
748
750
|
log_level: @log_level,
|
|
749
751
|
after_insert_hook: @after_insert_hook_path,
|
|
750
752
|
}.freeze
|
|
@@ -816,8 +818,10 @@ module Exwiw
|
|
|
816
818
|
opts.on("--ids-field=[FIELD]", "Field on the target collection that --ids is matched against. Defaults to the primary key. (mongodb adapter only)") { |v| @ids_field = v }
|
|
817
819
|
opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config and running with --target-table.") { |v| @scope_column = v }
|
|
818
820
|
opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
|
|
819
|
-
opts.on("--insert-only", "
|
|
820
|
-
|
|
821
|
+
opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
|
|
822
|
+
$stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"
|
|
823
|
+
end
|
|
824
|
+
opts.on("--after-insert-hook=PATH", "Path to a .rb or .sh post-processing hook executed after all insert files are written (export subcommand only)") do |v|
|
|
821
825
|
@after_insert_hook_path = File.expand_path(v)
|
|
822
826
|
end
|
|
823
827
|
opts.on("--parallel-workers=N", Integer, "Fork N workers for the MongoDB dump's parallel schedule (mongodb + export only; N>=2 enables it, default is serial). Output is byte-identical to serial; falls back to serial where fork is unavailable.") { |v| @parallel_workers = v }
|
|
@@ -58,10 +58,50 @@ module Exwiw
|
|
|
58
58
|
end
|
|
59
59
|
end
|
|
60
60
|
|
|
61
|
-
# pg_dump
|
|
62
|
-
#
|
|
63
|
-
|
|
64
|
-
|
|
61
|
+
# One trigger as pg_dump writes it: its header comment block ($1) followed by
|
|
62
|
+
# the `CREATE TRIGGER` statement ($2).
|
|
63
|
+
#
|
|
64
|
+
# --
|
|
65
|
+
# -- Name: users set_timestamp; Type: TRIGGER; Schema: public; Owner: -
|
|
66
|
+
# --
|
|
67
|
+
#
|
|
68
|
+
# CREATE TRIGGER set_timestamp BEFORE UPDATE ON public.users ...;
|
|
69
|
+
#
|
|
70
|
+
# The header is what keeps the pass off a `CREATE TRIGGER` line inside a
|
|
71
|
+
# dollar-quoted function body — a function that installs a trigger itself,
|
|
72
|
+
# whose meaning a rewrite would change. pg_dump never writes a header block
|
|
73
|
+
# inside a body, so requiring one limits the match to top-level statements.
|
|
74
|
+
# (The stripping pass this replaced matched the bare form and deleted such a
|
|
75
|
+
# line, leaving a function that compiled but installed nothing.)
|
|
76
|
+
#
|
|
77
|
+
# The statement ends at the first semicolon outside a single-quoted string, so
|
|
78
|
+
# a `;` in a WHEN clause or an argument (`EXECUTE FUNCTION f('a;b')`) does not
|
|
79
|
+
# truncate it. A trigger definition has no body, so no dollar quoting either.
|
|
80
|
+
CREATE_TRIGGER_RE = /
|
|
81
|
+
(^--\r?\n
|
|
82
|
+
--[ \t]+Name:[^\n]*;[ \t]*Type:[ \t]*TRIGGER;[^\n]*\r?\n
|
|
83
|
+
--\r?\n
|
|
84
|
+
(?:[ \t]*\r?\n)*)
|
|
85
|
+
([ \t]*CREATE\s+(?:OR\s+REPLACE\s+)?(?:CONSTRAINT\s+)?TRIGGER\b
|
|
86
|
+
(?:[^;']|'(?:[^']|'')*')*;)
|
|
87
|
+
/xi.freeze
|
|
88
|
+
|
|
89
|
+
# A bare `CREATE TRIGGER ...;` is not idempotent (a second restore raises
|
|
90
|
+
# `duplicate_object`, 42710), so each statement goes into a DO block that
|
|
91
|
+
# swallows that error, in the form #wrap_add_constraint_in_do_block uses; the
|
|
92
|
+
# header is left as written. `CREATE OR REPLACE TRIGGER` would be shorter but
|
|
93
|
+
# is PostgreSQL 14+ only and pg_dump never emits it.
|
|
94
|
+
def wrap_create_trigger_in_do_block(sql)
|
|
95
|
+
sql.gsub(CREATE_TRIGGER_RE) do
|
|
96
|
+
header = Regexp.last_match(1)
|
|
97
|
+
stmt = Regexp.last_match(2).strip
|
|
98
|
+
<<~SQL.chomp
|
|
99
|
+
#{header}DO $exwiw$ BEGIN
|
|
100
|
+
#{stmt}
|
|
101
|
+
EXCEPTION WHEN duplicate_object THEN NULL;
|
|
102
|
+
END $exwiw$;
|
|
103
|
+
SQL
|
|
104
|
+
end
|
|
65
105
|
end
|
|
66
106
|
|
|
67
107
|
# A user@host pair as mysqldump writes it. Each side is independently a
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -11,7 +11,6 @@ module Exwiw
|
|
|
11
11
|
dump_target:,
|
|
12
12
|
logger:,
|
|
13
13
|
output_format: 'insert',
|
|
14
|
-
insert_only: false,
|
|
15
14
|
after_insert_hook_path: nil,
|
|
16
15
|
parallel_workers: nil,
|
|
17
16
|
cli_options: {}
|
|
@@ -21,7 +20,6 @@ module Exwiw
|
|
|
21
20
|
@schema_dir = schema_dir
|
|
22
21
|
@dump_target = dump_target
|
|
23
22
|
@output_format = output_format
|
|
24
|
-
@insert_only = insert_only
|
|
25
23
|
@after_insert_hook_path = after_insert_hook_path
|
|
26
24
|
@parallel_workers = parallel_workers
|
|
27
25
|
@cli_options = cli_options
|
|
@@ -105,8 +103,8 @@ module Exwiw
|
|
|
105
103
|
|
|
106
104
|
# `batch_scope` splits the extraction into one query per slice of the
|
|
107
105
|
# scope's id set, streaming rows like any adapter result. `query_ast`
|
|
108
|
-
# stays the unbatched query — the
|
|
109
|
-
#
|
|
106
|
+
# stays the unbatched query — the error message below describes that
|
|
107
|
+
# one.
|
|
110
108
|
phase = "resolving the batch_scope id set"
|
|
111
109
|
batched = BatchedExtraction.build(
|
|
112
110
|
adapter: adapter,
|
|
@@ -184,21 +182,6 @@ module Exwiw
|
|
|
184
182
|
|
|
185
183
|
@logger.info(" Generated INSERT statement for #{record_num} records (#{statement_count} statement(s)).")
|
|
186
184
|
end
|
|
187
|
-
|
|
188
|
-
if adapter.supports_bulk_delete? && !@insert_only && !(table.respond_to?(:rails_managed?) && table.rails_managed?)
|
|
189
|
-
phase = "generating DELETE statement"
|
|
190
|
-
@logger.debug(" Generate DELETE statement...")
|
|
191
|
-
delete_sql = adapter.to_bulk_delete(query_ast, table)
|
|
192
|
-
if @logger.debug?
|
|
193
|
-
@logger.debug(" Generated DELETE statement:\n#{delete_sql}")
|
|
194
|
-
else
|
|
195
|
-
@logger.info(" Generated DELETE statement.")
|
|
196
|
-
end
|
|
197
|
-
delete_idx = (total_size - idx).to_s.rjust(3, '0')
|
|
198
|
-
File.open(File.join(@output_dir, "delete-#{delete_idx}-#{table_name}.#{adapter.output_extension}"), 'w') do |file|
|
|
199
|
-
file.puts(delete_sql)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
185
|
rescue => e
|
|
203
186
|
@logger.error("Error while #{phase} for table '#{table_name}' (#{idx + 1}/#{total_size}): #{e.class}: #{e.message}")
|
|
204
187
|
@logger.error(" Extraction query that produced the data being processed:")
|
data/lib/exwiw/version.rb
CHANGED