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 +4 -4
- data/CHANGELOG.md +32 -0
- data/README.md +34 -29
- 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 +17 -94
- 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 +55 -13
- data/lib/exwiw/ddl_postprocessor.rb +44 -4
- data/lib/exwiw/query_ast_builder.rb +129 -31
- data/lib/exwiw/runner.rb +2 -19
- data/lib/exwiw/schema_check.rb +41 -2
- 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: e0b25a06efa96c6a7cfcc0e19187a735e7d83f317dc41f585af904e665a0ff46
|
|
4
|
+
data.tar.gz: b646c7949e2425e0155a0e70cd93eb4190506d97f9ecca790e77f7a9f9d469ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -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)).
|
|
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
|
|
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
|
|
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-*`
|
|
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
|
|
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
|
-
-
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
|