exwiw 0.4.0 → 0.4.1

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: 568620ee295b1822c2606a42be450bbe5601aabb874606b6773b72619261cbba
4
- data.tar.gz: 46f7b6f97c6b77b7046dfb19117734286ecb5d88001214f9d6e1995560f0b50a
3
+ metadata.gz: a743c4759cdb8b292e4fdfe8ea3d4e10fa66e15a70677dd685366fb9e26a376b
4
+ data.tar.gz: '04048ceca802c0d418495cdf7c002bd47047beb05a53655a451c2c77a4d8270d'
5
5
  SHA512:
6
- metadata.gz: 8800c7adfa78ea01fb513eb3dbbe2b3fbfa98b13916d5b4e5a205232851b73e1bbef8cd9d0190abe5a9375d073c753d2f92be583c3a2a96d898cb757cd883a0a
7
- data.tar.gz: 02f0ebd17ed423dfffbba3aefe2fbba01b533c23672d680ccce7c21edf2b36dbfc0c66ee72b8f4433af3af643aaea126861233a99d2222a2d347f407c8ab5c27
6
+ metadata.gz: 7d7a5ffca1cdae405e88fac5d72e4ed4e52ee3deec8cce0e503dcdc2b4136ec03ca2d84893c5897a3e56a78b0fa9e11dddc5f606566f32c90297815c8ade4702
7
+ data.tar.gz: 8e7742f720c371b41679c311d6323f8182e4f414f1ff199ac90928026ce46c2607a61a3c3a7ed91c23c97ea4b855d838bfee38b593d69c06f1dc743a17916eb0
data/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.1] - 2026-06-04
6
+
7
+ ### Added
8
+
9
+ - `MongoidSchemaGenerator` gains an opt-in `skip_unsupported:` mode (via the rake task, `EXWIW_SKIP_UNSUPPORTED=1 bundle exec rake exwiw:schema:generate_mongoid`). When enabled, generation no longer aborts on a construct exwiw cannot represent: an unresolvable `belongs_to` (whose target class no longer exists — e.g. a stale relation left behind after the model was removed) is skipped with a stderr warning while its foreign-key column is still kept as a field, and a polymorphic / self-referential-cyclic / unresolvable-parent `embedded_in` collection is emitted as a top-level `ignore: true` config annotated with a `comment` explaining why — so it is not wrongly dumped as its own collection — instead of raising. Off by default, so the existing fail-loud behavior is unchanged for callers that do not opt in. `MongodbCollectionConfig` now also carries an optional collection-level `comment` attribute, preserved across regeneration like the field / belongs_to `comment`.
10
+
5
11
  ## [0.4.0] - 2026-06-04
6
12
 
7
13
  ### Fixed
data/README.md CHANGED
@@ -200,6 +200,15 @@ Models in an inheritance hierarchy whose subclasses share the base's collection
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:
204
+
205
+ ```bash
206
+ EXWIW_SKIP_UNSUPPORTED=1 bundle exec rake exwiw:schema:generate_mongoid
207
+ ```
208
+
209
+ - 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.
211
+
203
212
  ### Configuration
204
213
 
205
214
  This is an example of the one table schema:
@@ -14,6 +14,12 @@ module Exwiw
14
14
  attribute :fields, array(MongodbField)
15
15
  attribute :bulk_insert_chunk_size, optional(Integer), skip_serializing_if_nil: true
16
16
  attribute :ignore, Serdes::OptionalType.new(Serdes::ConcreteType.new(Boolean)), skip_serializing_if_nil: true
17
+ # Free-form note. Purely informational — exwiw never reads it — and preserved
18
+ # across `MongoidSchemaGenerator` regeneration like the field / belongs_to
19
+ # `comment`. The generator also emits one when, under `skip_unsupported`, it
20
+ # marks an unrepresentable collection `ignore: true`, to record why extraction
21
+ # was skipped.
22
+ attribute :comment, optional(String), skip_serializing_if_nil: true
17
23
 
18
24
  # Marks this config as physically embedded inside another collection's
19
25
  # documents. When set, this config is not processed as a standalone dump
@@ -62,6 +68,10 @@ module Exwiw
62
68
  merged.filter = filter
63
69
  merged.bulk_insert_chunk_size = bulk_insert_chunk_size
64
70
  merged.ignore = ignore
71
+ # A freshly generated comment (e.g. the skip_unsupported marker) wins so
72
+ # it stays accurate; otherwise a hand-added note on a normal collection
73
+ # is kept.
74
+ merged.comment = passed.comment || comment
65
75
  merged.embedded_in = passed.embedded_in
66
76
 
67
77
  # Structural facts of each belongs_to come from the freshly generated
@@ -14,14 +14,38 @@ module Exwiw
14
14
  # (`fields`, `relations`, `collection_name`), so it does not require a live
15
15
  # MongoDB connection.
16
16
  class MongoidSchemaGenerator
17
- def self.from_rails_application(output_dir:)
17
+ # Raised when an embedded collection's `embedded_in` cannot be expressed as
18
+ # an exwiw config (polymorphic embedding, self-referential/cyclic embedding,
19
+ # or an unresolvable embedding-parent class). A subclass of ArgumentError so
20
+ # the historical `raise_error(ArgumentError, ...)` contract is preserved.
21
+ # Under `skip_unsupported` the generator rescues this and emits an
22
+ # `ignore: true` config instead of aborting the whole run.
23
+ class UnsupportedEmbedding < ArgumentError
24
+ # A concise phrase (as opposed to the long, actionable exception message)
25
+ # recorded as the generated config's `comment`.
26
+ attr_reader :reason
27
+
28
+ def initialize(message, reason:)
29
+ super(message)
30
+ @reason = reason
31
+ end
32
+ end
33
+
34
+ # `skip_unsupported`: when true, the generator does not abort on a construct
35
+ # it cannot represent. It skips an unresolvable `belongs_to` (keeping the
36
+ # foreign-key field) and emits an unrepresentable embedded collection as an
37
+ # `ignore: true` top-level config annotated with a `comment`, warning to
38
+ # stderr in both cases. Off by default, so the historical fail-loud behavior
39
+ # is unchanged unless a caller opts in.
40
+ def self.from_rails_application(output_dir:, skip_unsupported: false)
18
41
  Rails.application.eager_load!
19
- new(models: ::Mongoid.models, output_dir: output_dir)
42
+ new(models: ::Mongoid.models, output_dir: output_dir, skip_unsupported: skip_unsupported)
20
43
  end
21
44
 
22
- def initialize(models:, output_dir:)
45
+ def initialize(models:, output_dir:, skip_unsupported: false)
23
46
  @models = models
24
47
  @output_dir = output_dir
48
+ @skip_unsupported = skip_unsupported
25
49
  end
26
50
 
27
51
  def generate!
@@ -78,7 +102,31 @@ module Exwiw
78
102
  # supported (MongodbCollectionConfig rejects them), so embedded configs
79
103
  # always carry an empty belongs_tos and instead declare where they live.
80
104
  attrs[:belongs_tos] = []
81
- attrs[:embedded_in] = embedded_in_for(ordered.find(&:embedded?))
105
+ begin
106
+ attrs[:embedded_in] = embedded_in_for(ordered.find(&:embedded?))
107
+ rescue => e
108
+ # Known-unrepresentable shapes arrive as UnsupportedEmbedding (with a
109
+ # concise reason). Without skip_unsupported, re-raise so the historical
110
+ # fail-loud behavior is preserved. The broad rescue is a deliberate
111
+ # safety net for skip_unsupported (a best-effort bootstrapping mode):
112
+ # any other error while deriving the embedding is turned into an
113
+ # `ignore: true` config too, so a single odd model never aborts the run.
114
+ raise e unless @skip_unsupported
115
+
116
+ reason =
117
+ if e.is_a?(UnsupportedEmbedding)
118
+ e.reason
119
+ else
120
+ "raised #{e.class} while deriving embedded_in (#{e.message.lines.first&.strip})"
121
+ end
122
+
123
+ # Emit the collection as a top-level config marked `ignore: true` so it
124
+ # is NOT (wrongly) dumped as its own collection, and record why. The
125
+ # user can hand-write its embedded_in config later to dump/mask it.
126
+ warn("exwiw: skip_unsupported: '#{collection_name}' #{reason}; emitting ignore:true (define embedded_in by hand to dump/mask it).")
127
+ attrs[:ignore] = true
128
+ 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
+ end
82
130
  else
83
131
  attrs[:belongs_tos] = aggregate_belongs_tos(ordered)
84
132
  end
@@ -168,10 +216,25 @@ module Exwiw
168
216
  # same belongs_to twice, so uniq them.
169
217
  belongs_to_assocs
170
218
  .reject(&:polymorphic?)
171
- .map { |assoc| { table_name: assoc.klass.collection_name.to_s, foreign_key: assoc.foreign_key } }
219
+ .filter_map { |assoc| belongs_to_for(assoc) }
172
220
  .uniq
173
221
  end
174
222
 
223
+ # Resolves a referenced belongs_to to a `{ table_name, foreign_key }` pair.
224
+ # `assoc.klass` raises NameError when the association's target class no longer
225
+ # exists (a stale/legacy `belongs_to`, e.g. pointing at a model removed years
226
+ # ago). Under `skip_unsupported` such a relation is skipped with a warning —
227
+ # its foreign-key column is still tracked as an ordinary field by
228
+ # `aggregate_fields`, mirroring how polymorphic / HABTM relations are dropped.
229
+ private def belongs_to_for(assoc)
230
+ { table_name: assoc.klass.collection_name.to_s, foreign_key: assoc.foreign_key }
231
+ rescue NameError, ::Mongoid::Errors::MongoidError => e
232
+ raise e unless @skip_unsupported
233
+
234
+ 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.")
235
+ nil
236
+ end
237
+
175
238
  # Resolves the `embedded_in` config for an embedded model. Each embedded
176
239
  # model points at its *immediate* embedding parent: the parent's collection
177
240
  # name plus the single document key (`store_as`, defaulting to the relation
@@ -196,14 +259,30 @@ module Exwiw
196
259
  # names exactly one parent collection + path, so this shape cannot be
197
260
  # represented; fail loudly with an actionable message instead of crashing.
198
261
  if assoc.polymorphic?
199
- raise ArgumentError,
200
- "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
201
- "declares a polymorphic `embedded_in :#{assoc.name}`, which has no single embedding " \
202
- "parent collection and cannot be expressed as an exwiw `embedded_in` config. " \
203
- "Define the collection's config by hand, or make the relation non-polymorphic."
262
+ raise UnsupportedEmbedding.new(
263
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
264
+ "declares a polymorphic `embedded_in :#{assoc.name}`, which has no single embedding " \
265
+ "parent collection and cannot be expressed as an exwiw `embedded_in` config. " \
266
+ "Define the collection's config by hand, or make the relation non-polymorphic.",
267
+ reason: "has a polymorphic embedded_in :#{assoc.name}",
268
+ )
204
269
  end
205
270
 
206
- parent = assoc.klass
271
+ parent =
272
+ begin
273
+ assoc.klass
274
+ rescue NameError => e
275
+ # The embedding-parent class named by `class_name` (or inferred from
276
+ # the relation) does not exist — a stale/renamed parent. exwiw cannot
277
+ # name a parent collection it cannot resolve.
278
+ raise UnsupportedEmbedding.new(
279
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
280
+ "declares `embedded_in :#{assoc.name}` whose parent class cannot be resolved " \
281
+ "(#{e.message.lines.first&.strip}). Fix the association's class_name, or define the " \
282
+ "collection's config by hand.",
283
+ reason: "has an embedded_in :#{assoc.name} whose parent class is unresolvable",
284
+ )
285
+ end
207
286
 
208
287
  # A self-referential / cyclic `embedded_in` — Mongoid's
209
288
  # `recursively_embeds_many` / `recursively_embeds_one` (which declare a
@@ -216,19 +295,47 @@ module Exwiw
216
295
  # `MongodbAdapter#dumpable?` (`!embedded?`) would silently never dump the
217
296
  # collection's root documents. Fail loudly instead.
218
297
  if parent.collection_name.to_s == model.collection_name.to_s
219
- raise ArgumentError,
220
- "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
221
- "declares a self-referential (cyclic) `embedded_in :#{assoc.name}` that embeds the " \
222
- "collection inside documents of its own type (e.g. `recursively_embeds_many` / " \
223
- "`recursively_embeds_one`). " \
224
- "exwiw represents a collection as either top-level or embedded, not both, so this " \
225
- "cannot be expressed as an exwiw `embedded_in` config. Define the collection's config " \
226
- "by hand."
298
+ raise UnsupportedEmbedding.new(
299
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
300
+ "declares a self-referential (cyclic) `embedded_in :#{assoc.name}` that embeds the " \
301
+ "collection inside documents of its own type (e.g. `recursively_embeds_many` / " \
302
+ "`recursively_embeds_one`). " \
303
+ "exwiw represents a collection as either top-level or embedded, not both, so this " \
304
+ "cannot be expressed as an exwiw `embedded_in` config. Define the collection's config " \
305
+ "by hand.",
306
+ reason: "has a self-referential (cyclic) embedded_in :#{assoc.name}",
307
+ )
227
308
  end
228
309
 
229
310
  # `store_as` defaults to the relation name and is the actual document key
230
311
  # the subdocuments are stored under inside the immediate parent.
231
- parent_relation = parent.relations[assoc.inverse.to_s]
312
+ parent_relation =
313
+ begin
314
+ parent.relations[assoc.inverse.to_s]
315
+ rescue ::Mongoid::Errors::MongoidError, NameError => e
316
+ # e.g. AmbiguousRelationship: the embedded class is embedded under
317
+ # several document keys in the parent (or otherwise has no single
318
+ # resolvable inverse), so exwiw cannot pick the one path it lives under.
319
+ raise UnsupportedEmbedding.new(
320
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
321
+ "declares `embedded_in :#{assoc.name}` whose inverse on '#{parent.name}' is ambiguous " \
322
+ "or unresolvable (#{e.class}: #{e.message.lines.first&.strip}). Add an `inverse_of:` to " \
323
+ "disambiguate, or define the collection's config by hand.",
324
+ reason: "has an embedded_in :#{assoc.name} with an ambiguous/unresolvable inverse",
325
+ )
326
+ end
327
+
328
+ unless parent_relation
329
+ # `assoc.inverse` resolved to a name that is not an association on the
330
+ # parent (or to nothing), so there is no document key to embed under.
331
+ raise UnsupportedEmbedding.new(
332
+ "MongoidSchemaGenerator: '#{model.name}' (collection '#{model.collection_name}') " \
333
+ "declares `embedded_in :#{assoc.name}` but its inverse relation could not be located on " \
334
+ "'#{parent.name}' (the embedding document key is indeterminable). Add an `inverse_of:`, or " \
335
+ "define the collection's config by hand.",
336
+ reason: "has an embedded_in :#{assoc.name} whose inverse relation could not be located",
337
+ )
338
+ end
232
339
 
233
340
  { collection_name: parent.collection_name.to_s, path: parent_relation.store_as }
234
341
  end
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.0"
4
+ VERSION = "0.4.1"
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
38
+ # unrepresentable embedded collection is emitted as `ignore: true` with a
39
+ # `comment`, each warned to stderr, instead of aborting the whole run.
35
40
  task generate_mongoid: :environment do
36
41
  require "exwiw"
37
42
 
38
43
  Exwiw::MongoidSchemaGenerator.from_rails_application(
39
44
  output_dir: ENV["OUTPUT_DIR_PATH"] || "exwiw",
45
+ skip_unsupported: ENV["EXWIW_SKIP_UNSUPPORTED"] == "1",
40
46
  ).generate!
41
47
  end
42
48
  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.4.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shia