exwiw 0.4.10 → 0.4.11

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a89e3da8899badc5bcccc98f58878744b9b106b31ca38af6a70b41109002d126
4
- data.tar.gz: 13dd0757b0699c7865cd4c89b214288d16d017a9447c042e90c3da5cedc6f4b2
3
+ metadata.gz: 23b64ea4c8b3562427b3e605fc17dec1251cf6916f2be02aeed0b3a29e03a62d
4
+ data.tar.gz: ce8a51a31bb84c22abd164e3ec928065dff99f1dbd6eb83c72a9520e8c7e0737
5
5
  SHA512:
6
- metadata.gz: b04b7e070c1215b24c6dfa6453e572bf560d77fbd9a6eb6172fbd301a8b662ebc1da815f13d5fb4ba31799a5e8e45c610c0271e810eff6c8c675e75fcf261e2d
7
- data.tar.gz: 4b88cfc7ed7a758b7a82b76d9e936c251b388825411fef9c305434919b7695f6866e5011828a6a2bf00c95692d157c1f0e4e768e0dc3e9ab79b980f025e4c626
6
+ metadata.gz: 779684b310965b1f59a7d9bb20d20d61526435845c1c3c5d7b6a6e1c7febcb96ce82d12ebf22bcb894546d8dd9f876984d302a87a2e321a1fb3aa8de8aeb4447
7
+ data.tar.gz: e19d3931e974e09487e6a661e482cdeac9cf665e34d80dc82ed9fff6f099ffa7cf0a2a8fec2b7f682b301899f9121f3c6fd49680fe0a70fb300bb04d7b9c9f78
data/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.11] - 2026-06-15
6
+
7
+ ### Fixed
8
+
9
+ - MongoDB: `schema:generate_mongoid` now resolves an embedded collection's `embedded_in` document key by locating the parent's `embeds_one` / `embeds_many` that stores the collection, instead of trusting Mongoid's computed `assoc.inverse`. Mongoid returns a `nil` inverse for many valid embeddings (when no explicit `inverse_of:` is declared and it declines to infer one), and previously such a model was wrongly reported as having an "unresolvable inverse" and skipped (or aborted the run). Matching by the embedded collection also resolves an STI subclass embedded through a relation declared against its base class. Genuinely ambiguous embeddings (the same collection stored under several keys in the parent) are still reported as unrepresentable.
10
+
11
+ ### Added
12
+
13
+ - MongoDB: `schema:generate_mongoid` now **honors an explicit `ignore: true` on disk** and skips re-introspecting it, so a construct exwiw cannot represent that you have already triaged no longer aborts the (fail-loud, default) run — and the annotation survives regeneration. Works at two granularities: a whole **collection** marked `ignore: true` is preserved as-is without introspection, and a single **`belongs_to`** marked `ignore: true` (no `table_name` required) is preserved while the rest of its collection still generates and dumps (its foreign-key column stays an ordinary field). This lets you keep the generator strict by default while opting individual stale/unrepresentable constructs out by hand, rather than relying on `EXWIW_SKIP_UNSUPPORTED`.
14
+ - `MongodbCollectionConfig` and `BelongsTo` gain an optional, user-owned **`ignore_type`** tag (free-form; exwiw never interprets or emits it) to record *why* something is ignored — e.g. `"need_code_fix"` for an application-side bug, `"unsupported"` for a shape exwiw cannot express. Preserved across regeneration like `comment`. `BelongsTo#table_name` is now optional so an ignored, no-longer-resolvable relation can be recorded without a target collection (a non-ignored `belongs_to` still requires one).
15
+
5
16
  ## [0.4.10] - 2026-06-12
6
17
 
7
18
  ### Fixed
data/README.md CHANGED
@@ -194,20 +194,53 @@ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the Activ
194
194
  - the collection name and the `_id` primary key,
195
195
  - `fields` from the declared Mongoid fields (referenced `belongs_to` foreign keys such as `shop_id`, and the `created_at` / `updated_at` columns added by `Mongoid::Timestamps`, are ordinary fields — their BSON `ObjectId` / `Date` values serialize as MongoDB Extended JSON at dump time). For an aliased field (`field :ctry, as: :country`), the generator emits the **stored** document key (`ctry`), never the Ruby accessor (`country`), so masking and projection target the key that actually appears in the document, and additionally records the accessor as `mongoid_field_name` on that field so the short key stays understandable (association aliases such as `shop => shop_id` and the built-in `id => _id` are not field renames and are not annotated),
196
196
  - `belongs_tos` from referenced `belongs_to` associations (`{ table_name, foreign_key }`). A referenced `belongs_to` declared on an *embedded* document is dropped (cross-collection refs from inside embedded subdocuments are unsupported — see [MongoDB notes](#mongodb-notes)), but its foreign-key column is still kept as an ordinary field. A `has_and_belongs_to_many` association is also dropped (its foreign keys are stored as an array field, e.g. `tag_ids`, which exwiw cannot follow as a single-valued foreign key), while that `*_ids` array column is kept as an ordinary field,
197
- - `embedded_in` from `embedded_in` / `embeds_many` / `embeds_one` associations. Each embedded config names its *immediate* parent collection and the document key it lives under (`store_as`, defaulting to the relation name); nested embedding is represented as a chain (`comments` → `embedded_in` `posts`, `posts` → `embedded_in` `users`) rather than a flattened dot-path, matching how the adapter recurses through array and Hash subdocuments. A *polymorphic* `embedded_in` (`embedded_in :addressable, polymorphic: true`) has no single embedding parent collection and so cannot be expressed as an `embedded_in` config; the generator raises a clear error pointing you to define that collection's config by hand. A *self-referential / cyclic* embedding (Mongoid's `recursively_embeds_many` / `recursively_embeds_one`) makes a collection both a top-level document and embedded inside documents of its own type; exwiw represents a collection as either top-level or embedded, not both, so the generator likewise raises a clear error rather than emit a config that would silently make the collection undumpable.
197
+ - `embedded_in` from `embedded_in` / `embeds_many` / `embeds_one` associations. Each embedded config names its *immediate* parent collection and the document key it lives under (`store_as`, defaulting to the relation name); nested embedding is represented as a chain (`comments` → `embedded_in` `posts`, `posts` → `embedded_in` `users`) rather than a flattened dot-path, matching how the adapter recurses through array and Hash subdocuments. The document key is resolved by locating the parent's `embeds_one` / `embeds_many` that stores this collection. (Mongoid's computed inverse is frequently `nil` when no explicit `inverse_of:` is set, so exwiw matches by the collection the parent's embedding relations store rather than trusting that inverse — this also resolves an STI subclass embedded through a relation declared against its base class.) When the same collection is embedded under several keys in the parent, the path is ambiguous and treated as unrepresentable (see below). A *polymorphic* `embedded_in` (`embedded_in :addressable, polymorphic: true`) has no single embedding parent collection and so cannot be expressed as an `embedded_in` config. A *self-referential / cyclic* embedding (Mongoid's `recursively_embeds_many` / `recursively_embeds_one`) makes a collection both a top-level document and embedded inside documents of its own type; exwiw represents a collection as either top-level or embedded, not both, so it cannot emit an `embedded_in` config that would silently make the collection undumpable. These unrepresentable shapes are handled best-effort by default and abort only in strict mode (see below).
198
198
 
199
199
  Models in an inheritance hierarchy whose subclasses share the base's collection (Mongoid STI, distinguished by the auto-added `_type` discriminator) collapse into a single config: the generator discovers the subclasses via `descendants` (Mongoid registers only the base class in `Mongoid.models`) and unions every class's `fields` and `belongs_tos` into the collection config, so subclass-only fields and associations are not lost.
200
200
 
201
201
  Regeneration preserves hand-edited `replace_with`, `filter`, `ignore`, and `bulk_insert_chunk_size` values, like the ActiveRecord generator. Indexes are not written to the config — they are introspected from the live database at dump time (see [MongoDB notes](#mongodb-notes)). Polymorphic `belongs_to` is not yet expanded by this task.
202
202
 
203
- By default the task **aborts** when a model uses a construct exwiw cannot represent: a `belongs_to` whose target class can no longer be resolved (a stale relation left behind after its model was removed), or a polymorphic / self-referential-cyclic / unresolvable-parent `embedded_in` (see the cases above). Set `EXWIW_SKIP_UNSUPPORTED=1` to keep going instead:
203
+ By default the task **aborts** when a model uses a construct exwiw cannot represent: a `belongs_to` whose target class can no longer be resolved (a stale relation left behind after its model was removed), or a polymorphic / self-referential-cyclic / ambiguous / unresolvable-parent `embedded_in` (see the cases above).
204
+
205
+ #### Honoring an explicit `ignore` (the recommended way to keep these out)
206
+
207
+ When you have reviewed such a construct and decided exwiw should leave it alone, mark it `ignore: true` in its config on disk. The generator **honors an explicit `ignore` and skips re-introspecting it**, so it never aborts the run on something you have already triaged — and your annotation survives regeneration. Two granularities:
208
+
209
+ - A whole **collection** exwiw cannot represent (e.g. a polymorphic / ambiguous `embedded_in`) — mark the collection config `"ignore": true`. To actually dump/mask it later, define its `embedded_in` config by hand (see [Embedded documents](#embedded-documents)).
210
+ - A single **`belongs_to`** that no longer resolves while the rest of its collection is fine (e.g. a stale relation pointing at a removed model) — mark that entry `"ignore": true`, with no `table_name`. The relation is dropped from extraction (`#reject_ignored_members!`) while its foreign-key column stays an ordinary field, and the collection keeps dumping.
211
+
212
+ Record *why* with the optional **`ignore_type`** (a free-form tag exwiw never interprets — e.g. `"need_code_fix"` for an application-side bug, `"unsupported"` for a shape exwiw cannot express) and a **`comment`**. Both are user-owned and preserved across regeneration; the generator never emits `ignore_type` itself.
213
+
214
+ ```json
215
+ // orders.json — a stale belongs_to flagged for a code fix; the collection still dumps
216
+ {
217
+ "name": "orders",
218
+ "primary_key": "_id",
219
+ "belongs_to": [
220
+ { "table_name": "shops", "foreign_key": "shop_id" },
221
+ {
222
+ "foreign_key": "coupon_id",
223
+ "ignore": true,
224
+ "ignore_type": "need_code_fix",
225
+ "comment": "FIXME: belongs_to :coupon -> Coupon does not exist (dead relation)."
226
+ }
227
+ ],
228
+ "fields": [ /* ... coupon_id is kept as an ordinary field ... */ ]
229
+ }
230
+ ```
231
+
232
+ #### First bootstrap pass: `EXWIW_SKIP_UNSUPPORTED=1`
233
+
234
+ For the very first pass against a large app — before any `ignore` annotations exist — set `EXWIW_SKIP_UNSUPPORTED=1` to keep going past *un-annotated* unrepresentable constructs instead of aborting one at a time:
204
235
 
205
236
  ```bash
206
237
  EXWIW_SKIP_UNSUPPORTED=1 bundle exec rake exwiw:schema:generate_mongoid
207
238
  ```
208
239
 
209
240
  - An unresolvable `belongs_to` is dropped from the collection's `belongs_tos` (its foreign-key column is still kept as an ordinary field, like the polymorphic / HABTM cases) and a warning naming the relation is printed to stderr.
210
- - An unrepresentable `embedded_in` collection is emitted as a **top-level** config marked `"ignore": true` with a `comment` recording why, and a warning is printed. `ignore: true` keeps it out of extraction so it is not wrongly dumped as its own collection; to actually dump/mask such an embedded collection, define its `embedded_in` config by hand (see [Embedded documents](#embedded-documents)). This is useful for bootstrapping a config against a large app where a handful of legacy/polymorphic collections would otherwise block the whole run.
241
+ - An unrepresentable `embedded_in` collection is emitted as a **top-level** config marked `"ignore": true` with a `comment` recording why, and a warning is printed.
242
+
243
+ Review the stderr warnings, annotate the affected configs (`ignore` / `ignore_type` / `comment`), and subsequent runs complete without the flag because the generator honors those explicit ignores.
211
244
 
212
245
  ### Configuration
213
246
 
@@ -5,7 +5,11 @@ module Exwiw
5
5
  include Serdes
6
6
 
7
7
  attribute :foreign_key, String
8
- attribute :table_name, String
8
+ # Optional so an ignored, no-longer-resolvable relation (a stale
9
+ # `belongs_to` whose target class is gone) can be recorded with no target
10
+ # collection. A non-ignored belongs_to still requires it — enforced by the
11
+ # owning config's validation (e.g. MongodbCollectionConfig#validate_belongs_tos!).
12
+ attribute :table_name, optional(String), skip_serializing_if_nil: true
9
13
  # Set only for a polymorphic association. `foreign_type` is the name of the
10
14
  # column storing the type (e.g. `reviewable_type`), and `type_value` is the
11
15
  # value held in that column (e.g. `"Product"`). Both are nil for a
@@ -32,6 +36,11 @@ module Exwiw
32
36
  # extraction once the config is loaded (see #reject_ignored_members!).
33
37
  attribute :comment, optional(String), skip_serializing_if_nil: true
34
38
  attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
39
+ # Free-form tag recording *why* this relation is ignored (e.g.
40
+ # "need_code_fix" for an application-side bug, "unsupported" for a shape
41
+ # exwiw cannot express). exwiw never interprets or emits it; purely
42
+ # informational and preserved across regeneration like `comment`.
43
+ attribute :ignore_type, optional(String), skip_serializing_if_nil: true
35
44
 
36
45
  def self.from_symbol_keys(hash)
37
46
  from(hash.transform_keys(&:to_s))
@@ -20,6 +20,11 @@ module Exwiw
20
20
  # marks an unrepresentable collection `ignore: true`, to record why extraction
21
21
  # was skipped.
22
22
  attribute :comment, optional(String), skip_serializing_if_nil: true
23
+ # Free-form tag recording *why* this collection is ignored (e.g.
24
+ # "need_code_fix" for an application-side bug, "unsupported" for a shape
25
+ # exwiw cannot express). exwiw never interprets or emits it; informational
26
+ # and preserved across regeneration like `comment`.
27
+ attribute :ignore_type, optional(String), skip_serializing_if_nil: true
23
28
 
24
29
  # Marks this config as physically embedded inside another collection's
25
30
  # documents. When set, this config is not processed as a standalone dump
@@ -30,6 +35,7 @@ module Exwiw
30
35
  def self.from(obj)
31
36
  instance = super
32
37
  instance.__send__(:validate_embedded!)
38
+ instance.__send__(:validate_belongs_tos!)
33
39
  instance
34
40
  end
35
41
 
@@ -68,6 +74,7 @@ module Exwiw
68
74
  merged.filter = filter
69
75
  merged.bulk_insert_chunk_size = bulk_insert_chunk_size
70
76
  merged.ignore = ignore
77
+ merged.ignore_type = ignore_type
71
78
  # A freshly generated comment (e.g. the skip_unsupported marker) wins so
72
79
  # it stays accurate; otherwise a hand-added note on a normal collection
73
80
  # is kept.
@@ -84,6 +91,7 @@ module Exwiw
84
91
  if receiver_bt
85
92
  pbt.comment = receiver_bt.comment if receiver_bt.comment
86
93
  pbt.ignore = receiver_bt.ignore unless receiver_bt.ignore.nil?
94
+ pbt.ignore_type = receiver_bt.ignore_type if receiver_bt.ignore_type
87
95
  pbt.references = receiver_bt.references if receiver_bt.references
88
96
  end
89
97
  pbt
@@ -114,5 +122,18 @@ module Exwiw
114
122
  "belongs_tos must be empty (cross-collection refs from inside embedded arrays " \
115
123
  "are not supported)."
116
124
  end
125
+
126
+ # `table_name` is optional only so an *ignored* relation (a stale belongs_to
127
+ # whose target collection no longer exists) can be recorded without one. A
128
+ # belongs_to that still participates in extraction must name its target.
129
+ private def validate_belongs_tos!
130
+ offender = belongs_tos.find { |bt| bt.table_name.nil? && !bt.ignore }
131
+ return unless offender
132
+
133
+ raise ArgumentError,
134
+ "MongodbCollectionConfig '#{name}' has a belongs_to (foreign_key " \
135
+ "'#{offender.foreign_key}') with no table_name; only an `ignore: true` belongs_to " \
136
+ "may omit it."
137
+ end
117
138
  end
118
139
  end
@@ -49,7 +49,7 @@ module Exwiw
49
49
  end
50
50
 
51
51
  def generate!
52
- collections = build_collections
52
+ collections = build_collections(existing_configs_by_name(@output_dir))
53
53
  write_files(@output_dir, collections)
54
54
  collections
55
55
  end
@@ -61,11 +61,33 @@ module Exwiw
61
61
  # subclasses share the base's collection (Mongoid STI, discriminated by the
62
62
  # auto-added `_type` field) collapses into a single config that aggregates
63
63
  # every class's fields and associations. See `expand_with_descendants`.
64
- def build_collections
64
+ #
65
+ # `existing_by_name` maps a collection name to its config already on disk, so
66
+ # the build can honor an explicit `ignore: true` (collection- or
67
+ # belongs_to-level) without re-introspecting it — and thus without aborting
68
+ # on a construct the user has deliberately ignored. Empty (the default) when
69
+ # called directly without an output dir, in which case nothing is honored.
70
+ def build_collections(existing_by_name = {})
65
71
  models = expand_with_descendants(concrete_models)
66
72
  models
67
73
  .group_by { |model| model.collection_name.to_s }
68
- .map { |collection_name, group| build_collection_for(collection_name, group) }
74
+ .map { |collection_name, group| build_collection_for(collection_name, group, existing_by_name[collection_name]) }
75
+ end
76
+
77
+ # Loads the configs already on disk so the generator can honor an explicit
78
+ # `ignore: true` without re-introspecting (and thus without aborting on a
79
+ # construct the user has deliberately ignored). A file that cannot be read or
80
+ # parsed is skipped — a fresh run simply has none, and write_files surfaces
81
+ # genuine problems when it later merges/rewrites.
82
+ private def existing_configs_by_name(dir)
83
+ return {} unless dir && File.directory?(dir)
84
+
85
+ Dir[File.join(dir, "*.json")].each_with_object({}) do |path, acc|
86
+ config = MongodbCollectionConfig.from(JSON.parse(File.read(path)))
87
+ acc[config.name] = config
88
+ rescue JSON::ParserError, ArgumentError
89
+ next
90
+ end
69
91
  end
70
92
 
71
93
  def write_files(dir, collections)
@@ -88,7 +110,15 @@ module Exwiw
88
110
  # belongs_tos are unioned across the group; processing least-derived first
89
111
  # keeps the base's fields leading the list and the output deterministic
90
112
  # regardless of input order or sibling subclasses.
91
- private def build_collection_for(collection_name, models)
113
+ private def build_collection_for(collection_name, models, existing = nil)
114
+ # An explicit on-disk `ignore: true` means the user has triaged this
115
+ # collection and asked exwiw to leave it alone: preserve their config
116
+ # (ignore_type / comment intact) and skip introspection entirely, so a
117
+ # construct exwiw cannot represent never aborts a run the user has already
118
+ # accounted for. (A collection is never dumped while ignored, so its
119
+ # fields/structure need not track the model.)
120
+ return existing if existing&.ignore
121
+
92
122
  ordered = models.sort_by { |model| [model.fields.size, model.name] }
93
123
 
94
124
  attrs = {
@@ -128,7 +158,7 @@ module Exwiw
128
158
  attrs[:comment] = "exwiw could not derive embedded_in (#{reason}); marked ignore:true. Define this collection's embedded_in config by hand to dump/mask it."
129
159
  end
130
160
  else
131
- attrs[:belongs_tos] = aggregate_belongs_tos(ordered)
161
+ attrs[:belongs_tos] = aggregate_belongs_tos(ordered, existing)
132
162
  end
133
163
 
134
164
  MongodbCollectionConfig.from_symbol_keys(attrs)
@@ -200,7 +230,9 @@ module Exwiw
200
230
  end
201
231
  end
202
232
 
203
- private def aggregate_belongs_tos(models)
233
+ private def aggregate_belongs_tos(models, existing = nil)
234
+ ignored_by_fk = ignored_belongs_tos_by_foreign_key(existing)
235
+
204
236
  belongs_to_assocs = models.flat_map do |model|
205
237
  model.relations.values.select do |assoc|
206
238
  assoc.is_a?(::Mongoid::Association::Referenced::BelongsTo)
@@ -216,10 +248,22 @@ module Exwiw
216
248
  # same belongs_to twice, so uniq them.
217
249
  belongs_to_assocs
218
250
  .reject(&:polymorphic?)
219
- .filter_map { |assoc| belongs_to_for(assoc) }
251
+ .filter_map { |assoc| belongs_to_for(assoc, ignored_by_fk) }
220
252
  .uniq
221
253
  end
222
254
 
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
261
+
262
+ existing.belongs_tos.select(&:ignore).each_with_object({}) do |bt, acc|
263
+ acc[bt.foreign_key] = bt
264
+ end
265
+ end
266
+
223
267
  # Resolves a referenced belongs_to to a `{ table_name, foreign_key }` pair
224
268
  # (plus `references` when the FK points at a non-`_id` parent field).
225
269
  # `assoc.klass` raises NameError when the association's target class no longer
@@ -227,7 +271,18 @@ module Exwiw
227
271
  # ago). Under `skip_unsupported` such a relation is skipped with a warning —
228
272
  # its foreign-key column is still tracked as an ordinary field by
229
273
  # `aggregate_fields`, mirroring how polymorphic / HABTM relations are dropped.
230
- private def belongs_to_for(assoc)
274
+ #
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)
284
+ end
285
+
231
286
  result = { table_name: assoc.klass.collection_name.to_s, foreign_key: assoc.foreign_key }
232
287
  # Mongoid's `belongs_to ..., primary_key: :uuid` makes the child's foreign
233
288
  # key reference that parent field rather than the parent's `_id`. Surface
@@ -245,6 +300,21 @@ module Exwiw
245
300
  nil
246
301
  end
247
302
 
303
+ # Re-emits a user's on-disk ignored belongs_to as a symbol-keyed hash (the
304
+ # shape `build_collection_for` feeds to `from_symbol_keys`), carrying its
305
+ # `ignore` / `ignore_type` / `comment` (and `table_name` / `references` when
306
+ # present) so the annotation survives regeneration untouched.
307
+ private def preserve_ignored_belongs_to(bt)
308
+ {
309
+ table_name: bt.table_name,
310
+ foreign_key: bt.foreign_key,
311
+ references: bt.references,
312
+ ignore: true,
313
+ ignore_type: bt.ignore_type,
314
+ comment: bt.comment,
315
+ }.compact
316
+ end
317
+
248
318
  # Resolves the `embedded_in` config for an embedded model. Each embedded
249
319
  # model points at its *immediate* embedding parent: the parent's collection
250
320
  # name plus the single document key (`store_as`, defaulting to the relation
@@ -317,31 +387,17 @@ module Exwiw
317
387
  )
318
388
  end
319
389
 
320
- # `store_as` defaults to the relation name and is the actual document key
321
- # the subdocuments are stored under inside the immediate parent.
322
- parent_relation =
323
- begin
324
- parent.relations[assoc.inverse.to_s]
325
- rescue ::Mongoid::Errors::MongoidError, NameError => e
326
- # e.g. AmbiguousRelationship: the embedded class is embedded under
327
- # several document keys in the parent (or otherwise has no single
328
- # resolvable inverse), so exwiw cannot pick the one path it lives under.
329
- raise UnsupportedEmbedding.new(
330
- "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
331
- "declares `embedded_in :#{assoc.name}` whose inverse on '#{parent.name}' is ambiguous " \
332
- "or unresolvable (#{e.class}: #{e.message.lines.first&.strip}). Add an `inverse_of:` to " \
333
- "disambiguate, or define the collection's config by hand.",
334
- reason: "has an embedded_in :#{assoc.name} with an ambiguous/unresolvable inverse",
335
- )
336
- end
390
+ # Resolve the document key (`store_as`, defaulting to the relation name)
391
+ # the subdocuments live under inside the parent.
392
+ parent_relation = embedding_relation_in(parent, assoc, model)
337
393
 
338
394
  unless parent_relation
339
- # `assoc.inverse` resolved to a name that is not an association on the
340
- # parent (or to nothing), so there is no document key to embed under.
395
+ # No embeds_one / embeds_many on the parent stores this collection, so
396
+ # there is no document key to embed under.
341
397
  raise UnsupportedEmbedding.new(
342
398
  "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
343
- "declares `embedded_in :#{assoc.name}` but its inverse relation could not be located on " \
344
- "'#{parent.name}' (the embedding document key is indeterminable). Add an `inverse_of:`, or " \
399
+ "declares `embedded_in :#{assoc.name}` but no embeds_one/embeds_many on '#{parent.name}' " \
400
+ "stores this collection (the embedding document key is indeterminable). Add an `inverse_of:`, or " \
345
401
  "define the collection's config by hand.",
346
402
  reason: "has an embedded_in :#{assoc.name} whose inverse relation could not be located",
347
403
  )
@@ -350,6 +406,64 @@ module Exwiw
350
406
  { collection_name: parent.collection_name.to_s, path: parent_relation.store_as }
351
407
  end
352
408
 
409
+ # Locates the parent's `embeds_one` / `embeds_many` association that stores
410
+ # this embedded collection — i.e. the document key the subdocuments live
411
+ # under. Mongoid's computed `assoc.inverse` is preferred when it resolves
412
+ # cleanly, but it is frequently `nil` (no explicit `inverse_of:` and Mongoid
413
+ # declines to infer one) or raises `AmbiguousRelationship`; in those cases
414
+ # fall back to matching the parent's embedding relations by the collection
415
+ # they store. This resolves the common single-embedding case that
416
+ # `assoc.inverse` cannot (e.g. an `embeds_one :force_logout` / `embedded_in
417
+ # :customer` pair with no inverse_of). Returns the relation, `nil` when none
418
+ # stores this collection, and raises `UnsupportedEmbedding` when several
419
+ # distinct keys do (genuinely ambiguous — exwiw cannot pick one).
420
+ private def embedding_relation_in(parent, assoc, model)
421
+ inverse_name =
422
+ begin
423
+ assoc.inverse
424
+ rescue ::Mongoid::Errors::MongoidError, NameError
425
+ nil
426
+ end
427
+
428
+ if inverse_name
429
+ rel = parent.relations[inverse_name.to_s]
430
+ return rel if rel
431
+ end
432
+
433
+ candidates = parent.relations.values.select do |rel|
434
+ (rel.is_a?(::Mongoid::Association::Embedded::EmbedsMany) ||
435
+ rel.is_a?(::Mongoid::Association::Embedded::EmbedsOne)) &&
436
+ embeds_collection?(rel, model)
437
+ end
438
+ paths = candidates.map(&:store_as).uniq
439
+
440
+ if paths.size > 1
441
+ # The same collection is embedded under several document keys in the
442
+ # parent, so `embedded_in :#{assoc.name}` has no single resolvable path.
443
+ raise UnsupportedEmbedding.new(
444
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
445
+ "is embedded under multiple document keys (#{paths.join(', ')}) in '#{parent.name}', so its " \
446
+ "`embedded_in :#{assoc.name}` is ambiguous or unresolvable — exwiw cannot pick the single path " \
447
+ "it lives under. Add an `inverse_of:` to disambiguate, or define the collection's config by hand.",
448
+ reason: "has an embedded_in :#{assoc.name} with an ambiguous/unresolvable inverse",
449
+ )
450
+ end
451
+
452
+ candidates.first
453
+ end
454
+
455
+ # True when `rel` (an embeds_one / embeds_many on the parent) stores the same
456
+ # collection as `model`. Comparing collection names (rather than class
457
+ # identity) also matches an STI subclass embedded through a relation declared
458
+ # against its base class, since both share the base's collection. A sibling
459
+ # embedding relation whose target class no longer resolves is treated as a
460
+ # non-match rather than blowing up the whole derivation.
461
+ private def embeds_collection?(rel, model)
462
+ rel.klass.collection_name.to_s == model.collection_name.to_s
463
+ rescue NameError, ::Mongoid::Errors::MongoidError
464
+ false
465
+ end
466
+
353
467
  private def embedded_in_association(model)
354
468
  model.relations.values.find do |assoc|
355
469
  assoc.is_a?(::Mongoid::Association::Embedded::EmbeddedIn)
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.4.10"
4
+ VERSION = "0.4.11"
5
5
  end
data/lib/tasks/exwiw.rake CHANGED
@@ -32,11 +32,17 @@ namespace :exwiw do
32
32
  end
33
33
 
34
34
  desc "Generate schema from a Mongoid application"
35
- # Set EXWIW_SKIP_UNSUPPORTED=1 to keep generation going past constructs exwiw
36
- # cannot represent (an unresolvable `belongs_to`, or a polymorphic / cyclic /
37
- # unresolvable `embedded_in`): the unresolvable belongs_to is skipped and an
35
+ # Fail-loud by default: the task aborts on a construct exwiw cannot represent
36
+ # (an unresolvable `belongs_to`, or a polymorphic / cyclic / ambiguous /
37
+ # unresolvable `embedded_in`). To keep a deliberately-unrepresentable
38
+ # collection or relation from aborting the run, mark it `ignore: true` in its
39
+ # config on disk (optionally with an `ignore_type` / `comment` recording why);
40
+ # the generator honors that and skips re-introspecting it. Set
41
+ # EXWIW_SKIP_UNSUPPORTED=1 to additionally keep going past *un-annotated*
42
+ # unrepresentable constructs (the unresolvable belongs_to is skipped and an
38
43
  # unrepresentable embedded collection is emitted as `ignore: true` with a
39
- # `comment`, each warned to stderr, instead of aborting the whole run.
44
+ # `comment`, each warned to stderr) useful for the first bootstrap pass
45
+ # against a large app before the ignores are written.
40
46
  task generate_mongoid: :environment do
41
47
  require "exwiw"
42
48
 
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.4.10
4
+ version: 0.4.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia