exwiw 0.9.6 → 0.9.7
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 +7 -0
- data/README.md +89 -9
- data/docs/row-transform-masking-notes.md +156 -0
- data/lib/exwiw/fake_data.rb +21 -0
- data/lib/exwiw/row_transformer.rb +243 -0
- data/lib/exwiw/runner.rb +9 -1
- data/lib/exwiw/table_column.rb +5 -0
- data/lib/exwiw/table_config.rb +36 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +2 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 70b1d51562a341ea502ebb5d22ed3aa625d902264d9112bf338aa6142268dd0a
|
|
4
|
+
data.tar.gz: e1110a13b9d95cddec6d32f0f829360ded75742f4d17c6969a4ebf51d272b454
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: acd60932985992f2863640102a34d79e87434b566ee96484abbf3a36f3b2d08e7700a13a63657f9f45cd97c8f0f76d83f221b13e23ae3b0b7b257500f9f99890
|
|
7
|
+
data.tar.gz: 0de13c9385276a68b8ceb582777da4c9f6b329d1f796db5dfc1411ea936843e0aff0f6a5d178c1a883230b7f0bc7378daedd5382ac9dcfc935879ad74df924f9
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.7] - 2026-07-08
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`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).
|
|
10
|
+
- **`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`.
|
|
11
|
+
|
|
5
12
|
## [0.9.6] - 2026-07-08
|
|
6
13
|
|
|
7
14
|
### Added
|
data/README.md
CHANGED
|
@@ -788,19 +788,99 @@ If it used with `replace_with`, `replace_with` will be ignored.
|
|
|
788
788
|
|
|
789
789
|
#### `map`
|
|
790
790
|
|
|
791
|
-
|
|
791
|
+
The value is evaluated as Ruby code once (per table, at dump time), must yield a
|
|
792
|
+
`Proc`, and the proc is called for every fetched row. Its return value replaces
|
|
793
|
+
the column value in the dump:
|
|
792
794
|
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
```
|
|
796
|
-
"map": "proc { |r| 'user' + v['id'].to_s + '@example.com' }"
|
|
795
|
+
```jsonc
|
|
796
|
+
{ "name": "email", "map": "proc { |r| 'user' + r['id'].to_s + '@example.com' }" }
|
|
797
797
|
```
|
|
798
798
|
|
|
799
799
|
which is equivalent to `"replace_with": "user{id}@example.com"`.
|
|
800
800
|
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
801
|
+
- `r['column_name']` reads any column of the current row — the value as fetched
|
|
802
|
+
from the database (i.e. after SQL-side masking such as another column's
|
|
803
|
+
`replace_with`, before Ruby-side transforms). `r` is only valid inside the
|
|
804
|
+
call; do not retain it.
|
|
805
|
+
- Return a `String`, `Numeric`, or `nil`. Unlike `replace_with` there is **no
|
|
806
|
+
automatic NULL preservation** — the proc receives `nil` and decides.
|
|
807
|
+
- `map` is exclusive with the other masking keys on the same column
|
|
808
|
+
(`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`.
|
|
812
|
+
|
|
813
|
+
**Security note**: `map` executes arbitrary Ruby from the schema config. Treat
|
|
814
|
+
config files with the same trust as your Gemfile — only load trusted configs.
|
|
815
|
+
|
|
816
|
+
This is the most powerful option, but it runs per row in the exwiw process
|
|
817
|
+
rather than in the database. The measured dispatch cost is small, though
|
|
818
|
+
(~0.6–0.8µs/row plus whatever the proc body does — see
|
|
819
|
+
[`docs/row-transform-masking-notes.md`](docs/row-transform-masking-notes.md)).
|
|
820
|
+
Prefer `replace_with`/`raw_sql` when they can express the transform; reach for
|
|
821
|
+
`map` when they cannot.
|
|
822
|
+
|
|
823
|
+
#### `replace_with_fake_data`
|
|
824
|
+
|
|
825
|
+
Replaces the value with realistic-looking fake data generated by the
|
|
826
|
+
[faker](https://github.com/faker-ruby/faker) gem, picked **deterministically**
|
|
827
|
+
from the value of a seed column — the same seed value always maps to the same
|
|
828
|
+
fake value, across tables, runs, and adapters:
|
|
829
|
+
|
|
830
|
+
```jsonc
|
|
831
|
+
{
|
|
832
|
+
"name": "name",
|
|
833
|
+
"replace_with_fake_data": { "seed": "users.id", "type": "human_name" }
|
|
834
|
+
}
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
- `seed` names a column of the same table, bare (`"id"`) or table-qualified
|
|
838
|
+
(`"users.id"`). The seed value is hashed (SHA-256, after `to_s`
|
|
839
|
+
normalization, so sqlite's integer `123` and postgres/mysql's string `"123"`
|
|
840
|
+
agree) and the hash picks the fake value. Use a stable identifier (integer or
|
|
841
|
+
string primary key) as the seed; float/decimal/binary columns are discouraged
|
|
842
|
+
because their text forms differ per adapter. A `NULL` seed value hashes `""`
|
|
843
|
+
(still deterministic).
|
|
844
|
+
- 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.
|
|
848
|
+
- Supported `type`s:
|
|
849
|
+
|
|
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
|
|
863
|
+
uniqueness-sensitive types (`email`, `username`) additionally embed a 64-bit
|
|
864
|
+
hex token derived from the seed hash, so they stay collision-free under a
|
|
865
|
+
unique index even at millions of rows (collision probability at 5M distinct
|
|
866
|
+
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
|
|
869
|
+
seeds to different values. The seed→value mapping itself never changes within
|
|
870
|
+
one version.
|
|
871
|
+
- 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).
|
|
873
|
+
- 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`.
|
|
875
|
+
|
|
876
|
+
**Performance**: this is a per-row Ruby transform, measured at ~1.5–1.6µs/row
|
|
877
|
+
per fake column (so ≈ +8s per 5M rows per column; ~+40% against a local sqlite
|
|
878
|
+
fetch — the worst case — and proportionally less against a network database,
|
|
879
|
+
where the fetch dominates). Values are drawn from a pool pre-generated once,
|
|
880
|
+
not by calling faker per row (which would be ~20× slower). Memory is
|
|
881
|
+
unaffected: the transform streams with the dump. See
|
|
882
|
+
[`docs/row-transform-masking-notes.md`](docs/row-transform-masking-notes.md)
|
|
883
|
+
for the benchmark, and `script/bench_row_transform.rb` to measure on your data.
|
|
804
884
|
|
|
805
885
|
### MongoDB notes
|
|
806
886
|
|
|
@@ -826,7 +906,7 @@ The MongoDB adapter is experimental. To use it:
|
|
|
826
906
|
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
827
907
|
```
|
|
828
908
|
- Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
|
|
829
|
-
- `raw_sql`
|
|
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.
|
|
830
910
|
- 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
911
|
|
|
832
912
|
#### Embedded documents
|
|
@@ -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.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exwiw
|
|
4
|
+
# Configuration for a column's `replace_with_fake_data` masking mode
|
|
5
|
+
# (see {RowTransformer}): the value is replaced with a fake value picked
|
|
6
|
+
# deterministically from a faker-generated pool, keyed by a hash of the
|
|
7
|
+
# `seed` column's value — so the same seed value always maps to the same
|
|
8
|
+
# fake value, across runs and adapters.
|
|
9
|
+
#
|
|
10
|
+
# `seed` names a column of the same table, either bare (`"id"`) or
|
|
11
|
+
# table-qualified (`"users.id"`). `type` selects the kind of fake value
|
|
12
|
+
# (see RowTransformer::FAKE_TYPES). `locale` optionally sets the faker
|
|
13
|
+
# locale used to build the pool (e.g. `"ja"`); nil uses faker's default.
|
|
14
|
+
class FakeData
|
|
15
|
+
include Serdes
|
|
16
|
+
|
|
17
|
+
attribute :seed, String
|
|
18
|
+
attribute :type, String
|
|
19
|
+
attribute :locale, optional(String), skip_serializing_if_nil: true
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Exwiw
|
|
6
|
+
# Applies the Ruby-process-side masking modes — `map` and
|
|
7
|
+
# `replace_with_fake_data` — to the rows streamed out of a SQL adapter's
|
|
8
|
+
# #execute. Unlike replace_with/raw_sql, which compile into the SELECT and
|
|
9
|
+
# run in the database, these run in the exwiw process, so they can execute
|
|
10
|
+
# arbitrary Ruby (`map`) or derive deterministic fake values (`fake data`).
|
|
11
|
+
#
|
|
12
|
+
# Built once per table (.build compiles the map procs, resolves seed column
|
|
13
|
+
# indexes, and pre-generates the fake-value pools), then #wrap decorates the
|
|
14
|
+
# adapter's StreamingResult: rows are transformed one at a time as the
|
|
15
|
+
# stream is drained, so the bounded-memory profile of the streaming dump is
|
|
16
|
+
# preserved. #size delegates to the underlying result, keeping the COPY
|
|
17
|
+
# path's upfront count and each_slice's allocation-hint COUNT identical to
|
|
18
|
+
# an unwrapped run.
|
|
19
|
+
#
|
|
20
|
+
# SQL adapters only: .build returns nil for configs without a `columns`
|
|
21
|
+
# list (MongodbCollectionConfig has `fields`; rails-managed tables have no
|
|
22
|
+
# columns), and for tables where no column carries a Ruby-side mode.
|
|
23
|
+
class RowTransformer
|
|
24
|
+
# Fake values are picked from a pool of this many pre-generated values.
|
|
25
|
+
# Distinct seed values beyond the pool size reuse pool entries (fine for
|
|
26
|
+
# fake data — determinism, not uniqueness, is the contract; uniqueness-
|
|
27
|
+
# sensitive types compose a 64-bit token on top, see FAKE_TYPES).
|
|
28
|
+
POOL_SIZE = 10_000
|
|
29
|
+
|
|
30
|
+
# Fixed Random seed for pool generation, so a pool is deterministic for a
|
|
31
|
+
# given faker gem version + locale.
|
|
32
|
+
POOL_RANDOM_SEED = 715_517
|
|
33
|
+
|
|
34
|
+
# Supported `replace_with_fake_data` types. `pool` builds one candidate
|
|
35
|
+
# value (called POOL_SIZE times under a seeded Faker random). Types with
|
|
36
|
+
# `compose` are uniqueness-sensitive: the pooled base alone would collide
|
|
37
|
+
# under a unique index at scale, so the final value composes the base with
|
|
38
|
+
# a 64-bit hex token derived from the same seed digest (collision
|
|
39
|
+
# probability at 5M distinct seeds ≈ 7e-7).
|
|
40
|
+
FAKE_TYPES = {
|
|
41
|
+
"human_name" => { pool: -> { Faker::Name.name } },
|
|
42
|
+
"first_name" => { pool: -> { Faker::Name.first_name } },
|
|
43
|
+
"last_name" => { pool: -> { Faker::Name.last_name } },
|
|
44
|
+
"phone_number" => { pool: -> { Faker::PhoneNumber.phone_number } },
|
|
45
|
+
"address" => { pool: -> { Faker::Address.full_address } },
|
|
46
|
+
"company_name" => { pool: -> { Faker::Company.name } },
|
|
47
|
+
"email" => { pool: -> { Faker::Internet.username(specifier: 5..12) },
|
|
48
|
+
compose: ->(base, token) { "#{base}.#{token}@example.com" } },
|
|
49
|
+
"username" => { pool: -> { Faker::Internet.username(specifier: 5..12) },
|
|
50
|
+
compose: ->(base, token) { "#{base}_#{token}" } },
|
|
51
|
+
}.freeze
|
|
52
|
+
|
|
53
|
+
# The `r` a map proc receives: read-only access to the current row's
|
|
54
|
+
# values by column name (`r['id']`). One instance is reused across all
|
|
55
|
+
# rows (zero per-row allocation) — do not retain it outside the proc.
|
|
56
|
+
class Row
|
|
57
|
+
def initialize(table_name, name_to_index)
|
|
58
|
+
@table_name = table_name
|
|
59
|
+
@name_to_index = name_to_index
|
|
60
|
+
@values = nil
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
attr_writer :values
|
|
64
|
+
|
|
65
|
+
def [](column_name)
|
|
66
|
+
index = @name_to_index[column_name]
|
|
67
|
+
unless index
|
|
68
|
+
raise ArgumentError,
|
|
69
|
+
"unknown column '#{column_name}' in map proc for table '#{@table_name}' " \
|
|
70
|
+
"(available: #{@name_to_index.keys.join(', ')})"
|
|
71
|
+
end
|
|
72
|
+
@values[index]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Lazy Enumerable decorator returned by #wrap. Mirrors the adapters'
|
|
77
|
+
# StreamingResult contract (#size COUNT delegation, sized enum_for, #each
|
|
78
|
+
# returning self) so it is a drop-in for both write_inserts and
|
|
79
|
+
# to_copy_from_stdin.
|
|
80
|
+
class TransformedResult
|
|
81
|
+
include Enumerable
|
|
82
|
+
|
|
83
|
+
def initialize(inner, transformer)
|
|
84
|
+
@inner = inner
|
|
85
|
+
@transformer = transformer
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def size
|
|
89
|
+
@inner.size
|
|
90
|
+
end
|
|
91
|
+
alias length size
|
|
92
|
+
|
|
93
|
+
def each
|
|
94
|
+
return enum_for(:each) { size } unless block_given?
|
|
95
|
+
|
|
96
|
+
@inner.each { |row| yield @transformer.transform(row) }
|
|
97
|
+
self
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# -> RowTransformer | nil. nil when the table carries no Ruby-side mode,
|
|
102
|
+
# so the Runner can skip wrapping entirely (the unused path stays
|
|
103
|
+
# byte-identical and cost-free).
|
|
104
|
+
def self.build(table)
|
|
105
|
+
return nil unless table.respond_to?(:columns)
|
|
106
|
+
|
|
107
|
+
columns = table.columns
|
|
108
|
+
return nil if columns.nil? || columns.none? { |c| c.map || c.replace_with_fake_data }
|
|
109
|
+
|
|
110
|
+
new(table)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def self.require_faker!
|
|
114
|
+
require "faker"
|
|
115
|
+
rescue LoadError
|
|
116
|
+
raise LoadError,
|
|
117
|
+
"replace_with_fake_data requires the faker gem. " \
|
|
118
|
+
"Add `gem \"faker\"` to your Gemfile (it is not a runtime dependency of exwiw)."
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Pools are memoized per (type, locale): every column sharing a type+locale
|
|
122
|
+
# sees the same pool, which is what makes equal seed values map to equal
|
|
123
|
+
# fake values across tables and runs.
|
|
124
|
+
def self.fake_pool(type, locale)
|
|
125
|
+
@fake_pools ||= {}
|
|
126
|
+
@fake_pools[[type, locale]] ||= build_fake_pool(type, locale)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def self.build_fake_pool(type, locale)
|
|
130
|
+
spec = FAKE_TYPES.fetch(type)
|
|
131
|
+
previous_locale = Faker::Config.locale
|
|
132
|
+
previous_random = Faker::Config.random
|
|
133
|
+
Faker::Config.locale = locale if locale
|
|
134
|
+
Faker::Config.random = Random.new(POOL_RANDOM_SEED)
|
|
135
|
+
Array.new(POOL_SIZE) { spec[:pool].call.freeze }.freeze
|
|
136
|
+
ensure
|
|
137
|
+
Faker::Config.locale = previous_locale
|
|
138
|
+
Faker::Config.random = previous_random
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def initialize(table)
|
|
142
|
+
@table_name = table.name
|
|
143
|
+
@name_to_index = {}
|
|
144
|
+
table.columns.each_with_index { |column, index| @name_to_index[column.name] = index }
|
|
145
|
+
@name_to_index.freeze
|
|
146
|
+
@row_accessor = Row.new(@table_name, @name_to_index)
|
|
147
|
+
|
|
148
|
+
self.class.require_faker! if table.columns.any?(&:replace_with_fake_data)
|
|
149
|
+
|
|
150
|
+
@transforms = table.columns.each_with_index.filter_map do |column, index|
|
|
151
|
+
if column.map
|
|
152
|
+
[index, compile_map(column)]
|
|
153
|
+
elsif column.replace_with_fake_data
|
|
154
|
+
[index, compile_fake(column, index)]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
@replacement_buffer = Array.new(@transforms.size)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def wrap(results)
|
|
161
|
+
TransformedResult.new(results, self)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Replacements are all computed from the original row before any is
|
|
165
|
+
# written back, so a map proc / fake seed always reads the pre-transform
|
|
166
|
+
# (post-SQL-masking) value regardless of column order. Rows are mutated in
|
|
167
|
+
# place when possible (each cursor yields a fresh Array per row), but
|
|
168
|
+
# sqlite3's Statement#each yields frozen rows — those are duped first.
|
|
169
|
+
def transform(row)
|
|
170
|
+
@transforms.each_with_index do |(_, callable), k|
|
|
171
|
+
@replacement_buffer[k] = callable.call(row)
|
|
172
|
+
end
|
|
173
|
+
row = row.dup if row.frozen?
|
|
174
|
+
@transforms.each_with_index do |(index, _), k|
|
|
175
|
+
row[index] = @replacement_buffer[k]
|
|
176
|
+
end
|
|
177
|
+
row
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
private def compile_map(column)
|
|
181
|
+
# eval is the documented contract of `map`: the schema config supplies a
|
|
182
|
+
# Ruby proc source and exwiw runs it. Configs are trusted local files
|
|
183
|
+
# (same trust level as the Gemfile); the README warns to only load
|
|
184
|
+
# trusted configs. Evaluated once per table, at dump time only — never
|
|
185
|
+
# during schema generation/regeneration.
|
|
186
|
+
evaluated =
|
|
187
|
+
begin
|
|
188
|
+
eval(column.map, TOPLEVEL_BINDING.dup, "(exwiw map for #{@table_name}.#{column.name})") # rubocop:disable Security/Eval
|
|
189
|
+
rescue StandardError, ScriptError => e
|
|
190
|
+
raise ArgumentError,
|
|
191
|
+
"map for column '#{@table_name}.#{column.name}' failed to eval: #{e.class}: #{e.message}"
|
|
192
|
+
end
|
|
193
|
+
unless evaluated.is_a?(Proc)
|
|
194
|
+
raise ArgumentError,
|
|
195
|
+
"map for column '#{@table_name}.#{column.name}' must evaluate to a Proc, got #{evaluated.class}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
row_accessor = @row_accessor
|
|
199
|
+
table_name = @table_name
|
|
200
|
+
column_name = column.name
|
|
201
|
+
lambda do |row|
|
|
202
|
+
row_accessor.values = row
|
|
203
|
+
begin
|
|
204
|
+
evaluated.call(row_accessor)
|
|
205
|
+
rescue StandardError => e
|
|
206
|
+
raise "map proc for column '#{table_name}.#{column_name}' raised: #{e.class}: #{e.message}"
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
private def compile_fake(column, column_index)
|
|
212
|
+
fake_data = column.replace_with_fake_data
|
|
213
|
+
spec = FAKE_TYPES.fetch(fake_data.type)
|
|
214
|
+
|
|
215
|
+
# Re-resolve the seed here, against the effective (post-ignore) columns:
|
|
216
|
+
# load-time validation sees the full column list, so a seed column that
|
|
217
|
+
# was ignore:true is only caught at dump time.
|
|
218
|
+
seed_column = fake_data.seed.delete_prefix("#{@table_name}.")
|
|
219
|
+
seed_index = @name_to_index[seed_column]
|
|
220
|
+
unless seed_index
|
|
221
|
+
raise ArgumentError,
|
|
222
|
+
"replace_with_fake_data for column '#{@table_name}.#{column.name}': " \
|
|
223
|
+
"seed '#{fake_data.seed}' does not resolve to an extracted column " \
|
|
224
|
+
"(is it ignore:true?)"
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
pool = self.class.fake_pool(fake_data.type, fake_data.locale)
|
|
228
|
+
compose = spec[:compose]
|
|
229
|
+
|
|
230
|
+
# NULL-preserving like replace_with: a NULL target stays NULL. A nil
|
|
231
|
+
# seed value hashes "" (deterministic). Seed values are normalized with
|
|
232
|
+
# to_s so sqlite's native Integer 123 and pg/mysql's string "123" pick
|
|
233
|
+
# the same fake value.
|
|
234
|
+
lambda do |row|
|
|
235
|
+
next nil if row[column_index].nil?
|
|
236
|
+
|
|
237
|
+
digest = Digest::SHA256.digest(row[seed_index].to_s)
|
|
238
|
+
base = pool[digest[0, 8].unpack1("Q>") % POOL_SIZE]
|
|
239
|
+
compose ? compose.call(base, digest[8, 8].unpack1("H*")) : base
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
data/lib/exwiw/runner.rb
CHANGED
|
@@ -86,9 +86,17 @@ module Exwiw
|
|
|
86
86
|
# turning the fetched rows into SQL/JSONL, the rescue below can report
|
|
87
87
|
# both the failing step and the exact extraction query that produced the
|
|
88
88
|
# data being processed.
|
|
89
|
-
phase = "
|
|
89
|
+
phase = "compiling row transforms (map/replace_with_fake_data)"
|
|
90
90
|
begin
|
|
91
|
+
# Ruby-side masking (map / replace_with_fake_data): wrap the streamed
|
|
92
|
+
# results so rows are transformed as they are drained. nil (and thus
|
|
93
|
+
# a byte-identical, cost-free run) when no column opts in; covers
|
|
94
|
+
# both the INSERT and COPY branches below.
|
|
95
|
+
row_transformer = RowTransformer.build(table)
|
|
96
|
+
|
|
97
|
+
phase = "executing extraction query"
|
|
91
98
|
results = adapter.execute(query_ast)
|
|
99
|
+
results = row_transformer.wrap(results) if row_transformer
|
|
92
100
|
insert_idx = (idx + 1).to_s.rjust(3, '0')
|
|
93
101
|
|
|
94
102
|
if @output_format == 'copy'
|
data/lib/exwiw/table_column.rb
CHANGED
|
@@ -7,6 +7,11 @@ module Exwiw
|
|
|
7
7
|
attribute :name, String
|
|
8
8
|
attribute :replace_with, optional(String), skip_serializing_if_nil: true
|
|
9
9
|
attribute :raw_sql, optional(String), skip_serializing_if_nil: true
|
|
10
|
+
# Ruby-process-side masking modes, applied to the fetched rows by
|
|
11
|
+
# RowTransformer (SQL adapters only) — unlike replace_with/raw_sql, which
|
|
12
|
+
# compile into the SELECT and run in the database.
|
|
13
|
+
attribute :map, optional(String), skip_serializing_if_nil: true
|
|
14
|
+
attribute :replace_with_fake_data, Serdes::OptionalType.new(FakeData), skip_serializing_if_nil: true
|
|
10
15
|
# User-owned fields preserved across schema regeneration (see
|
|
11
16
|
# TableConfig#merge). `ignore:true` drops the column from extraction (SELECT /
|
|
12
17
|
# INSERT) once the config is loaded (see TableConfig#reject_ignored_members!).
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -219,6 +219,42 @@ module Exwiw
|
|
|
219
219
|
if primary_key.nil? && !ignore
|
|
220
220
|
raise ArgumentError, "Table '#{name}' requires primary_key."
|
|
221
221
|
end
|
|
222
|
+
|
|
223
|
+
columns.each { |column| validate_ruby_side_masking!(column) }
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# The Ruby-side masking modes (`map` / `replace_with_fake_data`) are strictly
|
|
228
|
+
# exclusive — with each other and with the SQL-side modes. Only the new keys
|
|
229
|
+
# are restricted: the legacy raw_sql > replace_with precedence stays lenient.
|
|
230
|
+
# Deliberately static (no faker require, no proc eval) so schema
|
|
231
|
+
# regeneration never executes config Ruby; the seed column and the map proc
|
|
232
|
+
# are resolved at dump time by RowTransformer.build.
|
|
233
|
+
private def validate_ruby_side_masking!(column)
|
|
234
|
+
ruby_side = [column.map && "map", column.replace_with_fake_data && "replace_with_fake_data"].compact
|
|
235
|
+
return if ruby_side.empty?
|
|
236
|
+
|
|
237
|
+
sql_side = [column.raw_sql && "raw_sql", column.replace_with && "replace_with"].compact
|
|
238
|
+
if ruby_side.size > 1 || sql_side.any?
|
|
239
|
+
raise ArgumentError,
|
|
240
|
+
"Table '#{name}' column '#{column.name}': #{(ruby_side + sql_side).join('/')} cannot be combined; " \
|
|
241
|
+
"use at most one of map/replace_with_fake_data, without raw_sql/replace_with."
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
fake_data = column.replace_with_fake_data
|
|
245
|
+
return unless fake_data
|
|
246
|
+
|
|
247
|
+
unless RowTransformer::FAKE_TYPES.key?(fake_data.type)
|
|
248
|
+
raise ArgumentError,
|
|
249
|
+
"Table '#{name}' column '#{column.name}': unknown replace_with_fake_data type '#{fake_data.type}' " \
|
|
250
|
+
"(supported: #{RowTransformer::FAKE_TYPES.keys.join(', ')})."
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
seed_column = fake_data.seed.delete_prefix("#{name}.")
|
|
254
|
+
if seed_column.include?(".") || columns.none? { |c| c.name == seed_column }
|
|
255
|
+
raise ArgumentError,
|
|
256
|
+
"Table '#{name}' column '#{column.name}': replace_with_fake_data seed '#{fake_data.seed}' " \
|
|
257
|
+
"does not name a column of this table (use 'column' or '#{name}.column')."
|
|
222
258
|
end
|
|
223
259
|
end
|
|
224
260
|
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "serdes"
|
|
|
8
8
|
require_relative "exwiw/ext_json"
|
|
9
9
|
require_relative "exwiw/config_file"
|
|
10
10
|
require_relative "exwiw/belongs_to"
|
|
11
|
+
require_relative "exwiw/fake_data"
|
|
11
12
|
require_relative "exwiw/table_column"
|
|
12
13
|
require_relative "exwiw/reverse_scope"
|
|
13
14
|
require_relative "exwiw/table_config"
|
|
@@ -28,6 +29,7 @@ require_relative "exwiw/mongodb_parallel_dumper"
|
|
|
28
29
|
require_relative "exwiw/mongo_query"
|
|
29
30
|
require_relative "exwiw/query_ast"
|
|
30
31
|
require_relative "exwiw/query_ast_builder"
|
|
32
|
+
require_relative "exwiw/row_transformer"
|
|
31
33
|
require_relative "exwiw/after_insert_hook"
|
|
32
34
|
require_relative "exwiw/runner"
|
|
33
35
|
require_relative "exwiw/explain_runner"
|
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.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -47,6 +47,7 @@ files:
|
|
|
47
47
|
- docs/plans/2026-05-29-rails-managed-tables.md
|
|
48
48
|
- docs/plans/2026-05-31-ids-column-for-sql-adapters.md
|
|
49
49
|
- docs/plans/2026-06-19-mongodb-export-remove-parallelism-native-ext.md
|
|
50
|
+
- docs/row-transform-masking-notes.md
|
|
50
51
|
- docs/scope-column-redesign.md
|
|
51
52
|
- docs/sql-dump-optimization-notes.md
|
|
52
53
|
- exe/exwiw
|
|
@@ -69,6 +70,7 @@ files:
|
|
|
69
70
|
- lib/exwiw/embedded_in.rb
|
|
70
71
|
- lib/exwiw/explain_runner.rb
|
|
71
72
|
- lib/exwiw/ext_json.rb
|
|
73
|
+
- lib/exwiw/fake_data.rb
|
|
72
74
|
- lib/exwiw/mongo_query.rb
|
|
73
75
|
- lib/exwiw/mongodb_collection_config.rb
|
|
74
76
|
- lib/exwiw/mongodb_field.rb
|
|
@@ -79,6 +81,7 @@ files:
|
|
|
79
81
|
- lib/exwiw/query_ast_builder.rb
|
|
80
82
|
- lib/exwiw/railtie.rb
|
|
81
83
|
- lib/exwiw/reverse_scope.rb
|
|
84
|
+
- lib/exwiw/row_transformer.rb
|
|
82
85
|
- lib/exwiw/runner.rb
|
|
83
86
|
- lib/exwiw/schema_generator.rb
|
|
84
87
|
- lib/exwiw/table_column.rb
|