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
data/lib/ecs_rails/dsl.rb CHANGED
@@ -9,21 +9,31 @@ module EcsRails
9
9
  # class User < ApplicationEntity
10
10
  # component Name
11
11
  # component Email
12
- # component Group, except: [:title]
12
+ # component PublishState, prefix: false
13
+ # component PostalAddress
14
+ # component PostalAddress, prefix: :business
13
15
  # end
14
16
  #
15
- # User.components # => [Name, Email, Group]
17
+ # User.components # => [Name, Email, PublishState, PostalAddress]
16
18
  # User.create!.email # => the Email row, or a virtual one (RFC-0006)
19
+ # User.new.email_address # => delegated, prefixed with the reader (ADR-0016)
20
+ # User.new.state # => delegated bare, because of `prefix: false`
21
+ # User.new.business_address # => the "business" slot of PostalAddress (RFC-0014)
17
22
  #
18
23
  # Each declaration does three things: it records itself in the registry
19
- # (RFC-0002), it sets up the has_one that reads the component row, and it
20
- # generates the lazy reader (RFC-0006) into #generated_component_methods —
21
- # the module RFC-0005's delegated methods also land in.
24
+ # (RFC-0002), it sets up the slot-scoped has_one that reads the component
25
+ # row, and it generates the lazy reader (RFC-0006) into
26
+ # #generated_component_methods — the module RFC-0005's delegated methods also
27
+ # land in.
22
28
  module DSL
23
29
  # Declares that this entity is composed from `component_class`.
24
30
  #
25
31
  # Defines a reader named for the component's model_name.singular, so
26
- # `component Email` gives `#email`.
32
+ # `component Email` gives `#email`. With a slot label — `component
33
+ # PostalAddress, prefix: :business` (RFC-0014 / ADR-0015) — the reader is
34
+ # `#business_address`, and the same component may be declared again under
35
+ # another label. Each `(entity, slot)` is one instance, stored in the same
36
+ # table under a `slot` column; the singular case is slot `""`.
27
37
  #
28
38
  # **The reader always returns an instance, never nil** (architecture.md §3).
29
39
  # If the entity has no row for the component, a virtual one is built with
@@ -34,7 +44,25 @@ module EcsRails
34
44
  # `only:` / `except:` restrict which of the component's methods are delegated
35
45
  # onto the entity (RFC-0005). They never affect the reader: `user.group`
36
46
  # exists whatever `except:` says. RFC-0004 validates and records them;
37
- # RFC-0005 acts on them.
47
+ # RFC-0005 acts on them. Both name the **component's** methods (`:title`),
48
+ # never the prefixed entity-level name (`:group_title`).
49
+ #
50
+ # Delegated methods are **prefixed with the reader** by default (ADR-0016):
51
+ # `component Email` gives `user.email_address`, `user.email_verified` and
52
+ # `user.email_send_welcome_email`, all routed through `user.email`. The rule
53
+ # is uniform — attributes and behaviour alike — so two components can never
54
+ # collide on a shared attribute name, and a reader is never shadowed by a
55
+ # delegated `belongs_to`. `prefix: false` restores bare delegation for a
56
+ # component whose prefixed names would be redundant (`publish_state_state`).
57
+ # A verb that reads badly prefixed is reached through the reader instead
58
+ # (`user.email.send_welcome_email`), or renamed on the component.
59
+ # `delegate: false` (RFC-0014) drops delegation entirely — reader only — for
60
+ # when even the prefixed names get long (`billing_address_postal_code`).
61
+ #
62
+ # Any further keyword is a **per-slot option** for the component
63
+ # (RFC-0014): `component State, prefix: :order, states: %w[pending paid]`.
64
+ # The component must have declared it with `slot_option`; an unknown one
65
+ # raises here. See {EcsRails::Slots::Component::ClassMethods#slot_option}.
38
66
  #
39
67
  # Deliberately no `dependent:` option on the has_one, contradicting RFC-0004
40
68
  # and matching architecture.md §3 and RFC-0003: cascade is owned by the
@@ -61,62 +89,104 @@ module EcsRails
61
89
  #
62
90
  # User.new.email # => #<Email> — never nil
63
91
  #
64
- # @example Resolving a delegation conflict
65
- # component Group, except: [:title]
92
+ # @example Prefixed delegation, the default (ADR-0016)
93
+ # component Email
94
+ #
95
+ # user.email_address = "a@b.com" # => user.email.address = "a@b.com"
96
+ # user.email_send_welcome_email # => user.email.send_welcome_email
97
+ #
98
+ # @example Bare delegation, where the prefix would be redundant
99
+ # component PublishState, prefix: false
100
+ #
101
+ # post.state # => post.publish_state.state
102
+ #
103
+ # @example Resolving a delegation conflict between two bare components
104
+ # component Group, prefix: false, except: [:title]
105
+ #
106
+ # @example Two slots of one component (RFC-0014)
107
+ # component PostalAddress # user.postal_address
108
+ # component PostalAddress, prefix: :business # user.business_address,
109
+ # # user.business_address_line1
110
+ #
111
+ # @example Reader only, and a per-slot option
112
+ # component PostalAddress, prefix: :remit, delegate: false, country: "NZ"
66
113
  #
67
114
  # @param component_class [Class<EcsRails::Component>] a concrete component
68
115
  # @param only [Array<Symbol>, Symbol, nil] delegate only these methods.
69
- # Attribute aware: `:title` covers `#title` and `#title=`.
116
+ # Attribute aware: `:title` covers `#title` and `#title=`. Names the
117
+ # component's methods, not the prefixed entity-level names.
70
118
  # Mutually exclusive with `except:`.
71
119
  # @param except [Array<Symbol>, Symbol, nil] delegate everything but these.
72
120
  # Attribute aware, as `only:` is. Mutually exclusive with `only:`.
121
+ # @param prefix [Symbol, String, Boolean, nil] the slot label and, with it,
122
+ # how the reader and delegated methods are named. Omitted (or `true`): the
123
+ # default slot, reader `email`, delegation `email_address` (ADR-0016).
124
+ # `false`: the default slot with bare delegation — `address`. A Symbol
125
+ # (`:business`): slot `"business"`, reader `business_address`, delegation
126
+ # `business_address_line1` (RFC-0014). Must be a valid method-name segment.
127
+ # @param delegate [Boolean] `false` generates the reader and predicate only,
128
+ # no delegated methods (RFC-0014). Meaningless with `only:`/`except:`.
129
+ # @param slot_options [Hash{Symbol => Object}] per-slot options for the
130
+ # component, each declared on it with `slot_option` (RFC-0014)
73
131
  # @return [EcsRails::Registry::Declaration] the recorded declaration
74
132
  # @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
75
133
  # {EcsRails::Component} subclass, or is abstract and so owns no table
76
134
  # @raise [EcsRails::DuplicateComponent] if this entity, or one it inherits
77
- # from, already declares this component (ADR-0005)
135
+ # from, already declares this component in this slot (ADR-0005 / ADR-0015)
78
136
  # @raise [EcsRails::DelegationConflict] if a delegated name is already
79
- # delegated by a sibling component, or collides with a component reader
137
+ # delegated by a sibling component, or collides with a component reader,
138
+ # or the new reader collides with an existing reader or delegated method
80
139
  # (ADR-0004)
81
140
  # @raise [ArgumentError] if both `only:` and `except:` are given, if either
82
- # names a method the component does not delegate, or if either class is
83
- # anonymous
141
+ # names a method the component does not delegate, if `prefix:` is not a
142
+ # Boolean or a valid label, if `delegate:` is not a Boolean, if a slot
143
+ # option is not one the component declares, or if either class is anonymous
84
144
  # @see #components
145
+ # @see #declaration_for
85
146
  # @see EcsRails::Presence::Entity#has? the `<reader>?` predicate this generates
86
- def component(component_class, only: nil, except: nil)
147
+ def component(component_class, only: nil, except: nil, prefix: nil, delegate: true, **slot_options)
87
148
  validate_component_class!(component_class)
88
- options = normalized_delegation_options(only: only, except: except)
89
- validate_not_inherited!(component_class)
149
+ slot = normalized_slot(prefix)
150
+ options = normalized_delegation_options(only: only, except: except, prefix: prefix, delegate: delegate)
151
+ slot_options = normalized_slot_options(component_class, slot_options)
152
+ validate_not_inherited!(component_class, slot)
153
+
154
+ validate_primary!(component_class)
90
155
 
91
156
  # RFC-0005 is resolved *before* anything is registered or defined, so that
92
157
  # a bad `only:`/`except:` name or a DelegationConflict leaves the class in
93
- # exactly the state it was in. #delegated_method_names validates the option
94
- # names against the component's real method set (ArgumentError on a typo);
95
- # #detect_delegation_conflict! raises DelegationConflict (ADR-0004) if any
96
- # of those names is already delegated by a sibling component.
97
- delegated = delegated_method_names(component_class, options)
98
- detect_delegation_conflict!(component_class, delegated)
99
- detect_reader_collision!(component_class, delegated)
158
+ # exactly the state it was in. #delegation_map validates the option names
159
+ # against the component's real method set (ArgumentError on a typo) and
160
+ # applies the ADR-0016 prefix; #detect_delegation_conflict! raises
161
+ # DelegationConflict (ADR-0004) if any resulting entity-level name is
162
+ # already delegated by a sibling component.
163
+ delegated = delegation_map(component_class, slot, options)
164
+ detect_delegation_conflict!(component_class, slot, delegated)
165
+ detect_reader_collision!(component_class, slot, delegated)
166
+ detect_component_operation_collision!(component_class, slot, delegated)
100
167
 
101
168
  # Registered first, so that the registry's own duplicate check (RFC-0002)
102
169
  # is what stops a doubled `component` line — before any method is defined.
103
170
  declaration = EcsRails.registry.register(
104
171
  entity_class: self,
105
172
  component_class: component_class,
106
- options: options
173
+ slot: slot,
174
+ options: options,
175
+ slot_options: slot_options
107
176
  )
108
177
 
109
- define_component_association(component_class)
178
+ define_component_association(component_class, slot)
110
179
 
111
180
  # Must follow the has_one: see #generated_component_methods.
112
- define_component_reader(component_class)
181
+ define_component_reader(component_class, slot)
182
+ define_component_operations(component_class, slot)
113
183
 
114
184
  # RFC-0005: the delegating methods, into the same module as the reader.
115
- define_component_delegation(component_class, delegated)
185
+ define_component_delegation(component_class, slot, delegated)
116
186
 
117
187
  # RFC-0009: the `<reader>?` presence predicate. Generated last so it can
118
188
  # see, and defer to, any delegated method that already owns its name.
119
- define_component_predicate(component_class)
189
+ define_component_predicate(component_class, slot)
120
190
 
121
191
  declaration
122
192
  end
@@ -124,7 +194,9 @@ module EcsRails
124
194
  # Every component this entity is composed from, nearest ancestor last.
125
195
  #
126
196
  # Inherited components are included: a subclass is composed from its parents'
127
- # components as well as its own.
197
+ # components as well as its own. A component declared under two slots
198
+ # (RFC-0014) appears once — this answers "what types is it made of"; see
199
+ # {#component_declarations} for the per-slot view.
128
200
  #
129
201
  # @example
130
202
  # User.components # => [Name, Email, Group]
@@ -133,7 +205,29 @@ module EcsRails
133
205
  # live from the registry (so a reloaded constant is picked up)
134
206
  # @see #component_declarations
135
207
  def components
136
- component_declarations.map(&:component_class)
208
+ component_declarations.map(&:component_class).uniq
209
+ end
210
+
211
+ # The declaration of `component_class` on this entity under `prefix`, or nil.
212
+ #
213
+ # The lookup presence (RFC-0009), preloading (RFC-0011) and a component's
214
+ # own {EcsRails::Slots::Component#slot_options} all resolve through, so
215
+ # "which reader, which slot options" is answered in one place.
216
+ #
217
+ # @example
218
+ # User.declaration_for(PostalAddress) # the default slot
219
+ # User.declaration_for(PostalAddress, prefix: :business) # the labelled one
220
+ # User.declaration_for(PostalAddress, prefix: :business).reader_name # => :business_address
221
+ #
222
+ # @param component_class [Class<EcsRails::Component>] the component
223
+ # @param prefix [Symbol, String, nil] the slot label; nil for the default slot
224
+ # @return [EcsRails::Registry::Declaration, nil] ancestry included
225
+ # @see #component_declarations
226
+ def declaration_for(component_class, prefix: nil)
227
+ slot = prefix.to_s
228
+ component_declarations.find do |declaration|
229
+ declaration.component_class_name == component_class.name && declaration.slot == slot
230
+ end
137
231
  end
138
232
 
139
233
  # Every declaration this entity is composed from, parent's before its own.
@@ -151,9 +245,10 @@ module EcsRails
151
245
  # and this is the method that answers "what is an Admin made of".
152
246
  #
153
247
  # @return [Array<EcsRails::Registry::Declaration>] declarations, ancestors'
154
- # first, in declaration order. Carries the `only:`/`except:` options that
155
- # {#components} discards.
248
+ # first, in declaration order. Carries the slot and the `only:`/`except:`
249
+ # options that {#components} discards.
156
250
  # @see #components
251
+ # @see #declaration_for
157
252
  def component_declarations
158
253
  entity_ancestry.flat_map { |klass| EcsRails.registry.components_for(klass) }
159
254
  end
@@ -219,17 +314,53 @@ module EcsRails
219
314
  #
220
315
  # The reader is generated per component rather than defined once on
221
316
  # Lazy::Entity because there is nothing generic to define — each one closes
222
- # over its own name and its own has_one to call through to.
223
- def define_component_reader(component_class)
224
- name = component_class.model_name.singular.to_sym
317
+ # over its own name, its slot, and its own has_one to call through to. The
318
+ # slot is handed to the memo so a virtual is built with it preset
319
+ # (RFC-0014): `user.business_address` with no row is a PostalAddress whose
320
+ # `slot` is already `"business"`.
321
+ def define_component_reader(component_class, slot)
322
+ name = reader_name_for(component_class, slot)
323
+ slot_value = slot.to_s
225
324
 
226
325
  generated_component_methods.define_method(name) do
227
- ecs_component(name) { super() }
326
+ ecs_component(name, slot_value) { super() }
327
+ end
328
+ end
329
+
330
+ # RFC-0006's ECS-26 amendment: Rails' has_one writers/builders otherwise
331
+ # bypass both the lazy cascade and its memo. Keep the supported operations
332
+ # explicit and leave inverse relationship associations on their native API.
333
+ def define_component_operations(component_class, slot)
334
+ name = reader_name_for(component_class, slot)
335
+ slot_value = slot.to_s
336
+ mod = generated_component_methods
337
+
338
+ mod.define_method(:"#{name}=") do |value|
339
+ ecs_replace_component(name, slot_value, value)
340
+ end
341
+
342
+ %W[build_#{name} create_#{name} create_#{name}!].each do |method|
343
+ mod.define_method(method) do |*|
344
+ raise InvalidComponent,
345
+ "#{method} bypasses lazy components; use #{name}.assign_attributes(...) " \
346
+ "and save the entity, or #{name}= to replace the component"
347
+ end
348
+ end
349
+
350
+ mod.define_method(:"reload_#{name}") do
351
+ @ecs_components&.delete(name)
352
+ super()
353
+ public_send(name)
354
+ end
355
+
356
+ mod.define_method(:"reset_#{name}") do
357
+ @ecs_components&.delete(name)
358
+ super()
228
359
  end
229
360
  end
230
361
 
231
- # RFC-0005: generates one delegating method on the entity, per name in the
232
- # delegated set, into the same module the reader lives in.
362
+ # RFC-0005: generates one delegating method on the entity, per entry in the
363
+ # delegation map, into the same module the reader lives in.
233
364
  #
234
365
  # The methods live in generated_component_methods (an *included* module), so
235
366
  # a method defined directly on the entity class shadows them by Ruby's own
@@ -238,19 +369,23 @@ module EcsRails
238
369
  #
239
370
  # Each generated method calls the entity's own component reader (`email`),
240
371
  # so it goes through RFC-0006's memo and reaches the one instance the save
241
- # cascade will later persist — the seam that makes `user.address = "x";
242
- # user.save!` write a single row. It does *not* rebind self or instance_exec
243
- # (ADR-0001): it forwards the call, so `self` inside the component method is
244
- # the component, never the entity.
372
+ # cascade will later persist — the seam that makes `user.email_address =
373
+ # "x"; user.save!` write a single row. It does *not* rebind self or
374
+ # instance_exec (ADR-0001): it forwards the call, so `self` inside the
375
+ # component method is the component, never the entity.
376
+ #
377
+ # The entity-level name (`email_address`) and the component method it
378
+ # forwards to (`address`) differ under ADR-0016's default prefix; the map
379
+ # carries both, so this is the only place that needs to know.
245
380
  #
246
381
  # *args, **kwargs and &block are all forwarded untouched (RFC-0005).
247
- def define_component_delegation(component_class, delegated)
248
- reader = component_class.model_name.singular.to_sym
382
+ def define_component_delegation(component_class, slot, delegated)
383
+ reader = reader_name_for(component_class, slot)
249
384
  mod = generated_component_methods
250
385
 
251
- delegated.each do |method_name|
252
- mod.define_method(method_name) do |*args, **kwargs, &block|
253
- public_send(reader).public_send(method_name, *args, **kwargs, &block)
386
+ delegated.each do |entity_method, component_method|
387
+ mod.define_method(entity_method) do |*args, **kwargs, &block|
388
+ public_send(reader).public_send(component_method, *args, **kwargs, &block)
254
389
  end
255
390
  end
256
391
  end
@@ -272,19 +407,72 @@ module EcsRails
272
407
  # delegation, generated just above, or a sibling's), the delegated method
273
408
  # wins and no predicate is generated. The common case has no such method and
274
409
  # the predicate is defined normally.
275
- def define_component_predicate(component_class)
276
- predicate = :"#{component_class.model_name.singular}?"
410
+ #
411
+ # Per slot (RFC-0014): `user.business_address?` asks `has?(PostalAddress,
412
+ # prefix: :business)`.
413
+ def define_component_predicate(component_class, slot)
414
+ predicate = :"#{reader_name_for(component_class, slot)}?"
277
415
  mod = generated_component_methods
278
416
  return if mod.instance_methods(false).include?(predicate)
279
417
 
280
418
  # Closed over the class so a reloaded constant still resolves through
281
419
  # #has?'s declared-set check, same as the reader closes over its name.
282
420
  component = component_class
283
- mod.define_method(predicate) { has?(component) }
421
+ prefix = slot.to_s.empty? ? nil : slot.to_sym
422
+ mod.define_method(predicate) { has?(component, prefix: prefix) }
423
+ end
424
+
425
+ # ADR-0016: the names an entity answers for one component, each mapped to
426
+ # the component method it forwards to. Keys are entity-level
427
+ # (`email_address`), values component-level (`address`).
428
+ #
429
+ # By default every name is prefixed with the component's reader, so the one
430
+ # rule is `#{reader}_#{method}` — the same shape RFC-0014's labelled slots
431
+ # use, which is what makes the two cases one rule. `prefix: false` makes
432
+ # key and value identical (bare delegation). The prefix is applied to the
433
+ # whole delegated set, behaviour included: `email_send_welcome_email` is
434
+ # ugly but unambiguous, and the reader (`user.email.send_welcome_email`) is
435
+ # always there for a verb that reads badly prefixed. An attributes-only rule
436
+ # would need a heuristic for what counts as an attribute and would reopen the
437
+ # verb collisions ADR-0016 exists to close.
438
+ #
439
+ # A labelled slot (RFC-0014) prefixes with *its* reader — `business_address_line1`
440
+ # — which is what keeps two slots of one component apart. `delegate: false`
441
+ # is the empty map: reader and predicate only.
442
+ #
443
+ # A labelled slot of a component with a **primary attribute**
444
+ # (EcsRails::Slots::Component::ClassMethods#primary) also gets the bare
445
+ # pair: `component Text, prefix: :title` maps `title` and `title=` to
446
+ # `value` and `value=`, so `post.title` is the string. The slot name is the
447
+ # field name. Only when the primary survived `only:`/`except:`.
448
+ #
449
+ # Conflict detection, reader collision and method generation all work on
450
+ # this map, so "what does the entity answer" is computed in one place.
451
+ def delegation_map(component_class, slot, options)
452
+ return {} if options[:delegate] == false
453
+
454
+ names = delegated_method_names(component_class, options)
455
+ return names.to_h { |name| [name, name] } if options[:prefix] == false
456
+
457
+ reader = reader_name_for(component_class, slot)
458
+ map = names.to_h { |name| [:"#{reader}_#{name}", name] }
459
+
460
+ primary = component_class.primary
461
+ if primary && !slot.to_s.empty? && names.include?(primary)
462
+ map[slot.to_sym] = primary
463
+ map[:"#{slot}="] = :"#{primary}=" if names.include?(:"#{primary}=")
464
+ end
465
+ map
284
466
  end
285
467
 
286
- # RFC-0005: the set of method names delegated for one component, after
287
- # `only:`/`except:` are applied. Also the place their names are validated —
468
+ # The same map for a declaration already in the registry.
469
+ def delegation_map_for(declaration)
470
+ delegation_map(declaration.component_class, declaration.slot, declaration.options)
471
+ end
472
+
473
+ # RFC-0005: the set of the component's own method names delegated for one
474
+ # component, after `only:`/`except:` are applied — before ADR-0016's prefix,
475
+ # which #delegation_map adds. Also the place the option names are validated —
288
476
  # RFC-0004 stored them but never checked they name anything real.
289
477
  #
290
478
  # `only:` keeps the named members; `except:` drops them. Both are attribute
@@ -350,11 +538,13 @@ module EcsRails
350
538
  end
351
539
 
352
540
  # Identity, not state: never delegated (RFC-0005). The primary key, the
353
- # entity_id foreign key, and the component timestamps — with their writers —
354
- # plus the :entity association (already excluded via the Component subtraction
355
- # above, restated here so the boundary is explicit rather than incidental).
541
+ # entity_id foreign key, the slot (RFC-0014: which of an entity's rows this
542
+ # is, never something about it), and the component timestamps — with their
543
+ # writers — plus the :entity association (already excluded via the Component
544
+ # subtraction above, restated here so the boundary is explicit rather than
545
+ # incidental).
356
546
  def never_delegated(component_class)
357
- attributes = [component_class.primary_key, "entity_id", "created_at", "updated_at"]
547
+ attributes = [component_class.primary_key, "entity_id", "slot", "created_at", "updated_at"]
358
548
 
359
549
  attributes.flat_map { |attribute| [attribute.to_sym, :"#{attribute}="] } +
360
550
  %i[entity entity=]
@@ -406,31 +596,78 @@ module EcsRails
406
596
  # and its ancestors (the new one is not registered yet), so the message can
407
597
  # name the sibling that got there first.
408
598
  #
599
+ # Compared on the *entity-level* names (ADR-0016), so two components that
600
+ # share an attribute (`Name#title`, `Group#title`) no longer clash — they
601
+ # delegate `name_title` and `group_title`. A clash now needs both to be
602
+ # bare (`prefix: false`), or a prefixed name to spell out another's
603
+ # (`EmailAddress#verified` vs `Email#address_verified`). The raise stays as
604
+ # the backstop ADR-0004 promised.
605
+ #
409
606
  # Only component-vs-component overlaps count. An overlap with a method the
410
607
  # entity itself defines is not a conflict: that method wins by Ruby's lookup
411
608
  # (the generated module is included), which is ADR-0004's other half.
412
- def detect_delegation_conflict!(component_class, delegated)
609
+ def detect_delegation_conflict!(component_class, slot, delegated)
413
610
  owners = {}
414
611
  component_declarations.each do |declaration|
415
612
  # A component never conflicts with itself: the same component declared
416
- # twice on one entity is a DuplicateComponent (ADR-0005), which the
417
- # registry raises on #register just after this check. Comparing it here
418
- # would report a spurious "#address is defined by both Email and Email".
419
- next if declaration.component_class_name == component_class.name
613
+ # twice in one slot on one entity is a DuplicateComponent (ADR-0005),
614
+ # which the registry raises on #register just after this check.
615
+ # Comparing it here would report a spurious "#address is defined by both
616
+ # Email and Email". The same component in *another* slot is a genuine
617
+ # sibling (RFC-0014) and is compared like any other.
618
+ next if declaration.component_class_name == component_class.name && declaration.slot == slot
420
619
 
421
620
  other = declaration.component_class
422
- delegated_method_names(other, declaration.options).each do |name|
621
+ delegation_map_for(declaration).each_key do |name|
423
622
  owners[name] ||= other
424
623
  end
425
624
  end
426
625
 
626
+ # Names reserved outside the declarations — a relationship's `author` /
627
+ # `author=` (EcsRails::Relationships), which no declaration delegates.
628
+ ecs_reserved_names.each { |name, owner| owners[name] ||= owner }
629
+
427
630
  # Sort so the reader (`title`) is reported before its writer (`title=`) —
428
631
  # "title" < "title=" — giving the tidier message and except: hint.
429
- clash = delegated.select { |name| owners.key?(name) }.min_by(&:to_s)
632
+ clash = delegated.keys.select { |name| owners.key?(name) }.min_by(&:to_s)
430
633
  return unless clash
431
634
 
432
635
  raise DelegationConflict,
433
- delegation_conflict_message(component_class, owners[clash], clash)
636
+ delegation_conflict_message(component_class, slot, owners[clash], clash, delegated[clash])
637
+ end
638
+
639
+ # Structural association operations, mapped to their claimant. Extended by
640
+ # EcsRails::Relationships (extended after this module, so its override wins
641
+ # and calls `super`) with each relationship's four accessors. Conflict and
642
+ # reader-collision detection fold these in, so `relates_to :author` and a
643
+ # later bare `component Author` cannot both own `#author`.
644
+ def ecs_reserved_names
645
+ component_declarations.each_with_object({}) do |declaration, reserved|
646
+ component_operation_names(declaration.reader_name).each do |method|
647
+ reserved[method] = "component association for #{declaration.reader_name}"
648
+ end
649
+ end
650
+ end
651
+
652
+ def component_operation_names(reader)
653
+ %W[#{reader}= build_#{reader} create_#{reader} create_#{reader}! reload_#{reader} reset_#{reader}].map(&:to_sym)
654
+ end
655
+
656
+ # These wrappers now live beside delegation in the generated module. A
657
+ # delegated method must never silently replace a wrapper, or vice versa
658
+ # (ADR-0004). Check before registering or generating anything.
659
+ def detect_component_operation_collision!(component_class, slot, delegated)
660
+ reader = reader_name_for(component_class, slot)
661
+ siblings = component_declarations.reject do |declaration|
662
+ declaration.component_class_name == component_class.name && declaration.slot == slot
663
+ end
664
+ taken = siblings.flat_map { |declaration| [declaration.reader_name] + delegation_map_for(declaration).keys }
665
+ clash = component_operation_names(reader).find { |method| (taken + delegated.keys).include?(method) }
666
+ return unless clash
667
+
668
+ raise DelegationConflict,
669
+ "##{clash} is reserved for the #{reader} component association; " \
670
+ "choose another prefix or exclude the delegated method"
434
671
  end
435
672
 
436
673
  # A component reader (`post.author`) is structural — it is how you reach the
@@ -447,44 +684,94 @@ module EcsRails
447
684
  # a sibling's. The reverse — a sibling's delegated method colliding with the
448
685
  # new reader — is the same names from the other side, and
449
686
  # #detect_delegation_conflict! on the earlier declaration already covers it.
450
- def detect_reader_collision!(component_class, delegated)
451
- readers = component_declarations.map { |d| reader_name_for(d.component_class) }
452
- readers << reader_name_for(component_class)
687
+ #
688
+ # Under ADR-0016's default prefix this can only fire for a `prefix: false`
689
+ # component (a prefixed name always starts with its own reader plus an
690
+ # underscore, so it can never *equal* a reader) — or when a prefixed name
691
+ # happens to spell a sibling's reader. Checked on the entity-level names.
692
+ #
693
+ # The other direction is checked too (RFC-0014): the NEW reader must not be
694
+ # a name the entity already answers — a sibling's reader, or a sibling's
695
+ # delegated method. Slots make this likelier: `component Address, prefix:
696
+ # :business` wants `business_address`, which a `BusinessAddress` component
697
+ # or an `Email#business_address` delegation could already own. The same
698
+ # (component, slot) twice is *not* caught here — that is the registry's
699
+ # DuplicateComponent, raised a moment later.
700
+ def detect_reader_collision!(component_class, slot, delegated)
701
+ reader = reader_name_for(component_class, slot)
702
+ siblings = component_declarations.reject do |d|
703
+ d.component_class_name == component_class.name && d.slot == slot
704
+ end
705
+ readers = siblings.map(&:reader_name)
453
706
 
454
- clash = delegated.find { |name| readers.include?(name) }
455
- return unless clash
707
+ clash = delegated.keys.find { |name| readers.include?(name) || name == reader }
708
+ if clash
709
+ raise DelegationConflict,
710
+ reader_collision_message(component_class, slot, clash, delegated[clash])
711
+ end
456
712
 
457
- raise DelegationConflict, reader_collision_message(component_class, clash)
713
+ taken = readers + siblings.flat_map { |d| delegation_map_for(d).keys } + ecs_reserved_names.keys
714
+ return unless taken.include?(reader)
715
+
716
+ raise DelegationConflict,
717
+ "##{reader} on #{name} is the reader `component #{component_class.name}" "#{slot.empty? ? '' : ", prefix: :#{slot}"}` would generate, but #{name} " "already answers ##{reader} — another component's reader or delegated " "method. Choose a different prefix, or exclude the delegated method."
458
718
  end
459
719
 
460
- def reader_name_for(component_class)
461
- component_class.model_name.singular.to_sym
720
+ # The reader for a component under a slot — `email`, or `business_address`
721
+ # for `component PostalAddress, prefix: :business`. Derived by the component
722
+ # so the declaration, presence, preloading and the component's own
723
+ # destroy-reset all agree (EcsRails::Slots::Component).
724
+ def reader_name_for(component_class, slot = "")
725
+ component_class.ecs_reader_name(slot)
462
726
  end
463
727
 
464
- # Names the collision and the two ways out: rename the offending method
465
- # (usually a relationship component's `belongs_to`), or exclude it.
466
- def reader_collision_message(component_class, method)
728
+ # Names the collision and the three ways out: keep the default prefix,
729
+ # rename the offending method (usually a relationship component's
730
+ # `belongs_to`), or exclude it. `except:` takes the component's own method
731
+ # name, which is why the map's value is passed alongside the clashing key.
732
+ def reader_collision_message(component_class, slot, method, component_method)
733
+ base = component_method.to_s.chomp("=")
734
+ excluded = [base]
735
+ excluded.concat(component_operation_names(base).select { |operation| component_class.method_defined?(operation) })
736
+ except_names = excluded.map { |name| ":#{name}" }.join(", ")
467
737
  "##{method} on #{name} is both a component reader and a method delegated " \
468
- "from #{component_class.name}. A reader name is reserved. Rename the " \
738
+ "from #{component_class.name}. A reader name is reserved. Drop " \
739
+ "`prefix: false` so the method is delegated as " \
740
+ "#{reader_name_for(component_class, slot)}_#{component_method}, rename the " \
469
741
  "method — for a relationship component, name the association for its " \
470
742
  "target (e.g. `belongs_to :user`) rather than the component — or exclude " \
471
- "it with `component #{component_class.name}, except: [:#{method.to_s.chomp("=")}]`."
743
+ "it with `component #{component_class.name}, except: [#{except_names}]`."
472
744
  end
473
745
 
474
746
  # The message ADR-0004 specifies: the method, both components, the entity,
475
- # and the exact `except:` line that resolves it.
476
- def delegation_conflict_message(new_component, existing_component, method)
477
- attribute = method.to_s.chomp("=").to_sym
478
- reader = new_component.model_name.singular
479
-
480
- "##{method} is defined by both #{existing_component.name} and " \
747
+ # and the exact `except:` line that resolves it. The `except:` hint names
748
+ # the component's own method (`:title`), not the entity-level name. The
749
+ # existing owner is a component class, or a description such as
750
+ # "relates_to :author" when a relationship reserved the name.
751
+ def delegation_conflict_message(new_component, slot, existing_owner, method, component_method)
752
+ attribute = component_method.to_s.chomp("=").to_sym
753
+ reader = reader_name_for(new_component, slot)
754
+ owner = existing_owner.is_a?(Module) ? existing_owner.name : existing_owner
755
+
756
+ "##{method} is defined by both #{owner} and " \
481
757
  "#{new_component.name} on #{name}. " \
482
758
  "Disambiguate with `component #{new_component.name}, except: [:#{attribute}]` " \
483
759
  "or call #{model_name.singular}.#{reader}.#{attribute} directly."
484
760
  end
485
761
 
486
- def define_component_association(component_class)
487
- has_one component_class.model_name.singular.to_sym,
762
+ # The has_one is **slot-scoped** (RFC-0014 / ADR-0015): `where(slot:
763
+ # "business")` for a labelled slot, and `where(slot: "")` for the default
764
+ # one. Always, not only when a component is declared twice — with two slots
765
+ # of `PostalAddress` on one entity, an unscoped default has_one would return
766
+ # whichever row the database offered first. Uniform scoping is what makes
767
+ # singular a genuine special case of one code path (ADR-0015), at the cost
768
+ # of one `AND slot = ''` per component read. ActiveRecord's preloader
769
+ # applies the same scope, so `includes_components` stays correct.
770
+ def define_component_association(component_class, slot)
771
+ slot_value = slot.to_s
772
+
773
+ has_one reader_name_for(component_class, slot),
774
+ -> { where(slot: slot_value) },
488
775
  # The name, not the class object — a reloaded component must
489
776
  # resolve to the new constant. Same rule as the registry's.
490
777
  class_name: component_class.name,
@@ -504,7 +791,11 @@ module EcsRails
504
791
  # ApplicationEntity (RFC-0003) — too far from convention for Rails
505
792
  # to find the inverse itself. Naming it means `user.email.entity`
506
793
  # is `user`, with no second query.
507
- inverse_of: :entity
794
+ inverse_of: :entity,
795
+ # The lazy cascade owns validation and persistence, including
796
+ # replacements on new owners. Native has_one autosave would save
797
+ # a default-only virtual merely because it is in the cache.
798
+ autosave: false, validate: false
508
799
  end
509
800
 
510
801
  def validate_component_class!(component_class)
@@ -524,32 +815,107 @@ module EcsRails
524
815
  end
525
816
 
526
817
  # ADR-0005 is per entity, and a subclass is an entity. Redeclaring would
527
- # define a second has_one over the same unique entity_id row, so it is a
528
- # duplicate however the registry happens to be keyed. Only the inherited case
529
- # is checked here — the registry raises for this class's own duplicates.
530
- def validate_not_inherited!(component_class)
818
+ # define a second has_one over the same unique (entity_id, slot) row, so it
819
+ # is a duplicate however the registry happens to be keyed. Only the
820
+ # inherited case is checked here — the registry raises for this class's own
821
+ # duplicates. The same component under a *different* slot is not a duplicate
822
+ # (RFC-0014).
823
+ def validate_not_inherited!(component_class, slot)
531
824
  return unless superclass.respond_to?(:component_declarations)
532
825
 
533
826
  clash = superclass.component_declarations.find do |declaration|
534
- declaration.component_class_name == component_class.name
827
+ declaration.component_class_name == component_class.name && declaration.slot == slot
535
828
  end
536
829
  return unless clash
537
830
 
831
+ where = slot.empty? ? "" : " (prefix: :#{slot})"
538
832
  raise DuplicateComponent,
539
- "#{name} already declares #{component_class.name}, " \
833
+ "#{name} already declares #{component_class.name}#{where}, " \
540
834
  "inherited from #{clash.entity_class_name}"
541
835
  end
542
836
 
543
- def normalized_delegation_options(only:, except:)
837
+ # The options the registry records for a declaration: `only:`/`except:` as
838
+ # normalised symbol arrays, `prefix: false` when bare delegation was asked
839
+ # for, and `delegate: false` when none was. The defaults are *not* recorded
840
+ # — `{}` still means "all of it, prefixed" — so a plain `component Email`
841
+ # declaration compares equal before and after ADR-0016, and conflict
842
+ # detection re-derives the same map from the same options. The slot itself
843
+ # is not an option: it is the declaration's identity, recorded alongside.
844
+ def normalized_delegation_options(only:, except:, prefix:, delegate:)
544
845
  if only && except
545
846
  raise ArgumentError,
546
847
  "`only:` and `except:` are mutually exclusive; pass at most one"
547
848
  end
548
849
 
549
- return { only: method_names(only) } if only
550
- return { except: method_names(except) } if except
850
+ unless delegate == true || delegate == false
851
+ raise ArgumentError, "`delegate:` expects true or false; got #{delegate.inspect}"
852
+ end
853
+
854
+ if !delegate && (only || except)
855
+ raise ArgumentError,
856
+ "`delegate: false` generates no delegated methods, so `only:`/`except:` " \
857
+ "have nothing to select from; drop one or the other"
858
+ end
859
+
860
+ options = {}
861
+ options[:only] = method_names(only) if only
862
+ options[:except] = method_names(except) if except
863
+ options[:prefix] = false if prefix == false
864
+ options[:delegate] = false unless delegate
865
+ options
866
+ end
867
+
868
+ # The slot a `prefix:` names (RFC-0014 / ADR-0015). `nil`, `true` and
869
+ # `false` are all the default slot, `""` — ADR-0016's Booleans say how the
870
+ # default slot delegates, not which slot it is. A Symbol or String is a
871
+ # label, stored as its string form; it must be a valid method-name segment,
872
+ # because it becomes the head of the reader (`business_address`) and of
873
+ # every delegated method. `""` is rejected explicitly: it would silently
874
+ # mean the default slot while reading as a label.
875
+ def normalized_slot(prefix)
876
+ case prefix
877
+ when nil, true, false then ""
878
+ when Symbol, String
879
+ label = prefix.to_s
880
+ return label if label.match?(/\A[a-z_][a-z0-9_]*\z/)
881
+
882
+ raise ArgumentError,
883
+ "`prefix: #{prefix.inspect}` is not a valid slot label; use a " \
884
+ "lowercase method-name segment such as :business or :mobile"
885
+ else
886
+ raise ArgumentError,
887
+ "`prefix:` expects true (the default: delegated methods are named " \
888
+ "#{model_name.singular}.<reader>_<method>), false (bare <method>), " \
889
+ "or a Symbol slot label (`prefix: :business`, RFC-0014); got #{prefix.inspect}"
890
+ end
891
+ end
892
+
893
+ # A declared primary attribute must be something the component delegates —
894
+ # a typo (`primary :valu`) would otherwise silently produce no bare pair.
895
+ def validate_primary!(component_class)
896
+ primary = component_class.primary
897
+ return if primary.nil? || delegable_methods(component_class).include?(primary)
898
+
899
+ raise ArgumentError,
900
+ "#{component_class.name} declares `primary :#{primary}`, but has no delegable " \
901
+ "##{primary}. Delegable methods: " \
902
+ "#{delegable_methods(component_class).map { |m| "##{m}" }.join(', ')}."
903
+ end
551
904
 
552
- {}
905
+ # RFC-0014 slot configuration: every extra keyword on `component` must be an
906
+ # option the component declared with `slot_option`. Fail-loud (ADR-0004): a
907
+ # mistyped option that silently configured nothing would be exactly the
908
+ # action-at-a-distance the rest of the DSL refuses.
909
+ def normalized_slot_options(component_class, slot_options)
910
+ accepted = component_class.slot_option_names
911
+ unknown = slot_options.keys.map(&:to_sym) - accepted
912
+ return slot_options.transform_keys(&:to_sym) if unknown.empty?
913
+
914
+ known = accepted.empty? ? "none" : accepted.map { |k| ":#{k}" }.join(", ")
915
+ raise ArgumentError,
916
+ "#{component_class.name} does not accept the slot option#{'s' if unknown.size > 1} " \
917
+ "#{unknown.map { |k| ":#{k}" }.join(', ')}. Declared slot options: #{known}. " \
918
+ "Declare one with `slot_option :#{unknown.first}` on #{component_class.name}."
553
919
  end
554
920
 
555
921
  def method_names(value)