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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +88 -0
- data/LICENSE.txt +21 -0
- data/README.md +128 -0
- data/lib/ecs_on_rails.rb +10 -0
- data/lib/ecs_rails/component.rb +68 -0
- data/lib/ecs_rails/config.rb +31 -0
- data/lib/ecs_rails/dsl.rb +516 -0
- data/lib/ecs_rails/entity.rb +233 -0
- data/lib/ecs_rails/errors.rb +28 -0
- data/lib/ecs_rails/lazy.rb +244 -0
- data/lib/ecs_rails/preloading.rb +84 -0
- data/lib/ecs_rails/presence.rb +125 -0
- data/lib/ecs_rails/querying.rb +102 -0
- data/lib/ecs_rails/railtie.rb +14 -0
- data/lib/ecs_rails/registry.rb +152 -0
- data/lib/ecs_rails/relationships.rb +316 -0
- data/lib/ecs_rails/validations.rb +150 -0
- data/lib/ecs_rails/version.rb +5 -0
- data/lib/ecs_rails.rb +47 -0
- data/lib/generators/ecs_rails/component/component_generator.rb +101 -0
- data/lib/generators/ecs_rails/component/templates/component_spec.rb.tt +16 -0
- data/lib/generators/ecs_rails/component/templates/migration.rb.tt +25 -0
- data/lib/generators/ecs_rails/component/templates/model.rb.tt +13 -0
- data/lib/generators/ecs_rails/install/install_generator.rb +83 -0
- data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +16 -0
- data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +15 -0
- data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +19 -0
- data/lib/generators/ecs_rails/install/templates/migration.rb.tt +20 -0
- data/lib/generators/ecs_rails/relationship/relationship_generator.rb +94 -0
- data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +32 -0
- 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
|