exwiw 1.1.4 → 1.1.6

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: edfbfddf23cb6df92da93914c6ee111a1ca7fc0085f096e82b894c8a4573b398
4
- data.tar.gz: 8a96062ee9e37c1a12daec122420c1e91a06194c5f1ba1aadc3a5a5bc303a534
3
+ metadata.gz: 345a459c251b837b718e00ed92b011c8975016af2a92149868fcd24744356041
4
+ data.tar.gz: c79d6c2525ae322bab05956102c424eecc6fa40864e0bc7d581912a68d20928e
5
5
  SHA512:
6
- metadata.gz: f16c16b056138f857fcc54b1015eea30969989bb6604e5ea7fd99e2e0ab43d649a80dffc2e1887939f934ec951da28a53981a1debca854ad2bf006cf2f4cfaf3
7
- data.tar.gz: e4e119962a0bc285f03e18e9540c5d7efd98b7d758240ab81f0274ee5cd44105dfeb0b15302734f5586f4e7ac4ad7933c331343090e0445aaee32c22c5e2459f
6
+ metadata.gz: 881bc0c87eb1121c9670075e059eafd77a3c551f444442575b4545a5c9b78387dd90e2726c0745618b7486e4b1f907d5cb49ce414aefd772c7fd10cd8c80060d
7
+ data.tar.gz: c6a2ee871b7f8ff89e1ddd048c4494c71b7b8776e9d26a1d677b24b11614d1ff4a82c418e5169935514d17404ca18d30626dcfdf105469f6a01e5b0d5664782e
data/AGENTS.md ADDED
@@ -0,0 +1,24 @@
1
+ # AGENTS.md
2
+
3
+ ## This repository is public
4
+
5
+ Everything here is world-readable: code, comments, fixtures, commit messages,
6
+ PR and issue text, review comments, and their edit history. Deleting or editing
7
+ text after the fact does not unpublish it.
8
+
9
+ Do not include anything that comes from a private codebase:
10
+
11
+ - table, column, model, or system names from a private application
12
+ - references to private repositories or their PR / issue numbers
13
+ - internal terminology, internal links (chat, wiki, tickets), or any customer or production data
14
+
15
+ Describe motivating cases with invented, generic names, and keep planning and
16
+ tracking in the private repository that needs the change. Re-read the title,
17
+ body, and commit messages against this list before committing or opening a PR.
18
+
19
+ ## Development
20
+
21
+ See [README.md](README.md) for usage and [CHANGELOG.md](CHANGELOG.md) for the
22
+ release history. `bundle exec rake` compiles the native extension and runs the
23
+ specs; most scripts under `e2e/` need the databases from `compose.yml`
24
+ (the sqlite ones run without it).
data/CHANGELOG.md CHANGED
@@ -2,11 +2,30 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.6] - 2026-09-29
6
+
7
+ ### Added
8
+
9
+ - **`--target-table` can be omitted in scope-column mode.** Scope-column mode starts from every table that declares a `scope_column`, so the target only had to name one of them and was otherwise unused. `--ids` alone now runs in scope-column mode when any table in the schema declares a `scope_column` (tables marked `ignore: true` do not count), for both `export` and `explain`. When none does, the run stops before any output with an error saying to either name the table the ids belong to with `--target-table` (single-target mode) or declare a `scope_column` (scope-column mode). This check moved from the CLI to the schema pre-flight (`QueryAstBuilder.validate_scope!`), since it needs the schema. Runs that pass `--target-table`, and the deprecated `--scope-column` flag, behave as before. The mongodb adapter has no scope-column mode, so `--ids` there still requires `--target-collection` and is rejected by the CLI without it.
10
+ - **Scope-column mode can filter unrelated groups of tables by different kinds of id in one run.** A table can declare `id_space` next to its `scope_column` to say which ID space the column's values belong to, and `--ids` takes one value list per ID space: `--ids=1,2 --ids=org=<uuid>,<uuid>`. Tables without `id_space`, and `--ids` without a prefix, are the `default` ID space, so existing schemas, commands and configs keep their meaning. A table without a scope column is filtered by the ID space of the table it is scoped through. The config file's `ids:` also accepts a mapping (`ids: { default: [1, 2], org: [...] }`), and a shell after-insert hook receives each space given values as `EXWIW_IDS_<NAME>` (`EXWIW_IDS` stays the `default` space and is always set; Ruby hooks get `ids_for("<name>")`). The run aborts before extracting anything when a table's ID space was given no values, when a given ID space is used by no table, or when a table reaches scoped tables of more than one ID space, which the `belongs_to` walk would otherwise settle silently by picking the nearest. Single `--target-table` mode and the MongoDB adapter use only the `default` space and abort when a named one is given. See [Per-table `scope_column` and ID spaces](README.md#per-table-scope_column-and-id-spaces).
11
+
12
+ ### Changed
13
+
14
+ - **`--ids` is read as `<id_space>=<ids>` when the part before its first `=` looks like an ID space name** (a lowercase letter followed by lowercase letters, digits or underscores). An id list whose first id contains such a prefix, like `--ids=key=1`, now names the ID space `key`; pass `--ids=default=key=1` to keep it as an id. Giving `--ids` more than once for the same ID space is now an error instead of the last one silently winning.
15
+ - **`Exwiw::DumpTarget#ids` is a Hash from ID space name to values.** An Array passed to `DumpTarget.new(ids:)` is still accepted as the `default` space's values; read them with `#default_ids`.
16
+
17
+ ## [1.1.5] - 2026-09-25
18
+
19
+ ### Fixed
20
+
21
+ - **Single `--target-table` mode extracts every polymorphic arm, not just one.** The multi-arm union was only built in scope-column mode. In single-target mode a table reached through a polymorphic `belongs_to` followed one arm: the arm pointing at the dump target if there was one, otherwise the arm on the BFS's shortest path. Every other type's rows were dropped without a warning. Dumping `shops`, a `reviews` table with `Shop` and `Product` arms came out with the shop's own reviews but none of the reviews of its products. The arms are now resolved as in scope-column mode (an arm pointing at the dump target compares its foreign key with `--ids`, the others join or probe their target's ids) and the table is constrained to their `UNION`. One single-target rule keeps existing dumps from widening: an arm target that is constrained only by the automatic referenced-by detection does not count, because such a parent is extracted just to keep a child's foreign key valid. Dumping `products` therefore still yields only `reviewable_type = 'Product'` reviews. A declared `reverse_scope` does count. Tables that reach the target through a single arm, or through a non-polymorphic `belongs_to`, emit the same SQL as before.
22
+ - **A table whose only route is one polymorphic arm to a table scoped by other means is no longer unscopable.** The parent cascade skipped polymorphic `belongs_to`s outright, so a table like `pictures` with a lone `imageable_type = 'Shop'` arm, pointing at a `shops` table narrowed by `reverse_scope`, was rejected in scope-column mode and dumped in full in single-target mode. The cascade now falls back to the polymorphic arms, pinned by the type column, when no plain `belongs_to` parent is scopable. A table that already had a scopable plain parent is scoped as before. A newly scoped table does, however, count as a constrained child in the automatic referenced-by detection of the tables it points at. A parent that had exactly one constrained child can now have two, so the detection steps aside and the parent falls back to the parent cascade, or is left unscopable when nothing else narrows it. Declare `reverse_scope` on the parent to keep the referencer it used before. When two independent polymorphic associations are both scopable, the table stays unscopable, and single-target mode now warns about the full dump as it does for two plain parents.
23
+
5
24
  ## [1.1.4] - 2026-09-18
6
25
 
7
26
  ### Fixed
8
27
 
9
- - **Polymorphic arms are grouped by the type column, so a config that gives each arm its own foreign key no longer loses rows.** The multi-arm walk keyed the group on `(foreign_key, foreign_type)`, which fits what the generators emit for Rails (`<name>_id` + `<name>_type`) but not a hand-written config where one type column selects between several foreign key columns — `merchant_application_type` choosing between `merchant_application_id` and `external_application_id`, say. Such arms were not recognised as siblings, so the table fell back to the single-path behavior: only the arm the BFS happened to settle on was extracted and every other arm's rows were dropped without a warning. Which arm survived depended on path length, so a table could even lose the arm it used to keep once a shorter one appeared. The group is now keyed on `foreign_type` alone and each arm joins on its own foreign key; two *independent* polymorphic associations have distinct type columns and stay in distinct groups, as before. Two consequences for such a table, both already true of the single-foreign-key case: its SQL becomes the `UNION` id-set form instead of a single JOIN, and `batch_scope` on it is now rejected in pre-flight (a batch of one terminus's ids does not constrain the other arms).
28
+ - **Polymorphic arms are grouped by the type column, so a config that gives each arm its own foreign key no longer loses rows.** The multi-arm walk keyed the group on `(foreign_key, foreign_type)`, which fits what the generators emit for Rails (`<name>_id` + `<name>_type`) but not a hand-written config where one type column selects between several foreign key columns — `owner_type` choosing between `user_id` and `team_id`, say. Such arms were not recognised as siblings, so the table fell back to the single-path behavior: only the arm the BFS happened to settle on was extracted and every other arm's rows were dropped without a warning. Which arm survived depended on path length, so a table could even lose the arm it used to keep once a shorter one appeared. The group is now keyed on `foreign_type` alone and each arm joins on its own foreign key; two *independent* polymorphic associations have distinct type columns and stay in distinct groups, as before. Two consequences for such a table, both already true of the single-foreign-key case: its SQL becomes the `UNION` id-set form instead of a single JOIN, and `batch_scope` on it is now rejected in pre-flight (a batch of one terminus's ids does not constrain the other arms).
10
29
 
11
30
  ## [1.1.3] - 2026-09-08
12
31
 
data/README.md CHANGED
@@ -80,7 +80,13 @@ exwiw \
80
80
  --log-level=info
81
81
  ```
82
82
 
83
- By default `--ids` are matched against the target table's primary key. If the target table declares a per-table `scope_column`, exwiw runs in [scope-column mode](#scope-column-mode) instead — `--ids` are then values of that shared column, and the table is scoped like any other rather than anchored by primary key.
83
+ By default `--ids` are matched against the target table's primary key. If the target table declares a per-table `scope_column`, exwiw runs in [scope-column mode](#scope-column-mode) instead — `--ids` are then values of that shared column, and the table is scoped like any other rather than anchored by primary key. In scope-column mode `--target-table` can be omitted: passing only `--ids` runs in scope-column mode as long as some table in the schema declares a `scope_column`.
84
+
85
+ | `--target-table` | `--ids` | Mode |
86
+ |---|---|---|
87
+ | given | given | Scope-column mode if the table declares a `scope_column`, single-target mode otherwise |
88
+ | omitted | given | Scope-column mode if any table declares a `scope_column`, an error otherwise (SQL adapters only) |
89
+ | omitted | omitted | Every table is dumped in full |
84
90
 
85
91
  When `--target-table` and `--ids` are omitted, exwiw dumps all tables defined in `--schema-dir`:
86
92
 
@@ -153,8 +159,8 @@ Only the dump target itself is filtered by `--ids` directly. Every *other* table
153
159
  How the paths behave and interact:
154
160
 
155
161
  1. **Direct filter.** In the default single-target mode the target is anchored on its primary key (or a custom field via the mongodb-only `--ids-field`). In [scope-column mode](#scope-column-mode) there is no single anchor: every table that declares a `scope_column` is filtered on that column directly.
156
- 2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column; in scope-column mode a polymorphic hop is resolved for **every** concrete arm and the arms are `UNION`ed (see [Every arm is extracted](#every-arm-is-extracted-scope-column-mode)).
157
- 3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside (path 6) unless you declare `reverse_scope`.
162
+ 2. **`belongs_to` join walk** — the "normal join" path. exwiw BFS-walks `belongs_to` edges to the nearest terminus (the target table, or a directly scoped table in scope-column mode) and compiles the shortest path into `INNER JOIN`s. A [polymorphic `belongs_to`](#polymorphic-belongs_to) hop additionally pins the type column, and is resolved for **every** concrete arm with the arms `UNION`ed (see [Every arm is extracted](#every-arm-is-extracted)).
163
+ 3. **Referenced-by** handles a table with no outgoing path that is pointed *at* by a constrained child — `active_storage_blobs`, referenced by `active_storage_attachments.blob_id`, is the canonical case (see [ActiveStorage](#activestorage-has_one_attached--has_many_attached)). It is automatic but deliberately narrow: it requires a single, non-polymorphic referencer. With two or more referencers it steps aside to path 5, then 6, unless you declare `reverse_scope`.
158
164
  4. **[`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope)** is the declared, multi-referencer form of path 3: the config enumerates which referencers' (already scoped) queries feed the id set. Unscoped arms are skipped with a warning rather than widening the dump.
159
165
  5. **Scoped-parent cascade** rescues satellites: a table whose only link is a `belongs_to` toward a hub that is itself scoped (e.g. via referenced-by or `reverse_scope`) is constrained to that parent's in-scope ids. The cascade recurses hop by hop (each level requires a single unambiguous scopable parent) and stops on `belongs_to` cycles.
160
166
  6. **Full dump** is the fallback for a genuinely unrelated table — intended for reference/master data. Single-target mode dumps it in full (with a warning when an ambiguous cascade was the reason); scope-column mode refuses to run instead, unless the table is explicitly marked [`scope_exempt: true`](#scope_exempt-intentional-full-dump) (Rails-managed tables are exempt automatically).
@@ -173,7 +179,8 @@ all. Choosing one table as `--target-table` would leave the others unrelated to
173
179
  and an unrelated table is dumped in full — a problem if it holds personal data.
174
180
 
175
181
  Scope-column mode handles this shape: instead of anchoring on one table's primary
176
- key, **every table is filtered by a shared column** whose values are `--ids`.
182
+ key, **every table is filtered by a shared column** whose values are `--ids` (tables
183
+ keyed by an unrelated kind of id can use their own [ID space](#per-table-scope_column-and-id-spaces)).
177
184
  Declare that column per table in the schema config with `scope_column:`:
178
185
 
179
186
  ```json
@@ -185,7 +192,7 @@ Declare that column per table in the schema config with `scope_column:`:
185
192
  }
186
193
  ```
187
194
 
188
- Then name any scoped table as `--target-table` and pass the scope values as `--ids`:
195
+ Then pass the scope values as `--ids`, without `--target-table`:
189
196
 
190
197
  ```bash
191
198
  exwiw \
@@ -193,15 +200,22 @@ exwiw \
193
200
  --host=localhost --port=5432 --user=reader \
194
201
  --database=app_production \
195
202
  --schema-dir=exwiw/schema \
196
- --target-table=shops --ids=42,43 \
203
+ --ids=42,43 \
197
204
  --output-dir=dump
198
205
  ```
199
206
 
200
- Because `shops` declares a `scope_column`, exwiw switches to scope-column mode: the
207
+ Because the schema declares a `scope_column`, exwiw runs in scope-column mode: the
201
208
  `--ids` (`42,43`) are **`business_entity_id` values, not shop primary keys**, and
202
- `shops` itself is scoped by `business_entity_id IN (42,43)` like every other scoped
203
- table — it is *not* used as a primary-key anchor. (A table that declares a
204
- `scope_column` therefore can no longer be single-extracted by primary key.)
209
+ `shops` is scoped by `business_entity_id IN (42,43)` like every other scoped table.
210
+ Where extraction starts is decided by the schema, so `--target-table` is not
211
+ needed. If no table declares a `scope_column`, `--ids` alone is an error: name the
212
+ table the ids belong to with `--target-table` (single-target mode), or declare a
213
+ `scope_column`. Tables marked `ignore: true` do not count.
214
+
215
+ Naming a scoped table as `--target-table` (`--target-table=shops --ids=42,43`)
216
+ selects the same mode and extracts the same rows; the target is *not* used as a
217
+ primary-key anchor. (A table that declares a `scope_column` therefore can no longer
218
+ be single-extracted by primary key.)
205
219
 
206
220
  Each table is resolved as follows:
207
221
 
@@ -236,7 +250,8 @@ Each table is resolved as follows:
236
250
  > the broader hub cascade, set `ignore: true` on the child's `belongs_to` edge that
237
251
  > points at this table.
238
252
 
239
- Scope-column mode is SQL-only (mysql / postgresql / sqlite). It works with `exwiw
253
+ Scope-column mode is SQL-only (mysql / postgresql / sqlite); with the mongodb
254
+ adapter, `--ids` still requires `--target-collection`. It works with `exwiw
240
255
  explain` too, which is the recommended way to preview the queries before exporting.
241
256
 
242
257
  #### Cross-database foreign keys
@@ -266,11 +281,13 @@ opt out of the strict check and be exported in full:
266
281
  Rails-managed tables (`schema_migrations`, `ar_internal_metadata`) are treated as
267
282
  exempt automatically.
268
283
 
269
- #### Per-table `scope_column` and the value space
284
+ #### Per-table `scope_column` and ID spaces
270
285
 
271
- Scope-column mode assumes a single shared **value** space — the same `--ids` apply
272
- to every scoped table. Each table names its own column, so a table that stores that
273
- same value under a differently named column simply declares that name:
286
+ The values of every `scope_column` belong to an **ID space**, and `--ids` gives the
287
+ values of each space. A table that does not declare `id_space` is in the `default`
288
+ space, which a plain `--ids=1,2` fills, so without any `id_space` every scoped table
289
+ is filtered by the same `--ids`. Each table names its own column, so a table that
290
+ stores that same value under a differently named column simply declares that name:
274
291
 
275
292
  ```json
276
293
  {
@@ -281,8 +298,42 @@ same value under a differently named column simply declares that name:
281
298
  }
282
299
  ```
283
300
 
284
- Both `scope_exempt` and `scope_column` are user-maintained and preserved across
285
- `schema:generate` regeneration (the generators never emit them).
301
+ When one database holds two groups of tables that no foreign key connects, each
302
+ keyed by a different kind of id, put one group in a named ID space. Here `tenants`
303
+ are identified by integer ids and `organizations` by UUIDs (each object is its own
304
+ schema file):
305
+
306
+ ```json
307
+ { "name": "tenants", "primary_key": "id", "scope_column": "id", "columns": [{ "name": "id" }] }
308
+ { "name": "organizations", "primary_key": "id", "scope_column": "id", "id_space": "org", "columns": [{ "name": "id" }] }
309
+ ```
310
+
311
+ ```bash
312
+ exwiw ... --target-table=tenants \
313
+ --ids=1,2 \
314
+ --ids=org=0b6f4c1e-0000-4000-8000-000000000001
315
+ ```
316
+
317
+ `--ids=1,2` is the same as `--ids=default=1,2`. The part before the first `=` is
318
+ read as an ID space name only when it is shaped like one (a lowercase letter
319
+ followed by lowercase letters, digits or underscores), so an id that contains `=`
320
+ itself is passed with an explicit `default=`. Each ID space may be given once. A
321
+ table without a scope column is filtered by the ID space of the table it is scoped
322
+ through (a `belongs_to` join, `reverse_scope`, referenced-by or the parent cascade).
323
+
324
+ The run aborts before extracting anything when a table is filtered by an ID space
325
+ that was given no values, when values are given for an ID space no table uses, or
326
+ when a table reaches scoped tables of more than one ID space (otherwise the
327
+ `belongs_to` walk would silently settle on the nearest one).
328
+
329
+ Single `--target-table` mode and the MongoDB adapter use only the `default` space
330
+ and abort when a named one is given. In the config file, `ids:` takes either a list
331
+ (the `default` space) or a mapping from ID space name to values, such as
332
+ `ids: { default: [1, 2], org: [0b6f4c1e-0000-4000-8000-000000000001] }`; as with
333
+ the other keys, it is used only when `--ids` is not passed at all.
334
+
335
+ `scope_exempt`, `scope_column` and `id_space` are user-maintained and preserved
336
+ across `schema:generate` regeneration (the generators never emit them).
286
337
 
287
338
  #### Deprecated: the `--scope-column` flag
288
339
 
@@ -290,7 +341,7 @@ Before per-table declarations, scope-column mode was selected with a global
290
341
  `--scope-column=COLUMN` flag (every table filtered by that one column, `--ids` its
291
342
  values, no `--target-table`). The flag still works — SQL-only and mutually
292
343
  exclusive with `--target-table` — but is **deprecated** and emits a warning; prefer
293
- declaring a per-table `scope_column` and running with `--target-table`. A per-table
344
+ declaring a per-table `scope_column` and dropping the flag. A per-table
294
345
  `scope_column` takes precedence over the flag for any table that sets both.
295
346
 
296
347
  ### Config file (`exwiw.yml`)
@@ -308,6 +359,7 @@ output_format: insert # insert | copy
308
359
  after_insert_hook: hooks/seed.rb
309
360
  log_level: info # debug | info
310
361
  # target_table / ids / ids_field / scope_column may also be set here
362
+ # (ids may map ID spaces to values; see "Per-table scope_column and ID spaces")
311
363
  # mongodb_query_timeout_ms: 30000 # global query timeout (mongodb only)
312
364
  ```
313
365
 
@@ -637,7 +689,8 @@ psql -d app_dev -f dump/insert-001-shops.sql
637
689
 
638
690
  **Ruby hook (`.rb`)**: provides a tiny DSL with these builtins:
639
691
 
640
- - `cli_options` — Hash of all parsed CLI options (e.g. `cli_options.fetch(:ids)` returns the `--ids` array).
692
+ - `cli_options` — Hash of all parsed CLI options (e.g. `cli_options.fetch(:ids)` returns the `--ids` array of the `default` ID space)
693
+ - `ids_for(id_space = "default")` — the values the run was scoped by for that ID space (e.g. `ids_for("org")` for `--ids=org=...`)
641
694
  - `insert_sql(template)` — appends an ERB-rendered string to a buffer. After the hook finishes, the buffer is concatenated and written to `insert-{N+1}-after_insert.{ext}` where `{N+1}` is one past the last per-table insert file. For the MongoDB adapter the equivalent alias `insert_jsonl(template)` is available; output goes to `insert-{N+1}-after_insert.jsonl`. Multiple `insert_sql` calls in a single hook are joined with `"\n"` into the same file. If no `insert_sql` call is made, no file is created.
642
695
  - `insert_jsonl(collection, template)` — **MongoDB adapter only**. SQL statements name their table in-band, but JSONL documents do not — the import convention derives the target collection from the filename — so the two-argument form writes the ERB-rendered extended-JSON lines to the named collection's own `insert-NNN-<collection>.jsonl` file, importable with the same `mongoimport --collection <collection>` convention as the per-collection dump files. Multiple calls targeting the same collection are appended (joined with `"\n"`) into that collection's file; distinct collections get one file each, numbered sequentially after the last per-collection dump file (the collection-less `after_insert` buffer, when also used, keeps `{N+1}` and the collection files follow it). Calling this form with a SQL adapter raises an error.
643
696
 
@@ -667,7 +720,8 @@ insert_jsonl 'posts', '{"title":"welcome"}'
667
720
 
668
721
  - `EXWIW_OUTPUT_DIR`, `EXWIW_SCHEMA_DIR`
669
722
  - `EXWIW_DATABASE_ADAPTER`, `EXWIW_DATABASE_HOST`, `EXWIW_DATABASE_PORT`, `EXWIW_DATABASE_USER`, `EXWIW_DATABASE_NAME`
670
- - `EXWIW_TARGET_TABLE`, `EXWIW_IDS` (comma-separated), `EXWIW_OUTPUT_FORMAT`
723
+ - `EXWIW_TARGET_TABLE`, `EXWIW_IDS` (comma-separated, the `default` ID space), `EXWIW_OUTPUT_FORMAT`
724
+ - `EXWIW_IDS_<ID_SPACE>` for each ID space given values, with the name uppercased (`--ids=org=...` becomes `EXWIW_IDS_ORG`). `EXWIW_IDS_DEFAULT` is set only when the `default` space is given, while `EXWIW_IDS` is always set
671
725
 
672
726
  A non-zero exit code from the shell hook aborts exwiw.
673
727
 
@@ -783,9 +837,9 @@ WHERE reviews.reviewable_id IN (/* products subquery */)
783
837
 
784
838
  The same type filter is applied on the join path when the polymorphic table is an intermediate hop rather than the directly-dumped table.
785
839
 
786
- #### Every arm is extracted (scope-column mode)
840
+ #### Every arm is extracted
787
841
 
788
- A polymorphic `belongs_to` is several `belongs_to` entries — one per concrete target — that a row selects between via its type column. A single JOIN can only follow **one** of them, so a join table reached through such a hop would come out holding only the rows of that one `type_value`. In [scope-column mode](#scope-column-mode) exwiw therefore resolves **every** arm of the group and constrains the table to the union of the ids the arms keep:
842
+ A polymorphic `belongs_to` is several `belongs_to` entries — one per concrete target — that a row selects between via its type column. A single JOIN can only follow **one** of them, so a join table reached through such a hop would come out holding only the rows of that one `type_value`. exwiw therefore resolves **every** arm of the group and constrains the table to the union of the ids the arms keep. In [scope-column mode](#scope-column-mode):
789
843
 
790
844
  ```sql
791
845
  SELECT comments.* FROM comments
@@ -811,15 +865,35 @@ Notes:
811
865
  - **Only arms that reach the scope are included.** An arm whose target has no scope of its own is dropped, never widened — an unscoped arm would pull in every tenant's rows. An arm marked `"ignore": true` is dropped as usual, before any of this.
812
866
  - An arm's target does not need a `belongs_to` path to a scoped table: if it is scoped by other means (referenced-by, [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope), or the parent cascade) the arm probes that query's ids instead, still pinned by the type column.
813
867
  - An arm whose target is scoped **through this same table** (e.g. `active_storage_blobs`, narrowed by referenced-by from `active_storage_attachments`, appearing as an `ActiveStorage::Blob` arm of those same attachments) is dropped: adopting it would make the two tables scope each other and leave the referenced table short of rows the join table kept — a dangling foreign key on import.
814
- - **Arms are grouped by the type column (`foreign_type`), not by the foreign key.** Rails keeps every arm's id in one `<name>_id` column, which is what the generators emit, but a hand-written config may give each arm its own foreign key while one type column still selects between them — e.g. `merchant_application_type` choosing between `merchant_application_id` and `external_application_id`. Those are arms of the same discriminator, so each is resolved and joined on its own key. Two *independent* polymorphic associations on one table have distinct type columns and therefore stay in distinct groups.
815
- - Nothing changes when there is a single arm, or when the walk leaves through a non-polymorphic `belongs_to`: the plain single-JOIN SQL is emitted, byte for byte as before.
816
- - This applies to the scope-column mode walk. The single `--target-table` mode still follows one path per table.
868
+ - **Arms are grouped by the type column (`foreign_type`), not by the foreign key.** Rails keeps every arm's id in one `<name>_id` column, which is what the generators emit, but a hand-written config may give each arm its own foreign key while one type column still selects between them — e.g. `owner_type` choosing between `user_id` and `team_id`. Those are arms of the same discriminator, so each is resolved and joined on its own key. Two *independent* polymorphic associations on one table have distinct type columns and therefore stay in distinct groups.
869
+ - **The route the walk picks decides whether the arms are unioned.** The route is the shortest one; in single-target mode a `belongs_to` pointing at the dump target itself is taken first. When the route leaves through a polymorphic arm, every arm of that type column is unioned. When it leaves through a non-polymorphic `belongs_to`, the table is joined along that route alone and the polymorphic arms are not unioned in, so a row reachable only through an arm is not extracted. A plain `belongs_to` is not preferred over a shorter polymorphic route. A single arm is emitted as a plain JOIN.
870
+ - A table with no route of its own falls back to the parent cascade, which does prefer plain parents. A scopable plain parent is used when there is exactly one. Only when there is none are the polymorphic `belongs_to`s consulted: the arms of one type column that point at tables scoped by other means (referenced-by, `reverse_scope`, or the cascade) are unioned, each pinned by the type column. Multiple independent polymorphic associations that are all scopable are as ambiguous as multiple plain parents, so the table is left unscopable (dumped in full with a warning in single-target mode). A table scoped this way also counts as a constrained child in the [referenced-by](#how-each-table-is-narrowed--the-six-scoping-paths) detection of the tables it points at, so a parent that had a single constrained referencer may now have two and be narrowed by the next path instead.
871
+
872
+ In single `--target-table` mode the same union is built. An arm that points at the dump target itself compares its foreign key with `--ids`, and the other arms join or probe their way to the target as above. One difference: an arm target that is constrained **only** by the automatic referenced-by detection does not count. Such a parent is extracted just to keep a child's foreign key valid; it does not own the rows that point at it. So dumping `products` still pulls only `reviewable_type = 'Product'` reviews, even though the product's shop is extracted too, while dumping `shops` pulls the shop's own reviews **and** the reviews of its products:
873
+
874
+ ```sql
875
+ SELECT reviews.* FROM reviews
876
+ JOIN (
877
+ SELECT DISTINCT exwiw_scope_src_0.id AS exwiw_scope_id
878
+ FROM (
879
+ SELECT reviews.id FROM reviews
880
+ JOIN products ON reviews.reviewable_id = products.id
881
+ AND products.shop_id = 1 AND reviews.reviewable_type = 'Product'
882
+ UNION
883
+ SELECT reviews.id FROM reviews
884
+ WHERE reviews.reviewable_id = 1 AND reviews.reviewable_type = 'Shop'
885
+ ) AS exwiw_scope_src_0
886
+ ) AS exwiw_scope_ids_0
887
+ ON reviews.id = exwiw_scope_ids_0.exwiw_scope_id
888
+ ```
889
+
890
+ A declared [`reverse_scope`](#reverse-scope-for-multi-referencer-tables-reverse_scope) does count, since declaring it states that the referencers own the rows.
817
891
 
818
892
  ### ActiveStorage (`has_one_attached` / `has_many_attached`)
819
893
 
820
894
  ActiveStorage is handled automatically — no ActiveStorage-specific configuration is required. The `has_one_attached` / `has_many_attached` macros don't add a column to the owning model; they generate ordinary associations that exwiw already understands:
821
895
 
822
- - **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted. In scope-column mode every owner type that reaches the scope is extracted (see [Every arm is extracted](#every-arm-is-extracted-scope-column-mode)); before that, only the single owner type the walk happened to settle on came out.
896
+ - **`active_storage_attachments`** is the polymorphic join row (`belongs_to :record, polymorphic: true` + `belongs_to :blob`). `exwiw:schema:generate` expands the polymorphic `record` into one `belongs_to` per model that declared `has_*_attached` (found via the generated `has_* ..., as: :record` reflections), exactly like any other [polymorphic `belongs_to`](#polymorphic-belongs_to). So only the attachments whose owner is among the dumped rows are extracted. Every owner type that reaches the scope is extracted (see [Every arm is extracted](#every-arm-is-extracted)); before that, only the single owner type the walk happened to settle on came out.
823
897
  - **`active_storage_blobs`** has no `belongs_to` of its own (attachments point *at* it), so it has no path to the dump target. exwiw narrows it via **reverse / "referenced_by" extraction**: a parent table referenced by exactly one constrained, non-polymorphic child is constrained to just the referenced ids instead of dumping every row. The id set is materialized once and joined back (see [Why a JOIN, not `IN (subquery)`](#why-a-join-not-in-subquery)):
824
898
 
825
899
  ```sql
@@ -164,9 +164,9 @@ module Exwiw
164
164
  # left as Strings rather than guessed at — the caller passes values
165
165
  # matching the field's actual type.
166
166
  if dump_target.ids_field
167
- { dump_target.ids_field => { "$in" => dump_target.ids } }
167
+ { dump_target.ids_field => { "$in" => dump_target.default_ids } }
168
168
  else
169
- { config.primary_key => { "$in" => coerce_ids(dump_target.ids) } }
169
+ { config.primary_key => { "$in" => coerce_ids(dump_target.default_ids) } }
170
170
  end
171
171
  else
172
172
  related_collection_filter(config, config_by_name, dump_target)
@@ -4,25 +4,26 @@ require 'erb'
4
4
 
5
5
  module Exwiw
6
6
  class AfterInsertHook
7
- def self.run(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
7
+ def self.run(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
8
8
  ext = File.extname(path)
9
9
 
10
10
  if ext == '.rb'
11
11
  run_ruby(
12
12
  path: path,
13
13
  cli_options: cli_options,
14
+ dump_target: dump_target,
14
15
  output_dir: output_dir,
15
16
  next_idx: next_idx,
16
17
  output_extension: output_extension,
17
18
  logger: logger,
18
19
  )
19
20
  else
20
- run_shell(path: path, cli_options: cli_options, output_dir: output_dir, logger: logger)
21
+ run_shell(path: path, cli_options: cli_options, dump_target: dump_target, output_dir: output_dir, logger: logger)
21
22
  end
22
23
  end
23
24
 
24
- def self.run_ruby(path:, cli_options:, output_dir:, next_idx:, output_extension:, logger:)
25
- ctx = Context.new(cli_options, output_extension: output_extension)
25
+ def self.run_ruby(path:, cli_options:, dump_target:, output_dir:, next_idx:, output_extension:, logger:)
26
+ ctx = Context.new(cli_options, dump_target: dump_target, output_extension: output_extension)
26
27
  ctx.instance_eval(File.read(path), path)
27
28
 
28
29
  # One output file per buffer, numbered sequentially from next_idx: first
@@ -53,7 +54,7 @@ module Exwiw
53
54
  end
54
55
  end
55
56
 
56
- def self.run_shell(path:, cli_options:, output_dir:, logger:)
57
+ def self.run_shell(path:, cli_options:, dump_target:, output_dir:, logger:)
57
58
  env = {
58
59
  'EXWIW_OUTPUT_DIR' => output_dir,
59
60
  'EXWIW_SCHEMA_DIR' => cli_options[:schema_dir].to_s,
@@ -64,9 +65,12 @@ module Exwiw
64
65
  'EXWIW_DATABASE_NAME' => cli_options[:database_name].to_s,
65
66
  'EXWIW_TARGET_TABLE' => cli_options[:target_table].to_s,
66
67
  'EXWIW_SCOPE_COLUMN' => cli_options[:scope_column].to_s,
67
- 'EXWIW_IDS' => Array(cli_options[:ids]).join(','),
68
+ 'EXWIW_IDS' => dump_target.default_ids.join(','),
68
69
  'EXWIW_OUTPUT_FORMAT' => cli_options[:output_format].to_s,
69
70
  }
71
+ dump_target.ids.each do |space, values|
72
+ env["EXWIW_IDS_#{space.upcase}"] = Array(values).join(',')
73
+ end
70
74
  logger.info("Running after-insert shell hook: #{path}")
71
75
  ok = system(env, path)
72
76
  raise "after-insert shell hook failed: #{path}" unless ok
@@ -79,13 +83,19 @@ module Exwiw
79
83
 
80
84
  attr_reader :cli_options, :collected, :collected_by_collection
81
85
 
82
- def initialize(cli_options, output_extension: nil)
86
+ def initialize(cli_options, dump_target:, output_extension: nil)
83
87
  @cli_options = cli_options
88
+ @dump_target = dump_target
84
89
  @output_extension = output_extension
85
90
  @collected = []
86
91
  @collected_by_collection = {}
87
92
  end
88
93
 
94
+ # The values the run was scoped by for the given ID space (`default` when omitted).
95
+ def ids_for(id_space = DEFAULT_ID_SPACE)
96
+ @dump_target.ids_for(id_space.to_s)
97
+ end
98
+
89
99
  def insert_sql(template)
90
100
  @collected << render(template)
91
101
  end
data/lib/exwiw/cli.rb CHANGED
@@ -111,7 +111,8 @@ module Exwiw
111
111
  @database_name = nil
112
112
  @target_table_name = nil
113
113
  @target_collection_name = nil
114
- @ids = []
114
+ @ids_by_id_space = {}
115
+ @ids_errors = []
115
116
  @ids_field = nil
116
117
  @scope_column = nil
117
118
  @output_format = nil
@@ -151,7 +152,7 @@ module Exwiw
151
152
 
152
153
  dump_target = DumpTarget.new(
153
154
  table_name: @target_table_name,
154
- ids: @ids,
155
+ ids: @ids_by_id_space,
155
156
  ids_field: @ids_field,
156
157
  scope_column: @scope_column,
157
158
  )
@@ -297,6 +298,9 @@ module Exwiw
297
298
  # Fill in any options not given on the CLI from the config file. Done first
298
299
  # so a config-provided `adapter` is in place before normalization below.
299
300
  # CLI values always win (the merge only fills nil/empty ivars).
301
+ @ids_errors.each { |message| $stderr.puts message }
302
+ exit 1 if @ids_errors.any?
303
+
300
304
  apply_config_file!
301
305
 
302
306
  # Default log level once CLI and config have both had their say.
@@ -316,6 +320,7 @@ module Exwiw
316
320
  end
317
321
 
318
322
  resolve_target_collection_alias!
323
+ resolve_named_ids!
319
324
  resolve_scope_column!
320
325
  resolve_ids_field!
321
326
  resolve_uri_option!
@@ -391,18 +396,22 @@ module Exwiw
391
396
  exit 1
392
397
  end
393
398
 
394
- if @target_table_name && @ids.empty?
399
+ if @target_table_name && !ids_given?
395
400
  $stderr.puts "--ids is required when --target-table is specified"
396
401
  exit 1
397
402
  end
398
403
 
399
- if @scope_column && @ids.empty?
404
+ if @scope_column && !ids_given?
400
405
  $stderr.puts "--ids is required when --scope-column is specified"
401
406
  exit 1
402
407
  end
403
408
 
404
- if !@target_table_name && !@scope_column && @ids.any?
405
- $stderr.puts "--target-table or --scope-column is required when --ids is specified"
409
+ # For the SQL adapters, --ids without a target is scope-column mode; whether
410
+ # the schema supports it is checked once it is loaded
411
+ # (QueryAstBuilder.validate_scope!). MongoDB has no scope-column mode.
412
+ if @database_adapter == "mongodb" && !@target_table_name && default_ids.any?
413
+ $stderr.puts "--target-collection (or --target-table) is required when --ids is specified " \
414
+ "with the mongodb adapter"
406
415
  exit 1
407
416
  end
408
417
 
@@ -479,11 +488,23 @@ module Exwiw
479
488
  @log_level ||= config["log_level"]&.to_sym
480
489
  @target_table_name ||= config["target_table"]
481
490
  @target_collection_name ||= config["target_collection"]
482
- if @ids.empty? && config.key?("ids")
491
+ # Like the other keys, `ids` is taken from the config only when the CLI
492
+ # passed no --ids at all; the two are never merged per ID space.
493
+ if !ids_given? && config.key?("ids")
483
494
  raw = config["ids"]
484
- # Accept either a YAML list or a "1,2" string; coerce to strings to match
485
- # the CLI's `--ids=1,2` -> ["1", "2"] shape.
486
- @ids = (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
495
+ if raw.is_a?(Hash)
496
+ raw.each do |space, values|
497
+ space = space.to_s
498
+ unless space.match?(ID_SPACE_NAME_PATTERN)
499
+ $stderr.puts "config 'ids' keys must be ID space names (a lowercase letter followed by " \
500
+ "lowercase letters, digits or underscores; got #{space.inspect}) in #{path}"
501
+ exit 1
502
+ end
503
+ @ids_by_id_space[space] = parse_config_ids(values)
504
+ end
505
+ else
506
+ @ids_by_id_space[DEFAULT_ID_SPACE] = parse_config_ids(raw)
507
+ end
487
508
  end
488
509
  @ids_field ||= config["ids_field"]
489
510
  @scope_column ||= config["scope_column"]
@@ -492,6 +513,56 @@ module Exwiw
492
513
  @explain_verbosity ||= config["explain_verbosity"]
493
514
  end
494
515
 
516
+ # Accept either a YAML list or a "1,2" string; coerce to strings to match
517
+ # the CLI's `--ids=1,2` -> ["1", "2"] shape.
518
+ private def parse_config_ids(raw)
519
+ (raw.is_a?(String) ? raw.split(",") : Array(raw)).map(&:to_s)
520
+ end
521
+
522
+ # `--ids=<id_space>=1,2` gives the values of a named ID space; without the
523
+ # prefix the values are the default space's. The prefix is recognized only
524
+ # when it is shaped like an ID space name, so an id containing `=` is passed
525
+ # as `--ids=default=<ids>`. Each ID space may be given once: a second --ids
526
+ # for it would otherwise replace the first without notice.
527
+ private def add_ids_option(value)
528
+ prefix, rest = value.to_s.split("=", 2)
529
+ space, list =
530
+ if rest && prefix.match?(ID_SPACE_NAME_PATTERN)
531
+ [prefix, rest]
532
+ else
533
+ [DEFAULT_ID_SPACE, value.to_s]
534
+ end
535
+
536
+ if @ids_by_id_space.key?(space)
537
+ @ids_errors << "--ids was given more than once for the ID space '#{space}'; " \
538
+ "pass all of its ids in one comma-separated --ids."
539
+ end
540
+ @ids_by_id_space[space] = list.split(",")
541
+ end
542
+
543
+ private def ids_given?
544
+ @ids_by_id_space.each_value.any?(&:any?)
545
+ end
546
+
547
+ private def default_ids
548
+ @ids_by_id_space.fetch(DEFAULT_ID_SPACE, [])
549
+ end
550
+
551
+ # Named ID spaces exist only for scope-column mode, which the mongodb
552
+ # adapter does not have. Whether a SQL run is in scope-column mode depends on
553
+ # the schema, so that is checked once it is loaded (QueryAstBuilder.validate_scope!).
554
+ private def resolve_named_ids!
555
+ named = @ids_by_id_space.keys - [DEFAULT_ID_SPACE]
556
+ return if named.empty?
557
+
558
+ if @database_adapter == "mongodb"
559
+ $stderr.puts "--ids for a named ID space (#{named.map { |space| "'#{space}'" }.join(', ')}) " \
560
+ "is not supported by the mongodb adapter, which uses only the default ID space " \
561
+ "(--ids without an `<id_space>=` prefix)."
562
+ exit 1
563
+ end
564
+ end
565
+
495
566
  # Strip a trailing slash (like the CLI's dir options) and expand relative to
496
567
  # `base` (the config file's directory). Returns nil for a nil value.
497
568
  private def expand_dir(value, base)
@@ -542,8 +613,8 @@ module Exwiw
542
613
 
543
614
  # `--scope-column` is **deprecated**: it selected scope-column mode with a
544
615
  # single global column for every table. The preferred way is to declare a
545
- # per-table `scope_column:` in the schema config and pass `--target-table`
546
- # (the target is then scoped like any other table). The flag still works as
616
+ # per-table `scope_column:` in the schema config (`--target-table` is then
617
+ # optional). The flag still works as
547
618
  # before — SQL-only and mutually exclusive with `--target-table` — but emits a
548
619
  # deprecation warning. Runs after resolve_target_collection_alias! so
549
620
  # --target-collection is already folded into @target_table_name.
@@ -567,7 +638,7 @@ module Exwiw
567
638
  end
568
639
 
569
640
  $stderr.puts "warning: --scope-column is deprecated; declare a per-table `scope_column:` " \
570
- "in the schema config and run with --target-table instead."
641
+ "in the schema config and drop the flag instead."
571
642
  end
572
643
 
573
644
  # `--uri` supplies a full connection string (e.g. `mongodb+srv://...`) and is
@@ -780,7 +851,7 @@ module Exwiw
780
851
  database_adapter: @database_adapter,
781
852
  database_name: @database_name,
782
853
  target_table: @target_table_name,
783
- ids: @ids.dup.freeze,
854
+ ids: default_ids.dup.freeze,
784
855
  ids_field: @ids_field,
785
856
  scope_column: @scope_column,
786
857
  output_format: @output_format,
@@ -849,11 +920,11 @@ module Exwiw
849
920
  opts.on("-a", "--adapter=ADAPTER", "Database adapter: mysql, sqlite, postgresql, mongodb (aliases: mysql2, sqlite3)") { |v| @database_adapter = v }
850
921
  opts.on("--uri=URI", "Full MongoDB connection URI (mongodb:// or mongodb+srv://). mongodb adapter only; takes precedence over --host/--port/--user. TLS, replicaSet, authSource and credentials are read from the URI.") { |v| @connection_uri = v }
851
922
  opts.on("--database=DATABASE", "Target database name") { |v| @database_name = v }
852
- opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted, dump all tables.") { |v| @target_table_name = v }
923
+ opts.on("--target-table=[TABLE]", "Target table for extraction. If omitted with --ids, run in scope-column mode (SQL adapters; the schema must declare `scope_column:`). If omitted without --ids, dump all tables.") { |v| @target_table_name = v }
853
924
  opts.on("--target-collection=[COLLECTION]", "Alias of --target-table for the mongodb adapter.") { |v| @target_collection_name = v }
854
- opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given.") { |v| @ids = v.split(',') }
925
+ opts.on("--ids=[IDS]", "Comma-separated list of identifiers. Required when --target-table is given. Prefix with `<id_space>=` (e.g. --ids=org=a,b) to give the values of a named ID space; may be repeated, once per ID space. An id containing `=` is passed as --ids=default=<ids>.") { |v| add_ids_option(v) }
855
926
  opts.on("--ids-field=[FIELD]", "Field on the target collection that --ids is matched against. Defaults to the primary key. (mongodb adapter only)") { |v| @ids_field = v }
856
- opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config and running with --target-table.") { |v| @scope_column = v }
927
+ opts.on("--scope-column=[COLUMN]", "DEPRECATED. Filter every table by this shared global column (--ids are its values) instead of a single --target-table. SQL adapters only; mutually exclusive with --target-table. Prefer declaring a per-table `scope_column:` in the schema config.") { |v| @scope_column = v }
857
928
  opts.on("--output-format=[FORMAT]", "Output format: insert (default) or copy (PostgreSQL only, export subcommand only)") { |v| @output_format = v }
858
929
  opts.on("--insert-only", "DEPRECATED: ignored. exwiw no longer generates delete-*.sql files, so every export is insert-only.") do
859
930
  $stderr.puts "warning: --insert-only is obsolete and ignored (exwiw no longer generates delete-*.sql files); remove the flag"