exwiw 0.9.6 → 0.9.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +17 -0
- data/README.md +132 -12
- data/docs/row-transform-masking-notes.md +156 -0
- data/lib/exwiw/adapter/mongodb_adapter.rb +118 -2
- data/lib/exwiw/determine_table_processing_order.rb +109 -31
- data/lib/exwiw/explain_runner.rb +13 -2
- data/lib/exwiw/fake_data.rb +21 -0
- data/lib/exwiw/mongodb_collection_config.rb +31 -0
- data/lib/exwiw/row_transformer.rb +243 -0
- data/lib/exwiw/runner.rb +36 -5
- data/lib/exwiw/strict_keys.rb +79 -0
- data/lib/exwiw/table_column.rb +5 -0
- data/lib/exwiw/table_config.rb +45 -0
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +3 -0
- metadata +5 -1
|
@@ -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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# (
|
|
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
|
|
42
|
-
# topological order exists.
|
|
43
|
-
#
|
|
44
|
-
#
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
#
|
|
71
|
-
#
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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 `
|
|
104
|
-
# extraction filter will be constrained rather than an unscoped full
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
data/lib/exwiw/explain_runner.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
|