exwiw 0.9.6 → 0.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,18 @@ module Exwiw
8
8
 
9
9
  # @param tables [Array<Exwiw::TableConfig>] tables
10
10
  # @param logger [Logger, nil] receives a warning when a cycle has to be broken
11
+ # @param runtime_reverse_scope [Boolean] when true (mongodb), a table
12
+ # declaring `reverse_scope` is ordered AFTER its `via` referencer tables:
13
+ # the adapter builds the reverse filter from ids captured at runtime while
14
+ # the referencers were dumped, so they must be processed first. Each arm's
15
+ # own belongs_to edge back to the reverse-scoped table (the usual
16
+ # `referencer.fk -> hub` relation) is inverted rather than kept, since the
17
+ # declaration states ids flow referencer -> hub. False (the default)
18
+ # preserves the historical belongs_to-only ordering — the SQL adapters
19
+ # scope via subqueries and need the hub emitted before its referencers so
20
+ # the INSERT output stays loadable in foreign-key order.
11
21
  # @return [Array<String>] sorted table names
12
- def run(tables, logger: nil)
22
+ def run(tables, logger: nil, runtime_reverse_scope: false)
13
23
  return tables.map(&:name) if tables.size < 2
14
24
 
15
25
  ordered_table_names = []
@@ -19,31 +29,45 @@ module Exwiw
19
29
  acc[table.name] = table
20
30
  end
21
31
 
22
- # Only belongs_to relations whose target is also in this run constrain the
23
- # order. A belongs_to pointing at a table that is not being processed here
24
- # — e.g. an embedded MongoDB collection (masked through its parent, never
25
- # dumped on its own) or any table excluded from the run — is not something
26
- # we can or need to order against, so it must never block resolution.
27
- # Without this, such a dependency would stay unresolved forever and
28
- # masquerade as a circular dependency, freezing every table that
29
- # (transitively) references it.
32
+ reverse_scope_deps = runtime_reverse_scope ? compute_reverse_scope_dependencies(tables) : {}
33
+ dependencies_by_name = tables.each_with_object({}) do |table, acc|
34
+ acc[table.name] = compute_dependencies(table, reverse_scope_deps)
35
+ end
36
+
37
+ # Only relations whose target is also in this run constrain the order. A
38
+ # dependency pointing at a table that is not being processed here — e.g.
39
+ # an embedded MongoDB collection (masked through its parent, never dumped
40
+ # on its own) or any table excluded from the run — is not something we can
41
+ # or need to order against, so it must never block resolution. Without
42
+ # this, such a dependency would stay unresolved forever and masquerade as
43
+ # a circular dependency, freezing every table that (transitively)
44
+ # references it.
30
45
  present_names = table_by_name.keys.to_set
31
46
 
32
47
  loop do
33
48
  break if table_by_name.empty?
34
49
 
35
50
  resolvable = table_by_name.values.select do |table|
36
- unresolved_dependencies(table, present_names, ordered).empty?
51
+ unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered).empty?
37
52
  end
38
53
 
39
54
  if resolvable.empty?
40
55
  # No table has all its (in-run) dependencies satisfied, yet tables
41
- # remain: the belongs_to graph has a genuine cycle and no strict
42
- # topological order exists. Rather than aborting the whole export, break
43
- # the cycle by emitting one cycle member; see pick_cycle_victim for how
44
- # the member is chosen. Warn so the dropped constraint is visible.
45
- victim = pick_cycle_victim(table_by_name.values, present_names, ordered)
46
- warn_cycle_break(logger, victim, unresolved_dependencies(victim, present_names, ordered))
56
+ # remain: the dependency graph has a genuine cycle and no strict
57
+ # topological order exists.
58
+ #
59
+ # When a reverse_scope ordering edge participates in the cycle, there
60
+ # is no safe way out: emitting the reverse-scoped table before an arm
61
+ # would build its filter from missing state (silently dropping rows),
62
+ # so fail loudly instead of guessing.
63
+ detect_reverse_scope_cycle!(table_by_name, dependencies_by_name, reverse_scope_deps, present_names, ordered)
64
+
65
+ # Otherwise the cycle is a plain belongs_to cycle. Rather than
66
+ # aborting the whole export, break it by emitting one cycle member;
67
+ # see pick_cycle_victim for how the member is chosen. Warn so the
68
+ # dropped constraint is visible.
69
+ victim = pick_cycle_victim(table_by_name.values, dependencies_by_name, present_names, ordered)
70
+ warn_cycle_break(logger, victim, unresolved_dependencies(victim.name, dependencies_by_name, present_names, ordered))
47
71
  resolvable = [victim]
48
72
  end
49
73
 
@@ -67,13 +91,66 @@ module Exwiw
67
91
  table.belongs_tos.map(&:table_name)
68
92
  end
69
93
 
70
- # The dependencies still blocking `table`: belongs_to targets that are part
71
- # of this run, not yet ordered, and not the table itself (a self-referential
72
- # belongs_to never blocks).
73
- private_class_method def unresolved_dependencies(table, present_names, ordered)
74
- compute_table_dependencies(table).uniq.select do |dep|
75
- present_names.include?(dep) && !ordered.include?(dep) && dep != table.name
94
+ # reverse_scope ordering edges: reverse-scoped table name => its `via`
95
+ # referencer table names (the tables that must be processed before it).
96
+ private_class_method def compute_reverse_scope_dependencies(tables)
97
+ tables.each_with_object({}) do |table, acc|
98
+ next unless table.respond_to?(:reverse_scope)
99
+
100
+ arm_tables = (table.reverse_scope&.via || []).map(&:table).uniq
101
+ acc[table.name] = arm_tables if arm_tables.any?
102
+ end
103
+ end
104
+
105
+ # The ordering dependencies of `table`: its belongs_to targets — minus any
106
+ # belongs_to pointing at a reverse-scoped table that names `table` as a
107
+ # `via` arm (that edge is inverted: the arm feeds the reverse-scoped table
108
+ # its ids, so the arm goes first) — plus, when `table` itself is
109
+ # reverse-scoped, its `via` referencer tables.
110
+ private_class_method def compute_dependencies(table, reverse_scope_deps)
111
+ deps = compute_table_dependencies(table).reject do |dep|
112
+ reverse_scope_deps[dep]&.include?(table.name)
113
+ end
114
+ deps += reverse_scope_deps[table.name] || []
115
+ deps.uniq
116
+ end
117
+
118
+ # The dependencies still blocking `table_name`: dependency targets that are
119
+ # part of this run, not yet ordered, and not the table itself (a
120
+ # self-referential dependency never blocks).
121
+ private_class_method def unresolved_dependencies(table_name, dependencies_by_name, present_names, ordered)
122
+ dependencies_by_name.fetch(table_name).select do |dep|
123
+ present_names.include?(dep) && !ordered.include?(dep) && dep != table_name
124
+ end
125
+ end
126
+
127
+ # Raise when the stall is caused by a reverse_scope ordering edge: a
128
+ # reverse-scoped table and one of its `via` arms sit in the same non-trivial
129
+ # strongly-connected component, so no processing order can put every arm
130
+ # before the table. Typical shape: two reverse-scoped tables naming each
131
+ # other as arms, or an arm that (transitively) belongs_to the table it
132
+ # feeds. A plain belongs_to cycle (no reverse_scope edge involved) returns
133
+ # without raising, leaving the historical cycle-break to handle it.
134
+ private_class_method def detect_reverse_scope_cycle!(table_by_name, dependencies_by_name, reverse_scope_deps, present_names, ordered)
135
+ return if reverse_scope_deps.empty?
136
+
137
+ adjacency = table_by_name.each_key.each_with_object({}) do |name, acc|
138
+ acc[name] = unresolved_dependencies(name, dependencies_by_name, present_names, ordered)
76
139
  end
140
+ cyclic_names = strongly_connected_members(adjacency)
141
+
142
+ offenders = cyclic_names.select do |name|
143
+ (reverse_scope_deps[name] || []).any? { |arm| cyclic_names.include?(arm) }
144
+ end
145
+ return if offenders.empty?
146
+
147
+ details = offenders.sort.map { |name| "'#{name}' (via: #{(reverse_scope_deps[name] & cyclic_names.to_a).sort.join(', ')})" }
148
+ raise ArgumentError,
149
+ "reverse_scope creates an ordering cycle: #{details.join('; ')}. " \
150
+ "A reverse-scoped collection must be processed after all of its reverse_scope.via " \
151
+ "referencers, but these dependencies form a cycle with the belongs_to/reverse_scope " \
152
+ "graph (cycle members: #{cyclic_names.to_a.sort.join(', ')}). Remove one of the " \
153
+ "reverse_scope arms, or break the belongs_to edge that closes the cycle with `ignore: true`."
77
154
  end
78
155
 
79
156
  # Choose the next table to emit when the order is stuck in a cycle. Only
@@ -85,26 +162,27 @@ module Exwiw
85
162
  # collapsing to "match every row" (a cross-scope over-extraction risk for the
86
163
  # mongodb adapter); break remaining ties by fewest unresolved dependencies,
87
164
  # then by name, for determinism.
88
- private_class_method def pick_cycle_victim(remaining, present_names, ordered)
165
+ private_class_method def pick_cycle_victim(remaining, dependencies_by_name, present_names, ordered)
89
166
  adjacency = remaining.each_with_object({}) do |table, acc|
90
- acc[table.name] = unresolved_dependencies(table, present_names, ordered)
167
+ acc[table.name] = unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered)
91
168
  end
92
169
  cyclic_names = strongly_connected_members(adjacency)
93
170
 
94
171
  candidates = remaining.select { |table| cyclic_names.include?(table.name) }
95
172
  candidates = remaining if candidates.empty? # defensive; a stall implies a cycle
96
173
 
97
- anchored = candidates.select { |table| ordered_parent?(table, present_names, ordered) }
174
+ anchored = candidates.select { |table| ordered_parent?(table.name, dependencies_by_name, present_names, ordered) }
98
175
  pool = anchored.empty? ? candidates : anchored
99
176
 
100
- pool.min_by { |table| [unresolved_dependencies(table, present_names, ordered).size, table.name] }
177
+ pool.min_by { |table| [unresolved_dependencies(table.name, dependencies_by_name, present_names, ordered).size, table.name] }
101
178
  end
102
179
 
103
- # True when `table` has a belongs_to whose target was already ordered, so its
104
- # extraction filter will be constrained rather than an unscoped full scan.
105
- private_class_method def ordered_parent?(table, present_names, ordered)
106
- compute_table_dependencies(table).any? do |dep|
107
- dep != table.name && present_names.include?(dep) && ordered.include?(dep)
180
+ # True when `table_name` has a dependency whose target was already ordered,
181
+ # so its extraction filter will be constrained rather than an unscoped full
182
+ # scan.
183
+ private_class_method def ordered_parent?(table_name, dependencies_by_name, present_names, ordered)
184
+ dependencies_by_name.fetch(table_name).any? do |dep|
185
+ dep != table_name && present_names.include?(dep) && ordered.include?(dep)
108
186
  end
109
187
  end
110
188
 
@@ -38,7 +38,13 @@ module Exwiw
38
38
  QueryAstBuilder.validate_scope!(dumpable_configs, table_by_name, @dump_target, @logger)
39
39
 
40
40
  @logger.debug("Determining table processing order...")
41
- ordered_table_names = DetermineTableProcessingOrder.run(dumpable_configs, logger: @logger)
41
+ # Match the export's processing order (see Runner#run): mongodb orders a
42
+ # reverse-scoped collection after its `via` referencers.
43
+ ordered_table_names = DetermineTableProcessingOrder.run(
44
+ dumpable_configs,
45
+ logger: @logger,
46
+ runtime_reverse_scope: adapter.is_a?(Adapter::MongodbAdapter),
47
+ )
42
48
 
43
49
  total_size = ordered_table_names.size
44
50
  ordered_table_names.each_with_index do |table_name, idx|
@@ -68,7 +74,12 @@ module Exwiw
68
74
  private def load_table_config(klass)
69
75
  Dir[File.join(@schema_dir, "*.json")].map do |file|
70
76
  json = JSON.parse(File.read(file))
71
- klass.from(json).reject_ignored_members!
77
+ begin
78
+ klass.from(json).reject_ignored_members!
79
+ rescue UnknownConfigKeyError => e
80
+ # `.from` knows the table, not the file; point at the offending file.
81
+ raise UnknownConfigKeyError, "#{file}: #{e.message}", e.backtrace
82
+ end
72
83
  end
73
84
  end
74
85
 
@@ -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
@@ -39,7 +39,30 @@ module Exwiw
39
39
  # `path`.
40
40
  attribute :embedded_in, optional(EmbeddedIn), skip_serializing_if_nil: true
41
41
 
42
+ # `reverse_scope` opts a collection into multi-referencer reverse scoping,
43
+ # mirroring the SQL TableConfig key (see Exwiw::ReverseScope): a
44
+ # global-identity collection referenced by many scoped collections is
45
+ # constrained to the union of the ids those referencers actually point at,
46
+ # instead of being dumped in full. Unlike the SQL adapters (which emit a
47
+ # UNION subquery), the mongodb adapter captures each `via` arm's column
48
+ # values at runtime while the referencer collection streams, so the
49
+ # reverse-scoped collection must be processed AFTER its referencers — see
50
+ # DetermineTableProcessingOrder (runtime_reverse_scope) and
51
+ # MongodbAdapter#reverse_scope_filter. User-configured and never emitted by
52
+ # MongoidSchemaGenerator; preserved across regeneration (see #merge).
53
+ attribute :reverse_scope, Serdes::OptionalType.new(ReverseScope), skip_serializing_if_nil: true
54
+
42
55
  def self.from(obj)
56
+ # Reject unknown keys before deserializing: Serdes silently drops them,
57
+ # which would turn a typo'd key — or a key only the SQL adapters support,
58
+ # like a field-level `raw_sql` — into a silent no-op (see
59
+ # Exwiw::StrictKeys). `comment` is a declared attribute here and on the
60
+ # nested belongs_to/field entries, so free-form notes stay accepted.
61
+ if obj.is_a?(Hash)
62
+ collection_name = obj["name"] || obj[:name]
63
+ StrictKeys.validate!(self, obj, owner: "collection '#{collection_name}'")
64
+ end
65
+
43
66
  instance = super
44
67
  instance.__send__(:validate_embedded!)
45
68
  instance.__send__(:validate_belongs_tos!)
@@ -89,6 +112,8 @@ module Exwiw
89
112
  # is kept.
90
113
  merged.comment = passed.comment || comment
91
114
  merged.embedded_in = passed.embedded_in
115
+ # User-owned, never regenerated: carry over from the existing config.
116
+ merged.reverse_scope = reverse_scope
92
117
 
93
118
  # Structural facts of each belongs_to come from the freshly generated
94
119
  # config (including a generator-derived `references`), but the user-owned
@@ -124,6 +149,12 @@ module Exwiw
124
149
 
125
150
  private def validate_embedded!
126
151
  return unless embedded?
152
+
153
+ if reverse_scope
154
+ raise ArgumentError,
155
+ "MongodbCollectionConfig '#{name}' is embedded_in '#{embedded_in.collection_name}'; " \
156
+ "reverse_scope must not be defined (an embedded config is never dumped on its own)."
157
+ end
127
158
  return if belongs_tos.empty?
128
159
 
129
160
  raise ArgumentError,
@@ -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
@@ -47,7 +47,16 @@ module Exwiw
47
47
  QueryAstBuilder.validate_scope!(dumpable_configs, table_by_name, @dump_target, @logger)
48
48
 
49
49
  @logger.info("Determining table processing order...")
50
- ordered_table_names = DetermineTableProcessingOrder.run(dumpable_configs, logger: @logger)
50
+ # runtime_reverse_scope: the mongodb adapter builds a reverse-scoped
51
+ # collection's filter from ids captured while its `via` referencers were
52
+ # dumped, so those referencers must be processed first. The SQL adapters
53
+ # scope via subqueries and keep the historical belongs_to-only order
54
+ # (which also keeps their INSERT output loadable in foreign-key order).
55
+ ordered_table_names = DetermineTableProcessingOrder.run(
56
+ dumpable_configs,
57
+ logger: @logger,
58
+ runtime_reverse_scope: adapter.is_a?(Adapter::MongodbAdapter),
59
+ )
51
60
 
52
61
  clean_output_dir!
53
62
 
@@ -58,7 +67,7 @@ module Exwiw
58
67
  # replacement for the whole schema+inserts pass, after which the common
59
68
  # after-insert hook still runs. Everything before this point (validation,
60
69
  # scope check, ordering, output-dir clean) applies to both paths.
61
- if use_mongodb_parallel?(adapter)
70
+ if use_mongodb_parallel?(adapter, configs)
62
71
  dump_mongodb_parallel(configs, table_by_name)
63
72
  run_after_insert_hook(adapter, ordered_table_names.size)
64
73
  return
@@ -86,9 +95,17 @@ module Exwiw
86
95
  # turning the fetched rows into SQL/JSONL, the rescue below can report
87
96
  # both the failing step and the exact extraction query that produced the
88
97
  # data being processed.
89
- phase = "executing extraction query"
98
+ phase = "compiling row transforms (map/replace_with_fake_data)"
90
99
  begin
100
+ # Ruby-side masking (map / replace_with_fake_data): wrap the streamed
101
+ # results so rows are transformed as they are drained. nil (and thus
102
+ # a byte-identical, cost-free run) when no column opts in; covers
103
+ # both the INSERT and COPY branches below.
104
+ row_transformer = RowTransformer.build(table)
105
+
106
+ phase = "executing extraction query"
91
107
  results = adapter.execute(query_ast)
108
+ results = row_transformer.wrap(results) if row_transformer
92
109
  insert_idx = (idx + 1).to_s.rjust(3, '0')
93
110
 
94
111
  if @output_format == 'copy'
@@ -201,7 +218,7 @@ module Exwiw
201
218
  # target (the schedule is built around the scoped DAG), and a runtime that can
202
219
  # fork. Anything else falls back to the serial path (warning when the user
203
220
  # explicitly asked for parallelism but it cannot apply).
204
- private def use_mongodb_parallel?(adapter)
221
+ private def use_mongodb_parallel?(adapter, configs)
205
222
  return false unless adapter.is_a?(Adapter::MongodbAdapter)
206
223
  return false unless @parallel_workers && @parallel_workers > 1
207
224
 
@@ -210,6 +227,15 @@ module Exwiw
210
227
  return false
211
228
  end
212
229
 
230
+ # A reverse-scoped collection consumes @state captured from its `via`
231
+ # referencers, an ordering constraint the parallel schedule (built around
232
+ # the belongs_to DAG only) does not express yet — a worker could dump the
233
+ # collection before its arms and silently drop rows. Run serially instead.
234
+ if configs.any? { |c| c.respond_to?(:reverse_scope) && c.reverse_scope&.via&.any? }
235
+ @logger.warn("--parallel-workers ignored: reverse_scope collections require the serial processing order; running serially.")
236
+ return false
237
+ end
238
+
213
239
  unless MongodbParallelDumper.available?
214
240
  @logger.warn("--parallel-workers ignored: fork is unavailable on this runtime; running serially.")
215
241
  return false
@@ -267,7 +293,12 @@ module Exwiw
267
293
  # considered during extraction. Done here (after loading from file)
268
294
  # rather than in `.from` so the schema generators keep the full config
269
295
  # and can preserve the ignored entries on regeneration.
270
- klass.from(json).reject_ignored_members!
296
+ begin
297
+ klass.from(json).reject_ignored_members!
298
+ rescue UnknownConfigKeyError => e
299
+ # `.from` knows the table, not the file; point at the offending file.
300
+ raise UnknownConfigKeyError, "#{file}: #{e.message}", e.backtrace
301
+ end
271
302
  end
272
303
  end
273
304