exwiw 0.9.24 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 73740836024ec336feb7138c5a94e396df206de932de00737404d556fc403e0a
4
- data.tar.gz: de53ac3f5e4c93f6761524041261c2becb65baa9bd396e528ca0ffa9b82b518e
3
+ metadata.gz: e0b25a06efa96c6a7cfcc0e19187a735e7d83f317dc41f585af904e665a0ff46
4
+ data.tar.gz: b646c7949e2425e0155a0e70cd93eb4190506d97f9ecca790e77f7a9f9d469ed
5
5
  SHA512:
6
- metadata.gz: ac457715cf1eaf521840903e2b2fbb6c5f503230d0abfb7cc24510067c2aa68c8b841d370d8eeed3e0c0ee009a105bea87cfe455a21c50b5bd462feb48c14f22
7
- data.tar.gz: 945c4c46077e9ee6678f6fbc6e72b13186e393c4f06035dd1e9d63f10efa3c1fe6adb67a05f470e1330a48d5d8f34d008c67cb8795ba7dbda8294178f13082a0
6
+ metadata.gz: d6b42bb54f5bc27345d69c4d634f70f964be04b67845c8b64a791fa8659eb04cbdb92fe13ce86ebb8dec717ef8caa8b9cbfb33d5de5e55769010363f8f4c2bdf
7
+ data.tar.gz: 3f6f4bc0637983318900442495f2b5dd9a29d3d147064494d22216e6a3ee284b4ff5af5b882e51b5bcc1c4e3655c39664a7caf7ae010105818b46644e1a278c8
data/CHANGELOG.md CHANGED
@@ -2,6 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.0] - 2026-09-07
6
+
7
+ ### Added
8
+
9
+ - **`exwiw schema check --from-db --fail-on=stale` keys the exit code to the drift that would break an export, so the check can run as a pre-extraction gate ([#223](https://github.com/heyinc/exwiw/pull/223)).** The report gains two categories, `stale_tables` and `stale_columns`: a non-ignored config still naming a table or column the schema no longer has — the extraction SELECT would name something that does not exist. Additions (a new table or column the config has not adopted yet) and unresolved `needs_mask_decision` flags stay in the report but no longer drive the exit code under `--fail-on=stale`, because for a gate that runs before every extraction they are someone's TODO rather than a reason to stop the run; `--fail-on=any` (the default) keeps today's behavior exactly, and the new report keys are additive, so existing consumers of the JSON are unaffected. Removals on an `ignore: true` table or of an `ignore: true` column are not stale — nothing selects them; a removed rails-managed table IS stale (it is dumped whole), though its columns can never be (the dump is `SELECT *`) — a case only the rake tasks' reports can produce today, since the `--from-db` generator never emits rails-managed configs. When staleness is what fails the check, the CLI names the stale tables/columns on stderr instead of the generic "resolve every `needs_mask_decision` column" advice.
10
+
11
+ - **`reverse_scope` declarations now chain: an arm whose referencer is itself scoped only by its own `reverse_scope` resolves instead of being dropped ([#224](https://github.com/heyinc/exwiw/pull/224)).** Previously the arm's referencer query was built with reverse extraction disabled entirely, so a referencer with no `belongs_to` path of its own came out unconstrained and the arm was skipped — and in single `--target-table` mode the table then fell back to a **full dump** (scope-column mode rejected it in pre-flight). Each skipped arm did warn, but nothing stated that consequence, which is what the new outcome warning below now does. Declared chains — `attachments <- documents <- join rows <- the target` — now nest: each arm resolves the referencer's own declared `reverse_scope`. The nesting applies **only inside reverse_scope arm builds**: the automatic single-referencer detection still builds its candidate children without it, so which tables that detection scopes is unchanged (a widened candidate set could flip its single-candidate requirement and cost a table the scope it had). A cycle of declarations is cut (the repeated table's arm comes out unconstrained and is dropped with the existing warning) rather than recursing forever, and a chain more than three declarations deep is flagged with a warning — every level re-embeds its referencers' subqueries, so generated SQL grows exponentially with depth. MongoDB's runtime reverse scoping is unchanged.
12
+
13
+ ### Changed
14
+
15
+ - **A table left unscoped because several constrained tables reference it is now flagged at `warn`, not `debug`.** In single `--target-table` mode that outcome is a full dump of the table, and the only signal used to be a debug line; the warning names the referencers and suggests declaring `reverse_scope` to union their ids. In scope-column mode the pre-flight abort message changed shape for the same situation: the affected table's entry now names the referencers with the same `reverse_scope` suggestion, per table, instead of a generic clause in the message tail.
16
+
17
+ ### Fixed
18
+
19
+ - **MySQL: sibling `UNION` arms sharing a nested scope id-set no longer fail id-set materialization.** Each arm of a union id-set (several foreign keys of one referencer, or polymorphic arms probing the same ancestor) could embed the same materialized id-set, and MySQL cannot reference one `TEMPORARY` table twice in a single statement (`ER_CANT_REOPEN_TABLE`); the failed `CREATE` then disabled materialization for the rest of the run. Nested scopes inside union arms now compile as inline derived tables, so the outer id-set materializes once and the temp table appears once per statement.
20
+
21
+ ## [1.0.0] - 2026-09-01
22
+
23
+ ### Removed
24
+
25
+ - **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.
26
+
27
+ ### Changed
28
+
29
+ - **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).
30
+
31
+ - **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.
32
+
33
+ ### Fixed
34
+
35
+ - **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.
36
+
5
37
  ## [0.9.24] - 2026-08-17
6
38
 
7
39
  ### Fixed
data/README.md CHANGED
@@ -60,7 +60,7 @@ exwiw has three subcommands:
60
60
 
61
61
  - `export` (default) — generate INSERT/COPY SQL files. If the subcommand is omitted, `export` is assumed.
62
62
  - `explain` — print each query `export` would run together with its `EXPLAIN` output. SQL adapters compile the SELECT without executing it; mongodb runs the server's explain (defaulting to the execution-free `queryPlanner`).
63
- - `schema generate|check|tidy --from-db` — maintain the schema config by reading a live database, for applications that cannot be loaded to generate it from their models. See [Non-Rails applications](#non-rails-applications-exwiw-schema---from-db).
63
+ - `schema generate|check|tidy --from-db` — maintain the schema config by reading a live database, for applications that cannot be loaded to generate it from their models. See [Non-Rails applications](#non-rails-applications-exwiw-schema----from-db).
64
64
 
65
65
  ### `exwiw export`
66
66
 
@@ -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
- `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` 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.
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`, `--insert-only`, and `--after-insert-hook` options are dump-specific and rejected when used with `explain`.
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`, `insert_only`, `after_insert_hook`) are ignored when running `explain` or `schema`, so a single config file can be shared by every subcommand.
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
 
@@ -445,15 +442,22 @@ the comparison as JSON, then exits non-zero when anything needs attention:
445
442
  "added_tables": [],
446
443
  "added_columns": ["users.contact_email"],
447
444
  "removed_tables": [],
448
- "removed_columns": [],
449
- "changed_tables": ["users"],
450
- "needs_mask_decision": ["orders.memo"]
445
+ "removed_columns": ["orders.legacy_flag"],
446
+ "changed_tables": ["orders", "users"],
447
+ "needs_mask_decision": ["orders.memo"],
448
+ "stale_tables": [],
449
+ "stale_columns": ["orders.legacy_flag"]
451
450
  }
452
451
  ```
453
452
 
454
453
  `added_*` / `removed_*` / `changed_tables` mean the config no longer matches the schema — run
455
454
  `schema:generate` and `schema:tidy` to reconcile it. `needs_mask_decision` lists the columns
456
- whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). The exit code
455
+ whose masking nobody has decided on yet (see [the flag](#needs_mask_decision)). `stale_tables` /
456
+ `stale_columns` are the subset of the removals an extraction would actually trip over — a
457
+ non-ignored config still naming a table or column the schema no longer has, so the export's
458
+ SELECT would fail; removals of `ignore: true` entries (and of a rails-managed table's columns,
459
+ which are dumped as `SELECT *`) stay out of them. They drive the exit code only under
460
+ [`--fail-on=stale`](#non-rails-applications-exwiw-schema----from-db). The exit code
457
461
  makes it usable as a CI check that keeps a schema change from being merged until both are
458
462
  resolved; the JSON is stable and sorted, so it can be posted as-is. In a multi-database app each
459
463
  entry is prefixed with its database (`primary/users.email`), so the same table name in two
@@ -467,7 +471,7 @@ the same `EXWIW_SCHEMA_CHECK_OUTPUT` file and the same exit code — it just reg
467
471
  `MongoidSchemaGenerator` (safe mode + `tidy_mongoid`) instead. Collections and fields are
468
472
  reported under the same keys as tables and columns. An application that cannot be loaded to
469
473
  generate from its models at all can run the same check against its database instead: see
470
- [Non-Rails applications](#non-rails-applications-exwiw-schema---from-db).
474
+ [Non-Rails applications](#non-rails-applications-exwiw-schema----from-db).
471
475
 
472
476
  #### Multiple databases
473
477
 
@@ -536,6 +540,15 @@ exwiw schema tidy --from-db -a postgresql -h db.example.com -p 5432 -u app --dat
536
540
  `EXWIW_SCHEMA_CHECK_OUTPUT`, and exits 1 when the config needs attention. A check that could
537
541
  not *run* (an unreachable database, a malformed config) exits with a different status, so CI
538
542
  can tell the two apart.
543
+ - `check` also accepts `--fail-on=stale` for use as a pre-extraction gate: the exit code then
544
+ tracks only the report's `stale_tables` / `stale_columns` — a non-ignored config still naming
545
+ a table or column the schema no longer has, which is exactly the drift that would fail the
546
+ export's SELECT. Additions and unresolved `needs_mask_decision` flags stay visible in the
547
+ report but do not stop the run, so a schema migration that merely *adds* a column does not
548
+ block extraction. The default (`--fail-on=any`) is the CI behavior above, unchanged.
549
+ `--fail-on` is command-line only (not a config-file key — a gate flag belongs to the
550
+ invocation, not the committed config) and is meaningful only on `schema check`: the other
551
+ schema verbs reject it, and `export` ignores it like the other schema-only flags.
539
552
  - One run covers one database — the connection addresses one — so the files are written flat into
540
553
  the schema directory. There is no per-database subdirectory layout here; a second database is a
541
554
  second run against a second connection.
@@ -618,13 +631,9 @@ psql -d app_dev -f dump/insert-001-shops.sql
618
631
 
619
632
  `--output-format=copy` is only supported with the `postgresql` adapter.
620
633
 
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
634
  ### After-insert hook
626
635
 
627
- `--after-insert-hook=PATH` runs a post-processing hook **after** all per-table insert/delete files have been written. The hook can be either a Ruby file (`.rb`) or any executable script (e.g. `.sh`).
636
+ `--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
637
 
629
638
  **Ruby hook (`.rb`)**: provides a tiny DSL with these builtins:
630
639
 
@@ -666,7 +675,7 @@ Note: Ruby hooks are evaluated via `instance_eval` inside the exwiw process —
666
675
 
667
676
  ### Ignore a table
668
677
 
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-*` / `delete-*` files are generated for it and the table is never queried.
678
+ 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
679
 
671
680
  ```json
672
681
  {
@@ -772,7 +781,7 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
772
781
  AND reviews.reviewable_type = 'Product'
773
782
  ```
774
783
 
775
- The same type filter is applied on the join path — and in the matching `delete-*.sql` bulk-delete subquery — when the polymorphic table is an intermediate hop rather than the directly-dumped table.
784
+ 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
785
 
777
786
  #### Every arm is extracted (scope-column mode)
778
787
 
@@ -871,6 +880,7 @@ Notes:
871
880
 
872
881
  - **`column` is explicit**, so a *non-default* foreign key (e.g. `kantan_yoyaku_user_id`, or `organization_admins.id` which itself references `users.id`) is honored, and even a column with no declared `belongs_to` edge can be enumerated.
873
882
  - **Only scoped referencers belong in `via`.** Each arm's query must come out constrained; an unconstrained referencer (e.g. a `scope_exempt` table, or one with no path to a scope) would project *every* id and union the whole table back — so such an arm is **skipped with a warning** rather than silently widening the dump. An unknown table is likewise skipped with a warning. If no arm survives, the table stays unscopable and (in [scope-column mode](#scope-column-mode)) the run aborts via `validate_scope!`.
883
+ - **Declarations chain.** A referencer that is itself scoped only by its *own* `reverse_scope` counts as scoped: the arm nests that declaration's `UNION` inside its query, so a normalized side table referenced by a document-style hub that is in turn reverse-scoped through a join table resolves end to end (`attachments <- documents <- join rows <- the target`). A cycle of declarations is cut — the repeated table's arm comes out unconstrained and is dropped with the warning above. The chaining is deliberately limited to `reverse_scope` arms: the *automatic* single-referencer detection still treats a child scoped solely by its own declaration as unconstrained, so such a child never rescues (or, by widening the candidate set past one, costs) a parent that relies on the automatic detection — declare `reverse_scope` on the parent if you need that shape. Chains deeper than three declarations are flagged with a warning, since each level re-embeds its referencers' subqueries and the generated SQL grows exponentially with depth.
874
884
  - **NULLs are excluded** per arm (`IS NOT NULL`).
875
885
  - **Satellites need no config.** A table that `belongs_to` the reverse-scoped table (e.g. `end_users.id → users.id`, or `identities.user_id → users.id`) tightens to the kept ids automatically through the normal cascade — only the reverse-scoped table itself declares `reverse_scope`. The cascade is **multi-hop**, so a table several `belongs_to` hops below the reverse-scoped table (e.g. `end_user_profiles → end_users → users`) also tightens automatically, with no config of its own.
876
886
  - Works in both single-target and scope-column mode. In single-target mode there is no scope-column pre-flight (`validate_scope!`), so a satellite the cascade cannot resolve to a single scopable parent (e.g. it `belongs_to` two scopable hubs) is dumped in full with a warning rather than aborting. Polymorphic foreign keys are not eligible as anchors (the named `column` is always a concrete column).
@@ -912,7 +922,6 @@ Behavior at dump time:
912
922
 
913
923
  - Extraction uses `SELECT *` so the dump is robust against Rails-side column additions.
914
924
  - `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
925
 
917
926
  Constraints:
918
927
 
@@ -982,7 +991,6 @@ An explicit id list of that size is exactly estimated and selective, so the fore
982
991
  - 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
992
  - 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
993
  - 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
994
  - `bulk_insert_chunk_size` is independent: batches are query boundaries, chunks are `INSERT` statement boundaries.
987
995
  - 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
996
 
@@ -1186,9 +1194,6 @@ exwiw can export MongoDB databases too (`--adapter=mongodb`): JSONL output impor
1186
1194
  - Generate the full list of INSERT sql based on the specified conditions.
1187
1195
  - If the processing table has no relation with target tables, then dump all records.
1188
1196
  - 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
1197
 
1193
1198
  ## Development
1194
1199
 
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
- - Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
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/DELETE/JOIN table
174
- # name, CASE masking, derived-table scope JOIN). SQLite's parser accepts
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 / DELETE), and MysqlClient#stream_rows drains the
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
@@ -327,6 +269,16 @@ module Exwiw
327
269
  @materialize_scopes = false
328
270
  end
329
271
 
272
+ # The inverse, for a region that may embed the same id-set more than once
273
+ # in one statement (MySQL cannot reopen a TEMPORARY table).
274
+ private def without_scope_materialization
275
+ saved = @materialize_scopes
276
+ @materialize_scopes = false
277
+ yield
278
+ ensure
279
+ @materialize_scopes = saved
280
+ end
281
+
330
282
  private def materialized_scope_table(subquery)
331
283
  return nil unless @materialize_scopes
332
284
  return nil if @scope_materialization_disabled
@@ -377,39 +329,6 @@ module Exwiw
377
329
  name
378
330
  end
379
331
 
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
332
  private def compile_where_condition(where_clause, table_name)
414
333
  # Use as it is if it's a raw query
415
334
  return where_clause if where_clause.is_a?(String)
@@ -439,9 +358,13 @@ module Exwiw
439
358
  return compile_ast(subquery.query) if subquery.is_a?(Exwiw::QueryAst::SelectSubquery)
440
359
 
441
360
  # A UnionSubquery wraps several such Selects; UNION their compiled forms
442
- # into a single id set.
361
+ # into a single id set. Sibling arms can carry the same nested scope
362
+ # id-set, and MySQL cannot reference one TEMPORARY table twice in a
363
+ # statement (ER_CANT_REOPEN_TABLE), so nested scopes compile inline here.
443
364
  if subquery.is_a?(Exwiw::QueryAst::UnionSubquery)
444
- return subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
365
+ return without_scope_materialization do
366
+ subquery.queries.map { |q| compile_ast(q) }.join(' UNION ')
367
+ end
445
368
  end
446
369
 
447
370
  inner_values = subquery.where_values.map { |v| escape_value(v) }
@@ -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 / DELETE) on the same connection, so the
28
- # ordering invariant holds.
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.strip_triggers(idempotent)
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