exwiw 0.9.7 → 0.9.9

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: 70b1d51562a341ea502ebb5d22ed3aa625d902264d9112bf338aa6142268dd0a
4
- data.tar.gz: e1110a13b9d95cddec6d32f0f829360ded75742f4d17c6969a4ebf51d272b454
3
+ metadata.gz: 8b734fe407ce3c430c3197b04db275df36640849f6795d7636614d8fab0c6574
4
+ data.tar.gz: 4be4a94e18f929ef13c97072248e4c7b7e867ad80160841a81c0146a929b9e21
5
5
  SHA512:
6
- metadata.gz: acd60932985992f2863640102a34d79e87434b566ee96484abbf3a36f3b2d08e7700a13a63657f9f45cd97c8f0f76d83f221b13e23ae3b0b7b257500f9f99890
7
- data.tar.gz: 0de13c9385276a68b8ceb582777da4c9f6b329d1f796db5dfc1411ea936843e0aff0f6a5d178c1a883230b7f0bc7378daedd5382ac9dcfc935879ad74df924f9
6
+ metadata.gz: cdb946b9f0b3ae2bfca4aa74aca93454cbc0ae6f332f0900216c3a35be9d8bdcef2b2082744bcc6262b6bc9cf72afae9a477c85f7e00d60da8d6eaa4ed55b825
7
+ data.tar.gz: 12f63468a2d834e558b98e73937e739aa2ab4581cf83699e0a675e280cfd6e6f158d136156400e3ec7c7672beaa63189a6e90c727cc1c87281412e061c72b417
data/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.9] - 2026-07-09
6
+
7
+ ### Changed
8
+
9
+ - **`replace_with_fake_data` name types now produce a coherent identity, and gained kana types.** The person-family types (`human_name`, `first_name`, `last_name`) previously drew from three independent faker pools, so a single seed's first name, last name, and full name were unrelated people. They now share one per-locale pool of whole people keyed by the seed, so for a given seed `human_name` always equals `last_name` + `first_name` (ordered per locale: `姓 名` for `ja`, `First Last` otherwise). Three kana types are added — `human_name_kana`, `first_name_kana`, `last_name_kana` — for the reading of the same person. Because faker's `ja` locale has kanji names with no readings, exwiw bundles its own paired (kanji, katakana) dataset (`Exwiw::JapaneseNames`) and builds the `ja` name pool from it, so kana matches the kanji and a `ja`-name-only config needs no faker at all. Kana types require `locale: ja` (they raise a clear error otherwise). The person pool is sized independently of the other types at 20,000 (`PERSON_POOL_SIZE`), and for `ja` is filled with 20,000 *distinct* people enumerated from a 142×142 name dataset (rather than sampled with replacement, which left ~37% of slots duplicated), so the name space is large and evenly used. Determinism for the `ja` name family is now pinned to exwiw's bundled dataset version rather than the faker version; other types and locales are unchanged.
10
+
11
+ ## [0.9.8] - 2026-07-09
12
+
13
+ ### Added
14
+
15
+ - **The MongoDB adapter now supports `reverse_scope` (multi-referencer reverse scoping).** The `reverse_scope.via` key that `TableConfig` already offers for the SQL adapters is now accepted by `MongodbCollectionConfig` with the same shape and semantics: a global-identity collection with no `belongs_to` path to the dump target, referenced by several scoped collections, is constrained to the union of the ids its enumerated referencer arms actually point at instead of being dumped in full. Where SQL emits a `UNION` subquery, the mongodb adapter captures each arm's foreign-key values **at runtime** while the referencer collection streams (the existing parent-id propagation mechanism, extended to via-arm columns; `null`s are dropped per arm, array-valued columns contribute one id per element, and captured values keep their native BSON types). Because of that runtime capture, `DetermineTableProcessingOrder` gains a mongodb-only mode that orders a reverse-scoped collection **after** all of its `via` referencers (inverting an arm's own `belongs_to` edge back to the collection) and aborts with a clear error when the arms create a genuine ordering cycle; the SQL processing order is unchanged. Arm hygiene mirrors SQL (an unknown / embedded / undumped / unscoped referencer is skipped with a warning rather than silently widening the dump), a collection with its own `belongs_to` path to the target keeps that scope (`reverse_scope` is ignored, as in SQL), satellites of the reverse-scoped collection tighten automatically, `exwiw explain` shows the real filter shape with a placeholder id, and `--parallel-workers` falls back to serial with a warning when a reverse-scoped collection is present (the parallel schedule does not express the new ordering constraint yet). Masking/fields behavior is unchanged, and the key stays user-owned: never emitted by the schema generator and preserved across regeneration.
16
+
17
+ ### Changed
18
+
19
+ - **Unknown keys in schema config JSON are now rejected on load instead of being silently dropped.** Serdes deserialization is lenient, so a key that matched no declared attribute was discarded without a word — turning a typo (`reverse_scop`, `bulk_insert_chunk_sise`) or a key another adapter supports but this one does not (`raw_sql`/`map` on a MongoDB field; `reverse_scope` on a MongoDB collection before this release) into a silent no-op: the dump ran and the requested masking/scoping simply never happened. `TableConfig.from` / `MongodbCollectionConfig.from` now validate the raw hash against the declared attributes — including the nested `belongs_tos` / `columns` / `fields` / `reverse_scope` / `embedded_in` / `replace_with_fake_data` entries — and raise `Exwiw::UnknownConfigKeyError` (an `ArgumentError` subclass) naming the key(s), the table/collection, the nested position, and the allowed keys; `export`/`explain` prepend the offending file path. This is a deliberate hard error with no opt-out: every declared key still passes — including the documentation-only `comment` on table/collection configs and their `belongs_tos`/`columns`/`fields` entries, which remains the supported place for free-form notes — so a config that only uses supported keys is unaffected, while anything now rejected was already being ignored. If a config carries stray keys, remove them or fold them into `comment`.
20
+
5
21
  ## [0.9.7] - 2026-07-08
6
22
 
7
23
  ### Added
data/README.md CHANGED
@@ -451,12 +451,12 @@ This is an example of the one table schema:
451
451
  "primary_key": "id",
452
452
  "filter": "users.id > 0",
453
453
  "bulk_insert_chunk_size": 1000,
454
- "belongs_to": [{
455
- "name": "companies",
454
+ "belongs_tos": [{
455
+ "table_name": "companies",
456
456
  "foreign_key": "company_id"
457
457
  }],
458
458
  "columns": [{
459
- "name": "id",
459
+ "name": "id"
460
460
  }, {
461
461
  "name": "email",
462
462
  "replace_with": "user{id}@example.com"
@@ -468,6 +468,12 @@ This is an example of the one table schema:
468
468
 
469
469
  `--schema-dir` will use all json files in the specified directory.
470
470
 
471
+ #### Unknown keys are rejected
472
+
473
+ Loading a table/collection config with a key that no declared attribute accepts is an **error** (`Exwiw::UnknownConfigKeyError`, an `ArgumentError` subclass) naming the key, the table/collection, the offending file, and the allowed keys. This also applies to the nested `belongs_tos` / `columns` / `fields` / `reverse_scope` / `embedded_in` / `replace_with_fake_data` entries. Previously such keys were silently dropped, which turned a typo (`reverse_scop`) — or a key another adapter supports but this one does not (e.g. `raw_sql` on a MongoDB field) — into a silent no-op: the config loaded, the dump ran, and the requested masking/scoping simply never happened.
474
+
475
+ For free-form annotations, use the `comment` key — it is a declared, documentation-only attribute on table/collection configs and on their `belongs_tos` / `columns` / `fields` entries, so it always passes (see [Ignore / annotate a column or `belongs_to`](#ignore--annotate-a-column-or-belongs_to)).
476
+
471
477
  ### Output format
472
478
 
473
479
  By default, exwiw generates `INSERT` statements. For PostgreSQL, you can pass `--output-format=copy` to generate `COPY FROM stdin` format instead, which is significantly faster for bulk loading.
@@ -680,6 +686,7 @@ Notes:
680
686
  - **NULLs are excluded** per arm (`IS NOT NULL`).
681
687
  - **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.
682
688
  - 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).
689
+ - **The MongoDB adapter supports `reverse_scope` too** — same config shape and semantics, but the id set is captured at runtime instead of being emitted as a `UNION` subquery. See [`reverse_scope` on collections](#reverse_scope-on-collections) under MongoDB notes.
683
690
 
684
691
  ### Why a JOIN, not `IN (subquery)`
685
692
 
@@ -806,9 +813,10 @@ which is equivalent to `"replace_with": "user{id}@example.com"`.
806
813
  automatic NULL preservation** — the proc receives `nil` and decides.
807
814
  - `map` is exclusive with the other masking keys on the same column
808
815
  (`raw_sql` / `replace_with` / `replace_with_fake_data`).
809
- - SQL adapters only. The MongoDB adapter silently drops the key (like
810
- `raw_sql`). Because the transform runs in the exwiw process, it is invisible
811
- to `explain`.
816
+ - SQL adapters only. On the MongoDB adapter the key is rejected on load, like
817
+ `raw_sql` (see [Unknown keys are rejected](#unknown-keys-are-rejected)).
818
+ Because the transform runs in the exwiw process, it is invisible to
819
+ `explain`.
812
820
 
813
821
  **Security note**: `map` executes arbitrary Ruby from the schema config. Treat
814
822
  config files with the same trust as your Gemfile — only load trusted configs.
@@ -842,36 +850,61 @@ fake value, across tables, runs, and adapters:
842
850
  because their text forms differ per adapter. A `NULL` seed value hashes `""`
843
851
  (still deterministic).
844
852
  - Like `replace_with`, it **preserves NULL** in the target column.
845
- - `locale` (optional) sets the faker locale used to generate the candidate
846
- values, e.g. `{ "seed": "id", "type": "human_name", "locale": "ja" }` produces
847
- Japanese names.
853
+ - `locale` (optional) sets the locale used to build the candidate values, e.g.
854
+ `{ "seed": "id", "type": "human_name", "locale": "ja" }` produces Japanese
855
+ names.
848
856
  - Supported `type`s:
849
857
 
850
- | type | example output |
851
- |------|----------------|
852
- | `human_name` | `Adrianna Kilback` |
853
- | `first_name` | `Adrianna` |
854
- | `last_name` | `Kilback` |
855
- | `phone_number` | `(555) 123-4567` |
856
- | `address` | `282 Kevin Brook, Imogeneborough, CA 58517` |
857
- | `company_name` | `Hirthe-Ritchie` |
858
- | `email` | `cliff.fay.9d6b804eff5a3f57@example.com` |
859
- | `username` | `cliff.fay_9d6b804eff5a3f57` |
860
-
861
- - Values are drawn from a pool of 10,000 pre-generated candidates per
862
- (type, locale), so distinct seeds can share a fake value. The
858
+ | type | example output (en) | example output (`locale: ja`) |
859
+ |------|----------------|----------------|
860
+ | `human_name` | `Adrianna Kilback` | `山田 太郎` |
861
+ | `first_name` | `Adrianna` | `太郎` |
862
+ | `last_name` | `Kilback` | `山田` |
863
+ | `human_name_kana` | (ja only) | `ヤマダ タロウ` |
864
+ | `first_name_kana` | (ja only) | `タロウ` |
865
+ | `last_name_kana` | (ja only) | `ヤマダ` |
866
+ | `phone_number` | `(555) 123-4567` | |
867
+ | `address` | `282 Kevin Brook, Imogeneborough, CA 58517` | |
868
+ | `company_name` | `Hirthe-Ritchie` | |
869
+ | `email` | `cliff.fay.9d6b804eff5a3f57@example.com` | |
870
+ | `username` | `cliff.fay_9d6b804eff5a3f57` | |
871
+
872
+ - **Coherent identity across the name family.** The person-family types
873
+ (`human_name`, `first_name`, `last_name` and their `*_kana` counterparts) all
874
+ draw from a single shared pool of people per locale, keyed by the same seed —
875
+ so for one seed value the last name, first name, full name, and every kana
876
+ reading belong to the **same person**: `human_name` always equals
877
+ `last_name` + `first_name`, and `human_name_kana` matches `human_name`. Full
878
+ names are ordered per locale (`姓 名` for `ja`, `First Last` otherwise).
879
+ - **Kana (`*_kana`) types require `locale: ja`.** faker's `ja` locale ships
880
+ kanji names with no reading, so exwiw bundles its own paired (kanji, katakana)
881
+ dataset for `ja`; this is what lets a fake person carry a kana reading that
882
+ actually matches its kanji. Requesting a `*_kana` type with any other locale
883
+ raises a clear error at build time.
884
+ - Values are drawn from a pre-generated pool per (type, locale), so distinct
885
+ seeds can share a fake value. The name family uses one shared **person** pool
886
+ of 20,000 identities — for `ja` these are 20,000 *distinct* people enumerated
887
+ from the bundled (kanji, kana) dataset (142 surnames × 142 given names); the
888
+ other types use an independent 10,000-candidate pool. The
863
889
  uniqueness-sensitive types (`email`, `username`) additionally embed a 64-bit
864
890
  hex token derived from the seed hash, so they stay collision-free under a
865
891
  unique index even at millions of rows (collision probability at 5M distinct
866
892
  seeds ≈ 7e-7) and always use the `example.com` domain.
867
- - **Determinism caveat**: values are stable for a given faker gem version +
868
- locale. Upgrading faker (or changing `locale`) regenerates the pool and maps
893
+ - **Determinism caveat**: values are stable for a given locale plus the version
894
+ of the value source — the faker gem for the non-`ja` name family and the
895
+ independent types, and exwiw's bundled dataset for the `ja` name family.
896
+ Upgrading that source (or changing `locale`) regenerates the pool and maps
869
897
  seeds to different values. The seed→value mapping itself never changes within
870
898
  one version.
871
899
  - The faker gem is **not** a runtime dependency of exwiw — add `gem "faker"` to
872
- your Gemfile to use this mode (exwiw raises a clear error otherwise).
900
+ your Gemfile to use this mode (exwiw raises a clear error otherwise). A config
901
+ that uses **only** `ja` person types needs no faker (that pool is built
902
+ entirely from the bundled dataset); faker is required for every other type
903
+ and locale.
873
904
  - Exclusive with the other masking keys on the same column. SQL adapters only
874
- (the MongoDB adapter silently drops the key), and invisible to `explain`.
905
+ (the MongoDB adapter rejects the key on load, see
906
+ [Unknown keys are rejected](#unknown-keys-are-rejected)), and invisible to
907
+ `explain`.
875
908
 
876
909
  **Performance**: this is a per-row Ruby transform, measured at ~1.5–1.6µs/row
877
910
  per fake column (so ≈ +8s per 5M rows per column; ~+40% against a local sqlite
@@ -906,9 +939,39 @@ The MongoDB adapter is experimental. To use it:
906
939
  mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
907
940
  ```
908
941
  - Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
909
- - `raw_sql`, `map`, and `replace_with_fake_data` are not supported (the `MongodbField` schema does not declare them; such keys in scenario JSON are silently dropped on load). Use `replace_with` for masking.
942
+ - `raw_sql`, `map`, and `replace_with_fake_data` are not supported (the `MongodbField` schema does not declare them; such keys in a config are rejected on load — see [Unknown keys are rejected](#unknown-keys-are-rejected)). Use `replace_with` for masking.
910
943
  - The MongoDB adapter does not support the collection-level `filter` field (it raises `NotImplementedError` if set, since the SQL-string filter cannot be applied to MongoDB).
911
944
 
945
+ #### `reverse_scope` on collections
946
+
947
+ [Multi-referencer reverse scoping](#reverse-scope-for-multi-referencer-tables-reverse_scope) works on `MongodbCollectionConfig` with the same config shape and the same semantics as the SQL adapters — a global-identity collection (say `accounts`) with no `belongs_to` path to the dump target, but referenced by several scoped collections, is constrained to the union of the ids those referencers actually point at instead of being dumped in full:
948
+
949
+ ```json
950
+ {
951
+ "name": "accounts",
952
+ "primary_key": "_id",
953
+ "reverse_scope": {
954
+ "via": [
955
+ { "table": "articles", "column": "author_account_id" },
956
+ { "table": "invitations", "column": "invitee_account_id" }
957
+ ]
958
+ },
959
+ "belongs_tos": [],
960
+ "fields": [{ "name": "_id" }, { "name": "name" }]
961
+ }
962
+ ```
963
+
964
+ Where the SQL adapters emit a `UNION` subquery, MongoDB has no cross-collection joins, so the adapter captures each arm's column values **at runtime** while the referencer collection streams (the same mechanism that already propagates parent ids to children), then filters the reverse-scoped collection with `{"_id": {"$in": [<union of captured ids>]}}`. Consequences of that runtime capture:
965
+
966
+ - **Processing order**: a reverse-scoped collection is dumped **after** all of its `via` referencers (an arm's own `belongs_to` back to the reverse-scoped collection is inverted rather than kept — the declaration states ids flow referencer → collection). If the arms form a genuine ordering cycle with the `belongs_to` graph, the export aborts with an error naming the cycle members. SQL processing order is unchanged (its INSERT output must stay loadable in foreign-key order).
967
+ - **Arm hygiene mirrors SQL**: an arm whose referencer is unknown, embedded, not dumped, or itself unscoped (no path to the dump target and no `reverse_scope` of its own) is **skipped with a warning** — an unscoped referencer's ids span every scope and would silently widen the dump. Per-arm `null`/absent foreign keys are dropped (the SQL `IS NOT NULL`), an array-valued foreign-key column contributes one id per element, and captured values keep their native BSON types (an `ObjectId` foreign key matches an `ObjectId` `_id` with no coercion).
968
+ - **Precedence mirrors SQL**: a collection with its own `belongs_to` path to the dump target is scoped by that path; a `reverse_scope` declared on it is ignored.
969
+ - **Satellites need no config**, as in SQL: a collection that `belongs_to` the reverse-scoped collection tightens to the kept ids automatically through the ordinary captured-parent-id mechanism.
970
+ - **`--parallel-workers` falls back to serial** (with a warning) when any collection declares `reverse_scope` — the parallel schedule does not express the referencers-first ordering constraint yet.
971
+ - **`exwiw explain`** shows the real `{"_id": {"$in": [...]}}` filter shape with a placeholder id, like the other runtime-captured scopes.
972
+
973
+ Masking (`replace_with`) and `fields` behavior on a reverse-scoped collection are unchanged. Like the SQL key, `reverse_scope` is user-owned: `exwiw:mongoid:schema:generate` never emits it and regeneration preserves a hand-added value.
974
+
912
975
  #### Embedded documents
913
976
 
914
977
  MongoDB models often store one-to-many relationships as embedded subdocument arrays (e.g. `users` documents with a `posts: [...]` field). To mask fields inside embedded subdocuments, declare a separate config with `embedded_in`:
@@ -413,18 +413,34 @@ module Exwiw
413
413
  # The distinct set of this collection's fields that downstream children
414
414
  # constrain on (each child belongs_to's `references`, defaulting to this
415
415
  # collection's primary_key), with primary_key always included so the
416
- # historical primary-key-keyed propagation keeps working.
416
+ # historical primary-key-keyed propagation keeps working. A collection
417
+ # named as a `reverse_scope.via` referencer additionally captures the
418
+ # arm's foreign-key column, so the reverse-scoped collection (processed
419
+ # later) can constrain itself to the ids this collection points at.
417
420
  private def propagation_keys_for(config, config_by_name)
418
421
  referenced = config_by_name.each_value.flat_map do |child|
419
422
  next [] if child.embedded?
420
423
 
421
- child.belongs_tos
424
+ keys = child.belongs_tos
422
425
  .select { |relation| relation.table_name == config.name }
423
426
  .map { |relation| relation.references || config.primary_key }
427
+
428
+ reverse_scope_arms_of(child).each do |via|
429
+ keys << via.column if via.table == config.name
430
+ end
431
+
432
+ keys
424
433
  end
425
434
  ([config.primary_key] + referenced).uniq
426
435
  end
427
436
 
437
+ # The `reverse_scope.via` arms of `config`, or [] when it declares none.
438
+ private def reverse_scope_arms_of(config)
439
+ return [] unless config.respond_to?(:reverse_scope)
440
+
441
+ config.reverse_scope&.via || []
442
+ end
443
+
428
444
  # Build the scoping filter for a non-target collection from its belongs_to
429
445
  # parents' captured ids. Each belongs_to is constrained by the parent field
430
446
  # the FK references (`relation.references`, default the parent primary_key);
@@ -476,6 +492,24 @@ module Exwiw
476
492
  private def related_collection_filter(config, config_by_name, dump_target)
477
493
  genuine = genuine_scope_set(config_by_name, dump_target.table_name)
478
494
 
495
+ # Opt-in multi-referencer reverse scope (MongodbCollectionConfig
496
+ # #reverse_scope), mirroring the SQL adapters' semantics: it applies only
497
+ # to a collection with no belongs_to path to the dump target (exactly
498
+ # when SQL's reverse extraction is attempted — a genuinely scoped
499
+ # collection keeps its belongs_to scope and reverse_scope is ignored),
500
+ # and when it applies it IS the scope filter, replacing the
501
+ # reference-parent strict-AND fallback. When no arm survives, fall
502
+ # through to the historical behavior, as SQL falls back to dump-all.
503
+ if !genuine.include?(config.name) && reverse_scope_arms_of(config).any?
504
+ filter = reverse_scope_filter(config, config_by_name, genuine)
505
+ return filter unless filter.nil?
506
+ elsif genuine.include?(config.name) && reverse_scope_arms_of(config).any?
507
+ @logger.debug(
508
+ " Collection '#{config.name}' declares reverse_scope but is genuinely scoped " \
509
+ "via belongs_to; reverse_scope is ignored (same precedence as the SQL adapters)."
510
+ )
511
+ end
512
+
479
513
  genuine_clauses = []
480
514
  reference_clauses = []
481
515
  config.belongs_tos.each do |relation|
@@ -509,6 +543,88 @@ module Exwiw
509
543
  { config.primary_key => { "$in" => [] } }
510
544
  end
511
545
 
546
+ # Build the `pk $in <union of referenced ids>` filter for a reverse-scoped
547
+ # collection (MongodbCollectionConfig#reverse_scope). Each `via` arm names
548
+ # a referencer collection and the foreign-key column on it that points at
549
+ # this collection's primary key; the referencer was dumped earlier under
550
+ # its own scope (DetermineTableProcessingOrder orders every arm before the
551
+ # reverse-scoped collection) and #execute captured the arm column's values
552
+ # into @state, so the union here holds only in-scope ids. Values are used
553
+ # exactly as captured (native BSON — ObjectId FKs stay ObjectIds, string
554
+ # FKs stay strings), array-valued columns are flattened (one referenced id
555
+ # per element), and nil/absent FKs are dropped, mirroring the SQL arms'
556
+ # `IS NOT NULL`.
557
+ #
558
+ # An arm is skipped with a warning when its referencer is unknown,
559
+ # embedded, produced no captured state (not dumped — e.g. ignore:true), or
560
+ # is itself unscoped (neither genuinely scoped nor reverse-scoped): an
561
+ # unscoped referencer's captured ids span every scope and would silently
562
+ # widen the dump, exactly the case the SQL adapters skip. Returns nil when
563
+ # no arm survives, letting the caller fall through to the historical
564
+ # behavior (SQL parity: dump-all fallback).
565
+ #
566
+ # In `explain` placeholder mode there is no captured state; a placeholder
567
+ # id keeps the real filter shape (`pk $in [...]`) so index selection is
568
+ # reported correctly.
569
+ private def reverse_scope_filter(config, config_by_name, genuine)
570
+ ids = []
571
+ any_arm = false
572
+
573
+ reverse_scope_arms_of(config).each do |via|
574
+ referencer = config_by_name[via.table]
575
+ if referencer.nil? || referencer.embedded?
576
+ @logger.warn(
577
+ " #{config.name}.reverse_scope references #{referencer.nil? ? 'unknown' : 'embedded'} " \
578
+ "collection '#{via.table}'; skipping arm."
579
+ )
580
+ next
581
+ end
582
+
583
+ unless genuine.include?(via.table) || reverse_scope_arms_of(referencer).any?
584
+ @logger.warn(
585
+ " #{config.name}.reverse_scope arm '#{via.table}.#{via.column}' is not scoped; " \
586
+ "skipping it (an unscoped arm would union ids from every scope back). " \
587
+ "Make '#{via.table}' scopable or remove it from reverse_scope.via."
588
+ )
589
+ next
590
+ end
591
+
592
+ if @explain_placeholder
593
+ any_arm = true
594
+ ids << explain_placeholder_id
595
+ next
596
+ end
597
+
598
+ captured = @state[via.table]
599
+ if captured.nil?
600
+ @logger.warn(
601
+ " #{config.name}.reverse_scope arm '#{via.table}.#{via.column}' has no captured " \
602
+ "state ('#{via.table}' was not dumped before '#{config.name}'); skipping arm."
603
+ )
604
+ next
605
+ end
606
+
607
+ any_arm = true
608
+ values = captured[via.column]
609
+ if values.nil?
610
+ @logger.warn(
611
+ " #{config.name}.reverse_scope arm column '#{via.table}.#{via.column}' was not " \
612
+ "captured while dumping '#{via.table}'; treating the arm as empty."
613
+ )
614
+ next
615
+ end
616
+
617
+ ids.concat(values)
618
+ end
619
+
620
+ return nil unless any_arm
621
+
622
+ ids = ids.flat_map { |value| value.is_a?(Array) ? value : [value] }
623
+ ids.compact!
624
+ ids.uniq!
625
+ { config.primary_key => { "$in" => ids } }
626
+ end
627
+
512
628
  # The set of collection names *genuinely scoped* by the dump target: the
513
629
  # target itself, plus every collection that can reach it by following
514
630
  # belongs_to edges (child -> parent) transitively. Computed by fixpoint over
@@ -8,8 +8,18 @@ module Exwiw
8
8
 
9
9
  # @param tables [Array<Exwiw::TableConfig>] tables
10
10
  # @param logger [Logger, nil] receives a warning when a cycle has to be broken
11
+ # @param runtime_reverse_scope [Boolean] when true (mongodb), a table
12
+ # declaring `reverse_scope` is ordered AFTER its `via` referencer tables:
13
+ # the adapter builds the reverse filter from ids captured at runtime while
14
+ # the referencers were dumped, so they must be processed first. Each arm's
15
+ # own belongs_to edge back to the reverse-scoped table (the usual
16
+ # `referencer.fk -> hub` relation) is inverted rather than kept, since the
17
+ # declaration states ids flow referencer -> hub. False (the default)
18
+ # preserves the historical belongs_to-only ordering — the SQL adapters
19
+ # scope via subqueries and need the hub emitted before its referencers so
20
+ # the INSERT output stays loadable in foreign-key order.
11
21
  # @return [Array<String>] sorted table names
12
- def run(tables, logger: nil)
22
+ def run(tables, logger: nil, runtime_reverse_scope: false)
13
23
  return tables.map(&:name) if tables.size < 2
14
24
 
15
25
  ordered_table_names = []
@@ -19,31 +29,45 @@ module Exwiw
19
29
  acc[table.name] = table
20
30
  end
21
31
 
22
- # Only belongs_to relations whose target is also in this run constrain the
23
- # order. A belongs_to pointing at a table that is not being processed here
24
- # — e.g. an embedded MongoDB collection (masked through its parent, never
25
- # dumped on its own) or any table excluded from the run — is not something
26
- # we can or need to order against, so it must never block resolution.
27
- # Without this, such a dependency would stay unresolved forever and
28
- # masquerade as a circular dependency, freezing every table that
29
- # (transitively) references it.
32
+ reverse_scope_deps = runtime_reverse_scope ? compute_reverse_scope_dependencies(tables) : {}
33
+ dependencies_by_name = tables.each_with_object({}) do |table, acc|
34
+ acc[table.name] = compute_dependencies(table, reverse_scope_deps)
35
+ end
36
+
37
+ # Only relations whose target is also in this run constrain the order. A
38
+ # dependency pointing at a table that is not being processed here — e.g.
39
+ # an embedded MongoDB collection (masked through its parent, never dumped
40
+ # on its own) or any table excluded from the run — is not something we can
41
+ # or need to order against, so it must never block resolution. Without
42
+ # this, such a dependency would stay unresolved forever and masquerade as
43
+ # a circular dependency, freezing every table that (transitively)
44
+ # references it.
30
45
  present_names = table_by_name.keys.to_set
31
46
 
32
47
  loop do
33
48
  break if table_by_name.empty?
34
49
 
35
50
  resolvable = table_by_name.values.select do |table|
36
- unresolved_dependencies(table, present_names, ordered).empty?
51
+ unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered).empty?
37
52
  end
38
53
 
39
54
  if resolvable.empty?
40
55
  # No table has all its (in-run) dependencies satisfied, yet tables
41
- # remain: the belongs_to graph has a genuine cycle and no strict
42
- # topological order exists. Rather than aborting the whole export, break
43
- # the cycle by emitting one cycle member; see pick_cycle_victim for how
44
- # the member is chosen. Warn so the dropped constraint is visible.
45
- victim = pick_cycle_victim(table_by_name.values, present_names, ordered)
46
- warn_cycle_break(logger, victim, unresolved_dependencies(victim, present_names, ordered))
56
+ # remain: the dependency graph has a genuine cycle and no strict
57
+ # topological order exists.
58
+ #
59
+ # When a reverse_scope ordering edge participates in the cycle, there
60
+ # is no safe way out: emitting the reverse-scoped table before an arm
61
+ # would build its filter from missing state (silently dropping rows),
62
+ # so fail loudly instead of guessing.
63
+ detect_reverse_scope_cycle!(table_by_name, dependencies_by_name, reverse_scope_deps, present_names, ordered)
64
+
65
+ # Otherwise the cycle is a plain belongs_to cycle. Rather than
66
+ # aborting the whole export, break it by emitting one cycle member;
67
+ # see pick_cycle_victim for how the member is chosen. Warn so the
68
+ # dropped constraint is visible.
69
+ victim = pick_cycle_victim(table_by_name.values, dependencies_by_name, present_names, ordered)
70
+ warn_cycle_break(logger, victim, unresolved_dependencies(victim.name, dependencies_by_name, present_names, ordered))
47
71
  resolvable = [victim]
48
72
  end
49
73
 
@@ -67,13 +91,66 @@ module Exwiw
67
91
  table.belongs_tos.map(&:table_name)
68
92
  end
69
93
 
70
- # The dependencies still blocking `table`: belongs_to targets that are part
71
- # of this run, not yet ordered, and not the table itself (a self-referential
72
- # belongs_to never blocks).
73
- private_class_method def unresolved_dependencies(table, present_names, ordered)
74
- compute_table_dependencies(table).uniq.select do |dep|
75
- present_names.include?(dep) && !ordered.include?(dep) && dep != table.name
94
+ # reverse_scope ordering edges: reverse-scoped table name => its `via`
95
+ # referencer table names (the tables that must be processed before it).
96
+ private_class_method def compute_reverse_scope_dependencies(tables)
97
+ tables.each_with_object({}) do |table, acc|
98
+ next unless table.respond_to?(:reverse_scope)
99
+
100
+ arm_tables = (table.reverse_scope&.via || []).map(&:table).uniq
101
+ acc[table.name] = arm_tables if arm_tables.any?
102
+ end
103
+ end
104
+
105
+ # The ordering dependencies of `table`: its belongs_to targets — minus any
106
+ # belongs_to pointing at a reverse-scoped table that names `table` as a
107
+ # `via` arm (that edge is inverted: the arm feeds the reverse-scoped table
108
+ # its ids, so the arm goes first) — plus, when `table` itself is
109
+ # reverse-scoped, its `via` referencer tables.
110
+ private_class_method def compute_dependencies(table, reverse_scope_deps)
111
+ deps = compute_table_dependencies(table).reject do |dep|
112
+ reverse_scope_deps[dep]&.include?(table.name)
113
+ end
114
+ deps += reverse_scope_deps[table.name] || []
115
+ deps.uniq
116
+ end
117
+
118
+ # The dependencies still blocking `table_name`: dependency targets that are
119
+ # part of this run, not yet ordered, and not the table itself (a
120
+ # self-referential dependency never blocks).
121
+ private_class_method def unresolved_dependencies(table_name, dependencies_by_name, present_names, ordered)
122
+ dependencies_by_name.fetch(table_name).select do |dep|
123
+ present_names.include?(dep) && !ordered.include?(dep) && dep != table_name
124
+ end
125
+ end
126
+
127
+ # Raise when the stall is caused by a reverse_scope ordering edge: a
128
+ # reverse-scoped table and one of its `via` arms sit in the same non-trivial
129
+ # strongly-connected component, so no processing order can put every arm
130
+ # before the table. Typical shape: two reverse-scoped tables naming each
131
+ # other as arms, or an arm that (transitively) belongs_to the table it
132
+ # feeds. A plain belongs_to cycle (no reverse_scope edge involved) returns
133
+ # without raising, leaving the historical cycle-break to handle it.
134
+ private_class_method def detect_reverse_scope_cycle!(table_by_name, dependencies_by_name, reverse_scope_deps, present_names, ordered)
135
+ return if reverse_scope_deps.empty?
136
+
137
+ adjacency = table_by_name.each_key.each_with_object({}) do |name, acc|
138
+ acc[name] = unresolved_dependencies(name, dependencies_by_name, present_names, ordered)
76
139
  end
140
+ cyclic_names = strongly_connected_members(adjacency)
141
+
142
+ offenders = cyclic_names.select do |name|
143
+ (reverse_scope_deps[name] || []).any? { |arm| cyclic_names.include?(arm) }
144
+ end
145
+ return if offenders.empty?
146
+
147
+ details = offenders.sort.map { |name| "'#{name}' (via: #{(reverse_scope_deps[name] & cyclic_names.to_a).sort.join(', ')})" }
148
+ raise ArgumentError,
149
+ "reverse_scope creates an ordering cycle: #{details.join('; ')}. " \
150
+ "A reverse-scoped collection must be processed after all of its reverse_scope.via " \
151
+ "referencers, but these dependencies form a cycle with the belongs_to/reverse_scope " \
152
+ "graph (cycle members: #{cyclic_names.to_a.sort.join(', ')}). Remove one of the " \
153
+ "reverse_scope arms, or break the belongs_to edge that closes the cycle with `ignore: true`."
77
154
  end
78
155
 
79
156
  # Choose the next table to emit when the order is stuck in a cycle. Only
@@ -85,26 +162,27 @@ module Exwiw
85
162
  # collapsing to "match every row" (a cross-scope over-extraction risk for the
86
163
  # mongodb adapter); break remaining ties by fewest unresolved dependencies,
87
164
  # then by name, for determinism.
88
- private_class_method def pick_cycle_victim(remaining, present_names, ordered)
165
+ private_class_method def pick_cycle_victim(remaining, dependencies_by_name, present_names, ordered)
89
166
  adjacency = remaining.each_with_object({}) do |table, acc|
90
- acc[table.name] = unresolved_dependencies(table, present_names, ordered)
167
+ acc[table.name] = unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered)
91
168
  end
92
169
  cyclic_names = strongly_connected_members(adjacency)
93
170
 
94
171
  candidates = remaining.select { |table| cyclic_names.include?(table.name) }
95
172
  candidates = remaining if candidates.empty? # defensive; a stall implies a cycle
96
173
 
97
- anchored = candidates.select { |table| ordered_parent?(table, present_names, ordered) }
174
+ anchored = candidates.select { |table| ordered_parent?(table.name, dependencies_by_name, present_names, ordered) }
98
175
  pool = anchored.empty? ? candidates : anchored
99
176
 
100
- pool.min_by { |table| [unresolved_dependencies(table, present_names, ordered).size, table.name] }
177
+ pool.min_by { |table| [unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered).size, table.name] }
101
178
  end
102
179
 
103
- # True when `table` has a belongs_to whose target was already ordered, so its
104
- # extraction filter will be constrained rather than an unscoped full scan.
105
- private_class_method def ordered_parent?(table, present_names, ordered)
106
- compute_table_dependencies(table).any? do |dep|
107
- dep != table.name && present_names.include?(dep) && ordered.include?(dep)
180
+ # True when `table_name` has a dependency whose target was already ordered,
181
+ # so its extraction filter will be constrained rather than an unscoped full
182
+ # scan.
183
+ private_class_method def ordered_parent?(table_name, dependencies_by_name, present_names, ordered)
184
+ dependencies_by_name.fetch(table_name).any? do |dep|
185
+ dep != table_name && present_names.include?(dep) && ordered.include?(dep)
108
186
  end
109
187
  end
110
188
 
@@ -38,7 +38,13 @@ module Exwiw
38
38
  QueryAstBuilder.validate_scope!(dumpable_configs, table_by_name, @dump_target, @logger)
39
39
 
40
40
  @logger.debug("Determining table processing order...")
41
- ordered_table_names = DetermineTableProcessingOrder.run(dumpable_configs, logger: @logger)
41
+ # Match the export's processing order (see Runner#run): mongodb orders a
42
+ # reverse-scoped collection after its `via` referencers.
43
+ ordered_table_names = DetermineTableProcessingOrder.run(
44
+ dumpable_configs,
45
+ logger: @logger,
46
+ runtime_reverse_scope: adapter.is_a?(Adapter::MongodbAdapter),
47
+ )
42
48
 
43
49
  total_size = ordered_table_names.size
44
50
  ordered_table_names.each_with_index do |table_name, idx|
@@ -68,7 +74,12 @@ module Exwiw
68
74
  private def load_table_config(klass)
69
75
  Dir[File.join(@schema_dir, "*.json")].map do |file|
70
76
  json = JSON.parse(File.read(file))
71
- klass.from(json).reject_ignored_members!
77
+ begin
78
+ klass.from(json).reject_ignored_members!
79
+ rescue UnknownConfigKeyError => e
80
+ # `.from` knows the table, not the file; point at the offending file.
81
+ raise UnknownConfigKeyError, "#{file}: #{e.message}", e.backtrace
82
+ end
72
83
  end
73
84
  end
74
85