exwiw 0.9.21 → 0.9.22

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.
@@ -0,0 +1,298 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "set"
6
+
7
+ module Exwiw
8
+ # Generates (and tidies) a schema config from a live database connection
9
+ # instead of from an application's models — see DbIntrospector for why an
10
+ # application exwiw cannot load needs this.
11
+ #
12
+ # It mirrors SchemaGenerator table for table and column for column, with one
13
+ # deliberate difference in how belongs_tos are reconciled (see
14
+ # #merged_belongs_tos) and one structural simplification: the output directory
15
+ # is flat, because a connection addresses exactly one database. A second
16
+ # database is a second run against a second connection, which is also how the
17
+ # extraction itself treats it.
18
+ class DbSchemaGenerator
19
+ # SchemaGenerator::TidyResult plus the belongs_tos this generator can also
20
+ # remove. Subclassed rather than extended in place so the ActiveRecord
21
+ # generator's result — and the rake task reporting it — keeps exactly the
22
+ # contract it has today.
23
+ class TidyResult < SchemaGenerator::TidyResult
24
+ attr_reader :removed_belongs_tos
25
+
26
+ def initialize
27
+ super
28
+ @removed_belongs_tos = {}
29
+ end
30
+
31
+ def add_removed_belongs_to(table_name, target_table_name)
32
+ (@removed_belongs_tos[table_name] ||= []) << target_table_name
33
+ end
34
+
35
+ def empty?
36
+ super && @removed_belongs_tos.empty?
37
+ end
38
+ end
39
+
40
+ # `safe_new_columns` matches SchemaGenerator's: on (the default) every
41
+ # column is emitted masked as far as its type allows and flagged
42
+ # `needs_mask_decision: true`, and #merge lets an already-decided entry win,
43
+ # so in practice only genuinely new columns keep that treatment.
44
+ def initialize(introspector:, output_dir:, safe_new_columns: true)
45
+ @introspector = introspector
46
+ @output_dir = output_dir
47
+ @safe_new_columns = safe_new_columns
48
+ end
49
+
50
+ # Write one config file per table in the database, merged with whatever is
51
+ # already on disk, and return the configs as written.
52
+ #
53
+ # Unlike SchemaGenerator, the configs are built *inside* the write path
54
+ # rather than by a separate builder: what the generated belongs_tos are —
55
+ # and therefore which columns count as structural and must not be masked —
56
+ # depends on the existing file (see #merged_belongs_tos), so the two cannot
57
+ # be separated without introspecting the disk twice.
58
+ def generate!
59
+ FileUtils.mkdir_p(@output_dir)
60
+
61
+ @introspector.table_names.map do |table_name|
62
+ path = File.join(@output_dir, "#{table_name}.json")
63
+ existing = read_config(path)
64
+ generated = build_table(table_name, existing)
65
+
66
+ config_to_write = existing ? existing.merge(generated) : generated
67
+ File.write(path, JSON.pretty_generate(config_to_write.to_hash) + "\n")
68
+ config_to_write
69
+ end
70
+ end
71
+
72
+ # Reconcile the config files on disk against the database, removing only
73
+ # what no longer exists there:
74
+ #
75
+ # - a config file whose table is gone is deleted,
76
+ # - columns a surviving table no longer has are dropped, and
77
+ # - a belongs_to pointing at a table that is gone is dropped.
78
+ #
79
+ # The belongs_to case is this generator's own. Because #generate! only ever
80
+ # adds relations (never rewrites the list), a relation whose target table
81
+ # was dropped would otherwise survive every regeneration, and a belongs_to
82
+ # with no target table crashes dependency resolution at extraction time.
83
+ # An `ignore: true` entry is kept regardless: those are user tombstones
84
+ # recording a decision ("this relation is deliberately not extracted"), and
85
+ # deleting one would invite the next regeneration to add the edge back.
86
+ #
87
+ # Like SchemaGenerator#tidy!, nothing is added or regenerated here: every
88
+ # surviving entry keeps its hand-edited `comment` / `ignore` /
89
+ # `replace_with` untouched. Returns a TidyResult describing the removals.
90
+ def tidy!
91
+ result = TidyResult.new
92
+ return result unless Dir.exist?(@output_dir)
93
+
94
+ # Views are not generated (see DbIntrospector::Base#table_names), so a
95
+ # config naming one is stale by the same definition as a dropped table.
96
+ existing_tables = @introspector.table_names.to_set
97
+
98
+ Dir[File.join(@output_dir, "*.json")].sort.each do |path|
99
+ existing = TableConfig.from(JSON.parse(File.read(path)))
100
+
101
+ unless existing_tables.include?(existing.name)
102
+ File.delete(path)
103
+ result.add_removed_table(existing.name)
104
+ next
105
+ end
106
+
107
+ changed = false
108
+ changed |= remove_stale_columns(existing, result)
109
+ changed |= remove_dangling_belongs_tos(existing, existing_tables, result)
110
+ File.write(path, JSON.pretty_generate(existing.to_hash) + "\n") if changed
111
+ end
112
+
113
+ result
114
+ end
115
+
116
+ private def read_config(path)
117
+ return nil unless File.exist?(path)
118
+
119
+ TableConfig.from(JSON.parse(File.read(path)))
120
+ end
121
+
122
+ # The config for one table, in the same three shapes SchemaGenerator emits
123
+ # — with one more case it has to handle: a table with no primary key at
124
+ # all. ActiveRecord always reports one (a model without it cannot be
125
+ # queried), but a database is free to have none.
126
+ private def build_table(table_name, existing)
127
+ introspected_primary_key = @introspector.primary_key(table_name)
128
+ primary_key = declared_primary_key(introspected_primary_key, existing)
129
+ belongs_tos = merged_belongs_tos(table_name, existing)
130
+ column_names = @introspector.columns(table_name).map(&:name)
131
+
132
+ # A composite primary key is not supported yet. The config file is still
133
+ # generated — with `primary_key` omitted, `ignore: true` and a `type`
134
+ # marking it unsupported — so it can serve as a signpost for adding
135
+ # support later, and so a user can wire it up by hand meanwhile.
136
+ if primary_key.nil? && introspected_primary_key.is_a?(Array)
137
+ TableConfig.from_symbol_keys(
138
+ name: table_name,
139
+ type: TableConfig::UNSUPPORTED_COMPOSITE_PRIMARY_KEY,
140
+ ignore: true,
141
+ comment: "exwiw does not support composite primary keys " \
142
+ "(#{introspected_primary_key.join(', ')}); data extraction is skipped.",
143
+ belongs_tos: belongs_tos,
144
+ columns: column_names.map { |name| { name: name } },
145
+ )
146
+ elsif primary_key.nil?
147
+ # exwiw addresses rows by primary key — it is what an extraction query
148
+ # filters and joins on — so a table without one cannot be extracted as
149
+ # it stands. Emitted with `ignore: true` rather than skipped entirely so
150
+ # the table is visible in the config (and in schema:check) instead of
151
+ # silently missing, and so opting it in is an edit rather than a
152
+ # discovery.
153
+ TableConfig.from_symbol_keys(
154
+ name: table_name,
155
+ ignore: true,
156
+ comment: "This table has no primary key, which exwiw needs to identify and join rows; " \
157
+ "data extraction is skipped. To export it, set `primary_key` to a column that " \
158
+ "uniquely identifies a row and remove `ignore`.",
159
+ belongs_tos: belongs_tos,
160
+ columns: column_names.map { |name| { name: name } },
161
+ )
162
+ else
163
+ TableConfig.from_symbol_keys(
164
+ name: table_name,
165
+ primary_key: primary_key,
166
+ belongs_tos: belongs_tos,
167
+ columns: build_columns(table_name, primary_key, belongs_tos),
168
+ )
169
+ end
170
+ end
171
+
172
+ # The primary key to build a table's config from: the one the database
173
+ # reports, or — when it reports none exwiw can use — the one the config on
174
+ # disk declares.
175
+ #
176
+ # The fallback is what makes the two signpost shapes below actionable. Both
177
+ # tell the user to name a primary key by hand ("set `primary_key` to a
178
+ # column that uniquely identifies a row and remove `ignore`"), and a table
179
+ # can be perfectly extractable that way — a natural key with a unique index
180
+ # but no PK constraint, or one column of a composite key that is unique on
181
+ # its own. Without this, following those instructions would not survive the
182
+ # next run: `TableConfig#merge` takes `primary_key` from the generated side,
183
+ # which is still nil because the database is unchanged, so the hand-set key
184
+ # would be dropped and the table left with nothing to join or filter on —
185
+ # silently, since the regenerated config is also what `schema check`
186
+ # compares against.
187
+ #
188
+ # A declared key also selects the ordinary table shape, so the `type` /
189
+ # `comment` signposts are not re-imposed on a table the user has since wired
190
+ # up (`ignore` is receiver-owned in the merge and already stays removed).
191
+ private def declared_primary_key(introspected_primary_key, existing)
192
+ return introspected_primary_key if introspected_primary_key.is_a?(String)
193
+
194
+ declared = existing&.primary_key
195
+ declared.is_a?(String) ? declared : nil
196
+ end
197
+
198
+ # The belongs_tos to generate for a table: everything the existing config
199
+ # already declares, verbatim and in its on-disk order, plus the
200
+ # foreign-key-derived relations it does not have yet, appended in sorted
201
+ # order.
202
+ #
203
+ # This union is the one place this generator deliberately departs from the
204
+ # ActiveRecord one, and it exists because a foreign-key constraint is
205
+ # strictly weaker evidence than an application model. Plenty of schemas
206
+ # express a relation only in application code — no constraint backs it —
207
+ # and the belongs_tos in an existing config are frequently hand-written for
208
+ # exactly that reason. They are load-bearing: a belongs_to is the path
209
+ # extraction follows to reach a table, so dropping one silently narrows the
210
+ # dump. TableConfig#merge rebuilds `belongs_tos` from the generated side,
211
+ # which is safe when that side saw the models and knows the full set, but
212
+ # would delete every unbacked relation here.
213
+ #
214
+ # So introspection only ever *adds* an edge. Removing one is tidy's job,
215
+ # where it is driven by the target table actually being gone rather than by
216
+ # the absence of a constraint (see #tidy!).
217
+ # Returns plain hashes rather than BelongsTo objects, because that is what
218
+ # TableConfig.from_symbol_keys consumes (it round-trips the whole table
219
+ # through JSON) — and because a hash of the existing entry carries its
220
+ # user-owned `comment` / `ignore` / `ignore_type` / `references` along
221
+ # without this method having to know they exist.
222
+ private def merged_belongs_tos(table_name, existing)
223
+ declared = (existing&.belongs_tos || []).map(&:to_hash)
224
+ # Identity here is the physical join — target table plus foreign-key
225
+ # column — rather than BelongsTo#identity, which also distinguishes the
226
+ # polymorphic type value. A hand-written polymorphic relation already
227
+ # covers its foreign-key column, so re-adding the bare constraint edge
228
+ # would emit a second belongs_to joining on the same column.
229
+ declared_keys = declared.map { |entry| [entry["table_name"], entry["foreign_key"]] }.to_set
230
+
231
+ discovered = @introspector.foreign_keys(table_name)
232
+ .reject { |entry| declared_keys.include?([entry[:table_name], entry[:foreign_key]]) }
233
+ .map { |entry| { "table_name" => entry[:table_name], "foreign_key" => entry[:foreign_key] } }
234
+
235
+ declared + discovered
236
+ end
237
+
238
+ # The `columns` entries for a table: just the name, or — in safe mode — also
239
+ # a default mask and the `needs_mask_decision` flag, exactly as
240
+ # SchemaGenerator#build_columns does it. The primary key and the foreign
241
+ # keys/types the belongs_tos join on are flagged but never masked, since
242
+ # masking them would break the joins.
243
+ #
244
+ # The structural set is computed from the *merged* belongs_tos, so a
245
+ # relation that exists only in the config — with no constraint behind it —
246
+ # protects its foreign-key column from a default mask just as a discovered
247
+ # one does. An `ignore: true` relation counts too: the column is still a
248
+ # foreign key, and the tombstone says nothing about masking it.
249
+ private def build_columns(table_name, primary_key, belongs_tos)
250
+ columns = @introspector.columns(table_name)
251
+ return columns.map { |column| { name: column.name } } unless @safe_new_columns
252
+
253
+ structural = belongs_tos.flat_map { |bt| [bt["foreign_key"], bt["foreign_type"]] }.compact.to_set
254
+ structural << primary_key
255
+ unique = @introspector.unique_column_names(table_name)
256
+
257
+ columns.map do |column|
258
+ entry = { name: column.name, needs_mask_decision: true }
259
+ next entry if structural.include?(column.name)
260
+
261
+ mask = DefaultMask.for(
262
+ name: column.name,
263
+ type: column.type,
264
+ limit: column.limit,
265
+ primary_key: primary_key,
266
+ array: column.array,
267
+ unique: unique.nil? || unique.include?(column.name),
268
+ column_default: column.default,
269
+ )
270
+ mask.nil? ? entry : entry.merge(replace_with: mask)
271
+ end
272
+ end
273
+
274
+ private def remove_stale_columns(existing, result)
275
+ valid_column_names = @introspector.columns(existing.name).map(&:name).to_set
276
+ stale = existing.columns.reject { |column| valid_column_names.include?(column.name) }
277
+ return false if stale.empty?
278
+
279
+ existing.columns = existing.columns.select { |column| valid_column_names.include?(column.name) }
280
+ stale.each { |column| result.add_removed_column(existing.name, column.name) }
281
+ true
282
+ end
283
+
284
+ private def remove_dangling_belongs_tos(existing, existing_tables, result)
285
+ dangling = existing.belongs_tos.reject do |belongs_to|
286
+ # An ignored relation is a user tombstone and is kept whatever its
287
+ # target is; one with no target at all is already inert (it records a
288
+ # relation exwiw cannot resolve) and is left alone as well.
289
+ belongs_to.ignore || belongs_to.table_name.nil? || existing_tables.include?(belongs_to.table_name)
290
+ end
291
+ return false if dangling.empty?
292
+
293
+ existing.belongs_tos = existing.belongs_tos - dangling
294
+ dangling.each { |belongs_to| result.add_removed_belongs_to(existing.name, belongs_to.table_name) }
295
+ true
296
+ end
297
+ end
298
+ end
@@ -96,7 +96,8 @@ module Exwiw
96
96
  # bulk_insert_chunk_size, query_timeout_ms, and each field's
97
97
  # `replace_with` / `replace_with_fake_data` masking rule.
98
98
  # - generated fields drive the field list (so added/removed fields track the
99
- # model), but a matching receiver field wins to retain its masking.
99
+ # model), but for a field the receiver already has, its masking decision
100
+ # wins outright — including the parts of it left unset.
100
101
  def merge(passed)
101
102
  return passed if passed.to_hash == to_hash
102
103
 
@@ -133,20 +134,28 @@ module Exwiw
133
134
  end
134
135
 
135
136
  # Take each field from the freshly generated config (so structural facts
136
- # like `mongoid_field_name` track the model) but carry over the user's
137
- # hand-edited `replace_with`/`comment`/`ignore` when the field still exists.
137
+ # like `mongoid_field_name` track the model), but once the field already
138
+ # exists in the receiver, EVERY masking-decision attribute comes from the
139
+ # receiver — even when it is unset.
140
+ #
141
+ # "Even when unset" is the whole point: what these attributes record is a
142
+ # human's decision about the field, and an absent value is a decision too
143
+ # ("export this raw", "the flag is resolved"). Keeping the generated value
144
+ # where the receiver has none was equivalent to "receiver wins" only while
145
+ # the generator emitted no masks at all; under safe mode
146
+ # (MongoidSchemaGenerator's `safe_new_columns`) it would put a default mask
147
+ # back on a field somebody had deliberately unmasked, and silently mask it
148
+ # again on every regeneration.
138
149
  receiver_field_by_name = fields.each_with_object({}) { |f, h| h[f.name] = f }
139
150
  merged.fields = passed.fields.map do |pf|
140
151
  receiver = receiver_field_by_name[pf.name]
141
- if receiver
142
- pf.replace_with = receiver.replace_with unless receiver.replace_with.nil?
143
- pf.replace_with_fake_data = receiver.replace_with_fake_data if receiver.replace_with_fake_data
144
- pf.comment = receiver.comment if receiver.comment
145
- pf.ignore = receiver.ignore unless receiver.ignore.nil?
146
- # Receiver wins even when unset: clearing the flag is how a human
147
- # records the decision, and regeneration must not bring it back.
148
- pf.needs_mask_decision = receiver.needs_mask_decision
149
- end
152
+ next pf unless receiver
153
+
154
+ pf.replace_with = receiver.replace_with
155
+ pf.replace_with_fake_data = receiver.replace_with_fake_data
156
+ pf.comment = receiver.comment
157
+ pf.ignore = receiver.ignore
158
+ pf.needs_mask_decision = receiver.needs_mask_decision
150
159
  pf
151
160
  end
152
161
  end