ecs_on_rails 0.2.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.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +128 -0
  5. data/lib/ecs_on_rails.rb +10 -0
  6. data/lib/ecs_rails/component.rb +68 -0
  7. data/lib/ecs_rails/config.rb +31 -0
  8. data/lib/ecs_rails/dsl.rb +516 -0
  9. data/lib/ecs_rails/entity.rb +233 -0
  10. data/lib/ecs_rails/errors.rb +28 -0
  11. data/lib/ecs_rails/lazy.rb +244 -0
  12. data/lib/ecs_rails/preloading.rb +84 -0
  13. data/lib/ecs_rails/presence.rb +125 -0
  14. data/lib/ecs_rails/querying.rb +102 -0
  15. data/lib/ecs_rails/railtie.rb +14 -0
  16. data/lib/ecs_rails/registry.rb +152 -0
  17. data/lib/ecs_rails/relationships.rb +316 -0
  18. data/lib/ecs_rails/validations.rb +150 -0
  19. data/lib/ecs_rails/version.rb +5 -0
  20. data/lib/ecs_rails.rb +47 -0
  21. data/lib/generators/ecs_rails/component/component_generator.rb +101 -0
  22. data/lib/generators/ecs_rails/component/templates/component_spec.rb.tt +16 -0
  23. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +25 -0
  24. data/lib/generators/ecs_rails/component/templates/model.rb.tt +13 -0
  25. data/lib/generators/ecs_rails/install/install_generator.rb +83 -0
  26. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +16 -0
  27. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +15 -0
  28. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +19 -0
  29. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +20 -0
  30. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +94 -0
  31. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +32 -0
  32. metadata +138 -0
@@ -0,0 +1,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # The class-level DSL that composes an entity out of components.
5
+ #
6
+ # Implements RFC-0004. Extended into EcsRails::Entity, so every entity class —
7
+ # and every subclass of one — answers `component`.
8
+ #
9
+ # class User < ApplicationEntity
10
+ # component Name
11
+ # component Email
12
+ # component Group, except: [:title]
13
+ # end
14
+ #
15
+ # User.components # => [Name, Email, Group]
16
+ # User.create!.email # => the Email row, or a virtual one (RFC-0006)
17
+ #
18
+ # 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.
22
+ module DSL
23
+ # Declares that this entity is composed from `component_class`.
24
+ #
25
+ # Defines a reader named for the component's model_name.singular, so
26
+ # `component Email` gives `#email`.
27
+ #
28
+ # **The reader always returns an instance, never nil** (architecture.md §3).
29
+ # If the entity has no row for the component, a virtual one is built with
30
+ # every attribute at its default. That is RFC-0006's doing, layered on top of
31
+ # this RFC through #generated_component_methods rather than woven into it —
32
+ # see #define_component_reader.
33
+ #
34
+ # `only:` / `except:` restrict which of the component's methods are delegated
35
+ # onto the entity (RFC-0005). They never affect the reader: `user.group`
36
+ # exists whatever `except:` says. RFC-0004 validates and records them;
37
+ # RFC-0005 acts on them.
38
+ #
39
+ # Deliberately no `dependent:` option on the has_one, contradicting RFC-0004
40
+ # and matching architecture.md §3 and RFC-0003: cascade is owned by the
41
+ # database. Every component table has an ON DELETE CASCADE FK to
42
+ # entities(id), so entity.destroy already removes the rows, without loading a
43
+ # single component. Declaring dependent: :destroy as well would put two
44
+ # layers on one job — the ActiveRecord one masking the database one, so that
45
+ # dropping the FK would break the invariant with every test still passing.
46
+ # The price is that a component's own destroy callbacks do not run on
47
+ # entity.destroy; that is a real gap, and it wants an ADR rather than a
48
+ # has_one option.
49
+ #
50
+ # Raises InvalidComponent unless `component_class` is a concrete
51
+ # EcsRails::Component; DuplicateComponent if this entity — or any entity it
52
+ # inherits from — already declares it; ArgumentError for bad options or for
53
+ # an anonymous class on either side.
54
+ #
55
+ # Returns the Registry::Declaration.
56
+ def component(component_class, only: nil, except: nil)
57
+ validate_component_class!(component_class)
58
+ options = normalized_delegation_options(only: only, except: except)
59
+ validate_not_inherited!(component_class)
60
+
61
+ # RFC-0005 is resolved *before* anything is registered or defined, so that
62
+ # a bad `only:`/`except:` name or a DelegationConflict leaves the class in
63
+ # exactly the state it was in. #delegated_method_names validates the option
64
+ # names against the component's real method set (ArgumentError on a typo);
65
+ # #detect_delegation_conflict! raises DelegationConflict (ADR-0004) if any
66
+ # of those names is already delegated by a sibling component.
67
+ delegated = delegated_method_names(component_class, options)
68
+ detect_delegation_conflict!(component_class, delegated)
69
+ detect_reader_collision!(component_class, delegated)
70
+
71
+ # Registered first, so that the registry's own duplicate check (RFC-0002)
72
+ # is what stops a doubled `component` line — before any method is defined.
73
+ declaration = EcsRails.registry.register(
74
+ entity_class: self,
75
+ component_class: component_class,
76
+ options: options
77
+ )
78
+
79
+ define_component_association(component_class)
80
+
81
+ # Must follow the has_one: see #generated_component_methods.
82
+ define_component_reader(component_class)
83
+
84
+ # RFC-0005: the delegating methods, into the same module as the reader.
85
+ define_component_delegation(component_class, delegated)
86
+
87
+ # RFC-0009: the `<reader>?` presence predicate. Generated last so it can
88
+ # see, and defer to, any delegated method that already owns its name.
89
+ define_component_predicate(component_class)
90
+
91
+ declaration
92
+ end
93
+
94
+ # Every component this entity is composed from, nearest ancestor last.
95
+ def components
96
+ component_declarations.map(&:component_class)
97
+ end
98
+
99
+ # Every declaration this entity is composed from, parent's before its own.
100
+ #
101
+ # RFC-0004 requires subclasses to inherit their parent's declarations. The
102
+ # registry is not involved in that: it holds exactly what each class itself
103
+ # declared, keyed by that class's own name (RFC-0002), and the walk happens
104
+ # here on read. Copying declarations down into each subclass instead — which
105
+ # is what RFC-0004's example test implies — would triple-count component
106
+ # tables in #entities_for, miss anything the parent declares after the
107
+ # subclass is defined, and duplicate a name-keyed store whose entire purpose
108
+ # is to not hold stale copies.
109
+ #
110
+ # So EcsRails.registry.components_for(Admin) is only Admin's own, by design,
111
+ # and this is the method that answers "what is an Admin made of".
112
+ def component_declarations
113
+ entity_ancestry.flat_map { |klass| EcsRails.registry.components_for(klass) }
114
+ end
115
+
116
+ # The module the DSL generates methods into: RFC-0005's delegated methods,
117
+ # and RFC-0006's reader override. Empty in RFC-0004 — it exists here because
118
+ # the DSL owns method generation, and because the include ordering below is
119
+ # subtle enough to want pinning by tests now rather than in RFC-0006.
120
+ #
121
+ # ADR-0004 requires generated methods to live in an included module rather
122
+ # than on the class, so that a method defined on the entity itself wins by
123
+ # Ruby's ordinary lookup, with no special-casing.
124
+ #
125
+ # Mirrors ActiveRecord's own #generated_association_methods, and must land
126
+ # *after* it in the ancestor chain: the most recently included module sits
127
+ # closest to the class, so that ordering is what lets this module override
128
+ # the has_one reader (the seam RFC-0006 needs) rather than be shadowed by it.
129
+ #
130
+ # It holds for free. ActiveRecord's `inherited` hook calls
131
+ # initialize_generated_modules, which creates and includes
132
+ # GeneratedAssociationMethods at class-definition time — long before any
133
+ # `component` line can run. So there is nothing to force here, and no order
134
+ # of DSL calls that can invert it. That is an ActiveRecord internal, so the
135
+ # ordering is pinned by tests ("the generated methods module" in
136
+ # spec/dsl_spec.rb) and an upgrade that changed it would fail loudly.
137
+ def generated_component_methods
138
+ @generated_component_methods ||= begin
139
+ mod = const_set(:GeneratedComponentMethods, Module.new)
140
+ private_constant :GeneratedComponentMethods
141
+ include mod
142
+ mod
143
+ end
144
+ end
145
+
146
+ private
147
+
148
+ # This class and its entity superclasses, base first. Anonymous classes are
149
+ # skipped: the registry cannot key them, so they can hold no declarations.
150
+ def entity_ancestry
151
+ chain = []
152
+ klass = self
153
+
154
+ while klass.is_a?(Class) && klass <= EcsRails::Entity
155
+ chain.unshift(klass) if klass.name
156
+ klass = klass.superclass
157
+ end
158
+
159
+ chain
160
+ end
161
+
162
+ # The lazy reader (RFC-0006), generated into the seam this DSL already
163
+ # builds: generated_component_methods sits closer to the class than
164
+ # ActiveRecord's GeneratedAssociationMethods, so this wins, and `super`
165
+ # reaches the has_one reader underneath. Nothing else moves.
166
+ #
167
+ # `super()` with explicit parens is required, not style: a method defined by
168
+ # define_method cannot use bare `super`, because there is no static argument
169
+ # list for it to forward.
170
+ #
171
+ # The reader is generated per component rather than defined once on
172
+ # Lazy::Entity because there is nothing generic to define — each one closes
173
+ # over its own name and its own has_one to call through to.
174
+ def define_component_reader(component_class)
175
+ name = component_class.model_name.singular.to_sym
176
+
177
+ generated_component_methods.define_method(name) do
178
+ ecs_component(name) { super() }
179
+ end
180
+ end
181
+
182
+ # RFC-0005: generates one delegating method on the entity, per name in the
183
+ # delegated set, into the same module the reader lives in.
184
+ #
185
+ # The methods live in generated_component_methods (an *included* module), so
186
+ # a method defined directly on the entity class shadows them by Ruby's own
187
+ # lookup — which is exactly ADR-0004's "a method on the entity itself wins
188
+ # silently, no conflict". No special-casing here achieves that.
189
+ #
190
+ # Each generated method calls the entity's own component reader (`email`),
191
+ # so it goes through RFC-0006's memo and reaches the one instance the save
192
+ # cascade will later persist — the seam that makes `user.address = "x";
193
+ # user.save!` write a single row. It does *not* rebind self or instance_exec
194
+ # (ADR-0001): it forwards the call, so `self` inside the component method is
195
+ # the component, never the entity.
196
+ #
197
+ # *args, **kwargs and &block are all forwarded untouched (RFC-0005).
198
+ def define_component_delegation(component_class, delegated)
199
+ reader = component_class.model_name.singular.to_sym
200
+ mod = generated_component_methods
201
+
202
+ delegated.each do |method_name|
203
+ mod.define_method(method_name) do |*args, **kwargs, &block|
204
+ public_send(reader).public_send(method_name, *args, **kwargs, &block)
205
+ end
206
+ end
207
+ end
208
+
209
+ # RFC-0009: the presence predicate `entity.<reader>?`, generated per
210
+ # component into the same module as the reader and delegation. It is exactly
211
+ # `has?(ThatComponent)` — `user.moderator?`, `user.email?` — the per-component
212
+ # sugar over EcsRails::Presence::Entity#has?.
213
+ #
214
+ # Generated for every component, not just markers (ADR-0009): "does a row
215
+ # exist?" is a question every component answers.
216
+ #
217
+ # Collision: the predicate name is `<reader>?`, and a component *could*, in
218
+ # principle, delegate a method of that exact name (a `<reader>?` in its own
219
+ # delegable set). That is the reader-collision situation the same way the
220
+ # reader itself is (ADR-0009), but far rarer, and a delegated method is the
221
+ # developer's explicit choice — so rather than raise, we simply do not
222
+ # clobber it: if the module already defines this name (from this component's
223
+ # delegation, generated just above, or a sibling's), the delegated method
224
+ # wins and no predicate is generated. The common case has no such method and
225
+ # the predicate is defined normally.
226
+ def define_component_predicate(component_class)
227
+ predicate = :"#{component_class.model_name.singular}?"
228
+ mod = generated_component_methods
229
+ return if mod.instance_methods(false).include?(predicate)
230
+
231
+ # Closed over the class so a reloaded constant still resolves through
232
+ # #has?'s declared-set check, same as the reader closes over its name.
233
+ component = component_class
234
+ mod.define_method(predicate) { has?(component) }
235
+ end
236
+
237
+ # RFC-0005: the set of method names delegated for one component, after
238
+ # `only:`/`except:` are applied. Also the place their names are validated —
239
+ # RFC-0004 stored them but never checked they name anything real.
240
+ #
241
+ # `only:` keeps the named members; `except:` drops them. Both are attribute
242
+ # aware: naming an attribute (`:title`) covers both its reader and its writer
243
+ # (`:title`, `:title=`), so `except: [:title]` fully resolves a `#title`
244
+ # conflict rather than leaving `#title=` still clashing. The RFC's own
245
+ # resolution test — `component Group, except: [:title]` with no error —
246
+ # requires exactly this; a literal-name filter would still raise on `#title=`.
247
+ def delegated_method_names(component_class, options)
248
+ full = delegable_methods(component_class)
249
+ pairs = attribute_accessor_index(component_class, full)
250
+
251
+ if (only = options[:only])
252
+ validate_delegation_names!(component_class, only, full, pairs, :only)
253
+ full & expand_delegation_names(only, pairs)
254
+ elsif (except = options[:except])
255
+ validate_delegation_names!(component_class, except, full, pairs, :except)
256
+ full - expand_delegation_names(except, pairs)
257
+ else
258
+ full
259
+ end
260
+ end
261
+
262
+ # The full delegable set for a component, before `only:`/`except:`.
263
+ #
264
+ # "Methods the component itself declares" is the fiddly part (RFC-0005 says
265
+ # so), and neither obvious shape is right on its own:
266
+ #
267
+ # - instance_methods(false) misses methods gained from included modules
268
+ # (Name#initials comes from Nameable) and misses attribute accessors.
269
+ # - instance_methods minus EcsRails::Component's picks up module methods,
270
+ # but once ActiveRecord has lazily generated a component's attribute
271
+ # methods it also drags in every dirty-tracking helper — address_was,
272
+ # address_changed?, saved_change_to_address?, and a hundred more.
273
+ #
274
+ # So behaviour and attributes are computed separately and unioned:
275
+ #
276
+ # behaviour = public instance methods, minus everything Component and its
277
+ # ancestors define, minus the AR-generated attribute module
278
+ # (which is where all those helpers live). What remains is the
279
+ # methods the component genuinely wrote — send_welcome_email,
280
+ # who_am_i, full_name, initials.
281
+ # accessors = a reader and writer for each attribute the component owns.
282
+ #
283
+ # generated_attribute_methods is private ActiveRecord API. The gem already
284
+ # depends on AR internals with tests pinning them (ADR-0008's
285
+ # instantiate_instance_of; architecture.md open question 9), and this is the
286
+ # same bargain: pinned by the exact-set tests in delegation_spec, so a Rails
287
+ # upgrade that moved these methods fails loudly rather than silently widening
288
+ # what an entity delegates.
289
+ def delegable_methods(component_class)
290
+ attr_module = component_class.send(:generated_attribute_methods)
291
+
292
+ behaviour = component_class.public_instance_methods(true) -
293
+ EcsRails::Component.public_instance_methods(true) -
294
+ attr_module.instance_methods(false)
295
+
296
+ accessors = component_class.attribute_names.flat_map do |attribute|
297
+ [attribute.to_sym, :"#{attribute}="]
298
+ end
299
+
300
+ ((behaviour + accessors).uniq - never_delegated(component_class)).sort
301
+ end
302
+
303
+ # Identity, not state: never delegated (RFC-0005). The primary key, the
304
+ # entity_id foreign key, and the component timestamps — with their writers —
305
+ # plus the :entity association (already excluded via the Component subtraction
306
+ # above, restated here so the boundary is explicit rather than incidental).
307
+ def never_delegated(component_class)
308
+ attributes = [component_class.primary_key, "entity_id", "created_at", "updated_at"]
309
+
310
+ attributes.flat_map { |attribute| [attribute.to_sym, :"#{attribute}="] } +
311
+ %i[entity entity=]
312
+ end
313
+
314
+ # Maps each delegable attribute to its accessor pair, so `only:`/`except:`
315
+ # can be attribute aware. Keyed by the reader symbol; the value is whichever
316
+ # of [reader, writer] actually survived into the delegable set.
317
+ def attribute_accessor_index(component_class, full)
318
+ component_class.attribute_names.each_with_object({}) do |attribute, index|
319
+ reader = attribute.to_sym
320
+ writer = :"#{attribute}="
321
+ pair = [reader, writer].select { |name| full.include?(name) }
322
+ index[reader] = pair unless pair.empty?
323
+ end
324
+ end
325
+
326
+ # Expands `only:`/`except:` names to the concrete methods they name. An
327
+ # attribute name — whether given as `:title` or `:title=` — expands to its
328
+ # whole accessor pair; anything else is taken literally.
329
+ def expand_delegation_names(names, pairs)
330
+ names.flat_map do |name|
331
+ base = name.to_s.chomp("=").to_sym
332
+ pairs.key?(base) ? pairs[base] : [name]
333
+ end.uniq
334
+ end
335
+
336
+ # RFC-0005 / RFC-0004: `only:`/`except:` names were stored but never checked.
337
+ # A name that matches nothing the component delegates is almost certainly a
338
+ # typo — and a silent no-op here is precisely the action-at-a-distance
339
+ # ADR-0004 exists to stop (a mistyped `except:` fails to resolve a conflict;
340
+ # a mistyped `only:` delegates nothing). So an unknown name raises at
341
+ # declaration time, naming the component and the offending method.
342
+ def validate_delegation_names!(component_class, names, full, pairs, keyword)
343
+ names.each do |name|
344
+ base = name.to_s.chomp("=").to_sym
345
+ next if full.include?(name) || pairs.key?(base)
346
+
347
+ raise ArgumentError,
348
+ "`#{keyword}: [:#{name}]` names #{component_class.name}##{name}, " \
349
+ "which #{component_class.name} does not delegate. Delegable methods: " \
350
+ "#{full.map { |m| "##{m}" }.join(', ')}."
351
+ end
352
+ end
353
+
354
+ # ADR-0004: two components on one entity delegating the same name is a
355
+ # DelegationConflict, raised here at declaration time — never a silent
356
+ # last-wins. Checked against every component already declared on this entity
357
+ # and its ancestors (the new one is not registered yet), so the message can
358
+ # name the sibling that got there first.
359
+ #
360
+ # Only component-vs-component overlaps count. An overlap with a method the
361
+ # entity itself defines is not a conflict: that method wins by Ruby's lookup
362
+ # (the generated module is included), which is ADR-0004's other half.
363
+ def detect_delegation_conflict!(component_class, delegated)
364
+ owners = {}
365
+ component_declarations.each do |declaration|
366
+ # A component never conflicts with itself: the same component declared
367
+ # twice on one entity is a DuplicateComponent (ADR-0005), which the
368
+ # registry raises on #register just after this check. Comparing it here
369
+ # would report a spurious "#address is defined by both Email and Email".
370
+ next if declaration.component_class_name == component_class.name
371
+
372
+ other = declaration.component_class
373
+ delegated_method_names(other, declaration.options).each do |name|
374
+ owners[name] ||= other
375
+ end
376
+ end
377
+
378
+ # Sort so the reader (`title`) is reported before its writer (`title=`) —
379
+ # "title" < "title=" — giving the tidier message and except: hint.
380
+ clash = delegated.select { |name| owners.key?(name) }.min_by(&:to_s)
381
+ return unless clash
382
+
383
+ raise DelegationConflict,
384
+ delegation_conflict_message(component_class, owners[clash], clash)
385
+ end
386
+
387
+ # A component reader (`post.author`) is structural — it is how you reach the
388
+ # component at all — so its name is reserved. A delegated method that would
389
+ # take the same name must not silently overwrite the reader: it did, and
390
+ # because the generated method then called the reader, `post.author` recursed
391
+ # into itself (SystemStackError). This is genuine ambiguity of exactly the
392
+ # kind ADR-0004 raises on: does `post.author` mean the Author *component*, or
393
+ # the User that `belongs_to :author` inside it points at? Surface it at
394
+ # declaration time and make the developer choose.
395
+ #
396
+ # Fires when a delegated name equals any component reader on the entity — the
397
+ # new component's own reader (the `Author` with `belongs_to :author` case) or
398
+ # a sibling's. The reverse — a sibling's delegated method colliding with the
399
+ # new reader — is the same names from the other side, and
400
+ # #detect_delegation_conflict! on the earlier declaration already covers it.
401
+ def detect_reader_collision!(component_class, delegated)
402
+ readers = component_declarations.map { |d| reader_name_for(d.component_class) }
403
+ readers << reader_name_for(component_class)
404
+
405
+ clash = delegated.find { |name| readers.include?(name) }
406
+ return unless clash
407
+
408
+ raise DelegationConflict, reader_collision_message(component_class, clash)
409
+ end
410
+
411
+ def reader_name_for(component_class)
412
+ component_class.model_name.singular.to_sym
413
+ end
414
+
415
+ # Names the collision and the two ways out: rename the offending method
416
+ # (usually a relationship component's `belongs_to`), or exclude it.
417
+ def reader_collision_message(component_class, method)
418
+ "##{method} on #{name} is both a component reader and a method delegated " \
419
+ "from #{component_class.name}. A reader name is reserved. Rename the " \
420
+ "method — for a relationship component, name the association for its " \
421
+ "target (e.g. `belongs_to :user`) rather than the component — or exclude " \
422
+ "it with `component #{component_class.name}, except: [:#{method.to_s.chomp("=")}]`."
423
+ end
424
+
425
+ # The message ADR-0004 specifies: the method, both components, the entity,
426
+ # and the exact `except:` line that resolves it.
427
+ def delegation_conflict_message(new_component, existing_component, method)
428
+ attribute = method.to_s.chomp("=").to_sym
429
+ reader = new_component.model_name.singular
430
+
431
+ "##{method} is defined by both #{existing_component.name} and " \
432
+ "#{new_component.name} on #{name}. " \
433
+ "Disambiguate with `component #{new_component.name}, except: [:#{attribute}]` " \
434
+ "or call #{model_name.singular}.#{reader}.#{attribute} directly."
435
+ end
436
+
437
+ def define_component_association(component_class)
438
+ has_one component_class.model_name.singular.to_sym,
439
+ # The name, not the class object — a reloaded component must
440
+ # resolve to the new constant. Same rule as the registry's.
441
+ class_name: component_class.name,
442
+ # Every component keys on entity_id (architecture.md §2). Left to
443
+ # itself Rails derives a has_one's foreign key from the *owner's*
444
+ # model name, so `User has_one :email` would look for
445
+ # emails.user_id.
446
+ #
447
+ # Belt-and-braces: as of Rails 7.1 `derive_foreign_key` prefers the
448
+ # inverse's foreign key when `inverse_of:` is given, so the line
449
+ # below would say entity_id even without this option. That is an
450
+ # inference chain through a second option and a version-dependent
451
+ # branch, for an invariant the gem is built on — so it is stated
452
+ # outright rather than inferred.
453
+ foreign_key: :entity_id,
454
+ # The component's belongs_to is :entity, and targets the abstract
455
+ # ApplicationEntity (RFC-0003) — too far from convention for Rails
456
+ # to find the inverse itself. Naming it means `user.email.entity`
457
+ # is `user`, with no second query.
458
+ inverse_of: :entity
459
+ end
460
+
461
+ def validate_component_class!(component_class)
462
+ unless component_class.is_a?(Class) && component_class < EcsRails::Component
463
+ raise InvalidComponent,
464
+ "#{component_class.inspect} is not an EcsRails::Component subclass"
465
+ end
466
+
467
+ # Not in RFC-0004, but an abstract component owns no table
468
+ # (architecture.md §1), so its has_one could never resolve. Failing at
469
+ # declaration time beats failing at the first read.
470
+ return unless component_class.abstract_class?
471
+
472
+ raise InvalidComponent,
473
+ "#{component_class.name} is abstract and owns no table; " \
474
+ "declare a concrete component"
475
+ end
476
+
477
+ # ADR-0005 is per entity, and a subclass is an entity. Redeclaring would
478
+ # define a second has_one over the same unique entity_id row, so it is a
479
+ # duplicate however the registry happens to be keyed. Only the inherited case
480
+ # is checked here — the registry raises for this class's own duplicates.
481
+ def validate_not_inherited!(component_class)
482
+ return unless superclass.respond_to?(:component_declarations)
483
+
484
+ clash = superclass.component_declarations.find do |declaration|
485
+ declaration.component_class_name == component_class.name
486
+ end
487
+ return unless clash
488
+
489
+ raise DuplicateComponent,
490
+ "#{name} already declares #{component_class.name}, " \
491
+ "inherited from #{clash.entity_class_name}"
492
+ end
493
+
494
+ def normalized_delegation_options(only:, except:)
495
+ if only && except
496
+ raise ArgumentError,
497
+ "`only:` and `except:` are mutually exclusive; pass at most one"
498
+ end
499
+
500
+ return { only: method_names(only) } if only
501
+ return { except: method_names(except) } if except
502
+
503
+ {}
504
+ end
505
+
506
+ def method_names(value)
507
+ Array(value).map do |name|
508
+ unless name.is_a?(Symbol) || name.is_a?(String)
509
+ raise ArgumentError, "expected a method name, got #{name.inspect}"
510
+ end
511
+
512
+ name.to_sym
513
+ end
514
+ end
515
+ end
516
+ end