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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/README.md +101 -10
- data/docs/mongodb.md +13 -0
- data/lib/exwiw/cli.rb +190 -3
- data/lib/exwiw/db_introspector/mysql_introspector.rb +160 -0
- data/lib/exwiw/db_introspector/postgresql_introspector.rb +215 -0
- data/lib/exwiw/db_introspector.rb +166 -0
- data/lib/exwiw/db_schema_generator.rb +298 -0
- data/lib/exwiw/mongodb_collection_config.rb +21 -12
- data/lib/exwiw/mongoid_schema_generator.rb +368 -36
- data/lib/exwiw/schema_check.rb +48 -6
- data/lib/exwiw/table_config.rb +1 -1
- data/lib/exwiw/version.rb +1 -1
- data/lib/exwiw.rb +4 -0
- data/lib/tasks/exwiw.rake +37 -0
- metadata +5 -1
|
@@ -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
|
|
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
|
|
137
|
-
#
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|