exwiw 0.9.22 → 0.9.23

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: 3abd99db26266c75e79f46cd0fc14a2a0344308031422385ca22dd2a77ab0130
4
- data.tar.gz: 9c78446793e1c91947bcf7aa6b933d623c87d004d84fbcb3df8b86886e136482
3
+ metadata.gz: 9cbe5a94be08902db6990812f48eb07b3aa5cae7a507acb0f8c3cf1f90edc175
4
+ data.tar.gz: 19b92d8feee9e874135fc0c9f833d290aacdb1be35c6bcd3da72c7c212352327
5
5
  SHA512:
6
- metadata.gz: 0406c29183e0a896ae9de40e24622239caa4cf06b898784d1aa24633d479b0e4ee5717f8d755e5c323af1dd290e18e92f201f4d2ac6f192feb71e9c1a947eee2
7
- data.tar.gz: 63e754207a3c08d97d9b9ceed684b5e112c770c02e9638dd80e9ba958fd1d9ecc96784e6d0431cb27c818e254177d62b506ea587fc1ba55d7068125c889a7fd2
6
+ metadata.gz: 75e631242a39ba72bd16ed7d28435f935c9d591d89888632f5fd835b2b727d6f497a976b91bbcc69bdbc8c85fbfb68d2265488d3b07b19baa37001a7d832d173
7
+ data.tar.gz: abcfacf75eff9c74324fae77915f10b86ab7258f5251bf5fa7adbe73a3af318fd06ffc4e791e7abc00439b1007901d43e80865ff0394ada7eb8deba040707d1d
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.9.23] - 2026-08-13
6
+
7
+ ### Fixed
8
+
9
+ - **A collection stored into by both a top-level and an embedded model is now generated as the top-level collection it is, instead of silently ceasing to be dumped.** `MongoidSchemaGenerator` treated a collection group as embedded as soon as *any* model in it was — and an embedded class's collection name derives from its class name alone, so it collides with any top-level model that stores into a collection of that name, with nothing declaring the two to be related and neither side wrong. The emitted config then carried an `embedded_in` and an empty `belongs_tos`, with three consequences: `MongodbAdapter#dumpable?` (`!embedded?`) silently never dumped the collection's root documents; the top-level models' `belongs_tos` — the extraction paths that reach everything scoped below them — disappeared from the config; and a hand-maintained top-level config could not survive regeneration either, because `MongodbCollectionConfig#merge` always takes the freshly generated `embedded_in`, so `schema:check_mongoid` reported that file as changed on every run with no way to resolve it. Such a collection is genuinely top-level — it has root documents that must be dumped — so it is now built from its **root** models alone (their fields, their `belongs_tos`, and safe mode's structural/unique sets), with no `embedded_in`, and the collision is named on stderr instead: the embedded documents of the same name are **not** covered by that config and are masked, as before, through a config written by hand with an `embedded_in` and a name of its own (generation and `tidy_mongoid` both leave `embedded_in` configs alone, so such a file is stable). The collision is deliberately *not* recorded as a generated `comment` on the config, since a generated comment wins the merge and would overwrite — on every run — the note a user wrote about this very situation. Only a *root* model counts as evidence of a root collection, which is the second shape this distinguishes: an **embedded family under a plain base class** (`Address` holding the shared fields and declaring no `embedded_in`, `BillingAddress < Address` declaring one and inheriting the collection name) is not mixed at all. `embedded?` answers "does this class declare an `embedded_in`", not "does this collection have root documents", and a shared base stores nothing at the root — its fields describe subdocuments. Such a family keeps generating exactly the embedded config it always did (the same field union, in the same order, with the embedding derived from a class that actually declares one), because flipping it to a top-level config would delete the config masking those subdocuments: the same worst-case failure in the opposite direction. A non-embedded model whose embedded descendants are in the group is therefore always read as their base class, conservatively so even if the application also stored root documents under it — that keeps the behavior (and the limitation) the generator always had rather than trading a silent masking regression for a silent dumping one. Collections stored into only by top-level models, or only by embedded ones, are unaffected, including the unrepresentable embedding shapes that abort or, under `EXWIW_SKIP_UNSUPPORTED=1`, emit an `ignore: true` config. Related: a model declared `store_in collection: nil` — how an application says a document class is never persisted and only wants Mongoid's casting — is now skipped entirely instead of contributing to a config for the nameless group every such class collapses into, which describes nothing and, as a top-level config, would instruct the dump to read a collection with no name. They are dropped where the generator selects the models it describes, so they leave `tidy_mongoid`'s live-collection set as well.
10
+
5
11
  ## [0.9.22] - 2026-08-13
6
12
 
7
13
  ### Added
data/docs/mongodb.md CHANGED
@@ -138,7 +138,7 @@ For MongoDB applications backed by [Mongoid](https://www.mongodb.com/docs/mongoi
138
138
  bundle exec rake exwiw:schema:generate_mongoid
139
139
  ```
140
140
 
141
- It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the ActiveRecord generator because the two ORMs expose entirely different metadata. From each model it derives:
141
+ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the ActiveRecord generator because the two ORMs expose entirely different metadata. Every application document model is described, except one declared `store_in collection: nil` — the way an application says a document class is never persisted and only wants Mongoid's casting: it has no collection name to describe, so it is skipped (and does not count as a live collection when tidying). From each remaining model it derives:
142
142
 
143
143
  - the collection name and the `_id` primary key,
144
144
  - `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),
@@ -147,6 +147,13 @@ It is a distinct task and class (`Exwiw::MongoidSchemaGenerator`) from the Activ
147
147
 
148
148
  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.
149
149
 
150
+ A collection name can also be shared by embedded and non-embedded models, in two shapes the generator tells apart. `embedded?` only answers "does this class declare an `embedded_in`", which is not the same question as "does this collection have root documents":
151
+
152
+ - **An embedded family under a plain base class** — `Address` holding the shared fields and declaring no `embedded_in`, with `BillingAddress < Address` declaring one and inheriting the collection name. Nothing is stored at the root under the base; it is part of the embedded family, and its fields describe subdocuments. The collection stays **embedded**, exactly as before: one `embedded_in` config unioning the base's fields with its subclasses', derived from a class that actually declares the embedding. (A non-embedded model whose embedded descendants are in the same group is always read as their base — conservatively so, even if the application also stored root documents under it, since the alternative would silently delete the config masking those subdocuments.)
153
+ - **A genuine collision** — an unrelated top-level model stores into a collection whose name an embedded class derives from its own class name. Such a collection is genuinely **top-level**: it has root documents that must be dumped, so its config is built from the root models only (their fields and `belongs_tos`) with no `embedded_in`. An embedded base in the same group does not contribute its fields. Representing the group as embedded would make `dumpable?` skip the collection and silently drop those documents from the export.
154
+
155
+ The collision is reported on stderr, because the embedded documents of the same name are **not** covered by that config: to mask them, add a config by hand with an `embedded_in` and a `name` / file name of your own choosing (generation and `tidy_mongoid` both leave `embedded_in` configs alone, so a hand-written one is stable). This is deliberately not recorded as a `comment` on the generated config — a generated comment takes precedence when merging, so it would overwrite a note you wrote about this very situation on every run.
156
+
150
157
  Regeneration preserves hand-edited `replace_with`, `filter`, `ignore`, `bulk_insert_chunk_size`, and `query_timeout_ms` values, like the ActiveRecord generator. Indexes are not written to the config — they are introspected from the live database at dump time (see [Output](#output)). Polymorphic `belongs_to` is not yet expanded by this task.
151
158
 
152
159
  ### Safe mode, `tidy_mongoid` and `check_mongoid`
@@ -275,50 +275,108 @@ module Exwiw
275
275
  return existing if existing&.ignore
276
276
 
277
277
  ordered = models.sort_by { |model| [model.fields.size, model.name] }
278
+ embedded_models, non_embedded_models = ordered.partition(&:embedded?)
279
+
280
+ return top_level_collection(collection_name, ordered, existing) if embedded_models.empty?
281
+
282
+ # `embedded?` means "declares an `embedded_in`", not "has root documents":
283
+ # a plain base class of embedded documents declares none but stores nothing
284
+ # at the root either. Reading it as a root model would flip the family to a
285
+ # top-level config and delete the `embedded_in` that masks its subdocuments,
286
+ # so a non-embedded model with embedded descendants in the group counts as
287
+ # their base — even if root documents were also stored under it (keeps the
288
+ # generator's historical behavior rather than risking a masking regression).
289
+ root_models = non_embedded_models.reject do |model|
290
+ embedded_models.any? { |embedded| embedded < model }
291
+ end
278
292
 
279
- attrs = {
293
+ # A root model left after that is a genuine name collision (an embedded
294
+ # class derives its collection name from the class name alone). The
295
+ # collection has root documents that must keep being dumped, so generate it
296
+ # as top-level from the root models only — emitting `embedded_in` here would
297
+ # make `MongodbAdapter#dumpable?` skip it silently, and #merge would force
298
+ # that shape back onto a hand-maintained config on every run. The embedded
299
+ # namesakes are masked by a hand-written `embedded_in` config under a
300
+ # synthetic name (which generation and tidy leave alone); the warning says
301
+ # so instead of a generated `comment`, which would overwrite the user's own
302
+ # note via #merge.
303
+ if root_models.any?
304
+ warn_mixed_embedding(collection_name, embedded_models)
305
+ return top_level_collection(collection_name, root_models, existing)
306
+ end
307
+
308
+ # Purely embedded (possibly under a plain base, whose fields keep being
309
+ # unioned in as before).
310
+ embedded_collection(collection_name, ordered)
311
+ end
312
+
313
+ # The config for a collection dumped in its own right: its models' fields and
314
+ # the belongs_tos its extraction follows.
315
+ private def top_level_collection(collection_name, models, existing)
316
+ MongodbCollectionConfig.from_symbol_keys(
280
317
  name: collection_name,
281
318
  primary_key: PRIMARY_KEY,
282
- fields: aggregate_fields(ordered),
283
- }
319
+ belongs_tos: aggregate_belongs_tos(models, existing),
320
+ fields: aggregate_fields(models),
321
+ )
322
+ end
284
323
 
285
- if ordered.any?(&:embedded?)
324
+ # The config for a collection that exists only inside another's documents.
325
+ # `models` is the whole group, which may include the embedded documents' plain
326
+ # base class — its fields belong in the union, but only a class that declares
327
+ # an `embedded_in` can say where the collection lives, hence the `find`.
328
+ private def embedded_collection(collection_name, models)
329
+ attrs = {
330
+ name: collection_name,
331
+ primary_key: PRIMARY_KEY,
332
+ fields: aggregate_fields(models),
286
333
  # Cross-collection references from inside an embedded array are not
287
334
  # supported (MongodbCollectionConfig rejects them), so embedded configs
288
335
  # always carry an empty belongs_tos and instead declare where they live.
289
- attrs[:belongs_tos] = []
290
- begin
291
- attrs[:embedded_in] = embedded_in_for(ordered.find(&:embedded?))
292
- rescue => e
293
- # Known-unrepresentable shapes arrive as UnsupportedEmbedding (with a
294
- # concise reason). Without skip_unsupported, re-raise so the historical
295
- # fail-loud behavior is preserved. The broad rescue is a deliberate
296
- # safety net for skip_unsupported (a best-effort bootstrapping mode):
297
- # any other error while deriving the embedding is turned into an
298
- # `ignore: true` config too, so a single odd model never aborts the run.
299
- raise e unless @skip_unsupported
300
-
301
- reason =
302
- if e.is_a?(UnsupportedEmbedding)
303
- e.reason
304
- else
305
- "raised #{e.class} while deriving embedded_in (#{e.message.lines.first&.strip})"
306
- end
307
-
308
- # Emit the collection as a top-level config marked `ignore: true` so it
309
- # is NOT (wrongly) dumped as its own collection, and record why. The
310
- # user can hand-write its embedded_in config later to dump/mask it.
311
- warn("exwiw: skip_unsupported: '#{collection_name}' #{reason}; emitting ignore:true (define embedded_in by hand to dump/mask it).")
312
- attrs[:ignore] = true
313
- 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."
314
- end
315
- else
316
- attrs[:belongs_tos] = aggregate_belongs_tos(ordered, existing)
336
+ belongs_tos: [],
337
+ }
338
+
339
+ begin
340
+ attrs[:embedded_in] = embedded_in_for(models.find(&:embedded?))
341
+ rescue => e
342
+ # Known-unrepresentable shapes arrive as UnsupportedEmbedding (with a
343
+ # concise reason). Without skip_unsupported, re-raise so the historical
344
+ # fail-loud behavior is preserved. The broad rescue is a deliberate
345
+ # safety net for skip_unsupported (a best-effort bootstrapping mode):
346
+ # any other error while deriving the embedding is turned into an
347
+ # `ignore: true` config too, so a single odd model never aborts the run.
348
+ raise e unless @skip_unsupported
349
+
350
+ reason =
351
+ if e.is_a?(UnsupportedEmbedding)
352
+ e.reason
353
+ else
354
+ "raised #{e.class} while deriving embedded_in (#{e.message.lines.first&.strip})"
355
+ end
356
+
357
+ # Emit the collection as a top-level config marked `ignore: true` so it
358
+ # is NOT (wrongly) dumped as its own collection, and record why. The
359
+ # user can hand-write its embedded_in config later to dump/mask it.
360
+ warn("exwiw: skip_unsupported: '#{collection_name}' #{reason}; emitting ignore:true (define embedded_in by hand to dump/mask it).")
361
+ attrs[:ignore] = true
362
+ 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."
317
363
  end
318
364
 
319
365
  MongodbCollectionConfig.from_symbol_keys(attrs)
320
366
  end
321
367
 
368
+ # Names the collision on stderr, once per affected collection.
369
+ private def warn_mixed_embedding(collection_name, embedded_models)
370
+ warn(
371
+ "exwiw: collection '#{collection_name}' is stored into by both top-level and embedded models " \
372
+ "(embedded: #{embedded_models.map(&:name).sort.join(', ')}); generating it as a TOP-LEVEL collection " \
373
+ "from its non-embedded model(s) only, so its root documents keep being dumped. The embedded " \
374
+ "documents of the same name are NOT covered by this config: to mask them, add a config by hand " \
375
+ "with an `embedded_in` and a distinct `name`/file name of your choosing (generation and tidy leave " \
376
+ "embedded configs alone)."
377
+ )
378
+ end
379
+
322
380
  # Mongoid registers only the base class of an inheritance hierarchy in
323
381
  # `Mongoid.models`; subclasses that store into the base's collection
324
382
  # (STI-style, distinguished by the auto-added `_type` discriminator) are
@@ -339,11 +397,20 @@ module Exwiw
339
397
  concrete(@models)
340
398
  end
341
399
 
400
+ # The single place a model is judged fit to describe (used by generate! and
401
+ # tidy! alike): Mongoid's internal helper classes are dropped, and so is any
402
+ # class with an empty `collection_name` — `store_in collection: nil` is the
403
+ # never-persisted-model idiom, and a config for the nameless group such
404
+ # classes collapse into describes nothing (as top-level it would even
405
+ # instruct the dump to read a collection with no name). The emptiness test
406
+ # stays last: the preceding conditions establish that calling
407
+ # `collection_name` is meaningful.
342
408
  private def concrete(models)
343
409
  models.select do |model|
344
410
  model.respond_to?(:collection_name) &&
345
411
  model.name &&
346
- !model.name.start_with?("Mongoid::")
412
+ !model.name.start_with?("Mongoid::") &&
413
+ !model.collection_name.to_s.empty?
347
414
  end
348
415
  end
349
416
 
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.22"
4
+ VERSION = "0.9.23"
5
5
  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.22
4
+ version: 0.9.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia