exwiw 0.9.11 → 0.9.13
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 +9 -1
- data/README.md +5 -5
- data/lib/exwiw/adapter/identifier_quoting.rb +3 -3
- data/lib/exwiw/adapter/mongodb_adapter.rb +47 -7
- data/lib/exwiw/adapter/mysql_adapter.rb +78 -4
- data/lib/exwiw/mongodb_collection_config.rb +40 -2
- data/lib/exwiw/mongodb_field.rb +7 -0
- data/lib/exwiw/row_transformer.rb +69 -28
- data/lib/exwiw/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bc7d217b16a94d775d54d12c668886e433a44212a92631ea47258c194d71b542
|
|
4
|
+
data.tar.gz: 8ca5d14a2289315c1595ac249dc6bfc9ecdea1806403321850adadfcb8ae18d4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 134aa36ec540862c384ea51cbab3f1cd04a8ed14710bac097cb6aa9aeaf97dae03b9b74d752da3c3bd594390b70a5238961ad30a8713ca0b5c6403114c4bc666
|
|
7
|
+
data.tar.gz: 3b7a46ac569963c46faead09ad5f7357e3be1a815e0fa8bcc641a3287e8e1aa07a18af62cb552e8a09b4615e64376892df16e8aae0ad4cba048ee05b6555e6b5
|
data/CHANGELOG.md
CHANGED
|
@@ -2,10 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.9.13] - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- **The mysql adapter materializes each scope id-set into a session `TEMPORARY TABLE` and reuses it across tables.** Previously the same scope subquery (e.g. the dump target's users id-set, including a multi-arm `reverse_scope` UNION) was embedded — and re-evaluated by the server — in every descendant table's extraction query, which dominated export time on large tenants. During `export`, each distinct id-set is now computed once (`CREATE TEMPORARY TABLE ... AS SELECT DISTINCT ...`, indexed), and descendant queries JOIN the temp table; nested scopes are materialized bottom-up so outer sets build from already-materialized inner ones. The dumped rows are identical. `explain` still compiles the inline form and executes nothing. Read-only replicas are supported: `NO_ENGINE_SUBSTITUTION` is stripped from the session `sql_mode` before the first CREATE, so e.g. Aurora MySQL 3 reader instances (which cannot create InnoDB temp tables) substitute a permitted engine instead of failing with ERROR 3161. If temp table creation still fails (e.g. the DB user lacks `CREATE TEMPORARY TABLES`), materialization is disabled for the run with a warning and extraction falls back to the previous inline subqueries. postgresql/sqlite adapters are unchanged.
|
|
10
|
+
|
|
11
|
+
## [0.9.12] - 2026-07-09
|
|
12
|
+
|
|
5
13
|
## [0.9.11] - 2026-07-09
|
|
6
14
|
|
|
7
15
|
### Added
|
|
8
16
|
|
|
17
|
+
- **The MongoDB adapter now supports `replace_with_fake_data`.** The masking mode previously limited to the SQL adapters is now accepted on a `MongodbField`: its `seed` names a field of the same collection (bare or `collection.`-qualified) or the primary key (`_id`), and it derives the **same** fake value as the SQL adapters for the same seed value — the value-derivation logic (`build_value_deriver` et al.) was extracted from `RowTransformer` into shared class methods so both families produce byte-identical output for a given seed, type, and locale. It is applied document-side after `replace_with` (so a fake seed reads the already-masked value, matching the SQL adapters where `replace_with` runs in the database first), recurses into embedded subdocuments, and preserves `null`/absent values. `MongodbCollectionConfig` validates the key on load (exclusive with `replace_with` on the same field, unknown types rejected, seed field must resolve) and preserves it across schema regeneration. Add `gem "faker"` to use it (except a config using only `ja` person types, which build from exwiw's bundled dataset). `raw_sql` and `map` remain unsupported on MongoDB. See [MongoDB notes](README.md#mongodb-notes).
|
|
9
18
|
- **Reserved-word and special-character identifiers are now quoted (SQL adapters).** Table and column names that are reserved words (`order`, `from`, `group`, …) or contain characters invalid as a bare identifier are conditionally quoted — backticks on mysql, double quotes on postgresql/sqlite — across every emission path: SELECT projection and masking expressions, FROM/JOIN, WHERE, subqueries, materialized scope JOINs, INSERT / COPY headers and DELETE. Previously only the mysql INSERT header was quoted ([#83](https://github.com/heyinc/exwiw/pull/83)), so a reserved table name broke the extraction SELECT everywhere, a reserved column broke postgresql/sqlite INSERTs, and sqlite rejected even table-qualified reserved columns. Ordinary names stay bare, so output for existing configs is byte-identical (mysql INSERT headers keep their always-backtick form from 0.4.5). Dotted table names are treated as schema qualification and quoted per part (`billing.order` → `` billing.`order` ``), preserving Rails multi-schema `table_name`s. The reserved-word lists cover MySQL 8.4 and 9.x (including 9's `LIBRARY`) plus MariaDB-specific words, PostgreSQL's fully reserved key words, and — for sqlite — exactly the keywords that fail to parse bare in exwiw's emission positions, so fallback-accepted names like `key` are not churned. The lists are enforced by specs that probe the live mysql/postgresql servers' own keyword catalogs and re-derive the sqlite set empirically.
|
|
10
19
|
|
|
11
20
|
### Fixed
|
|
@@ -33,7 +42,6 @@
|
|
|
33
42
|
### Changed
|
|
34
43
|
|
|
35
44
|
- **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`.
|
|
36
|
-
>>>>>>> 71a0958eea6e031d2beeb7349bea35cb460414ca
|
|
37
45
|
|
|
38
46
|
## [0.9.7] - 2026-07-08
|
|
39
47
|
|
data/README.md
CHANGED
|
@@ -901,10 +901,10 @@ fake value, across tables, runs, and adapters:
|
|
|
901
901
|
that uses **only** `ja` person types needs no faker (that pool is built
|
|
902
902
|
entirely from the bundled dataset); faker is required for every other type
|
|
903
903
|
and locale.
|
|
904
|
-
- Exclusive with the other masking keys on the same column
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
`
|
|
904
|
+
- Exclusive with the other masking keys on the same column, and invisible to
|
|
905
|
+
`explain`. Also supported by the MongoDB adapter on a `MongodbField` (seed
|
|
906
|
+
names a field of the collection, or `_id`), where it is applied document-side
|
|
907
|
+
after `replace_with` — see [MongoDB notes](#mongodb-notes).
|
|
908
908
|
|
|
909
909
|
**Performance**: this is a per-row Ruby transform, measured at ~1.5–1.6µs/row
|
|
910
910
|
per fake column (so ≈ +8s per 5M rows per column; ~+40% against a local sqlite
|
|
@@ -939,7 +939,7 @@ The MongoDB adapter is experimental. To use it:
|
|
|
939
939
|
mongosh "mongodb://localhost/app_dev" dump/insert-000-schema.js
|
|
940
940
|
```
|
|
941
941
|
- Unlike SQL adapters, the MongoDB adapter does not emit `delete-*.jsonl` files (drop the database / collection yourself before importing if needed).
|
|
942
|
-
- `
|
|
942
|
+
- `replace_with_fake_data` is supported on a field ([full reference](#replace_with_fake_data)) — its `seed` names a field of the same collection (bare or `collection.`-qualified) or the primary key (`_id`), and it derives the same fake value as the SQL adapters for the same seed. It is applied document-side after `replace_with` (so a fake seed reads the already-masked value, matching the SQL adapters where `replace_with` runs in the database first), works inside embedded subdocuments, and is exclusive with `replace_with` on the same field. Add `gem "faker"` to use it (except a config using only `ja` person types). `raw_sql` and `map` are **not** supported (the `MongodbField` schema does not declare them; such keys are rejected on load — see [Unknown keys are rejected](#unknown-keys-are-rejected)); use `replace_with` for template masking.
|
|
943
943
|
- The MongoDB adapter does not support the collection-level `filter` field (it raises `NotImplementedError` if set, since the SQL-string filter cannot be applied to MongoDB).
|
|
944
944
|
|
|
945
945
|
#### `reverse_scope` on collections
|
|
@@ -119,7 +119,7 @@ module Exwiw
|
|
|
119
119
|
day_minute day_second dec decimal declare default delayed delete
|
|
120
120
|
dense_rank desc describe deterministic distinct distinctrow div double
|
|
121
121
|
drop dual each else elseif empty enclosed escaped except exists exit
|
|
122
|
-
explain false fetch first_value float float4 float8 for force foreign
|
|
122
|
+
explain external false fetch first_value float float4 float8 for force foreign
|
|
123
123
|
from fulltext function generated get grant group grouping groups
|
|
124
124
|
having high_priority hour_microsecond hour_minute hour_second if
|
|
125
125
|
ignore in index infile inner inout insensitive insert int int1 int2
|
|
@@ -132,13 +132,13 @@ module Exwiw
|
|
|
132
132
|
minute_second mod modifies natural not no_write_to_binlog nth_value
|
|
133
133
|
ntile null numeric of on optimize optimizer_costs option optionally
|
|
134
134
|
or order out outer outfile over partition percent_rank precision
|
|
135
|
-
primary procedure purge range rank read reads read_write real
|
|
135
|
+
primary procedure purge qualify range rank read reads read_write real
|
|
136
136
|
recursive references regexp release rename repeat replace require
|
|
137
137
|
resignal restrict return revoke right rlike row rows row_number
|
|
138
138
|
schema schemas second_microsecond select sensitive separator set show
|
|
139
139
|
signal smallint spatial specific sql sqlexception sqlstate sqlwarning
|
|
140
140
|
sql_big_result sql_calc_found_rows sql_small_result ssl starting
|
|
141
|
-
stored straight_join system table terminated then tinyblob tinyint
|
|
141
|
+
stored straight_join system table tablesample terminated then tinyblob tinyint
|
|
142
142
|
tinytext to trailing trigger true undo union unique unlock unsigned
|
|
143
143
|
update usage use using utc_date utc_time utc_timestamp values
|
|
144
144
|
varbinary varchar varcharacter varying virtual when where while
|
|
@@ -691,8 +691,10 @@ module Exwiw
|
|
|
691
691
|
# A masking plan compiled once per collection config and reused for every
|
|
692
692
|
# document of that collection. `masked_fields` is `[field_name,
|
|
693
693
|
# template_segments]` for each field carrying a `replace_with`;
|
|
694
|
-
# `
|
|
695
|
-
|
|
694
|
+
# `faked_fields` is `[field_name, deriver, seed_field]` for each field
|
|
695
|
+
# carrying a `replace_with_fake_data`; `embedded` is one EmbeddedMask per
|
|
696
|
+
# embedded child.
|
|
697
|
+
MaskPlan = Struct.new(:masked_fields, :faked_fields, :embedded)
|
|
696
698
|
|
|
697
699
|
# A pre-resolved embedded-child mask: the parent path split once into
|
|
698
700
|
# `prefix` (the containers to descend into) and `last` (the field holding
|
|
@@ -721,17 +723,47 @@ module Exwiw
|
|
|
721
723
|
|
|
722
724
|
acc << [field.name, compile_template(field.replace_with)]
|
|
723
725
|
end
|
|
726
|
+
faked_fields = build_faked_fields(config)
|
|
724
727
|
embedded = embedded_children_of(config).map do |child|
|
|
725
728
|
*prefix, last = child.embedded_in.path.split(".")
|
|
726
729
|
EmbeddedMask.new(prefix, last, build_mask_plan(child))
|
|
727
730
|
end
|
|
728
|
-
MaskPlan.new(masked_fields, embedded)
|
|
731
|
+
MaskPlan.new(masked_fields, faked_fields, embedded)
|
|
732
|
+
end
|
|
733
|
+
|
|
734
|
+
# Compile each `replace_with_fake_data` field into `[field_name, deriver,
|
|
735
|
+
# seed_field]`. The deriver (RowTransformer.build_value_deriver) is the
|
|
736
|
+
# same one the SQL adapters use, so a given seed value produces a
|
|
737
|
+
# byte-identical fake value across adapters. The seed is re-resolved here
|
|
738
|
+
# against the effective (post-ignore) fields, so a seed pointing at an
|
|
739
|
+
# `ignore:true` field — accepted by the load-time validation, which sees
|
|
740
|
+
# the full field list — is caught at dump time rather than silently
|
|
741
|
+
# hashing an absent value.
|
|
742
|
+
private def build_faked_fields(config)
|
|
743
|
+
config.fields.each_with_object([]) do |field, acc|
|
|
744
|
+
fake_data = field.replace_with_fake_data
|
|
745
|
+
next unless fake_data
|
|
746
|
+
|
|
747
|
+
seed_field = fake_data.seed.delete_prefix("#{config.name}.")
|
|
748
|
+
if seed_field != config.primary_key && config.fields.none? { |f| f.name == seed_field }
|
|
749
|
+
raise ArgumentError,
|
|
750
|
+
"replace_with_fake_data for collection '#{config.name}' field '#{field.name}': " \
|
|
751
|
+
"seed '#{fake_data.seed}' does not resolve to an extracted field (is it ignore:true?)"
|
|
752
|
+
end
|
|
753
|
+
|
|
754
|
+
deriver = RowTransformer.build_value_deriver(
|
|
755
|
+
fake_data, "collection '#{config.name}' field '#{field.name}'"
|
|
756
|
+
)
|
|
757
|
+
acc << [field.name, deriver, seed_field]
|
|
758
|
+
end
|
|
729
759
|
end
|
|
730
760
|
|
|
731
|
-
# Apply a precompiled MaskPlan to a document in place: render each
|
|
732
|
-
# field, then
|
|
733
|
-
#
|
|
734
|
-
#
|
|
761
|
+
# Apply a precompiled MaskPlan to a document in place: render each
|
|
762
|
+
# `replace_with` field, then each `replace_with_fake_data` field, then
|
|
763
|
+
# descend into each embedded child (recursing into its own plan). Fake
|
|
764
|
+
# fields are applied after replace_with so a fake seed reads the already-
|
|
765
|
+
# masked value — matching the SQL adapters, where replace_with runs in the
|
|
766
|
+
# database before the Ruby-side fake transform sees the row.
|
|
735
767
|
private def apply_mask_plan!(doc, plan)
|
|
736
768
|
plan.masked_fields.each do |name, segments|
|
|
737
769
|
# Preserve a NULL / absent source value instead of clobbering it into a
|
|
@@ -741,6 +773,14 @@ module Exwiw
|
|
|
741
773
|
|
|
742
774
|
doc[name] = render_template(segments, doc)
|
|
743
775
|
end
|
|
776
|
+
plan.faked_fields.each do |name, deriver, seed_field|
|
|
777
|
+
# NULL-preserving like replace_with (an absent key stays absent). The
|
|
778
|
+
# seed is read from the current doc; a nil/absent seed hashes ""
|
|
779
|
+
# deterministically.
|
|
780
|
+
next if doc[name].nil?
|
|
781
|
+
|
|
782
|
+
doc[name] = deriver.call(doc[seed_field])
|
|
783
|
+
end
|
|
744
784
|
plan.embedded.each do |child|
|
|
745
785
|
container = child.prefix.reduce(doc) { |acc, seg| acc.is_a?(Hash) ? acc[seg] : nil }
|
|
746
786
|
next unless container.is_a?(Hash)
|
|
@@ -59,11 +59,15 @@ module Exwiw
|
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def execute(query_ast)
|
|
62
|
-
data_sql =
|
|
62
|
+
data_sql = nil
|
|
63
|
+
count_sql = nil
|
|
63
64
|
# Count via the same FROM/JOIN/WHERE (projection replaced by COUNT(*)) so
|
|
64
65
|
# the Runner can skip empty tables and log the row count without draining
|
|
65
66
|
# the stream. See StreamingResult for why this is not a subquery wrap.
|
|
66
|
-
|
|
67
|
+
with_scope_materialization do
|
|
68
|
+
data_sql = commented_sql(query_ast)
|
|
69
|
+
count_sql = "#{sql_query_comment(query_ast)} #{compile_ast(query_ast, count_only: true)}"
|
|
70
|
+
end
|
|
67
71
|
|
|
68
72
|
@logger.debug(" Executing SQL (streaming): \n#{data_sql}")
|
|
69
73
|
StreamingResult.new(client: connection, data_sql: data_sql, count_sql: count_sql)
|
|
@@ -292,16 +296,86 @@ module Exwiw
|
|
|
292
296
|
# to `<col> IN (subquery)`.
|
|
293
297
|
private def compile_scope_join(from_table_name, where_clause, idx)
|
|
294
298
|
subquery = where_clause.value
|
|
295
|
-
projection = quote_identifier(subquery_projection_name(subquery))
|
|
296
|
-
src_alias = "exwiw_scope_src_#{idx}"
|
|
297
299
|
ids_alias = "exwiw_scope_ids_#{idx}"
|
|
298
300
|
outer_key = qualified_name(from_table_name, where_clause.column_name)
|
|
299
301
|
|
|
302
|
+
if (scope_table = materialized_scope_table(subquery))
|
|
303
|
+
return "JOIN #{quote_table_name(scope_table)} AS #{ids_alias} " \
|
|
304
|
+
"ON #{outer_key} = #{ids_alias}.exwiw_scope_id"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
projection = quote_identifier(subquery_projection_name(subquery))
|
|
308
|
+
src_alias = "exwiw_scope_src_#{idx}"
|
|
309
|
+
|
|
300
310
|
"JOIN (SELECT DISTINCT #{src_alias}.#{projection} AS exwiw_scope_id " \
|
|
301
311
|
"FROM (#{compile_subquery(subquery)}) AS #{src_alias}) AS #{ids_alias} " \
|
|
302
312
|
"ON #{outer_key} = #{ids_alias}.exwiw_scope_id"
|
|
303
313
|
end
|
|
304
314
|
|
|
315
|
+
# The same scope subquery (e.g. the target-tenant users id-set) is embedded
|
|
316
|
+
# in every descendant table's extraction query, so the source DB would
|
|
317
|
+
# re-evaluate it once per table — the dominant cost on large tenants.
|
|
318
|
+
# During #execute, materialize each distinct id-set once into a session
|
|
319
|
+
# TEMPORARY TABLE and JOIN that instead. Keyed by the compiled SELECT, so
|
|
320
|
+
# nested scopes reuse already-materialized parents. #explain and
|
|
321
|
+
# #describe_query compile without the flag and stay side-effect free.
|
|
322
|
+
private def with_scope_materialization
|
|
323
|
+
@materialize_scopes = true
|
|
324
|
+
yield
|
|
325
|
+
ensure
|
|
326
|
+
@materialize_scopes = false
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
private def materialized_scope_table(subquery)
|
|
330
|
+
return nil unless @materialize_scopes
|
|
331
|
+
return nil if @scope_materialization_disabled
|
|
332
|
+
|
|
333
|
+
select_sql = "SELECT DISTINCT exwiw_scope_src.#{quote_identifier(subquery_projection_name(subquery))} " \
|
|
334
|
+
"AS exwiw_scope_id FROM (#{compile_subquery(subquery)}) AS exwiw_scope_src"
|
|
335
|
+
|
|
336
|
+
@scope_id_tables ||= {}
|
|
337
|
+
begin
|
|
338
|
+
prepare_scope_session
|
|
339
|
+
@scope_id_tables[select_sql] ||= create_scope_id_table(select_sql)
|
|
340
|
+
rescue StandardError => e
|
|
341
|
+
# e.g. the DB user lacks CREATE TEMPORARY TABLES; keep extracting with
|
|
342
|
+
# the inline (per-query) scope subqueries instead of failing the run.
|
|
343
|
+
@scope_materialization_disabled = true
|
|
344
|
+
@logger.warn("Disabling scope id-set materialization (#{e.class}: #{e.message}); " \
|
|
345
|
+
"falling back to inline scope subqueries.")
|
|
346
|
+
nil
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
# Read-only replicas (e.g. Aurora MySQL 3 reader instances) cannot create
|
|
351
|
+
# InnoDB temporary tables: with NO_ENGINE_SUBSTITUTION in sql_mode the
|
|
352
|
+
# CREATE fails outright (ERROR 3161) instead of substituting a permitted
|
|
353
|
+
# engine such as MyISAM. Strip that flag once per session — it only
|
|
354
|
+
# governs DDL engine fallback, not query semantics.
|
|
355
|
+
private def prepare_scope_session
|
|
356
|
+
return if @scope_session_prepared
|
|
357
|
+
|
|
358
|
+
mode = connection.query("SELECT @@SESSION.sql_mode").rows.dig(0, 0).to_s
|
|
359
|
+
cleaned = mode.split(',').reject { |part| part == 'NO_ENGINE_SUBSTITUTION' }.join(',')
|
|
360
|
+
connection.query("SET SESSION sql_mode = '#{cleaned}'") unless cleaned == mode
|
|
361
|
+
@scope_session_prepared = true
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# If a step after CREATE fails, the temp table stays in the session until
|
|
365
|
+
# disconnect, but it is never referenced: the name is only cached (and thus
|
|
366
|
+
# only joined) after all three statements succeed, and the caller disables
|
|
367
|
+
# materialization for the rest of the run.
|
|
368
|
+
private def create_scope_id_table(select_sql)
|
|
369
|
+
name = "exwiw_scope_id_set_#{@scope_id_tables.size}"
|
|
370
|
+
connection.query("CREATE TEMPORARY TABLE #{quote_table_name(name)} AS #{select_sql}")
|
|
371
|
+
# ROW_COUNT() reads the CTAS insert count in O(1); a COUNT(*) would
|
|
372
|
+
# re-scan the whole id-set just for this log line.
|
|
373
|
+
count = connection.query("SELECT ROW_COUNT()").rows.dig(0, 0)
|
|
374
|
+
connection.query("ALTER TABLE #{quote_table_name(name)} ADD INDEX `index_exwiw_scope_id` (exwiw_scope_id)")
|
|
375
|
+
@logger.info(" Materialized scope id set #{name} (#{count} ids).")
|
|
376
|
+
name
|
|
377
|
+
end
|
|
378
|
+
|
|
305
379
|
private def compile_where_condition(where_clause, table_name)
|
|
306
380
|
# Use as it is if it's a raw query
|
|
307
381
|
return where_clause if where_clause.is_a?(String)
|
|
@@ -66,6 +66,7 @@ module Exwiw
|
|
|
66
66
|
instance = super
|
|
67
67
|
instance.__send__(:validate_embedded!)
|
|
68
68
|
instance.__send__(:validate_belongs_tos!)
|
|
69
|
+
instance.__send__(:validate_fake_data!)
|
|
69
70
|
instance
|
|
70
71
|
end
|
|
71
72
|
|
|
@@ -92,8 +93,8 @@ module Exwiw
|
|
|
92
93
|
# - structural facts come from the freshly generated config: primary_key,
|
|
93
94
|
# belongs_tos, embedded_in.
|
|
94
95
|
# - user customizations are kept from the receiver: filter, ignore,
|
|
95
|
-
# bulk_insert_chunk_size, query_timeout_ms, and each field's
|
|
96
|
-
# masking rule.
|
|
96
|
+
# bulk_insert_chunk_size, query_timeout_ms, and each field's
|
|
97
|
+
# `replace_with` / `replace_with_fake_data` masking rule.
|
|
97
98
|
# - generated fields drive the field list (so added/removed fields track the
|
|
98
99
|
# model), but a matching receiver field wins to retain its masking.
|
|
99
100
|
def merge(passed)
|
|
@@ -139,6 +140,7 @@ module Exwiw
|
|
|
139
140
|
receiver = receiver_field_by_name[pf.name]
|
|
140
141
|
if receiver
|
|
141
142
|
pf.replace_with = receiver.replace_with if receiver.replace_with
|
|
143
|
+
pf.replace_with_fake_data = receiver.replace_with_fake_data if receiver.replace_with_fake_data
|
|
142
144
|
pf.comment = receiver.comment if receiver.comment
|
|
143
145
|
pf.ignore = receiver.ignore unless receiver.ignore.nil?
|
|
144
146
|
end
|
|
@@ -147,6 +149,42 @@ module Exwiw
|
|
|
147
149
|
end
|
|
148
150
|
end
|
|
149
151
|
|
|
152
|
+
# Ruby-side masking validation, mirroring TableConfig#validate_ruby_side_masking!
|
|
153
|
+
# for the SQL adapters: `replace_with_fake_data` is exclusive with
|
|
154
|
+
# `replace_with` on the same field, the type must be supported, and the seed
|
|
155
|
+
# must name a field of this collection (bare or `name.`-qualified) or its
|
|
156
|
+
# primary key. Deliberately static (no faker require, no pool build) so
|
|
157
|
+
# schema regeneration never triggers value generation; the seed is resolved
|
|
158
|
+
# again against the effective (post-ignore) fields at dump time in
|
|
159
|
+
# MongodbAdapter#build_mask_plan.
|
|
160
|
+
private def validate_fake_data!
|
|
161
|
+
fields.each do |field|
|
|
162
|
+
fake_data = field.replace_with_fake_data
|
|
163
|
+
next unless fake_data
|
|
164
|
+
|
|
165
|
+
if field.replace_with
|
|
166
|
+
raise ArgumentError,
|
|
167
|
+
"MongodbCollectionConfig '#{name}' field '#{field.name}': replace_with and " \
|
|
168
|
+
"replace_with_fake_data cannot be combined; use only one."
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
supported_types = RowTransformer::PERSON_TYPES.keys + RowTransformer::FAKE_TYPES.keys
|
|
172
|
+
unless supported_types.include?(fake_data.type)
|
|
173
|
+
raise ArgumentError,
|
|
174
|
+
"MongodbCollectionConfig '#{name}' field '#{field.name}': unknown " \
|
|
175
|
+
"replace_with_fake_data type '#{fake_data.type}' (supported: #{supported_types.join(', ')})."
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
seed_field = fake_data.seed.delete_prefix("#{name}.")
|
|
179
|
+
if seed_field.include?(".") || (seed_field != primary_key && fields.none? { |f| f.name == seed_field })
|
|
180
|
+
raise ArgumentError,
|
|
181
|
+
"MongodbCollectionConfig '#{name}' field '#{field.name}': replace_with_fake_data " \
|
|
182
|
+
"seed '#{fake_data.seed}' does not name a field of this collection " \
|
|
183
|
+
"(use 'field' or '#{name}.field')."
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
150
188
|
private def validate_embedded!
|
|
151
189
|
return unless embedded?
|
|
152
190
|
|
data/lib/exwiw/mongodb_field.rb
CHANGED
|
@@ -6,6 +6,13 @@ module Exwiw
|
|
|
6
6
|
|
|
7
7
|
attribute :name, String
|
|
8
8
|
attribute :replace_with, optional(String), skip_serializing_if_nil: true
|
|
9
|
+
# Ruby-process-side masking: replace the value with a deterministic fake
|
|
10
|
+
# value derived from a seed field (see FakeData / RowTransformer). Unlike the
|
|
11
|
+
# SQL adapters — where replace_with runs in the database and fake data needs
|
|
12
|
+
# a separate streaming transform — the MongoDB adapter already masks
|
|
13
|
+
# document-side, so this is applied inside the collection's mask plan
|
|
14
|
+
# (MongodbAdapter#apply_mask_plan!), after `replace_with`.
|
|
15
|
+
attribute :replace_with_fake_data, Serdes::OptionalType.new(FakeData), skip_serializing_if_nil: true
|
|
9
16
|
# The Mongoid model's Ruby accessor when the stored document key (`name`)
|
|
10
17
|
# was renamed via `field :ctry, as: :country`. Purely informational — exwiw
|
|
11
18
|
# masks/projects by `name` (the storage key) — but surfacing the accessor
|
|
@@ -216,6 +216,65 @@ module Exwiw
|
|
|
216
216
|
# sampling with replacement (which wastes ~37% of slots to duplicates), this
|
|
217
217
|
# gives PERSON_POOL_SIZE distinct identities — so JapaneseNames must supply
|
|
218
218
|
# at least that many combinations.
|
|
219
|
+
# Compile a FakeData config into a callable `seed_value -> fake value`,
|
|
220
|
+
# requiring faker if the (type, locale) needs it. Shared by the SQL
|
|
221
|
+
# RowTransformer (per-row array) and the MongoDB mask plan (per-document
|
|
222
|
+
# hash): both derive byte-identical fake values from the same seed, keeping
|
|
223
|
+
# `replace_with_fake_data` consistent across adapters. The caller owns NULL
|
|
224
|
+
# preservation of the *target* (a nil seed still hashes "", deterministically,
|
|
225
|
+
# exactly like the SQL path). `error_context` names the offending column/field
|
|
226
|
+
# in raised messages.
|
|
227
|
+
def self.build_value_deriver(fake_data, error_context)
|
|
228
|
+
type = fake_data.type
|
|
229
|
+
unless PERSON_TYPES.key?(type) || FAKE_TYPES.key?(type)
|
|
230
|
+
raise ArgumentError,
|
|
231
|
+
"replace_with_fake_data for #{error_context}: unknown type '#{type}'. " \
|
|
232
|
+
"Supported: #{(PERSON_TYPES.keys + FAKE_TYPES.keys).join(', ')}"
|
|
233
|
+
end
|
|
234
|
+
require_faker! if type_needs_faker?(type, fake_data.locale)
|
|
235
|
+
|
|
236
|
+
if PERSON_TYPES.key?(type)
|
|
237
|
+
build_person_deriver(fake_data, error_context)
|
|
238
|
+
else
|
|
239
|
+
build_independent_deriver(fake_data)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Person-family deriver: pick a coherent Person from the per-locale pool.
|
|
244
|
+
# The digest[0,8] index is shared with every other person column/field, so
|
|
245
|
+
# one seed's name values all belong to the same person.
|
|
246
|
+
def self.build_person_deriver(fake_data, error_context)
|
|
247
|
+
type = fake_data.type
|
|
248
|
+
locale = fake_data.locale
|
|
249
|
+
pool = person_pool(locale)
|
|
250
|
+
extractor = PERSON_TYPES.fetch(type)
|
|
251
|
+
|
|
252
|
+
if KANA_TYPES.include?(type) && pool.first.last_name_kana.nil?
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"replace_with_fake_data for #{error_context}: type '#{type}' needs kana readings, " \
|
|
255
|
+
"which are only available with locale: ja (got locale: #{locale.inspect})"
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
lambda do |seed_value|
|
|
259
|
+
digest = Digest::SHA256.digest(seed_value.to_s)
|
|
260
|
+
person = pool[digest[0, 8].unpack1("Q>") % PERSON_POOL_SIZE]
|
|
261
|
+
extractor.call(person, locale)
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Independent-type deriver: pick a value from its own (type, locale) pool,
|
|
266
|
+
# composing a 64-bit token for uniqueness-sensitive types (email/username).
|
|
267
|
+
def self.build_independent_deriver(fake_data)
|
|
268
|
+
pool = fake_pool(fake_data.type, fake_data.locale)
|
|
269
|
+
compose = FAKE_TYPES.fetch(fake_data.type)[:compose]
|
|
270
|
+
|
|
271
|
+
lambda do |seed_value|
|
|
272
|
+
digest = Digest::SHA256.digest(seed_value.to_s)
|
|
273
|
+
base = pool[digest[0, 8].unpack1("Q>") % POOL_SIZE]
|
|
274
|
+
compose ? compose.call(base, digest[8, 8].unpack1("H*")) : base
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
219
278
|
def self.build_japanese_person_pool(random)
|
|
220
279
|
surnames = JapaneseNames::SURNAMES
|
|
221
280
|
given_names = JapaneseNames::GIVEN_NAMES
|
|
@@ -338,47 +397,29 @@ module Exwiw
|
|
|
338
397
|
seed_index
|
|
339
398
|
end
|
|
340
399
|
|
|
341
|
-
# Person-family type:
|
|
400
|
+
# Person-family type: wrap the shared per-locale person deriver with this
|
|
401
|
+
# column's NULL preservation (a NULL target stays NULL). The deriver's
|
|
342
402
|
# digest[0,8] index is shared with every other person column, so one seed's
|
|
343
403
|
# name columns all belong to the same person.
|
|
344
404
|
private def compile_person_fake(fake_data, column, column_index, seed_index)
|
|
345
|
-
|
|
346
|
-
locale = fake_data.locale
|
|
347
|
-
pool = self.class.person_pool(locale)
|
|
348
|
-
extractor = PERSON_TYPES.fetch(type)
|
|
349
|
-
|
|
350
|
-
if KANA_TYPES.include?(type) && pool.first.last_name_kana.nil?
|
|
351
|
-
raise ArgumentError,
|
|
352
|
-
"replace_with_fake_data for column '#{@table_name}.#{column.name}': " \
|
|
353
|
-
"type '#{type}' needs kana readings, which are only available with locale: ja " \
|
|
354
|
-
"(got locale: #{locale.inspect})"
|
|
355
|
-
end
|
|
356
|
-
|
|
357
|
-
# NULL-preserving like replace_with: a NULL target stays NULL.
|
|
405
|
+
deriver = self.class.build_person_deriver(fake_data, "column '#{@table_name}.#{column.name}'")
|
|
358
406
|
lambda do |row|
|
|
359
407
|
next nil if row[column_index].nil?
|
|
360
408
|
|
|
361
|
-
|
|
362
|
-
person = pool[digest[0, 8].unpack1("Q>") % PERSON_POOL_SIZE]
|
|
363
|
-
extractor.call(person, locale)
|
|
409
|
+
deriver.call(row[seed_index])
|
|
364
410
|
end
|
|
365
411
|
end
|
|
366
412
|
|
|
367
|
-
# Independent type:
|
|
413
|
+
# Independent type: wrap the shared per-(type, locale) deriver with this
|
|
414
|
+
# column's NULL preservation. A nil seed value hashes "" (deterministic).
|
|
415
|
+
# Seed values are normalized with to_s so sqlite's native Integer 123 and
|
|
416
|
+
# pg/mysql's string "123" pick the same fake value.
|
|
368
417
|
private def compile_independent_fake(fake_data, column_index, seed_index)
|
|
369
|
-
|
|
370
|
-
compose = FAKE_TYPES.fetch(fake_data.type)[:compose]
|
|
371
|
-
|
|
372
|
-
# NULL-preserving like replace_with: a NULL target stays NULL. A nil seed
|
|
373
|
-
# value hashes "" (deterministic). Seed values are normalized with to_s so
|
|
374
|
-
# sqlite's native Integer 123 and pg/mysql's string "123" pick the same
|
|
375
|
-
# fake value.
|
|
418
|
+
deriver = self.class.build_independent_deriver(fake_data)
|
|
376
419
|
lambda do |row|
|
|
377
420
|
next nil if row[column_index].nil?
|
|
378
421
|
|
|
379
|
-
|
|
380
|
-
base = pool[digest[0, 8].unpack1("Q>") % POOL_SIZE]
|
|
381
|
-
compose ? compose.call(base, digest[8, 8].unpack1("H*")) : base
|
|
422
|
+
deriver.call(row[seed_index])
|
|
382
423
|
end
|
|
383
424
|
end
|
|
384
425
|
end
|
data/lib/exwiw/version.rb
CHANGED