exwiw 0.9.6 → 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 +17 -0
- data/README.md +132 -12
- data/docs/row-transform-masking-notes.md +156 -0
- 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/fake_data.rb +21 -0
- data/lib/exwiw/mongodb_collection_config.rb +31 -0
- data/lib/exwiw/row_transformer.rb +243 -0
- data/lib/exwiw/runner.rb +36 -5
- data/lib/exwiw/strict_keys.rb +79 -0
- data/lib/exwiw/table_column.rb +5 -0
- data/lib/exwiw/table_config.rb +45 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +3 -0
- metadata +5 -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,23 @@
|
|
|
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
|
+
|
|
15
|
+
## [0.9.7] - 2026-07-08
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`map` masking mode (SQL adapters).** The long-promised `map` column key is now implemented: its value is evaluated once as Ruby and must yield a `Proc`, which is then called for every fetched row with a row accessor (`r['column_name']` reads any column's fetched value) and whose return value replaces the column value in the dump. Unlike `replace_with`/`raw_sql` (which compile into the `SELECT` and run in the database), `map` runs in the exwiw process — it can express transforms SQL cannot, at a measured cost of well under a microsecond per row. There is no automatic NULL preservation (the proc receives `nil` and decides), the key is exclusive with the other masking keys on the same column, and it executes arbitrary Ruby from the schema config — only load trusted configs. mysql/postgresql/sqlite only (the MongoDB adapter silently drops the key, like `raw_sql`); both the INSERT and the PostgreSQL COPY output paths are covered, and the transform streams with the existing bounded-memory dump (no change to the memory profile).
|
|
20
|
+
- **`replace_with_fake_data` masking mode (SQL adapters).** Replaces a column with realistic fake data from the [faker](https://github.com/faker-ruby/faker) gem, picked **deterministically** by a SHA-256 hash of a seed column's value — `{ "seed": "users.id", "type": "human_name" }` maps the same id to the same fake name across tables, runs, and adapters (seed values are `to_s`-normalized, so sqlite's integer ids agree with pg/mysql's string form). NULL targets stay NULL (like `replace_with`); an optional `"locale"` picks the faker locale (e.g. `"ja"`). Supported types: `human_name`, `first_name`, `last_name`, `phone_number`, `address`, `company_name`, plus uniqueness-sensitive `email`/`username`, which embed a 64-bit hex token from the seed hash so unique indexes survive multi-million-row dumps. Values are drawn from a pool of 10,000 candidates pre-generated per (type, locale) under a fixed seed — this keeps the per-row cost at ~1.5µs per fake column, ≈ +8s per 5M rows (a naive per-row faker call is ~30µs), and makes values stable for a given faker version + locale (upgrading faker remaps them). faker is deliberately **not** a runtime dependency: add `gem "faker"` to the consuming Gemfile. Benchmarks and design notes in `docs/row-transform-masking-notes.md` / `script/bench_row_transform.rb`.
|
|
21
|
+
|
|
5
22
|
## [0.9.6] - 2026-07-08
|
|
6
23
|
|
|
7
24
|
### 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
|
|
|
@@ -788,19 +795,102 @@ If it used with `replace_with`, `replace_with` will be ignored.
|
|
|
788
795
|
|
|
789
796
|
#### `map`
|
|
790
797
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
798
|
+
The value is evaluated as Ruby code once (per table, at dump time), must yield a
|
|
799
|
+
`Proc`, and the proc is called for every fetched row. Its return value replaces
|
|
800
|
+
the column value in the dump:
|
|
794
801
|
|
|
795
|
-
```
|
|
796
|
-
"map": "proc { |r| 'user' +
|
|
802
|
+
```jsonc
|
|
803
|
+
{ "name": "email", "map": "proc { |r| 'user' + r['id'].to_s + '@example.com' }" }
|
|
797
804
|
```
|
|
798
805
|
|
|
799
806
|
which is equivalent to `"replace_with": "user{id}@example.com"`.
|
|
800
807
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
808
|
+
- `r['column_name']` reads any column of the current row — the value as fetched
|
|
809
|
+
from the database (i.e. after SQL-side masking such as another column's
|
|
810
|
+
`replace_with`, before Ruby-side transforms). `r` is only valid inside the
|
|
811
|
+
call; do not retain it.
|
|
812
|
+
- Return a `String`, `Numeric`, or `nil`. Unlike `replace_with` there is **no
|
|
813
|
+
automatic NULL preservation** — the proc receives `nil` and decides.
|
|
814
|
+
- `map` is exclusive with the other masking keys on the same column
|
|
815
|
+
(`raw_sql` / `replace_with` / `replace_with_fake_data`).
|
|
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`.
|
|
820
|
+
|
|
821
|
+
**Security note**: `map` executes arbitrary Ruby from the schema config. Treat
|
|
822
|
+
config files with the same trust as your Gemfile — only load trusted configs.
|
|
823
|
+
|
|
824
|
+
This is the most powerful option, but it runs per row in the exwiw process
|
|
825
|
+
rather than in the database. The measured dispatch cost is small, though
|
|
826
|
+
(~0.6–0.8µs/row plus whatever the proc body does — see
|
|
827
|
+
[`docs/row-transform-masking-notes.md`](docs/row-transform-masking-notes.md)).
|
|
828
|
+
Prefer `replace_with`/`raw_sql` when they can express the transform; reach for
|
|
829
|
+
`map` when they cannot.
|
|
830
|
+
|
|
831
|
+
#### `replace_with_fake_data`
|
|
832
|
+
|
|
833
|
+
Replaces the value with realistic-looking fake data generated by the
|
|
834
|
+
[faker](https://github.com/faker-ruby/faker) gem, picked **deterministically**
|
|
835
|
+
from the value of a seed column — the same seed value always maps to the same
|
|
836
|
+
fake value, across tables, runs, and adapters:
|
|
837
|
+
|
|
838
|
+
```jsonc
|
|
839
|
+
{
|
|
840
|
+
"name": "name",
|
|
841
|
+
"replace_with_fake_data": { "seed": "users.id", "type": "human_name" }
|
|
842
|
+
}
|
|
843
|
+
```
|
|
844
|
+
|
|
845
|
+
- `seed` names a column of the same table, bare (`"id"`) or table-qualified
|
|
846
|
+
(`"users.id"`). The seed value is hashed (SHA-256, after `to_s`
|
|
847
|
+
normalization, so sqlite's integer `123` and postgres/mysql's string `"123"`
|
|
848
|
+
agree) and the hash picks the fake value. Use a stable identifier (integer or
|
|
849
|
+
string primary key) as the seed; float/decimal/binary columns are discouraged
|
|
850
|
+
because their text forms differ per adapter. A `NULL` seed value hashes `""`
|
|
851
|
+
(still deterministic).
|
|
852
|
+
- Like `replace_with`, it **preserves NULL** in the target column.
|
|
853
|
+
- `locale` (optional) sets the faker locale used to generate the candidate
|
|
854
|
+
values, e.g. `{ "seed": "id", "type": "human_name", "locale": "ja" }` produces
|
|
855
|
+
Japanese names.
|
|
856
|
+
- Supported `type`s:
|
|
857
|
+
|
|
858
|
+
| type | example output |
|
|
859
|
+
|------|----------------|
|
|
860
|
+
| `human_name` | `Adrianna Kilback` |
|
|
861
|
+
| `first_name` | `Adrianna` |
|
|
862
|
+
| `last_name` | `Kilback` |
|
|
863
|
+
| `phone_number` | `(555) 123-4567` |
|
|
864
|
+
| `address` | `282 Kevin Brook, Imogeneborough, CA 58517` |
|
|
865
|
+
| `company_name` | `Hirthe-Ritchie` |
|
|
866
|
+
| `email` | `cliff.fay.9d6b804eff5a3f57@example.com` |
|
|
867
|
+
| `username` | `cliff.fay_9d6b804eff5a3f57` |
|
|
868
|
+
|
|
869
|
+
- Values are drawn from a pool of 10,000 pre-generated candidates per
|
|
870
|
+
(type, locale), so distinct seeds can share a fake value. The
|
|
871
|
+
uniqueness-sensitive types (`email`, `username`) additionally embed a 64-bit
|
|
872
|
+
hex token derived from the seed hash, so they stay collision-free under a
|
|
873
|
+
unique index even at millions of rows (collision probability at 5M distinct
|
|
874
|
+
seeds ≈ 7e-7) and always use the `example.com` domain.
|
|
875
|
+
- **Determinism caveat**: values are stable for a given faker gem version +
|
|
876
|
+
locale. Upgrading faker (or changing `locale`) regenerates the pool and maps
|
|
877
|
+
seeds to different values. The seed→value mapping itself never changes within
|
|
878
|
+
one version.
|
|
879
|
+
- The faker gem is **not** a runtime dependency of exwiw — add `gem "faker"` to
|
|
880
|
+
your Gemfile to use this mode (exwiw raises a clear error otherwise).
|
|
881
|
+
- Exclusive with the other masking keys on the same column. SQL adapters only
|
|
882
|
+
(the MongoDB adapter rejects the key on load, see
|
|
883
|
+
[Unknown keys are rejected](#unknown-keys-are-rejected)), and invisible to
|
|
884
|
+
`explain`.
|
|
885
|
+
|
|
886
|
+
**Performance**: this is a per-row Ruby transform, measured at ~1.5–1.6µs/row
|
|
887
|
+
per fake column (so ≈ +8s per 5M rows per column; ~+40% against a local sqlite
|
|
888
|
+
fetch — the worst case — and proportionally less against a network database,
|
|
889
|
+
where the fetch dominates). Values are drawn from a pool pre-generated once,
|
|
890
|
+
not by calling faker per row (which would be ~20× slower). Memory is
|
|
891
|
+
unaffected: the transform streams with the dump. See
|
|
892
|
+
[`docs/row-transform-masking-notes.md`](docs/row-transform-masking-notes.md)
|
|
893
|
+
for the benchmark, and `script/bench_row_transform.rb` to measure on your data.
|
|
804
894
|
|
|
805
895
|
### MongoDB notes
|
|
806
896
|
|
|
@@ -826,9 +916,39 @@ The MongoDB adapter is experimental. To use it:
|
|
|
826
916
|
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
827
917
|
```
|
|
828
918
|
- Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
|
|
829
|
-
- `raw_sql`
|
|
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.
|
|
830
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).
|
|
831
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
|
+
|
|
832
952
|
#### Embedded documents
|
|
833
953
|
|
|
834
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`:
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# Ruby-side masking (`map` / `replace_with_fake_data`): design & benchmark notes
|
|
2
|
+
|
|
3
|
+
Companion to [`sql-dump-optimization-notes.md`](./sql-dump-optimization-notes.md).
|
|
4
|
+
That document records how the SQL dump path became a bounded-memory streaming
|
|
5
|
+
pipeline; this one records the cost of the first **per-row Ruby work** added on
|
|
6
|
+
top of it — the `RowTransformer` that implements the `map` and
|
|
7
|
+
`replace_with_fake_data` masking modes — and the design decisions the numbers
|
|
8
|
+
drove.
|
|
9
|
+
|
|
10
|
+
The reproducible harness is `script/bench_row_transform.rb`. The correctness
|
|
11
|
+
anchors are `spec/row_transformer_spec.rb` (determinism contract) and
|
|
12
|
+
`spec/insert_output_snapshot_spec.rb` (byte-exact output for `map`).
|
|
13
|
+
|
|
14
|
+
## Why these modes cost anything at all
|
|
15
|
+
|
|
16
|
+
`replace_with` / `raw_sql` compile into the `SELECT` and run **in the
|
|
17
|
+
database**; the Ruby pipeline (streaming fetch → `write_inserts`) previously
|
|
18
|
+
did zero per-row transform work. `map` (arbitrary Ruby proc) and
|
|
19
|
+
`replace_with_fake_data` (faker values) cannot be pushed into SQL, so they run
|
|
20
|
+
in the exwiw process, per row, between the cursor and the INSERT/COPY writer:
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
adapter.execute --> StreamingResult --(RowTransformer::TransformedResult#each)--> write_inserts / to_copy_from_stdin
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The wrapper delegates `#size` (so the COPY path's upfront count and
|
|
27
|
+
`each_slice`'s allocation-hint COUNT are unchanged) and transforms rows one at
|
|
28
|
+
a time off `#each`, so the bounded-memory profile of the streaming dump is
|
|
29
|
+
preserved. When no column opts in, `RowTransformer.build` returns nil and the
|
|
30
|
+
pipeline is byte-for-byte the pre-existing one.
|
|
31
|
+
|
|
32
|
+
## Design decisions the measurements drove
|
|
33
|
+
|
|
34
|
+
### Fake values come from a pre-generated pool, not per-row faker calls
|
|
35
|
+
|
|
36
|
+
A naive deterministic design — per row, seed faker's RNG with the hash and
|
|
37
|
+
call the generator —
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
Faker::Config.random = Random.new(seed_hash)
|
|
41
|
+
Faker::Name.name
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
measures **~30 µs/row** (I18n lookups + regex templating per call). At 5M rows
|
|
45
|
+
that is ~150 s for one column: unacceptable.
|
|
46
|
+
|
|
47
|
+
Instead, `RowTransformer` pre-generates a pool of `POOL_SIZE = 10_000`
|
|
48
|
+
candidate values per `(type, locale)` under a fixed RNG seed (~0.2 s, once),
|
|
49
|
+
and per row only hashes the seed value and indexes the pool:
|
|
50
|
+
|
|
51
|
+
```ruby
|
|
52
|
+
digest = Digest::SHA256.digest(seed_value.to_s)
|
|
53
|
+
pool[digest[0, 8].unpack1("Q>") % POOL_SIZE]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
That is ~1.4 µs/row — **~21× cheaper** — and just as deterministic (values are
|
|
57
|
+
stable for a given faker version + locale; the pool regenerates identically).
|
|
58
|
+
|
|
59
|
+
### One SHA-256 digest supplies both the pool index and the uniqueness token
|
|
60
|
+
|
|
61
|
+
`Zlib.crc32` is ~16× faster than SHA-256 (0.055 vs 0.87 µs/op), but 32 bits is
|
|
62
|
+
unusable for the uniqueness token the `email`/`username` types embed: a
|
|
63
|
+
birthday collision is even money at ~77k distinct seeds, far below the
|
|
64
|
+
multi-million-row target. One SHA-256 per row provides 64 bits for the pool
|
|
65
|
+
index (bytes 0–7) and an independent 64-bit hex token (bytes 8–15) whose
|
|
66
|
+
collision probability at 5M distinct seeds is ≈ 7e-7. The hash is not the
|
|
67
|
+
bottleneck (see numbers below), so there is no fast-path variant.
|
|
68
|
+
|
|
69
|
+
### Zero-allocation row accessor for `map`
|
|
70
|
+
|
|
71
|
+
The proc receives a single reused accessor object (`r['column']` resolves
|
|
72
|
+
through a frozen name→index Hash built once per table), not a per-row Hash of
|
|
73
|
+
the whole row. Replacements are computed from the original row before any is
|
|
74
|
+
written back, so transformed columns can read each other's *pre*-transform
|
|
75
|
+
values regardless of column order. Rows are mutated in place when the driver
|
|
76
|
+
allows it; sqlite3's `Statement#each` yields **frozen** rows, which are duped
|
|
77
|
+
first (the one extra allocation on that adapter).
|
|
78
|
+
|
|
79
|
+
## Measurements
|
|
80
|
+
|
|
81
|
+
Environment: Apple Silicon (arm64, 64 GB), Ruby 4.0.5, faker 3.8.0, sqlite
|
|
82
|
+
adapter for serialization. 8-column rows, ~211 B/row of output. Run:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
BENCH_ROWS=2000000 bundle exec ruby script/bench_row_transform.rb
|
|
86
|
+
BENCH_ROWS=5000000 BENCH_PART_A=0 bundle exec ruby script/bench_row_transform.rb # E2E only
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Part A — serialization path only (2M rows, 422 MB output)
|
|
90
|
+
|
|
91
|
+
The rows are pre-materialized, so the delta is exactly the transform cost on
|
|
92
|
+
the `write_inserts` path — the *most pessimistic* relative view, since a real
|
|
93
|
+
dump also pays the DB fetch.
|
|
94
|
+
|
|
95
|
+
| variant | wall | vs baseline | per row |
|
|
96
|
+
|---|---|---|---|
|
|
97
|
+
| baseline (no transform) | 6.99 s (286k rows/s) | — | — |
|
|
98
|
+
| `map` ×1 column | 8.27 s | +18% | +0.64 µs |
|
|
99
|
+
| fake ×1 column (email) | 10.17 s | +45% | +1.59 µs |
|
|
100
|
+
| fake ×3 columns | 13.85 s | +98% | +3.43 µs (~1.1 µs/col) |
|
|
101
|
+
|
|
102
|
+
### Per-operation microbench
|
|
103
|
+
|
|
104
|
+
| operation | µs/op |
|
|
105
|
+
|---|---|
|
|
106
|
+
| SHA-256 digest → u64 index + hex token | 0.87 |
|
|
107
|
+
| `Zlib.crc32` (comparison only) | 0.055 |
|
|
108
|
+
| fake pipeline, transform 1 row (email) | 1.42 |
|
|
109
|
+
| `map` dispatch, transform 1 row | 0.83 |
|
|
110
|
+
| naive per-row `Faker::Name.name` (rejected design) | ~30 |
|
|
111
|
+
|
|
112
|
+
### Part B — end-to-end against live sqlite (5M rows, ~1.06 GB output)
|
|
113
|
+
|
|
114
|
+
`execute` + wrap + `write_inserts`, fresh process (`BENCH_PART_A=0`; see
|
|
115
|
+
pitfalls below):
|
|
116
|
+
|
|
117
|
+
| variant | wall | vs baseline | per row |
|
|
118
|
+
|---|---|---|---|
|
|
119
|
+
| baseline | 19.97 s (250k rows/s) | — | — |
|
|
120
|
+
| fake ×1 column (email) | 27.98 s | +40% | +1.60 µs |
|
|
121
|
+
|
|
122
|
+
RSS stayed flat at ~70–100 MB for both variants across the whole 5M-row /
|
|
123
|
+
1 GB dump — the transform does not disturb the streaming memory profile.
|
|
124
|
+
|
|
125
|
+
The 2M-row E2E run reproduces the same per-row cost (+1.55 µs/row).
|
|
126
|
+
|
|
127
|
+
### Reading the numbers
|
|
128
|
+
|
|
129
|
+
- **The stable metric is µs/row per masked column**: ~0.6–0.8 µs for `map`
|
|
130
|
+
(plus whatever the user's proc does), ~1.5–1.6 µs for fake data
|
|
131
|
+
(SHA-256 + pool lookup + compose + the sqlite frozen-row dup).
|
|
132
|
+
- At **5M rows, one fake column costs ~8 s** of wall clock.
|
|
133
|
+
- The relative percentages above are worst-case: a local sqlite fetch is the
|
|
134
|
+
fastest possible source (baseline ~4 µs/row), so +1.6 µs reads as +40%.
|
|
135
|
+
Against a network mysql/postgresql source the same absolute overhead is a
|
|
136
|
+
much smaller fraction; on the fully-diluted end (2M E2E measured in a
|
|
137
|
+
process with a large heap) it read as +21%. Unused tables/columns cost
|
|
138
|
+
exactly zero (`build` returns nil; nothing is wrapped).
|
|
139
|
+
|
|
140
|
+
## Benchmarking pitfalls found on the way
|
|
141
|
+
|
|
142
|
+
Recorded because both would silently corrupt a rerun:
|
|
143
|
+
|
|
144
|
+
1. **A 10 ms `ps` RSS sampler is catastrophic on a large heap.** The
|
|
145
|
+
`script/bench_sql_dump.rb`-style background sampler forks the whole process
|
|
146
|
+
per sample; with the ~1 GB rows array a 2M-row `write_inserts` went from
|
|
147
|
+
10.8 s to **502 s**. This script reports before/after RSS (2 forks per
|
|
148
|
+
phase) instead; peak-transient capture matters less here because the
|
|
149
|
+
transform adds no table-sized structure.
|
|
150
|
+
2. **The first measured variant absorbs one-time heap expansion.** With only a
|
|
151
|
+
partial warm pass, the baseline (measured first) ran ~15% slower than the
|
|
152
|
+
later variants — enough to make `map` appear *faster* than no transform.
|
|
153
|
+
The script now does a full-size unmeasured warm pass first. Relatedly,
|
|
154
|
+
running Part A's materialized array before Part B in one process inflates
|
|
155
|
+
Part B's GC costs (the 5M E2E overhead read +6.0 µs/row instead of
|
|
156
|
+
+1.6 µs/row); hence `BENCH_PART_A=0` for clean E2E numbers.
|
|
@@ -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
|