exwiw 0.9.11 → 0.9.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f85d2a708bc4da44d7a32b0ef91dcf42967b65f5ddcfca516e0ce674de568b4
4
- data.tar.gz: 0eb9ea0cd15abd41d64380170323070c0961a37a1bd796bfd9c1c63484c8b6d3
3
+ metadata.gz: 4be92980b62c26dbb27cef847c3534134cf9e5d27c4676dc2ff28468168e52ed
4
+ data.tar.gz: 357c2efdbe2e818fa4e5cce3739b5d77bb05b0dda50817dc9a8e622746b3d6ce
5
5
  SHA512:
6
- metadata.gz: 645793841437d64ad976c84e89dc7f0bde405dbc549a953b870085a6fac26886060535a69bc3f41f5480f4dc31d5c15ce97b598d0146bb96981004cdcee3679e
7
- data.tar.gz: 15efb4638a90daf07b9cc83cf2f6613fd0ecee93daaff6fbad40608002d0bf26c4bf61a64a83389d7e672101a6d15c3b4250e34b7daa202d176c62d931b1a7a5
6
+ metadata.gz: 632102dbdeb9b713abf435fff5224565675f7d34843e6a1e447d96b71920018ff2a79f9f27971afc153a27813e6cd68cefc7df790dbc00fe8a371d8d4cb32c5a
7
+ data.tar.gz: c7dbd4ec63a7b711a276fb4e0c07e5e481621664c5bd07cdc2452b5a60c38c6843e11891cb46d966a969c57e6fe99714d20d5f786a6d9af087d4966180401f58
data/CHANGELOG.md CHANGED
@@ -2,10 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.12] - 2026-07-09
6
+
5
7
  ## [0.9.11] - 2026-07-09
6
8
 
7
9
  ### Added
8
10
 
11
+ - **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
12
  - **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
13
 
11
14
  ### Fixed
@@ -33,7 +36,6 @@
33
36
  ### Changed
34
37
 
35
38
  - **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
39
 
38
40
  ## [0.9.7] - 2026-07-08
39
41
 
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. SQL adapters only
905
- (the MongoDB adapter rejects the key on load, see
906
- [Unknown keys are rejected](#unknown-keys-are-rejected)), and invisible to
907
- `explain`.
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
- - `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.
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
@@ -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
- # `embedded` is one EmbeddedMask per embedded child.
695
- MaskPlan = Struct.new(:masked_fields, :embedded)
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 masked
732
- # field, then descend into each embedded child (recursing into its own
733
- # plan). Equivalent to the old apply_replace_with! + apply_embedded_masking!
734
- # pair, with all per-config lookups hoisted into the plan.
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)
@@ -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 `replace_with`
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
 
@@ -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: pick a coherent Person from the per-locale pool. The
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
- type = fake_data.type
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
- digest = Digest::SHA256.digest(row[seed_index].to_s)
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: pick a value from its own (type, locale) pool.
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
- pool = self.class.fake_pool(fake_data.type, fake_data.locale)
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
- digest = Digest::SHA256.digest(row[seed_index].to_s)
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.11"
4
+ VERSION = "0.9.12"
5
5
  end
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.11
4
+ version: 0.9.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia