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.
@@ -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
- def self.from_rails_application(output_dir:, skip_unsupported: false)
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(models: ::Mongoid.models, output_dir: output_dir, skip_unsupported: skip_unsupported)
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
- def initialize(models:, output_dir:, skip_unsupported: false)
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
- models = expand_with_descendants(concrete_models)
72
- models
73
- .group_by { |model| model.collection_name.to_s }
74
- .map { |collection_name, group| build_collection_for(collection_name, group, existing_by_name[collection_name]) }
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: "_id",
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.keys.each do |name|
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
- ignored_by_fk = ignored_belongs_tos_by_foreign_key(existing)
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, ignored_by_fk) }
529
+ .filter_map { |assoc| belongs_to_for(assoc, ignored) }
252
530
  .uniq
253
531
  end
254
532
 
255
- # Maps foreign_key -> the on-disk `ignore: true` belongs_to entry, so a
256
- # relation the user has explicitly ignored is preserved verbatim instead of
257
- # re-resolved (which, for a stale relation whose target class is gone, would
258
- # otherwise abort the run).
259
- private def ignored_belongs_tos_by_foreign_key(existing)
260
- return {} unless existing
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).each_with_object({}) do |bt, acc|
263
- acc[bt.foreign_key] = bt
264
- end
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
- # `ignored_by_fk` carries the on-disk `ignore: true` belongs_to entries: when
276
- # this relation's foreign key is among them, the user has explicitly ignored
277
- # it, so preserve their entry verbatim (its `ignore_type` / `comment`) without
278
- # resolving the possibly gone target. The relation is dropped from
279
- # extraction at load (`#reject_ignored_members!`) while its FK column stays a
280
- # field, and the run never aborts on a relation already triaged.
281
- private def belongs_to_for(assoc, ignored_by_fk = {})
282
- if (ignored = ignored_by_fk[assoc.foreign_key])
283
- return preserve_ignored_belongs_to(ignored)
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: assoc.klass.collection_name.to_s, foreign_key: assoc.foreign_key }
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
@@ -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). ActiveRecord only.
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
- def initialize(models:, schema_dir:)
24
- @models = models
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
- # Explicit: safe mode is not optional here, whatever the library default is.
51
- SchemaGenerator.new(models: @models, output_dir: tmp_dir, safe_new_columns: true).generate!
52
- SchemaGenerator.new(models: @models, output_dir: tmp_dir).tidy!
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exwiw
4
- VERSION = "0.9.21"
4
+ VERSION = "0.9.22"
5
5
  end
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.21
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