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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +304 -0
- data/README.md +365 -50
- data/lib/ecs_rails/catalogue/address.rb +39 -0
- data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
- data/lib/ecs_rails/catalogue/counter.rb +46 -0
- data/lib/ecs_rails/catalogue/discard.rb +36 -0
- data/lib/ecs_rails/catalogue/email.rb +35 -0
- data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
- data/lib/ecs_rails/catalogue/identifier.rb +23 -0
- data/lib/ecs_rails/catalogue/image.rb +28 -0
- data/lib/ecs_rails/catalogue/link.rb +27 -0
- data/lib/ecs_rails/catalogue/marker.rb +38 -0
- data/lib/ecs_rails/catalogue/money.rb +86 -0
- data/lib/ecs_rails/catalogue/name.rb +37 -0
- data/lib/ecs_rails/catalogue/password.rb +50 -0
- data/lib/ecs_rails/catalogue/period.rb +57 -0
- data/lib/ecs_rails/catalogue/phone.rb +30 -0
- data/lib/ecs_rails/catalogue/position.rb +18 -0
- data/lib/ecs_rails/catalogue/rating.rb +25 -0
- data/lib/ecs_rails/catalogue/relationship.rb +159 -0
- data/lib/ecs_rails/catalogue/role.rb +23 -0
- data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
- data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
- data/lib/ecs_rails/catalogue/state.rb +71 -0
- data/lib/ecs_rails/catalogue/tags.rb +64 -0
- data/lib/ecs_rails/catalogue/text.rb +29 -0
- data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
- data/lib/ecs_rails/catalogue/token.rb +77 -0
- data/lib/ecs_rails/catalogue.rb +405 -0
- data/lib/ecs_rails/component.rb +13 -7
- data/lib/ecs_rails/config.rb +33 -11
- data/lib/ecs_rails/dsl.rb +463 -97
- data/lib/ecs_rails/entity.rb +21 -5
- data/lib/ecs_rails/errors.rb +3 -0
- data/lib/ecs_rails/inverses.rb +398 -0
- data/lib/ecs_rails/lazy.rb +86 -23
- data/lib/ecs_rails/markers.rb +197 -0
- data/lib/ecs_rails/preloading.rb +19 -8
- data/lib/ecs_rails/presence.rb +83 -30
- data/lib/ecs_rails/querying.rb +158 -12
- data/lib/ecs_rails/railtie.rb +10 -0
- data/lib/ecs_rails/registry.rb +66 -21
- data/lib/ecs_rails/relationships.rb +168 -160
- data/lib/ecs_rails/slots.rb +183 -0
- data/lib/ecs_rails/validations.rb +3 -5
- data/lib/ecs_rails/version.rb +2 -2
- data/lib/ecs_rails.rb +7 -3
- data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
- data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
- data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
- data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
- data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
- data/lib/generators/ecs_rails/install/install_generator.rb +29 -12
- data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
- data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
- data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
- data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
- data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
- data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
- data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
- data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
- metadata +61 -4
- data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
- data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -99
- 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
|
|
12
|
+
# component PublishState, prefix: false
|
|
13
|
+
# component PostalAddress
|
|
14
|
+
# component PostalAddress, prefix: :business
|
|
13
15
|
# end
|
|
14
16
|
#
|
|
15
|
-
# User.components # => [Name, Email,
|
|
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
|
|
20
|
-
# generates the lazy reader (RFC-0006) into
|
|
21
|
-
# the module RFC-0005's delegated methods also
|
|
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
|
|
65
|
-
# component
|
|
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,
|
|
83
|
-
#
|
|
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
|
-
|
|
89
|
-
|
|
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. #
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
delegated
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
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:`
|
|
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
|
-
|
|
224
|
-
|
|
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
|
|
232
|
-
#
|
|
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.
|
|
242
|
-
# user.save!` write a single row. It does *not* rebind self or
|
|
243
|
-
# (ADR-0001): it forwards the call, so `self` inside the
|
|
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
|
|
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 |
|
|
252
|
-
mod.define_method(
|
|
253
|
-
public_send(reader).public_send(
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
287
|
-
|
|
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,
|
|
354
|
-
#
|
|
355
|
-
#
|
|
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),
|
|
417
|
-
# registry raises on #register just after this check.
|
|
418
|
-
# would report a spurious "#address is defined by both
|
|
419
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
|
465
|
-
# (usually a relationship component's
|
|
466
|
-
|
|
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.
|
|
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: [
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
-
|
|
487
|
-
|
|
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
|
|
528
|
-
# duplicate however the registry happens to be keyed. Only the
|
|
529
|
-
# is checked here — the registry raises for this class's own
|
|
530
|
-
|
|
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
|
-
|
|
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
|
-
|
|
550
|
-
|
|
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)
|