ecs_on_rails 0.2.2 → 0.3.0

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.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +304 -0
  3. data/README.md +365 -50
  4. data/lib/ecs_rails/catalogue/address.rb +39 -0
  5. data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
  6. data/lib/ecs_rails/catalogue/counter.rb +46 -0
  7. data/lib/ecs_rails/catalogue/discard.rb +36 -0
  8. data/lib/ecs_rails/catalogue/email.rb +35 -0
  9. data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
  10. data/lib/ecs_rails/catalogue/identifier.rb +23 -0
  11. data/lib/ecs_rails/catalogue/image.rb +28 -0
  12. data/lib/ecs_rails/catalogue/link.rb +27 -0
  13. data/lib/ecs_rails/catalogue/marker.rb +38 -0
  14. data/lib/ecs_rails/catalogue/money.rb +86 -0
  15. data/lib/ecs_rails/catalogue/name.rb +37 -0
  16. data/lib/ecs_rails/catalogue/password.rb +50 -0
  17. data/lib/ecs_rails/catalogue/period.rb +57 -0
  18. data/lib/ecs_rails/catalogue/phone.rb +30 -0
  19. data/lib/ecs_rails/catalogue/position.rb +18 -0
  20. data/lib/ecs_rails/catalogue/rating.rb +25 -0
  21. data/lib/ecs_rails/catalogue/relationship.rb +159 -0
  22. data/lib/ecs_rails/catalogue/role.rb +23 -0
  23. data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
  24. data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
  25. data/lib/ecs_rails/catalogue/state.rb +71 -0
  26. data/lib/ecs_rails/catalogue/tags.rb +64 -0
  27. data/lib/ecs_rails/catalogue/text.rb +29 -0
  28. data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
  29. data/lib/ecs_rails/catalogue/token.rb +77 -0
  30. data/lib/ecs_rails/catalogue.rb +405 -0
  31. data/lib/ecs_rails/component.rb +13 -7
  32. data/lib/ecs_rails/config.rb +33 -11
  33. data/lib/ecs_rails/dsl.rb +463 -97
  34. data/lib/ecs_rails/entity.rb +21 -5
  35. data/lib/ecs_rails/errors.rb +3 -0
  36. data/lib/ecs_rails/inverses.rb +398 -0
  37. data/lib/ecs_rails/lazy.rb +86 -23
  38. data/lib/ecs_rails/markers.rb +197 -0
  39. data/lib/ecs_rails/preloading.rb +19 -8
  40. data/lib/ecs_rails/presence.rb +83 -30
  41. data/lib/ecs_rails/querying.rb +158 -12
  42. data/lib/ecs_rails/railtie.rb +10 -0
  43. data/lib/ecs_rails/registry.rb +66 -21
  44. data/lib/ecs_rails/relationships.rb +168 -160
  45. data/lib/ecs_rails/slots.rb +183 -0
  46. data/lib/ecs_rails/validations.rb +3 -5
  47. data/lib/ecs_rails/version.rb +2 -2
  48. data/lib/ecs_rails.rb +7 -3
  49. data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
  50. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
  51. data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
  52. data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
  53. data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
  54. data/lib/generators/ecs_rails/install/install_generator.rb +29 -12
  55. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
  56. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
  57. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
  58. data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
  59. data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
  60. data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
  61. data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
  62. data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
  63. data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
  64. data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
  65. metadata +61 -4
  66. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
  67. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -99
  68. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +0 -32
@@ -89,16 +89,18 @@ module EcsRails
89
89
  # Lazy::Entity because it walks Lazy's memo (@ecs_components).
90
90
  include Validations::Entity
91
91
 
92
+ # The wildcard query `referrers` (RFC-0015): every relationship row pointing
93
+ # at this entity, whatever its name.
94
+ include Inverses::Entity
95
+
92
96
  # The `component` DSL (RFC-0004). Extended rather than defined here: RFC-0001
93
97
  # is about identity, and composition is a separate concern with its own file.
94
98
  # Singleton methods are inherited, so every entity subclass answers it.
95
99
  extend DSL
96
100
 
97
- # The `relates_to` DSL (RFC-0012): cross-entity links, decided by ADR-0013.
98
- # Extended after DSL because it is built on `component` — it dynamically
99
- # defines a backing component class and declares it — and reuses DSL's own
100
- # private reader/delegation resolution for its collision check. See
101
- # EcsRails::Relationships.
101
+ # The `relates_to` DSL (RFC-0012, ADR-0017): each link is a labelled slot
102
+ # of the shared Relationship component. Extended after DSL because it uses
103
+ # `component` and its reader/delegation collision checks.
102
104
  extend Relationships
103
105
 
104
106
  # The component query DSL (RFC-0010): with_component / without_component.
@@ -117,6 +119,20 @@ module EcsRails
117
119
  # yields virtual instances — see docs/adr/0012-component-preloading.md.
118
120
  extend Preloading
119
121
 
122
+ # The `marker` DSL (ADR-0018 §4 / RFC-0016): markers as slots of one Marker
123
+ # component. Extended last: it is built on `component` (DSL), reserves names
124
+ # through the hook Relationships also extends, and its query sugar rides
125
+ # Querying. See EcsRails::Markers.
126
+ extend Markers
127
+
128
+ # The parent side of a relationship (RFC-0015): `has_many :comments, Comment,
129
+ # via: :post` and `has_one`, expanding to native has_many :through over the
130
+ # shared relationships table. Extended last so its `has_many`/`has_one`
131
+ # overrides sit in front of ActiveRecord's — which they fall through to
132
+ # without `via:`, as the DSL's own slot-scoped has_one does — and so its
133
+ # reserved names extend Relationships' and Markers'. See EcsRails::Inverses.
134
+ extend Inverses
135
+
120
136
  # Immutable identity (architecture.md §1). Beyond the guard above, this is
121
137
  # what excludes id and model from any UPDATE statement.
122
138
  attr_readonly :id, :model
@@ -4,6 +4,9 @@ module EcsRails
4
4
  # Base class for every error the gem raises.
5
5
  class Error < StandardError; end
6
6
 
7
+ # Raised when catalogue upgrade finds structure needing an explicit repair.
8
+ class SchemaMismatch < Error; end
9
+
7
10
  # Raised when `component` is given something that is not a EcsRails::Component.
8
11
  # See RFC-0004.
9
12
  class InvalidComponent < Error; end
@@ -0,0 +1,398 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # The parent side of a relationship: `has_many` / `has_one` over the shared
5
+ # `relationships` table (RFC-0015, on ADR-0017's storage).
6
+ #
7
+ # class Comment < ApplicationEntity
8
+ # relates_to :post, Post # the child owns the link
9
+ # end
10
+ #
11
+ # class Post < ApplicationEntity
12
+ # has_many :comments, via: :post # the parent reads it back; child inferred from the name
13
+ # end
14
+ #
15
+ # post.comments # a real ActiveRecord CollectionProxy of Comments
16
+ # post.comments << comment # writes the relationships row
17
+ # post.comments.create!(...) # a Comment whose :post is this post
18
+ # Post.includes(:comments) # preloads, no N+1
19
+ #
20
+ # class Invoice < ApplicationEntity
21
+ # relates_to :order, Order, unique: true
22
+ # end
23
+ #
24
+ # class Order < ApplicationEntity
25
+ # has_one :invoice, via: :order # requires unique: true on the child
26
+ # end
27
+ #
28
+ # ## The expansion
29
+ #
30
+ # Every `relates_to` is a row in `relationships` — `entity_id` the child,
31
+ # `slot` the relationship name, `target_id` the parent, `owner_model` the
32
+ # child's discriminator (ADR-0017). The parent's primary key *is* the
33
+ # `entities.id` that `target_id` references, so the parent registers native
34
+ # ActiveRecord associations over it. `has_many :comments, via: :post` expands to
35
+ #
36
+ # has_many :comments_post_links,
37
+ # -> { where(slot: "post", owner_model: "comments") },
38
+ # class_name: "Relationship", foreign_key: :target_id, inverse_of: :target
39
+ # has_many :comments, through: :comments_post_links, source: :entity, class_name: "Comment"
40
+ #
41
+ # One shape for every inverse, and everything after it is Rails: the reader is
42
+ # a real `CollectionProxy`, preloading is `includes(:comments)`, and
43
+ # ADR-0008's read-time resolution returns `Comment`s (spiked before this
44
+ # module was written). `owner_model` on the link scope is load-bearing —
45
+ # without it a `Review` relating `:post` would be collected too — and
46
+ # `class_name` on the through adds the entity-model scope as a second guard.
47
+ # `has_one` is the same with a has_one link (`invoice_order_link`), because
48
+ # ActiveRecord's has_one :through cannot pass through a collection.
49
+ #
50
+ # ## The child is named, not referenced — and validated on first use
51
+ #
52
+ # The child may be given as a class (`Comment`), a string (`"Comment"`), or
53
+ # left to be inferred from the reader (`:comments` → `"Comment"`), exactly as
54
+ # ActiveRecord's `class_name:` works. It is held as a NAME and resolved when
55
+ # the association is used, for the reason Rails does the same: the parent
56
+ # names the child and the child's `relates_to` names the parent, and under
57
+ # autoloading a constant reference from either side loads the other *in the
58
+ # middle of its own class body*. Referencing `Membership` from `Group`'s body
59
+ # autoloads `membership.rb`, whose `relates_to :group, Group` re-enters a
60
+ # half-defined `Group` that then inspects a half-defined `Membership`. The
61
+ # forum hit exactly this. Names break the cycle.
62
+ #
63
+ # So the checks — the child is a concrete entity, it declares `via` towards
64
+ # this entity, and for `has_one` that relationship is `unique: true` — run
65
+ # **on the first read** of the association (once per class), and **at boot
66
+ # when `config.eager_load` is on** (the Railtie), so production fails to
67
+ # start rather than at the first request. Call {#validate_inverses!} to run
68
+ # them whenever you like. This is RFC-0015's declaration-time raise, moved to
69
+ # the earliest moment that is safe under autoloading.
70
+ #
71
+ # ## What you get, and what you don't (honestly)
72
+ #
73
+ # - `<<` and `create!` write the link row immediately, with `slot`,
74
+ # `owner_model` and `exclusive` preset by the link scope and the
75
+ # Relationship's own before_save. **`build` writes it when the parent
76
+ # saves**, as with any `has_many :through`; a built child that saves itself
77
+ # first is not linked. For `has_one`, assignment and `create_x!` link
78
+ # immediately (the latter flushes the join record Rails would otherwise
79
+ # leave for the owner's save); `build_x` links on the owner's save.
80
+ # - `dependent:` applies to the **link rows** only (`:destroy` or
81
+ # `:delete_all`): destroying the parent then removes its pointers rather
82
+ # than leaving them nullified by the foreign key. Destroying the child
83
+ # *entities* stays an explicit domain choice.
84
+ # - No counter cache (`counter_cache` is belongs_to-only). A cached count is
85
+ # a `Counter` slot plus callbacks.
86
+ #
87
+ # Both macros keep their ActiveRecord meaning when called without `via:` — the
88
+ # DSL's own slot-scoped `has_one` for component readers goes through here
89
+ # untouched.
90
+ module Inverses
91
+ # One recorded inverse, by class NAME (reload-safe, like the registry).
92
+ #
93
+ # @!attribute [rw] name
94
+ # @return [Symbol] the reader, e.g. `:comments`
95
+ # @!attribute [rw] macro
96
+ # @return [Symbol] `:has_many` or `:has_one`
97
+ # @!attribute [rw] child_class_name
98
+ # @return [String] the entity that owns the link, e.g. `"Comment"`
99
+ # @!attribute [rw] via
100
+ # @return [Symbol] the child's relationship name, e.g. `:post`
101
+ InverseMeta = Struct.new(:name, :macro, :child_class_name, :via) do
102
+ # @return [Class<EcsRails::Entity>]
103
+ # @raise [NameError] if no such constant
104
+ def child_class
105
+ child_class_name.constantize
106
+ end
107
+
108
+ # @return [Symbol] the link association's name — `:comments_post_links`
109
+ # for a has_many, `:invoice_order_link` for a has_one
110
+ def link_name
111
+ macro == :has_many ? :"#{name}_#{via}_links" : :"#{name}_#{via}_link"
112
+ end
113
+ end
114
+
115
+ # Instance-side helpers, included into EcsRails::Entity.
116
+ module Entity
117
+ # Every relationship row pointing at this entity, under any name — the
118
+ # Flecs `(*, target)` wildcard the shared table makes expressible
119
+ # (RFC-0015). A query, not an association: what a safe-destroy check or an
120
+ # orphan audit wants.
121
+ #
122
+ # @example
123
+ # user.referrers.pluck(:owner_model, :slot) # => [["posts", "author"], ...]
124
+ # user.referrers(slot: :author).count
125
+ #
126
+ # @param slot [Symbol, String, nil] restrict to one relationship name
127
+ # @return [ActiveRecord::Relation] Relationship rows
128
+ def referrers(slot: nil)
129
+ scope = EcsRails.config.relationship_class_name.constantize.where(target_id: id)
130
+ slot.nil? ? scope : scope.where(slot: slot.to_s)
131
+ end
132
+ end
133
+
134
+ # Declares a collection of the entities whose `via` relationship points at
135
+ # this one — or, without `via:`, ActiveRecord's own `has_many`.
136
+ #
137
+ # @example
138
+ # has_many :comments, via: :post # child inferred: Comment
139
+ # has_many :replies, "Comment", via: :parent # child named
140
+ # has_many :comments, via: :post, dependent: :destroy # destroy the link rows too
141
+ #
142
+ # @param name [Symbol] the reader
143
+ # @param child [Class<EcsRails::Entity>, String, Proc, nil] the child entity,
144
+ # as a class or class name; inferred from `name` when omitted. Without
145
+ # `via:`, ActiveRecord's optional scope.
146
+ # @param via [Symbol, nil] the child's `relates_to` name
147
+ # @param dependent [Symbol, nil] `:destroy` or `:delete_all` — for the link
148
+ # rows, never the children
149
+ # @param options [Hash] ActiveRecord's options, when `via:` is absent
150
+ # @return [void]
151
+ # @raise [EcsRails::DelegationConflict] if `name` is already taken
152
+ # @raise [ArgumentError] for a bad `child` or `dependent:`
153
+ # @see #has_one
154
+ # @see #validate_inverses!
155
+ def has_many(name, child = nil, via: nil, dependent: nil, **options, &block)
156
+ return super(name, child, **options, &block) if via.nil? && !child.is_a?(Class)
157
+
158
+ define_inverse(:has_many, name, child, via, dependent)
159
+ end
160
+
161
+ # Declares the single entity whose `via` relationship points at this one —
162
+ # or, without `via:`, ActiveRecord's own `has_one`.
163
+ #
164
+ # The child's relationship must be `unique: true`: that is what makes "at
165
+ # most one" a database guarantee (ADR-0017's partial unique index), and a
166
+ # `has_one` that could silently return one of several rows is refused —
167
+ # on first use, or at boot under `eager_load` (ADR-0004's ethos, at the
168
+ # earliest safe moment).
169
+ #
170
+ # @example
171
+ # has_one :invoice, via: :order
172
+ #
173
+ # @param name [Symbol] the reader
174
+ # @param child [Class<EcsRails::Entity>, String, Proc, nil] as for {#has_many}
175
+ # @param via [Symbol, nil] the child's `relates_to` name
176
+ # @param dependent [Symbol, nil] `:destroy` or `:delete_all` — for the link row
177
+ # @param options [Hash] ActiveRecord's options, when `via:` is absent
178
+ # @return [void]
179
+ # @raise [EcsRails::DelegationConflict] if `name` is already taken
180
+ # @raise [ArgumentError] for a bad `child` or `dependent:`
181
+ # @see #has_many
182
+ def has_one(name, child = nil, via: nil, dependent: nil, **options, &block)
183
+ return super(name, child, **options, &block) if via.nil? && !child.is_a?(Class)
184
+
185
+ define_inverse(:has_one, name, child, via, dependent)
186
+ end
187
+
188
+ # The inverses declared on this entity, ancestry included.
189
+ #
190
+ # @return [Array<InverseMeta>]
191
+ def inverses
192
+ entity_ancestry.flat_map { |klass| (klass.instance_variable_get(:@ecs_inverses) || {}).values }
193
+ end
194
+
195
+ # @param name [Symbol, String]
196
+ # @return [InverseMeta, nil]
197
+ def inverse_meta(name)
198
+ inverses.find { |meta| meta.name == name.to_sym }
199
+ end
200
+
201
+ # Checks every inverse declared on this entity against its child: the child
202
+ # is a concrete entity, it declares `via` towards this entity, and for a
203
+ # `has_one` that relationship is `unique: true`. Runs automatically on the
204
+ # first read of each association and at boot under `eager_load`; call it
205
+ # yourself to fail earlier.
206
+ #
207
+ # @return [void]
208
+ # @raise [EcsRails::InvalidRelationship] naming the exact `relates_to` line to write
209
+ def validate_inverses!
210
+ inverses.each { |meta| validate_inverse!(meta.name) }
211
+ end
212
+
213
+ # Checks one inverse, once per class.
214
+ #
215
+ # @param name [Symbol]
216
+ # @return [void]
217
+ # @raise [EcsRails::InvalidRelationship]
218
+ # @api private
219
+ def validate_inverse!(name)
220
+ validated = (@ecs_validated_inverses ||= {})
221
+ return if validated[name]
222
+
223
+ meta = inverse_meta(name) or return
224
+ validate_inverse_pair!(meta)
225
+ validated[name] = true
226
+ end
227
+
228
+ private
229
+
230
+ # ActiveRecord's own macros, reached by binding rather than `super` because
231
+ # the has_one expansion needs has_many for its link.
232
+ AR_HAS_MANY = ActiveRecord::Associations::ClassMethods.instance_method(:has_many)
233
+ AR_HAS_ONE = ActiveRecord::Associations::ClassMethods.instance_method(:has_one)
234
+ private_constant :AR_HAS_MANY, :AR_HAS_ONE
235
+
236
+ def define_inverse(macro, name, child, via, dependent)
237
+ name = name.to_sym
238
+ raise ArgumentError, "#{macro} :#{name} on #{self.name}: an entity child needs `via:` — the child's " \
239
+ "relates_to name, e.g. `#{macro} :#{name}, via: :owner`" if via.nil?
240
+
241
+ via = via.to_sym
242
+ child_name = inverse_child_name(macro, name, child)
243
+ validate_dependent!(name, dependent)
244
+ detect_inverse_collision!(name)
245
+
246
+ meta = InverseMeta.new(name, macro, child_name, via)
247
+ slot = via.to_s
248
+ link_macro = macro == :has_many ? AR_HAS_MANY : AR_HAS_ONE
249
+ link_options = { class_name: EcsRails.config.relationship_class_name, foreign_key: :target_id,
250
+ inverse_of: :target }
251
+ link_options[:dependent] = dependent if dependent
252
+
253
+ # owner_model is read inside the lambda, at query time, so the child is
254
+ # resolved then and never at class-load time (see the module comment).
255
+ link_macro.bind_call(self, meta.link_name,
256
+ -> { where(slot: slot, owner_model: meta.child_class.model_name.collection) },
257
+ **link_options)
258
+ link_macro.bind_call(self, name, through: meta.link_name, source: :entity, class_name: child_name)
259
+
260
+ (@ecs_inverses ||= {})[name] = meta
261
+ define_inverse_guards(meta)
262
+ end
263
+
264
+ # The child as a class name: a Class gives its name (and is checked now to be
265
+ # an entity — a Component here is a mistake we can see immediately), a
266
+ # String or Symbol is taken as given, nil infers from the reader
267
+ # (`:comments` → "Comment").
268
+ def inverse_child_name(macro, name, child)
269
+ case child
270
+ when nil then name.to_s.singularize.camelize
271
+ when String, Symbol then child.to_s
272
+ when Class
273
+ unless child < EcsRails::Entity && !child.abstract_class?
274
+ raise InvalidRelationship,
275
+ "#{macro} :#{name} on #{self.name} expected an entity as the child, got #{child.name}. " \
276
+ "Write `#{macro} :#{name}, via: :#{name.to_s.singularize}` and let the child be inferred, " \
277
+ "or name an entity class."
278
+ end
279
+ child.name
280
+ else
281
+ raise ArgumentError, "#{macro} :#{name}: the child must be an entity class or a class name, got #{child.inspect}"
282
+ end
283
+ end
284
+
285
+ # The first read of the association (and, for has_one, its build/create
286
+ # forms) validates the pair once. Defined into generated_component_methods,
287
+ # which sits in front of ActiveRecord's generated association methods, so
288
+ # `super` reaches the real association.
289
+ #
290
+ # `create_invoice` / `create_invoice!` also save the link row. ActiveRecord's
291
+ # has_one :through builds the join record on create and leaves it for the
292
+ # owner's next save — `club.create_invoice!` would otherwise return a saved
293
+ # Invoice whose `team` is nil until `club.save!`. Flushing the freshly built
294
+ # link here makes the method mean what it says; assignment
295
+ # (`club.invoice = invoice`) already links immediately.
296
+ def define_inverse_guards(meta)
297
+ mod = generated_component_methods
298
+ name = meta.name
299
+ link_name = meta.link_name
300
+
301
+ mod.define_method(name) do |*args, **kwargs, &block|
302
+ self.class.validate_inverse!(name)
303
+ super(*args, **kwargs, &block)
304
+ end
305
+ return unless meta.macro == :has_one
306
+
307
+ mod.define_method(:"build_#{name}") do |*args, **kwargs, &block|
308
+ self.class.validate_inverse!(name)
309
+ super(*args, **kwargs, &block)
310
+ end
311
+
312
+ [:"create_#{name}", :"create_#{name}!"].each do |method|
313
+ mod.define_method(method) do |*args, **kwargs, &block|
314
+ self.class.validate_inverse!(name)
315
+ record = super(*args, **kwargs, &block)
316
+ link = association(link_name).target
317
+ link.save! if record.persisted? && link&.new_record?
318
+ record
319
+ end
320
+ end
321
+ end
322
+
323
+ # The child must be a concrete entity that relates `via` to THIS entity (or
324
+ # an ancestor of it — a subclass reads its parent's inverses), and for
325
+ # has_one that relationship must be unique.
326
+ def validate_inverse_pair!(meta)
327
+ macro, name, via = meta.macro, meta.name, meta.via
328
+ child = begin
329
+ meta.child_class
330
+ rescue NameError
331
+ raise InvalidRelationship,
332
+ "#{macro} :#{name} on #{self.name}: no entity class named #{meta.child_class_name}. " \
333
+ "Name the child — `#{macro} :#{name}, \"TheClass\", via: :#{via}` — or define it."
334
+ end
335
+
336
+ unless child.is_a?(Class) && child < EcsRails::Entity && !child.abstract_class?
337
+ raise InvalidRelationship,
338
+ "#{macro} :#{name} on #{self.name}: #{meta.child_class_name} is not a concrete entity."
339
+ end
340
+
341
+ rel = child.relationship_meta(via)
342
+ unless rel
343
+ known = child.relationship_names
344
+ raise InvalidRelationship,
345
+ "#{macro} :#{name} on #{self.name}: #{child.name} has no relationship :#{via} " \
346
+ "(#{child.name} relates to: #{known.empty? ? 'none' : known.map { |n| ":#{n}" }.join(', ')}). " \
347
+ "Declare `relates_to :#{via}, #{self.name}` on #{child.name} first."
348
+ end
349
+
350
+ unless self <= rel.target_class
351
+ raise InvalidRelationship,
352
+ "#{macro} :#{name} on #{self.name}: #{child.name} relates :#{via} to " \
353
+ "#{rel.target_class_name}, not to #{self.name}."
354
+ end
355
+
356
+ return unless macro == :has_one && !rel.unique
357
+
358
+ raise InvalidRelationship,
359
+ "has_one :#{name} on #{self.name} needs #{child.name}'s relationship :#{via} to be " \
360
+ "declared `unique: true` — only the database's partial unique index makes \"at most one\" " \
361
+ "true. Write `relates_to :#{via}, #{self.name}, unique: true` on #{child.name}, or use has_many."
362
+ end
363
+
364
+ def validate_dependent!(name, dependent)
365
+ return if dependent.nil? || %i[destroy delete_all].include?(dependent)
366
+
367
+ raise ArgumentError,
368
+ "has_many/has_one :#{name}: dependent: applies to the link rows and accepts :destroy or " \
369
+ ":delete_all; got #{dependent.inspect}. Destroying child entities is a domain choice."
370
+ end
371
+
372
+ # The names an inverse reserves on the entity, for the DSL's conflict
373
+ # checks (EcsRails::DSL#ecs_reserved_names): a later component may not
374
+ # delegate `comments`, and a marker may not be named `comments`.
375
+ def ecs_reserved_names
376
+ inverses.each_with_object(super) do |meta, reserved|
377
+ [meta.name, :"#{meta.name}=", :"#{meta.name.to_s.singularize}_ids",
378
+ :"#{meta.name.to_s.singularize}_ids=", meta.link_name].each do |method|
379
+ reserved[method] ||= "#{meta.macro} :#{meta.name}"
380
+ end
381
+ end
382
+ end
383
+
384
+ # ADR-0004: the reader must not be a name the entity already answers.
385
+ def detect_inverse_collision!(name)
386
+ taken = component_declarations.flat_map do |declaration|
387
+ [declaration.reader_name, :"#{declaration.reader_name}?"] + delegation_map_for(declaration).keys
388
+ end
389
+ taken += ecs_reserved_names.keys
390
+
391
+ return unless taken.include?(name)
392
+
393
+ raise DelegationConflict,
394
+ "has_many/has_one :#{name} on #{self.name} collides with an existing ##{name} — a component, " \
395
+ "a relationship, a marker or another inverse already owns that name. Choose a different name."
396
+ end
397
+ end
398
+ end
@@ -66,12 +66,17 @@ module EcsRails
66
66
  # it means the cascade can simply walk the memo, rather than interrogating
67
67
  # ActiveRecord about which associations happen to be loaded.
68
68
  #
69
+ # `slot` is the declaration's slot (RFC-0014): `""` for a singular
70
+ # component, `"business"` for `component PostalAddress, prefix:
71
+ # :business`. It only matters when no row exists, to preset the virtual —
72
+ # see #ecs_build_component.
73
+ #
69
74
  # @api private
70
- def ecs_component(name)
75
+ def ecs_component(name, slot = "")
71
76
  cache = (@ecs_components ||= {})
72
77
  return cache[name] if cache.key?(name)
73
78
 
74
- cache[name] = yield || ecs_build_component(name)
79
+ cache[name] = yield || ecs_build_component(name, slot)
75
80
  end
76
81
 
77
82
  # Forgets a component, so the next read rebuilds it as virtual.
@@ -99,6 +104,60 @@ module EcsRails
99
104
  association(name).target = nil
100
105
  end
101
106
 
107
+ # Replaces one declared component without nullifying its required owner
108
+ # FK or inserting alongside the existing singleton (RFC-0006, ECS-26).
109
+ # Public for the generated writer; application code uses that writer.
110
+ #
111
+ # @param name [Symbol] component reader
112
+ # @param slot [String] declaration's slot
113
+ # @param value [EcsRails::Component, nil] replacement, or nil to remove
114
+ # @return [EcsRails::Component, nil]
115
+ # @raise [EcsRails::InvalidComponent] for a wrong type or owner/slot
116
+ # @raise [ActiveRecord::RecordInvalid] when a replacement fails validation
117
+ # @api private
118
+ def ecs_replace_component(name, slot, value)
119
+ component_association = association(name)
120
+ expected = component_association.klass
121
+ unless value.nil? || value.is_a?(expected)
122
+ raise InvalidComponent, "#{name}= expects #{expected.name} or nil"
123
+ end
124
+
125
+ if value
126
+ other_owner = (value.entity_id && value.entity_id != id) ||
127
+ (value.entity && value.entity != self)
128
+ other_slot = value.persisted? && value.slot != slot
129
+ if other_owner || other_slot
130
+ raise InvalidComponent, "#{name}= cannot move a component from another owner or slot"
131
+ end
132
+ value.entity = self
133
+ value.slot = slot
134
+ end
135
+
136
+ previous = public_send(name)
137
+ begin
138
+ if persisted?
139
+ # A savepoint keeps a failed replacement atomic even when the
140
+ # caller rescues it inside a larger transaction and continues.
141
+ self.class.transaction(requires_new: true) do
142
+ previous.destroy! if previous.persisted? && previous != value
143
+ value.save! if value && (value.persisted? || value.ecs_dirty?)
144
+ end
145
+ end
146
+ rescue StandardError
147
+ component_association.target = previous
148
+ (@ecs_components ||= {})[name] = previous
149
+ raise
150
+ end
151
+
152
+ component_association.target = value
153
+ if value
154
+ (@ecs_components ||= {})[name] = value
155
+ else
156
+ @ecs_components&.delete(name)
157
+ end
158
+ value
159
+ end
160
+
102
161
  private
103
162
 
104
163
  # An in-memory component with every attribute at its default and entity_id
@@ -106,8 +165,15 @@ module EcsRails
106
165
  # column definitions — so a virtual `user.email.address` and a bare
107
166
  # `Email.new.address` agree by construction rather than by us copying a
108
167
  # list of defaults about.
109
- def ecs_build_component(name)
168
+ #
169
+ # The one attribute NOT left at its default is `slot` (RFC-0014): a
170
+ # virtual `business_address` is built with `slot = "business"` preset, so
171
+ # its first write lands in the right slot. Like entity_id, slot is
172
+ # identity rather than state, and Component#ecs_dirty? skips it — a
173
+ # preset slot must not make an untouched virtual look dirty.
174
+ def ecs_build_component(name, slot = "")
110
175
  component = self.class.reflect_on_association(name).klass.new
176
+ component.slot = slot.to_s
111
177
  # Not `component.entity_id = id` — the belongs_to writer also sets the
112
178
  # association target, which is what makes `user.email.entity` return
113
179
  # this very entity without a query (architecture.md §1: a system reaches
@@ -144,23 +210,13 @@ module EcsRails
144
210
  # for exactly this reason.
145
211
  component.entity_id = id
146
212
 
147
- # Bang, so a failure is loud. This is a deliberate, temporary wart:
148
- # non-bang `entity.save` will raise RecordInvalid rather than return
149
- # false when a dirty component is invalid.
150
- #
151
- # The alternative is ActiveRecord's own autosave idiom, `raise
152
- # ActiveRecord::Rollback`. It is worse here. Rollback raised from an
153
- # after_save is swallowed by the transaction that save itself opened,
154
- # so `entity.save!` would return nil and raise *nothing at all* — a
155
- # silent failure to write, which is the one outcome a bang method must
156
- # never have. (`throw :abort` is not an option either: after_ callbacks
157
- # cannot halt a chain, and it escapes as an UncaughtThrowError.)
158
- #
159
- # RFC-0007 is what fixes this properly: once a dirty component's errors
160
- # merge onto the entity, `entity.valid?` is false and non-bang `save`
161
- # returns false before ever reaching this callback — leaving the bang
162
- # here as belt-and-braces, which is exactly the role the equivalent
163
- # line plays in ActiveRecord's autosave.
213
+ # RFC-0007 merges component errors before this callback: ordinary
214
+ # invalid input makes entity.save return false and save! raise. Keep
215
+ # the bang here as a final guard if validation fails during the
216
+ # cascade, so the transaction rolls back and the failure is visible.
217
+ # Raising ActiveRecord::Rollback here would be swallowed by the save
218
+ # transaction and could make save! fail silently. The original
219
+ # callback trade-off is recorded in RFC-0006 and RFC-0007.
164
220
  component.save!
165
221
  end
166
222
  end
@@ -220,9 +276,12 @@ module EcsRails
220
276
  private
221
277
 
222
278
  # Attributes that say something about the component, as opposed to which
223
- # component it is. Only these can make it dirty.
279
+ # component it is. Only these can make it dirty. `slot` (RFC-0014) is the
280
+ # second half of the row's identity — which of an entity's PostalAddresses
281
+ # this is — so a labelled virtual with `slot = "business"` preset is no
282
+ # more dirty than a singular one with entity_id set.
224
283
  def state_attribute?(attribute)
225
- attribute != self.class.primary_key && attribute != "entity_id"
284
+ attribute != self.class.primary_key && attribute != "entity_id" && attribute != "slot"
226
285
  end
227
286
 
228
287
  # Tells the owning entity to forget this component, so its reader reverts
@@ -233,11 +292,15 @@ module EcsRails
233
292
  # query and simply has nobody to notify. Nothing is reset on *this*
234
293
  # object: ActiveRecord freezes a destroyed record, and the entity is going
235
294
  # to hand out a fresh instance anyway.
295
+ #
296
+ # The reader to forget is slot-aware (RFC-0014): destroying the
297
+ # `business_address` row resets `user.business_address`, not
298
+ # `user.postal_address`.
236
299
  def reset_entity_component
237
300
  owner = association(:entity).target
238
301
  return unless owner.is_a?(EcsRails::Entity)
239
302
 
240
- owner.ecs_forget_component(model_name.singular.to_sym)
303
+ owner.ecs_forget_component(ecs_reader_name)
241
304
  end
242
305
  end
243
306
  end