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
|
@@ -31,21 +31,68 @@ module Exwiw
|
|
|
31
31
|
end
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
# Every generated collection is keyed by the MongoDB document id. Named so
|
|
35
|
+
# the value the configs carry and the value `DefaultMask` interpolates into a
|
|
36
|
+
# text mask (`masked-{_id}`) provably come from one place.
|
|
37
|
+
PRIMARY_KEY = "_id"
|
|
38
|
+
|
|
39
|
+
# Mongoid field types (`Model.fields[name].type`, a Ruby class) mapped to the
|
|
40
|
+
# type symbols `DefaultMask.for` understands, so safe mode can reuse the very
|
|
41
|
+
# same default masks the ActiveRecord generator emits.
|
|
42
|
+
#
|
|
43
|
+
# Keyed by class NAME rather than by the class itself: this constant is
|
|
44
|
+
# evaluated when exwiw is loaded, which happens in processes that never load
|
|
45
|
+
# Mongoid (the CLI), so naming `Mongoid::Boolean` here would raise. A name
|
|
46
|
+
# comparison also sidesteps `DateTime < Date` — an ancestry test would have to
|
|
47
|
+
# order its branches, while exact names cannot be confused for one another.
|
|
48
|
+
#
|
|
49
|
+
# Everything absent from this map (Hash, Array, Object — which is also what a
|
|
50
|
+
# typeless/dynamic field reports — BSON types, Mongoid::StringifiedSymbol, ...)
|
|
51
|
+
# deliberately gets NO default mask: a constant that does not fit the field
|
|
52
|
+
# would be restored as a value the application cannot read, which is worse
|
|
53
|
+
# than exporting the field while its `needs_mask_decision` flag keeps the
|
|
54
|
+
# change from being merged.
|
|
55
|
+
MASKABLE_FIELD_TYPES = {
|
|
56
|
+
"String" => :string,
|
|
57
|
+
"Integer" => :integer,
|
|
58
|
+
"Float" => :float,
|
|
59
|
+
"BigDecimal" => :decimal,
|
|
60
|
+
"Mongoid::Boolean" => :boolean,
|
|
61
|
+
"Time" => :datetime,
|
|
62
|
+
"DateTime" => :datetime,
|
|
63
|
+
"ActiveSupport::TimeWithZone" => :datetime,
|
|
64
|
+
"Date" => :date,
|
|
65
|
+
}.freeze
|
|
66
|
+
|
|
34
67
|
# `skip_unsupported`: when true, the generator does not abort on a construct
|
|
35
68
|
# it cannot represent. It skips an unresolvable `belongs_to` (keeping the
|
|
36
69
|
# foreign-key field) and emits an unrepresentable embedded collection as an
|
|
37
70
|
# `ignore: true` top-level config annotated with a `comment`, warning to
|
|
38
71
|
# stderr in both cases. Off by default, so the historical fail-loud behavior
|
|
39
72
|
# is unchanged unless a caller opts in.
|
|
40
|
-
|
|
73
|
+
#
|
|
74
|
+
# `safe_new_columns` mirrors `SchemaGenerator`; see #initialize.
|
|
75
|
+
def self.from_rails_application(output_dir:, skip_unsupported: false, safe_new_columns: true)
|
|
41
76
|
Rails.application.eager_load!
|
|
42
|
-
new(
|
|
77
|
+
new(
|
|
78
|
+
models: ::Mongoid.models,
|
|
79
|
+
output_dir: output_dir,
|
|
80
|
+
skip_unsupported: skip_unsupported,
|
|
81
|
+
safe_new_columns: safe_new_columns,
|
|
82
|
+
)
|
|
43
83
|
end
|
|
44
84
|
|
|
45
|
-
|
|
85
|
+
# `safe_new_columns` (the default) emits every field masked — as far as its
|
|
86
|
+
# Mongoid type allows, see MASKABLE_FIELD_TYPES and DefaultMask — and flagged
|
|
87
|
+
# `needs_mask_decision: true`. MongodbCollectionConfig#merge lets an existing
|
|
88
|
+
# entry win, so in practice only fields a model change has just added keep
|
|
89
|
+
# that treatment. Pass false to bootstrap a config, where every field is new
|
|
90
|
+
# and flagging all of them at once is noise.
|
|
91
|
+
def initialize(models:, output_dir:, skip_unsupported: false, safe_new_columns: true)
|
|
46
92
|
@models = models
|
|
47
93
|
@output_dir = output_dir
|
|
48
94
|
@skip_unsupported = skip_unsupported
|
|
95
|
+
@safe_new_columns = safe_new_columns
|
|
49
96
|
end
|
|
50
97
|
|
|
51
98
|
def generate!
|
|
@@ -68,10 +115,118 @@ module Exwiw
|
|
|
68
115
|
# on a construct the user has deliberately ignored. Empty (the default) when
|
|
69
116
|
# called directly without an output dir, in which case nothing is honored.
|
|
70
117
|
def build_collections(existing_by_name = {})
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
118
|
+
models_by_collection_name.map do |collection_name, group|
|
|
119
|
+
build_collection_for(collection_name, group, existing_by_name[collection_name])
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Reconcile the config files already on disk against the models, deleting the
|
|
124
|
+
# config of a collection no model stores into any more. This is the
|
|
125
|
+
# counterpart of `generate!`, which adds and updates config files but can
|
|
126
|
+
# never delete one: a collection whose model was removed leaves a config that
|
|
127
|
+
# would otherwise be dumped forever.
|
|
128
|
+
#
|
|
129
|
+
# Fields are deliberately NOT touched: `generate!`'s #merge already drives the
|
|
130
|
+
# field list from the model, so a field the model lost is dropped there.
|
|
131
|
+
#
|
|
132
|
+
# Unlike `SchemaGenerator#tidy!`, the source of truth is the model set, not a
|
|
133
|
+
# live connection. MongoDB has no schema to read: a collection exists only
|
|
134
|
+
# once a document is written to it, so "still in the database" is not a
|
|
135
|
+
# question that can be answered before a dump, and introspection here is
|
|
136
|
+
# class-level (see this class's preamble) and needs no connection at all. The
|
|
137
|
+
# name set therefore comes from exactly the grouping `generate!` writes files
|
|
138
|
+
# from (`models_by_collection_name`, descendants expanded and embedded models
|
|
139
|
+
# included), so the two can never disagree about which files are live.
|
|
140
|
+
#
|
|
141
|
+
# A config declaring `embedded_in` is never deleted, whatever its name — see
|
|
142
|
+
# #hand_written_embedded_config?.
|
|
143
|
+
#
|
|
144
|
+
# Returns a `SchemaGenerator::TidyResult` — reused rather than duplicated,
|
|
145
|
+
# since a removal report is the same shape whatever the ORM — describing the
|
|
146
|
+
# removals so callers (e.g. the rake task) can report them. Its
|
|
147
|
+
# `removed_columns` stays empty, for the reason above.
|
|
148
|
+
def tidy!
|
|
149
|
+
result = SchemaGenerator::TidyResult.new
|
|
150
|
+
return result unless @output_dir && File.directory?(@output_dir)
|
|
151
|
+
|
|
152
|
+
live_names = models_by_collection_name.keys
|
|
153
|
+
|
|
154
|
+
Dir[File.join(@output_dir, "*.json")].sort.each do |path|
|
|
155
|
+
config = read_raw_config(path)
|
|
156
|
+
|
|
157
|
+
# A file that does not parse says nothing about what it describes: its
|
|
158
|
+
# basename would stand in for the collection name, and for a hand-written
|
|
159
|
+
# embedded config that basename matches no collection by construction —
|
|
160
|
+
# so the guard below would be bypassed for exactly the files it exists to
|
|
161
|
+
# protect, and a stray merge-conflict marker would be enough to delete a
|
|
162
|
+
# collection's masking rules. Broken JSON already fails loudly wherever
|
|
163
|
+
# the config is loaded, so leave it in place and say so.
|
|
164
|
+
if config.nil?
|
|
165
|
+
warn("exwiw: skipping '#{path}' while tidying: it is not valid JSON, so what it describes cannot be determined.")
|
|
166
|
+
next
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
name = declared_name(config, path)
|
|
170
|
+
next if live_names.include?(name)
|
|
171
|
+
next if hand_written_embedded_config?(config)
|
|
172
|
+
|
|
173
|
+
File.delete(path)
|
|
174
|
+
result.add_removed_table(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
result
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether a config on disk describes an embedded collection this generator
|
|
181
|
+
# could not have written, and whose liveness it therefore cannot judge.
|
|
182
|
+
#
|
|
183
|
+
# `generate!` writes one config per *collection name*, so when two embedded
|
|
184
|
+
# classes share a collection name — which they routinely do, since an embedded
|
|
185
|
+
# class's collection name is derived from the class name alone (`Address`
|
|
186
|
+
# embedded under two different parents is `addresses` both times) — only one
|
|
187
|
+
# of them is emitted. The other can only be expressed by hand, under a name
|
|
188
|
+
# that says where it lives (`orders_deliveries_addresses`) rather than naming
|
|
189
|
+
# a collection. Such a name matches no model by construction, so "no model
|
|
190
|
+
# stores into it" is not evidence that it is dead: it is evidence that the
|
|
191
|
+
# config exists precisely because generation cannot reach it.
|
|
192
|
+
#
|
|
193
|
+
# Deleting one is silent and expensive — an embedded config carries the
|
|
194
|
+
# masking rules applied to those subdocuments, so removing it exports them
|
|
195
|
+
# raw, and nothing else in the pipeline notices. So an `embedded_in` config
|
|
196
|
+
# is kept whatever its name; a genuinely dead one is removed by hand, which is
|
|
197
|
+
# also how it arrived.
|
|
198
|
+
private def hand_written_embedded_config?(config)
|
|
199
|
+
config.is_a?(Hash) && !config["embedded_in"].nil?
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# The collection -> models grouping both `build_collections` and `tidy!` work
|
|
203
|
+
# from: every model that stores into a collection, keyed by that collection's
|
|
204
|
+
# name. Models are grouped by `collection_name` so an STI hierarchy collapses
|
|
205
|
+
# into one entry (see `build_collections`), and `expand_with_descendants`
|
|
206
|
+
# supplies the subclasses Mongoid does not register.
|
|
207
|
+
#
|
|
208
|
+
# Shared so the two operations can never disagree about which collections
|
|
209
|
+
# exist: `tidy!` deletes exactly the config files `generate!` would not write.
|
|
210
|
+
private def models_by_collection_name
|
|
211
|
+
expand_with_descendants(concrete_models).group_by { |model| model.collection_name.to_s }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A config file on disk as plain JSON, rather than through
|
|
215
|
+
# `MongodbCollectionConfig.from` on purpose: a stale file is exactly the one
|
|
216
|
+
# that may no longer satisfy the current validations (an unknown key, a
|
|
217
|
+
# belongs_to whose target is gone), and tidy has to be able to delete such a
|
|
218
|
+
# file rather than abort on it. Unparseable JSON reads as nil, which the
|
|
219
|
+
# caller treats as "cannot judge this file" rather than as an empty config.
|
|
220
|
+
private def read_raw_config(path)
|
|
221
|
+
JSON.parse(File.read(path))
|
|
222
|
+
rescue JSON::ParserError
|
|
223
|
+
nil
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# The collection a config declares itself to be. Without a readable `name` it
|
|
227
|
+
# falls back to the file's basename, which is the name `write_files` gives it.
|
|
228
|
+
private def declared_name(config, path)
|
|
229
|
+
(config.is_a?(Hash) && config["name"]) || File.basename(path, ".json")
|
|
75
230
|
end
|
|
76
231
|
|
|
77
232
|
# Loads the configs already on disk so the generator can honor an explicit
|
|
@@ -123,7 +278,7 @@ module Exwiw
|
|
|
123
278
|
|
|
124
279
|
attrs = {
|
|
125
280
|
name: collection_name,
|
|
126
|
-
primary_key:
|
|
281
|
+
primary_key: PRIMARY_KEY,
|
|
127
282
|
fields: aggregate_fields(ordered),
|
|
128
283
|
}
|
|
129
284
|
|
|
@@ -196,10 +351,13 @@ module Exwiw
|
|
|
196
351
|
# order. A subclass's `fields` already includes everything it inherits, so
|
|
197
352
|
# the base's fields lead and each subclass appends only its own.
|
|
198
353
|
private def aggregate_fields(models)
|
|
354
|
+
structural = structural_field_names(models)
|
|
355
|
+
unique = unique_field_names(models)
|
|
356
|
+
|
|
199
357
|
seen = {}
|
|
200
358
|
models.each_with_object([]) do |model, fields|
|
|
201
359
|
accessor_by_storage = aliased_field_accessors(model)
|
|
202
|
-
model.fields.
|
|
360
|
+
model.fields.each do |name, definition|
|
|
203
361
|
next if seen[name]
|
|
204
362
|
|
|
205
363
|
seen[name] = true
|
|
@@ -208,11 +366,131 @@ module Exwiw
|
|
|
208
366
|
# Ruby accessor so the short key is not cryptic in the config.
|
|
209
367
|
accessor = accessor_by_storage[name]
|
|
210
368
|
field[:mongoid_field_name] = accessor if accessor
|
|
211
|
-
fields << field
|
|
369
|
+
fields << field.merge(safe_mode_attributes(name, definition, structural, unique))
|
|
212
370
|
end
|
|
213
371
|
end
|
|
214
372
|
end
|
|
215
373
|
|
|
374
|
+
# The safe-mode additions to a field entry: the `needs_mask_decision` flag,
|
|
375
|
+
# plus a default `replace_with` where a safe one exists. Empty when safe mode
|
|
376
|
+
# is off, so the emitted config is byte-identical to the historical output.
|
|
377
|
+
#
|
|
378
|
+
# A structural field is flagged but never masked: masking it would break the
|
|
379
|
+
# very lookups the dump is assembled from (see #structural_field_names).
|
|
380
|
+
private def safe_mode_attributes(name, definition, structural, unique)
|
|
381
|
+
return {} unless @safe_new_columns
|
|
382
|
+
|
|
383
|
+
attrs = { needs_mask_decision: true }
|
|
384
|
+
return attrs if structural.include?(name)
|
|
385
|
+
|
|
386
|
+
mask = default_mask_for(name, definition, unique.include?(name))
|
|
387
|
+
mask.nil? ? attrs : attrs.merge(replace_with: mask)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# The default mask for one field, or nil when no safe constant fits its
|
|
391
|
+
# Mongoid type (see MASKABLE_FIELD_TYPES).
|
|
392
|
+
#
|
|
393
|
+
# `limit: nil` because MongoDB stores no per-field length: the length checks
|
|
394
|
+
# DefaultMask applies to a `varchar(n)` have nothing to read here, and any
|
|
395
|
+
# string the mask renders is storable. `unique` comes from the model's index
|
|
396
|
+
# declarations, so a constant is never emitted for a field a unique index
|
|
397
|
+
# would then collide on across every restored document.
|
|
398
|
+
#
|
|
399
|
+
# NOTE the mask a date/time field gets is DefaultMask's fixed *string*
|
|
400
|
+
# constant, which replaces a BSON Date with a String and so changes the
|
|
401
|
+
# field's BSON type. That is deliberate at this stage: the value is only a
|
|
402
|
+
# proposal, and it rides on `needs_mask_decision` precisely so a human either
|
|
403
|
+
# keeps it knowingly, replaces it, or drops it before the config is merged.
|
|
404
|
+
private def default_mask_for(name, definition, unique)
|
|
405
|
+
type = MASKABLE_FIELD_TYPES[mongoid_type_name(definition)]
|
|
406
|
+
return nil if type.nil?
|
|
407
|
+
|
|
408
|
+
DefaultMask.for(
|
|
409
|
+
name: name,
|
|
410
|
+
type: type,
|
|
411
|
+
limit: nil,
|
|
412
|
+
primary_key: PRIMARY_KEY,
|
|
413
|
+
unique: unique,
|
|
414
|
+
column_default: field_default(definition),
|
|
415
|
+
)
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# `Model.fields[name].type` is a Ruby class; an anonymous one (or a field
|
|
419
|
+
# object that does not expose a type at all) has no name to look up, which
|
|
420
|
+
# MASKABLE_FIELD_TYPES answers with "no default mask".
|
|
421
|
+
private def mongoid_type_name(definition)
|
|
422
|
+
type = definition.type if definition.respond_to?(:type)
|
|
423
|
+
type.name if type.is_a?(Module)
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# The field's declared `default:`, handed to DefaultMask so a field with a
|
|
427
|
+
# default of its own is masked with that value rather than the per-type
|
|
428
|
+
# constant (see DefaultMask.constant_mask). Mongoid also accepts a *lambda*
|
|
429
|
+
# default (`default: -> { Time.now }`), which is not a constant at all;
|
|
430
|
+
# DefaultMask's scalar_default answers such a value with nil, so it falls
|
|
431
|
+
# through to the per-type constant on its own.
|
|
432
|
+
private def field_default(definition)
|
|
433
|
+
definition.default_val if definition.respond_to?(:default_val)
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# The fields safe mode flags but must never mask, because the dump is
|
|
437
|
+
# assembled by looking documents up through them:
|
|
438
|
+
#
|
|
439
|
+
# - the primary key `_id`, which every belongs_to and every `--ids` filter
|
|
440
|
+
# resolves against (and which the text masks interpolate to stay unique),
|
|
441
|
+
# - the STI discriminator (`Model.discriminator_key`, `_type` by default),
|
|
442
|
+
# which is what tells one class's documents from another's inside the
|
|
443
|
+
# shared collection, and
|
|
444
|
+
# - every `belongs_to` foreign key declared by any model of this collection.
|
|
445
|
+
#
|
|
446
|
+
# The foreign keys are taken from the MODELS, not from the emitted
|
|
447
|
+
# `belongs_tos`, so the ones the generator deliberately drops are covered
|
|
448
|
+
# too: a polymorphic belongs_to (excluded because it has no single target
|
|
449
|
+
# collection) and a referenced belongs_to on an embedded document (excluded
|
|
450
|
+
# because an embedded config carries none) still store a reference some other
|
|
451
|
+
# collection's documents are found by, and masking it would silently rewrite
|
|
452
|
+
# that reference. A polymorphic relation's type field (`reviewable_type`)
|
|
453
|
+
# is exempt for the same reason, mirroring how the ActiveRecord generator
|
|
454
|
+
# treats a belongs_to's `foreign_type`.
|
|
455
|
+
private def structural_field_names(models)
|
|
456
|
+
names = [PRIMARY_KEY]
|
|
457
|
+
|
|
458
|
+
models.each do |model|
|
|
459
|
+
names << model.discriminator_key.to_s if model.respond_to?(:discriminator_key)
|
|
460
|
+
model.relations.each_value do |assoc|
|
|
461
|
+
next unless assoc.is_a?(::Mongoid::Association::Referenced::BelongsTo)
|
|
462
|
+
|
|
463
|
+
names << assoc.foreign_key.to_s
|
|
464
|
+
names << assoc.inverse_type.to_s if assoc.polymorphic? && assoc.inverse_type
|
|
465
|
+
end
|
|
466
|
+
end
|
|
467
|
+
|
|
468
|
+
names.uniq
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# The fields covered by a unique index declared on any model of this
|
|
472
|
+
# collection (`index({ email: 1 }, unique: true)`), so DefaultMask withholds
|
|
473
|
+
# any mask that does not vary per document — a constant would collapse every
|
|
474
|
+
# document onto one value and break the restore with a duplicate key. Every
|
|
475
|
+
# field of a compound unique index counts, mirroring the ActiveRecord
|
|
476
|
+
# generator's "any column of a unique index".
|
|
477
|
+
#
|
|
478
|
+
# Index declarations are class-level Mongoid metadata (they are what
|
|
479
|
+
# `db/mongoid.rake`'s create_indexes would build), so this needs no
|
|
480
|
+
# connection — unlike the ActiveRecord generator, which reads the live
|
|
481
|
+
# database and has to cope with that failing. An index created out of band
|
|
482
|
+
# and never declared on the model is therefore invisible here; declaring it
|
|
483
|
+
# is what makes exwiw (and Mongoid itself) aware of it.
|
|
484
|
+
private def unique_field_names(models)
|
|
485
|
+
models.flat_map do |model|
|
|
486
|
+
next [] unless model.respond_to?(:index_specifications)
|
|
487
|
+
|
|
488
|
+
model.index_specifications
|
|
489
|
+
.select { |spec| spec.options[:unique] }
|
|
490
|
+
.flat_map { |spec| spec.key.keys.map(&:to_s) }
|
|
491
|
+
end.uniq
|
|
492
|
+
end
|
|
493
|
+
|
|
216
494
|
# Maps a stored document key -> its Mongoid Ruby accessor, but ONLY for
|
|
217
495
|
# genuine `field ..., as:` renames. `Model.aliased_fields` also contains the
|
|
218
496
|
# built-in `id => _id` and one entry per association (e.g. `shop => shop_id`,
|
|
@@ -231,7 +509,7 @@ module Exwiw
|
|
|
231
509
|
end
|
|
232
510
|
|
|
233
511
|
private def aggregate_belongs_tos(models, existing = nil)
|
|
234
|
-
|
|
512
|
+
ignored = ignored_belongs_tos(existing)
|
|
235
513
|
|
|
236
514
|
belongs_to_assocs = models.flat_map do |model|
|
|
237
515
|
model.relations.values.select do |assoc|
|
|
@@ -248,20 +526,68 @@ module Exwiw
|
|
|
248
526
|
# same belongs_to twice, so uniq them.
|
|
249
527
|
belongs_to_assocs
|
|
250
528
|
.reject(&:polymorphic?)
|
|
251
|
-
.filter_map { |assoc| belongs_to_for(assoc,
|
|
529
|
+
.filter_map { |assoc| belongs_to_for(assoc, ignored) }
|
|
252
530
|
.uniq
|
|
253
531
|
end
|
|
254
532
|
|
|
255
|
-
#
|
|
256
|
-
#
|
|
257
|
-
#
|
|
258
|
-
#
|
|
259
|
-
|
|
260
|
-
|
|
533
|
+
# The on-disk `ignore: true` belongs_to entries, so a relation the user has
|
|
534
|
+
# explicitly ignored is preserved verbatim instead of re-resolved (which, for
|
|
535
|
+
# a stale relation whose target class is gone, would otherwise abort the run).
|
|
536
|
+
#
|
|
537
|
+
# Kept as a list rather than indexed by foreign key: a foreign key does not
|
|
538
|
+
# identify a relation on its own (see #ignored_belongs_to_for).
|
|
539
|
+
private def ignored_belongs_tos(existing)
|
|
540
|
+
return [] unless existing
|
|
261
541
|
|
|
262
|
-
existing.belongs_tos.select(&:ignore)
|
|
263
|
-
|
|
264
|
-
|
|
542
|
+
existing.belongs_tos.select(&:ignore)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# The on-disk ignored entry that stands for `assoc`, or nil when none does.
|
|
546
|
+
#
|
|
547
|
+
# `target_collection` is the association's resolved target, or nil when it
|
|
548
|
+
# could not be resolved, and that distinction is what the match is keyed on:
|
|
549
|
+
#
|
|
550
|
+
# - Resolved: the entry has to agree on BOTH the target collection and the
|
|
551
|
+
# foreign key. Two belongs_tos of one collection can legitimately share a
|
|
552
|
+
# foreign key — a relation to one collection plus a second relation to
|
|
553
|
+
# another declared with `primary_key:`, scoping by the same stored value —
|
|
554
|
+
# and matching on the foreign key alone let one ignored entry stand for both
|
|
555
|
+
# of them. The second relation was then re-emitted as a copy of that entry,
|
|
556
|
+
# `uniq` collapsed the two, and its edge silently vanished from the config.
|
|
557
|
+
# An entry naming a different target must therefore not swallow this
|
|
558
|
+
# relation; with none matching, the relation is generated normally.
|
|
559
|
+
# - Unresolved: there is no target to compare, which is precisely the case
|
|
560
|
+
# this mechanism exists for (a stale relation whose class is gone, whose
|
|
561
|
+
# entry usually carries no `table_name` either), so fall back to matching on
|
|
562
|
+
# the foreign key alone.
|
|
563
|
+
#
|
|
564
|
+
# An entry with no `table_name` still matches a resolved relation on the
|
|
565
|
+
# foreign key alone, as a fallback after the exact match: omitting the target
|
|
566
|
+
# is allowed only on an ignored entry, and dropping the user's decision
|
|
567
|
+
# because they wrote the minimal form would resurrect an edge they had
|
|
568
|
+
# deliberately cut. Two foreign-key-sharing relations with one such entry
|
|
569
|
+
# between them remain genuinely indistinguishable — writing the `table_name`
|
|
570
|
+
# is what tells exwiw which of them is meant.
|
|
571
|
+
private def ignored_belongs_to_for(assoc, target_collection, ignored)
|
|
572
|
+
foreign_key = assoc.foreign_key
|
|
573
|
+
return ignored.find { |bt| bt.foreign_key == foreign_key } if target_collection.nil?
|
|
574
|
+
|
|
575
|
+
ignored.find { |bt| bt.foreign_key == foreign_key && bt.table_name == target_collection } ||
|
|
576
|
+
ignored.find { |bt| bt.foreign_key == foreign_key && bt.table_name.nil? }
|
|
577
|
+
end
|
|
578
|
+
|
|
579
|
+
# The collection a referenced belongs_to targets, as `[collection_name, nil]`
|
|
580
|
+
# — or `[nil, error]` when the association's target class no longer exists (a
|
|
581
|
+
# stale/legacy `belongs_to`, e.g. pointing at a model removed years ago), so
|
|
582
|
+
# `assoc.klass` raised. The error is carried rather than raised because
|
|
583
|
+
# resolution is attempted BEFORE the on-disk ignored entries are consulted (to
|
|
584
|
+
# match one against the right relation), and a relation the user has already
|
|
585
|
+
# triaged must not abort the run; `belongs_to_for` raises it only once no
|
|
586
|
+
# entry stands for the relation.
|
|
587
|
+
private def resolve_target_collection(assoc)
|
|
588
|
+
[assoc.klass.collection_name.to_s, nil]
|
|
589
|
+
rescue NameError, ::Mongoid::Errors::MongoidError => e
|
|
590
|
+
[nil, e]
|
|
265
591
|
end
|
|
266
592
|
|
|
267
593
|
# Resolves a referenced belongs_to to a `{ table_name, foreign_key }` pair
|
|
@@ -272,18 +598,29 @@ module Exwiw
|
|
|
272
598
|
# its foreign-key column is still tracked as an ordinary field by
|
|
273
599
|
# `aggregate_fields`, mirroring how polymorphic / HABTM relations are dropped.
|
|
274
600
|
#
|
|
275
|
-
# `
|
|
276
|
-
#
|
|
277
|
-
# it, so preserve their entry verbatim (its `ignore_type` /
|
|
278
|
-
#
|
|
279
|
-
# extraction at load (`#reject_ignored_members!`) while its FK
|
|
280
|
-
# field, and the run never aborts on a relation already
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
601
|
+
# `ignored` carries the on-disk `ignore: true` belongs_to entries: when one of
|
|
602
|
+
# them stands for this relation (see #ignored_belongs_to_for), the user has
|
|
603
|
+
# explicitly ignored it, so preserve their entry verbatim (its `ignore_type` /
|
|
604
|
+
# `comment`) rather than emitting a freshly derived one. The relation is
|
|
605
|
+
# dropped from extraction at load (`#reject_ignored_members!`) while its FK
|
|
606
|
+
# column stays a field, and the run never aborts on a relation already
|
|
607
|
+
# triaged: resolving the — possibly gone — target is attempted first, but its
|
|
608
|
+
# failure is only raised once no entry stands for the relation.
|
|
609
|
+
private def belongs_to_for(assoc, ignored = [])
|
|
610
|
+
target_collection, resolution_error = resolve_target_collection(assoc)
|
|
611
|
+
|
|
612
|
+
if (entry = ignored_belongs_to_for(assoc, target_collection, ignored))
|
|
613
|
+
return preserve_ignored_belongs_to(entry)
|
|
614
|
+
end
|
|
615
|
+
|
|
616
|
+
if resolution_error
|
|
617
|
+
raise resolution_error unless @skip_unsupported
|
|
618
|
+
|
|
619
|
+
warn("exwiw: skip_unsupported: skipping belongs_to ':#{assoc.name}' that could not be resolved (#{resolution_error.class}: #{resolution_error.message.lines.first&.strip}); its foreign key '#{assoc.foreign_key}' is still kept as a field.")
|
|
620
|
+
return nil
|
|
284
621
|
end
|
|
285
622
|
|
|
286
|
-
result = { table_name:
|
|
623
|
+
result = { table_name: target_collection, foreign_key: assoc.foreign_key }
|
|
287
624
|
# Mongoid's `belongs_to ..., primary_key: :uuid` makes the child's foreign
|
|
288
625
|
# key reference that parent field rather than the parent's `_id`. Surface
|
|
289
626
|
# it as `references` so MongodbAdapter constrains children by the right
|
|
@@ -293,11 +630,6 @@ module Exwiw
|
|
|
293
630
|
reference_field = assoc.primary_key.to_s
|
|
294
631
|
result[:references] = reference_field unless reference_field == "_id"
|
|
295
632
|
result
|
|
296
|
-
rescue NameError, ::Mongoid::Errors::MongoidError => e
|
|
297
|
-
raise e unless @skip_unsupported
|
|
298
|
-
|
|
299
|
-
warn("exwiw: skip_unsupported: skipping belongs_to ':#{assoc.name}' that could not be resolved (#{e.class}: #{e.message.lines.first&.strip}); its foreign key '#{assoc.foreign_key}' is still kept as a field.")
|
|
300
|
-
nil
|
|
301
633
|
end
|
|
302
634
|
|
|
303
635
|
# Re-emits a user's on-disk ignored belongs_to as a symbol-keyed hash (the
|
data/lib/exwiw/schema_check.rb
CHANGED
|
@@ -9,7 +9,13 @@ module Exwiw
|
|
|
9
9
|
# Reports how the committed schema config differs from what the application
|
|
10
10
|
# would generate now, plus the columns still waiting for a masking decision.
|
|
11
11
|
# Regenerating into a copy rather than over the config directory lets it run
|
|
12
|
-
# on a working tree it must not modify (CI).
|
|
12
|
+
# on a working tree it must not modify (CI).
|
|
13
|
+
#
|
|
14
|
+
# The diff/report side is source-agnostic (it reads JSON config files); what
|
|
15
|
+
# varies per schema source is only how to regenerate, injected as
|
|
16
|
+
# `regenerator` — a callable that receives a directory pre-seeded with a copy
|
|
17
|
+
# of the committed config and rewrites it the way `generate!` + `tidy!`
|
|
18
|
+
# would. `models:` remains as the ActiveRecord shorthand for it.
|
|
13
19
|
class SchemaCheck
|
|
14
20
|
CATEGORIES = %w[
|
|
15
21
|
added_tables added_columns removed_tables removed_columns changed_tables needs_mask_decision
|
|
@@ -20,9 +26,39 @@ module Exwiw
|
|
|
20
26
|
new(models: ActiveRecord::Base.descendants, schema_dir: schema_dir)
|
|
21
27
|
end
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
# The Mongoid counterpart. `Mongoid.models` registers only the base class of
|
|
30
|
+
# an inheritance hierarchy, which is exactly what MongoidSchemaGenerator
|
|
31
|
+
# expects (it expands descendants itself), so the same source the generate
|
|
32
|
+
# task introspects is used here.
|
|
33
|
+
def self.from_mongoid_application(schema_dir:)
|
|
34
|
+
Rails.application.eager_load!
|
|
35
|
+
new(schema_dir: schema_dir, regenerator: mongoid_regenerator(::Mongoid.models))
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The regeneration step `from_mongoid_application` injects, exposed so a
|
|
39
|
+
# caller (and the specs) can run the check against an explicit model list
|
|
40
|
+
# without a Rails application — and so what they exercise is the very lambda
|
|
41
|
+
# production uses.
|
|
42
|
+
#
|
|
43
|
+
# `skip_unsupported` is deliberately left off: this runs as a CI gate, and a
|
|
44
|
+
# newly added construct exwiw cannot represent has to fail the task with the
|
|
45
|
+
# generator's actionable message rather than quietly become an `ignore: true`
|
|
46
|
+
# config that reports clean while the collection stops being dumped.
|
|
47
|
+
def self.mongoid_regenerator(models)
|
|
48
|
+
lambda do |tmp_dir|
|
|
49
|
+
# Explicit: safe mode is not optional here, whatever the library default is.
|
|
50
|
+
MongoidSchemaGenerator.new(models: models, output_dir: tmp_dir, safe_new_columns: true).generate!
|
|
51
|
+
MongoidSchemaGenerator.new(models: models, output_dir: tmp_dir).tidy!
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def initialize(schema_dir:, models: nil, regenerator: nil)
|
|
56
|
+
if models.nil? && regenerator.nil?
|
|
57
|
+
raise ArgumentError, "SchemaCheck requires either models: or regenerator:"
|
|
58
|
+
end
|
|
59
|
+
|
|
25
60
|
@schema_dir = schema_dir
|
|
61
|
+
@regenerator = regenerator || active_record_regenerator(models)
|
|
26
62
|
end
|
|
27
63
|
|
|
28
64
|
# The report as a plain Hash. Every list is sorted, so a given state always
|
|
@@ -47,9 +83,15 @@ module Exwiw
|
|
|
47
83
|
|
|
48
84
|
private def regenerate_into(tmp_dir)
|
|
49
85
|
FileUtils.cp_r(File.join(@schema_dir, "."), tmp_dir) if Dir.exist?(@schema_dir)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
86
|
+
@regenerator.call(tmp_dir)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private def active_record_regenerator(models)
|
|
90
|
+
lambda do |tmp_dir|
|
|
91
|
+
# Explicit: safe mode is not optional here, whatever the library default is.
|
|
92
|
+
SchemaGenerator.new(models: models, output_dir: tmp_dir, safe_new_columns: true).generate!
|
|
93
|
+
SchemaGenerator.new(models: models, output_dir: tmp_dir).tidy!
|
|
94
|
+
end
|
|
53
95
|
end
|
|
54
96
|
|
|
55
97
|
# Every config file under `dir`, keyed by its path relative to `dir` so the
|
data/lib/exwiw/table_config.rb
CHANGED
|
@@ -170,9 +170,9 @@ module Exwiw
|
|
|
170
170
|
merged_table.type = passed_table.type
|
|
171
171
|
merged_table.comment = comment
|
|
172
172
|
merged_table.filter = filter
|
|
173
|
-
merged_table.bulk_insert_chunk_size = passed_table.bulk_insert_chunk_size
|
|
174
173
|
merged_table.ignore = ignore
|
|
175
174
|
# User-owned, never regenerated: carry over from the existing config.
|
|
175
|
+
merged_table.bulk_insert_chunk_size = bulk_insert_chunk_size
|
|
176
176
|
merged_table.scope_exempt = scope_exempt
|
|
177
177
|
merged_table.scope_column = scope_column
|
|
178
178
|
merged_table.reverse_scope = reverse_scope
|
data/lib/exwiw/version.rb
CHANGED
data/lib/exwiw.rb
CHANGED
|
@@ -40,6 +40,10 @@ require_relative "exwiw/after_insert_hook"
|
|
|
40
40
|
require_relative "exwiw/runner"
|
|
41
41
|
require_relative "exwiw/explain_runner"
|
|
42
42
|
require_relative "exwiw/schema_generator"
|
|
43
|
+
require_relative "exwiw/db_introspector"
|
|
44
|
+
require_relative "exwiw/db_introspector/mysql_introspector"
|
|
45
|
+
require_relative "exwiw/db_introspector/postgresql_introspector"
|
|
46
|
+
require_relative "exwiw/db_schema_generator"
|
|
43
47
|
require_relative "exwiw/schema_check"
|
|
44
48
|
require_relative "exwiw/mongoid_schema_generator"
|
|
45
49
|
|
data/lib/tasks/exwiw.rake
CHANGED
|
@@ -100,7 +100,44 @@ namespace :exwiw do
|
|
|
100
100
|
Exwiw::MongoidSchemaGenerator.from_rails_application(
|
|
101
101
|
output_dir: resolve_schema_dir.call,
|
|
102
102
|
skip_unsupported: ENV["EXWIW_SKIP_UNSUPPORTED"] == "1",
|
|
103
|
+
# Same convention as `generate`: safe unless EXWIW_NEW_COLUMNS=plain.
|
|
104
|
+
safe_new_columns: safe_new_columns.call,
|
|
103
105
|
).generate!
|
|
104
106
|
end
|
|
107
|
+
|
|
108
|
+
desc "Remove collections from the schema config that no longer exist in the Mongoid application"
|
|
109
|
+
task tidy_mongoid: :environment do
|
|
110
|
+
require "exwiw"
|
|
111
|
+
|
|
112
|
+
result = Exwiw::MongoidSchemaGenerator.from_rails_application(
|
|
113
|
+
output_dir: resolve_schema_dir.call,
|
|
114
|
+
).tidy!
|
|
115
|
+
|
|
116
|
+
if result.empty?
|
|
117
|
+
puts "exwiw: schema config is already tidy; nothing to remove."
|
|
118
|
+
else
|
|
119
|
+
result.removed_tables.each do |name|
|
|
120
|
+
puts "exwiw: removed config for collection '#{name}' (no longer exists in the application)."
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
desc "Report how the committed schema config differs from the Mongoid application, without changing it"
|
|
126
|
+
task check_mongoid: :environment do
|
|
127
|
+
require "exwiw"
|
|
128
|
+
|
|
129
|
+
report = Exwiw::SchemaCheck.from_mongoid_application(schema_dir: resolve_schema_dir.call).run
|
|
130
|
+
json = JSON.pretty_generate(report)
|
|
131
|
+
puts json
|
|
132
|
+
# A file too, so a caller need not assume stdout carries only the JSON.
|
|
133
|
+
File.write(ENV["EXWIW_SCHEMA_CHECK_OUTPUT"], json + "\n") if ENV["EXWIW_SCHEMA_CHECK_OUTPUT"]
|
|
134
|
+
|
|
135
|
+
unless Exwiw::SchemaCheck.clean?(report)
|
|
136
|
+
$stderr.puts "exwiw: the schema config is out of date or has undecided masking; " \
|
|
137
|
+
"run `rake exwiw:schema:generate_mongoid exwiw:schema:tidy_mongoid` " \
|
|
138
|
+
"and resolve every `needs_mask_decision` field."
|
|
139
|
+
exit 1
|
|
140
|
+
end
|
|
141
|
+
end
|
|
105
142
|
end
|
|
106
143
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exwiw
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.9.
|
|
4
|
+
version: 0.9.22
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Shia
|
|
@@ -69,6 +69,10 @@ files:
|
|
|
69
69
|
- lib/exwiw/belongs_to.rb
|
|
70
70
|
- lib/exwiw/cli.rb
|
|
71
71
|
- lib/exwiw/config_file.rb
|
|
72
|
+
- lib/exwiw/db_introspector.rb
|
|
73
|
+
- lib/exwiw/db_introspector/mysql_introspector.rb
|
|
74
|
+
- lib/exwiw/db_introspector/postgresql_introspector.rb
|
|
75
|
+
- lib/exwiw/db_schema_generator.rb
|
|
72
76
|
- lib/exwiw/ddl_postprocessor.rb
|
|
73
77
|
- lib/exwiw/default_mask.rb
|
|
74
78
|
- lib/exwiw/determine_table_processing_order.rb
|