exwiw 0.9.7 → 0.9.8
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 +10 -0
- data/README.md +48 -8
- data/lib/exwiw/adapter/mongodb_adapter.rb +118 -2
- data/lib/exwiw/determine_table_processing_order.rb +109 -31
- data/lib/exwiw/explain_runner.rb +13 -2
- data/lib/exwiw/mongodb_collection_config.rb +31 -0
- data/lib/exwiw/runner.rb +27 -4
- data/lib/exwiw/strict_keys.rb +79 -0
- data/lib/exwiw/table_config.rb +9 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2aa2a3c673085993f1f62cd055370eef814301b9298bc486d7f49f71e4470ccc
|
|
4
|
+
data.tar.gz: 04f5a73f8001d45e402a9d72839bcc824974e9aab4feff998cabc5b4311b762e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c6a284caef2d3e5eb78d9252f36ef44a2f7e2d8531274ab13eedda9be0261cc9747319c211b556b17ebba1b471526df6bbea76670fed9005bca302f442582f26
|
|
7
|
+
data.tar.gz: fd3ec7cdf18b6db28b194a4fe694f09deb79a39428dc280c27709725f0619b24d6f8d361b02192558f20e51ad63b8028f565a3b5e98b9ab8d5dfeb57c51b73ef
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.8] - 2026-07-09
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **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.
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **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`.
|
|
14
|
+
|
|
5
15
|
## [0.9.7] - 2026-07-08
|
|
6
16
|
|
|
7
17
|
### 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
|
-
"
|
|
455
|
-
"
|
|
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.
|
|
810
|
-
`raw_sql`
|
|
811
|
-
to
|
|
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.
|
|
@@ -871,7 +879,9 @@ fake value, across tables, runs, and adapters:
|
|
|
871
879
|
- The faker gem is **not** a runtime dependency of exwiw — add `gem "faker"` to
|
|
872
880
|
your Gemfile to use this mode (exwiw raises a clear error otherwise).
|
|
873
881
|
- Exclusive with the other masking keys on the same column. SQL adapters only
|
|
874
|
-
(the MongoDB adapter
|
|
882
|
+
(the MongoDB adapter rejects the key on load, see
|
|
883
|
+
[Unknown keys are rejected](#unknown-keys-are-rejected)), and invisible to
|
|
884
|
+
`explain`.
|
|
875
885
|
|
|
876
886
|
**Performance**: this is a per-row Ruby transform, measured at ~1.5–1.6µs/row
|
|
877
887
|
per fake column (so ≈ +8s per 5M rows per column; ~+40% against a local sqlite
|
|
@@ -906,9 +916,39 @@ The MongoDB adapter is experimental. To use it:
|
|
|
906
916
|
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
907
917
|
```
|
|
908
918
|
- 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
|
|
919
|
+
- `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
920
|
- 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
921
|
|
|
922
|
+
#### `reverse_scope` on collections
|
|
923
|
+
|
|
924
|
+
[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:
|
|
925
|
+
|
|
926
|
+
```json
|
|
927
|
+
{
|
|
928
|
+
"name": "accounts",
|
|
929
|
+
"primary_key": "_id",
|
|
930
|
+
"reverse_scope": {
|
|
931
|
+
"via": [
|
|
932
|
+
{ "table": "articles", "column": "author_account_id" },
|
|
933
|
+
{ "table": "invitations", "column": "invitee_account_id" }
|
|
934
|
+
]
|
|
935
|
+
},
|
|
936
|
+
"belongs_tos": [],
|
|
937
|
+
"fields": [{ "name": "_id" }, { "name": "name" }]
|
|
938
|
+
}
|
|
939
|
+
```
|
|
940
|
+
|
|
941
|
+
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:
|
|
942
|
+
|
|
943
|
+
- **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).
|
|
944
|
+
- **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).
|
|
945
|
+
- **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.
|
|
946
|
+
- **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.
|
|
947
|
+
- **`--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.
|
|
948
|
+
- **`exwiw explain`** shows the real `{"_id": {"$in": [...]}}` filter shape with a placeholder id, like the other runtime-captured scopes.
|
|
949
|
+
|
|
950
|
+
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.
|
|
951
|
+
|
|
912
952
|
#### Embedded documents
|
|
913
953
|
|
|
914
954
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# (
|
|
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
|
|
42
|
-
# topological order exists.
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 `
|
|
104
|
-
# extraction filter will be constrained rather than an unscoped full
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
data/lib/exwiw/explain_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -39,7 +39,30 @@ module Exwiw
|
|
|
39
39
|
# `path`.
|
|
40
40
|
attribute :embedded_in, optional(EmbeddedIn), skip_serializing_if_nil: true
|
|
41
41
|
|
|
42
|
+
# `reverse_scope` opts a collection into multi-referencer reverse scoping,
|
|
43
|
+
# mirroring the SQL TableConfig key (see Exwiw::ReverseScope): a
|
|
44
|
+
# global-identity collection referenced by many scoped collections is
|
|
45
|
+
# constrained to the union of the ids those referencers actually point at,
|
|
46
|
+
# instead of being dumped in full. Unlike the SQL adapters (which emit a
|
|
47
|
+
# UNION subquery), the mongodb adapter captures each `via` arm's column
|
|
48
|
+
# values at runtime while the referencer collection streams, so the
|
|
49
|
+
# reverse-scoped collection must be processed AFTER its referencers — see
|
|
50
|
+
# DetermineTableProcessingOrder (runtime_reverse_scope) and
|
|
51
|
+
# MongodbAdapter#reverse_scope_filter. User-configured and never emitted by
|
|
52
|
+
# MongoidSchemaGenerator; preserved across regeneration (see #merge).
|
|
53
|
+
attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
|
|
54
|
+
|
|
42
55
|
def self.from(obj)
|
|
56
|
+
# Reject unknown keys before deserializing: Serdes silently drops them,
|
|
57
|
+
# which would turn a typo'd key — or a key only the SQL adapters support,
|
|
58
|
+
# like a field-level `raw_sql` — into a silent no-op (see
|
|
59
|
+
# Exwiw::StrictKeys). `comment` is a declared attribute here and on the
|
|
60
|
+
# nested belongs_to/field entries, so free-form notes stay accepted.
|
|
61
|
+
if obj.is_a?(Hash)
|
|
62
|
+
collection_name = obj["name"] || obj[:name]
|
|
63
|
+
StrictKeys.validate!(self, obj, owner: "collection '#{collection_name}'")
|
|
64
|
+
end
|
|
65
|
+
|
|
43
66
|
instance = super
|
|
44
67
|
instance.__send__(:validate_embedded!)
|
|
45
68
|
instance.__send__(:validate_belongs_tos!)
|
|
@@ -89,6 +112,8 @@ module Exwiw
|
|
|
89
112
|
# is kept.
|
|
90
113
|
merged.comment = passed.comment || comment
|
|
91
114
|
merged.embedded_in = passed.embedded_in
|
|
115
|
+
# User-owned, never regenerated: carry over from the existing config.
|
|
116
|
+
merged.reverse_scope = reverse_scope
|
|
92
117
|
|
|
93
118
|
# Structural facts of each belongs_to come from the freshly generated
|
|
94
119
|
# config (including a generator-derived `references`), but the user-owned
|
|
@@ -124,6 +149,12 @@ module Exwiw
|
|
|
124
149
|
|
|
125
150
|
private def validate_embedded!
|
|
126
151
|
return unless embedded?
|
|
152
|
+
|
|
153
|
+
if reverse_scope
|
|
154
|
+
raise ArgumentError,
|
|
155
|
+
"MongodbCollectionConfig '#{name}' is embedded_in '#{embedded_in.collection_name}'; " \
|
|
156
|
+
"reverse_scope must not be defined (an embedded config is never dumped on its own)."
|
|
157
|
+
end
|
|
127
158
|
return if belongs_tos.empty?
|
|
128
159
|
|
|
129
160
|
raise ArgumentError,
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -47,7 +47,16 @@ module Exwiw
|
|
|
47
47
|
QueryAstBuilder.validate_scope!(dumpable_configs, table_by_name, @dump_target, @logger)
|
|
48
48
|
|
|
49
49
|
@logger.info("Determining table processing order...")
|
|
50
|
-
|
|
50
|
+
# runtime_reverse_scope: the mongodb adapter builds a reverse-scoped
|
|
51
|
+
# collection's filter from ids captured while its `via` referencers were
|
|
52
|
+
# dumped, so those referencers must be processed first. The SQL adapters
|
|
53
|
+
# scope via subqueries and keep the historical belongs_to-only order
|
|
54
|
+
# (which also keeps their INSERT output loadable in foreign-key order).
|
|
55
|
+
ordered_table_names = DetermineTableProcessingOrder.run(
|
|
56
|
+
dumpable_configs,
|
|
57
|
+
logger: @logger,
|
|
58
|
+
runtime_reverse_scope: adapter.is_a?(Adapter::MongodbAdapter),
|
|
59
|
+
)
|
|
51
60
|
|
|
52
61
|
clean_output_dir!
|
|
53
62
|
|
|
@@ -58,7 +67,7 @@ module Exwiw
|
|
|
58
67
|
# replacement for the whole schema+inserts pass, after which the common
|
|
59
68
|
# after-insert hook still runs. Everything before this point (validation,
|
|
60
69
|
# scope check, ordering, output-dir clean) applies to both paths.
|
|
61
|
-
if use_mongodb_parallel?(adapter)
|
|
70
|
+
if use_mongodb_parallel?(adapter, configs)
|
|
62
71
|
dump_mongodb_parallel(configs, table_by_name)
|
|
63
72
|
run_after_insert_hook(adapter, ordered_table_names.size)
|
|
64
73
|
return
|
|
@@ -209,7 +218,7 @@ module Exwiw
|
|
|
209
218
|
# target (the schedule is built around the scoped DAG), and a runtime that can
|
|
210
219
|
# fork. Anything else falls back to the serial path (warning when the user
|
|
211
220
|
# explicitly asked for parallelism but it cannot apply).
|
|
212
|
-
private def use_mongodb_parallel?(adapter)
|
|
221
|
+
private def use_mongodb_parallel?(adapter, configs)
|
|
213
222
|
return false unless adapter.is_a?(Adapter::MongodbAdapter)
|
|
214
223
|
return false unless @parallel_workers && @parallel_workers > 1
|
|
215
224
|
|
|
@@ -218,6 +227,15 @@ module Exwiw
|
|
|
218
227
|
return false
|
|
219
228
|
end
|
|
220
229
|
|
|
230
|
+
# A reverse-scoped collection consumes @state captured from its `via`
|
|
231
|
+
# referencers, an ordering constraint the parallel schedule (built around
|
|
232
|
+
# the belongs_to DAG only) does not express yet — a worker could dump the
|
|
233
|
+
# collection before its arms and silently drop rows. Run serially instead.
|
|
234
|
+
if configs.any? { |c| c.respond_to?(:reverse_scope) && c.reverse_scope&.via&.any? }
|
|
235
|
+
@logger.warn("--parallel-workers ignored: reverse_scope collections require the serial processing order; running serially.")
|
|
236
|
+
return false
|
|
237
|
+
end
|
|
238
|
+
|
|
221
239
|
unless MongodbParallelDumper.available?
|
|
222
240
|
@logger.warn("--parallel-workers ignored: fork is unavailable on this runtime; running serially.")
|
|
223
241
|
return false
|
|
@@ -275,7 +293,12 @@ module Exwiw
|
|
|
275
293
|
# considered during extraction. Done here (after loading from file)
|
|
276
294
|
# rather than in `.from` so the schema generators keep the full config
|
|
277
295
|
# and can preserve the ignored entries on regeneration.
|
|
278
|
-
|
|
296
|
+
begin
|
|
297
|
+
klass.from(json).reject_ignored_members!
|
|
298
|
+
rescue UnknownConfigKeyError => e
|
|
299
|
+
# `.from` knows the table, not the file; point at the offending file.
|
|
300
|
+
raise UnknownConfigKeyError, "#{file}: #{e.message}", e.backtrace
|
|
301
|
+
end
|
|
279
302
|
end
|
|
280
303
|
end
|
|
281
304
|
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exwiw
|
|
4
|
+
# Raised when a schema config JSON carries a key that no declared attribute
|
|
5
|
+
# accepts. A subclass of ArgumentError so existing "invalid config" handling
|
|
6
|
+
# (and specs) that rescue ArgumentError keep working.
|
|
7
|
+
UnknownConfigKeyError = Class.new(ArgumentError)
|
|
8
|
+
|
|
9
|
+
# Strict key validation for the schema config JSON.
|
|
10
|
+
#
|
|
11
|
+
# Serdes deserialization is lenient: a key that matches no declared attribute
|
|
12
|
+
# is silently dropped. For hand-maintained schema configs that turns a typo
|
|
13
|
+
# (`reverse_scop`) — or a key another adapter supports but this one does not
|
|
14
|
+
# (`raw_sql` on a MongoDB field) — into a silent no-op: the config loads, the
|
|
15
|
+
# dump runs, and the requested masking/scoping simply never happens.
|
|
16
|
+
# {TableConfig.from} / {MongodbCollectionConfig.from} therefore validate the
|
|
17
|
+
# raw hash against the declared attributes before deserializing, recursing
|
|
18
|
+
# into nested config objects (belongs_tos, columns/fields, reverse_scope,
|
|
19
|
+
# embedded_in, replace_with_fake_data).
|
|
20
|
+
#
|
|
21
|
+
# Free-form annotations do not need an escape hatch: `comment` is a declared
|
|
22
|
+
# (documentation-only) attribute on the table/collection configs and their
|
|
23
|
+
# belongs_to/column/field entries, so it always passes.
|
|
24
|
+
module StrictKeys
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# Validate `hash` (a raw config hash, string- or symbol-keyed) against the
|
|
28
|
+
# attributes declared on the Serdes class `klass`, recursing into nested
|
|
29
|
+
# Serdes-typed attributes. `owner` names the config for the error message
|
|
30
|
+
# (e.g. "table 'users'"); `path` locates a nested entry within it (e.g.
|
|
31
|
+
# "belongs_tos[0]"). No-op for non-Hash input — Serdes' own type errors
|
|
32
|
+
# cover malformed values.
|
|
33
|
+
def validate!(klass, hash, owner:, path: nil)
|
|
34
|
+
return unless hash.is_a?(Hash)
|
|
35
|
+
|
|
36
|
+
attributes = klass.__send__(:_serde_attrs)
|
|
37
|
+
rename_strategy = klass.__send__(:_serde_rename_strategy)
|
|
38
|
+
allowed = attributes.each_value.to_h { |attr| [attr.serialized_name(rename_strategy), attr] }
|
|
39
|
+
|
|
40
|
+
unknown = hash.keys.map(&:to_s) - allowed.keys
|
|
41
|
+
unless unknown.empty?
|
|
42
|
+
location = path ? " (in #{path})" : ""
|
|
43
|
+
raise UnknownConfigKeyError,
|
|
44
|
+
"Unknown key#{'s' if unknown.size > 1} #{unknown.map { |key| "'#{key}'" }.join(', ')} " \
|
|
45
|
+
"in #{owner}#{location}. Allowed keys: #{allowed.keys.sort.join(', ')}. " \
|
|
46
|
+
"Unknown keys are rejected because they would otherwise be silently ignored " \
|
|
47
|
+
"(hiding typos and keys this adapter does not support); remove or rename the key, " \
|
|
48
|
+
"or use `comment` for free-form notes."
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
allowed.each do |key, attr|
|
|
52
|
+
nested_klass = serdes_class(attr.attr_type)
|
|
53
|
+
next if nested_klass.nil?
|
|
54
|
+
|
|
55
|
+
value = hash.key?(key) ? hash[key] : hash[key.to_sym]
|
|
56
|
+
nested_path = path ? "#{path}.#{key}" : key
|
|
57
|
+
case value
|
|
58
|
+
when Hash
|
|
59
|
+
validate!(nested_klass, value, owner: owner, path: nested_path)
|
|
60
|
+
when Array
|
|
61
|
+
value.each_with_index do |element, index|
|
|
62
|
+
validate!(nested_klass, element, owner: owner, path: "#{nested_path}[#{index}]") if element.is_a?(Hash)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# The Serdes-including class a (possibly optional/array-wrapped) attribute
|
|
69
|
+
# type deserializes into, or nil for scalar types.
|
|
70
|
+
def serdes_class(type)
|
|
71
|
+
case type
|
|
72
|
+
when Serdes::OptionalType then serdes_class(type.base_type)
|
|
73
|
+
when Serdes::ArrayType then serdes_class(type.element_type)
|
|
74
|
+
when Serdes::ConcreteType then serdes_class(type.exact_type)
|
|
75
|
+
when Class then type if type.include?(Serdes)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -48,6 +48,15 @@ module Exwiw
|
|
|
48
48
|
attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
|
|
49
49
|
|
|
50
50
|
def self.from(hash)
|
|
51
|
+
# Reject unknown keys before deserializing: Serdes silently drops them,
|
|
52
|
+
# which would turn a typo'd or unsupported key into a silent no-op (see
|
|
53
|
+
# Exwiw::StrictKeys). `comment` is a declared attribute here and on the
|
|
54
|
+
# nested belongs_to/column entries, so free-form notes stay accepted.
|
|
55
|
+
if hash.is_a?(Hash)
|
|
56
|
+
table_name = hash["name"] || hash[:name]
|
|
57
|
+
StrictKeys.validate!(self, hash, owner: "table '#{table_name}'")
|
|
58
|
+
end
|
|
59
|
+
|
|
51
60
|
config = super
|
|
52
61
|
config.send(:validate_after_load!)
|
|
53
62
|
config
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exwiw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.8
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -84,6 +84,7 @@ files:
|
|
|
84
84
|
- lib/exwiw/row_transformer.rb
|
|
85
85
|
- lib/exwiw/runner.rb
|
|
86
86
|
- lib/exwiw/schema_generator.rb
|
|
87
|
+
- lib/exwiw/strict_keys.rb
|
|
87
88
|
- lib/exwiw/table_column.rb
|
|
88
89
|
- lib/exwiw/table_config.rb
|
|
89
90
|
- lib/exwiw/version.rb
|