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,316 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# #constantize is load-bearing for reload safety: relationship metadata stores
|
|
4
|
+
# the backing and target class *names* (strings) and resolves them on read, so a
|
|
5
|
+
# reloaded constant is picked up — the same discipline as the registry
|
|
6
|
+
# (RFC-0002). Required explicitly rather than relying on ActiveRecord.
|
|
7
|
+
require "active_support/core_ext/string/inflections"
|
|
8
|
+
|
|
9
|
+
module EcsRails
|
|
10
|
+
# The class-level DSL for cross-entity links: `relates_to`.
|
|
11
|
+
#
|
|
12
|
+
# Implements RFC-0012, decided by ADR-0013. Extended into EcsRails::Entity,
|
|
13
|
+
# alongside the `component` DSL it is built on. Surfaced by the demo, where
|
|
14
|
+
# `Authorship`, `MemberUser` and `MemberGroup` were each *nothing but* a
|
|
15
|
+
# `belongs_to` in a component, plus a `component` declaration and a migration.
|
|
16
|
+
#
|
|
17
|
+
# class Post < ApplicationEntity
|
|
18
|
+
# relates_to :author, User
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# class Membership < ApplicationEntity
|
|
22
|
+
# relates_to :user, User
|
|
23
|
+
# relates_to :team, Team
|
|
24
|
+
# component Role
|
|
25
|
+
# end
|
|
26
|
+
#
|
|
27
|
+
# post.author = user # writer (delegated from the backing belongs_to)
|
|
28
|
+
# post.author # => the User (delegated)
|
|
29
|
+
# post.author_relationship # => the backing component row (the reader)
|
|
30
|
+
#
|
|
31
|
+
# `relates_to` writes no relationship component file. It defines the backing
|
|
32
|
+
# component dynamically and then declares it with `component`, so the whole
|
|
33
|
+
# RFC-0004/0005/0006/0009/0010/0011 stack applies for free: registry, lazy
|
|
34
|
+
# reader, delegation, `with_component`, presence, `includes_components`.
|
|
35
|
+
#
|
|
36
|
+
# ## How the backing component is built (ADR-0013)
|
|
37
|
+
#
|
|
38
|
+
# `relates_to :author, User` on `Post` dynamically defines
|
|
39
|
+
# `Post::AuthorRelationship`, a concrete EcsRails::Component subclass, with:
|
|
40
|
+
# - `table_name = "post_authors"` — `#{entity.singular}_#{relation.plural}`,
|
|
41
|
+
# owner-scoped so it is collision-free by construction (ADR-0013),
|
|
42
|
+
# - `belongs_to :author, class_name: "User", foreign_key: :author_id,
|
|
43
|
+
# optional: true` — the target link, optional so an unset relationship is
|
|
44
|
+
# valid (ADR-0003).
|
|
45
|
+
#
|
|
46
|
+
# Then it runs `component Post::AuthorRelationship`. Delegation surfaces the
|
|
47
|
+
# `belongs_to` as `post.author` / `post.author=`; the backing component's own
|
|
48
|
+
# reader is `post.author_relationship`. There is no reader collision, because
|
|
49
|
+
# the component is named for the *relationship* and the association for the
|
|
50
|
+
# *target* — the rule the ADR-0006 amendment arrived at the hard way.
|
|
51
|
+
#
|
|
52
|
+
# ## The reader name (RFC-0012 Open, resolved)
|
|
53
|
+
#
|
|
54
|
+
# The reader is `author_relationship`, not `post_author_relationship`. That is
|
|
55
|
+
# a real trap: the DSL derives the reader (and has_one name, and preload key)
|
|
56
|
+
# from `component_class.model_name.singular`, and for the *nested* constant
|
|
57
|
+
# `Post::AuthorRelationship` that is "post_author_relationship" — the namespace
|
|
58
|
+
# leaks in and the entity name is doubled up. ADR-0013 specifies
|
|
59
|
+
# `author_relationship`. So the backing class pins its own `model_name` to the
|
|
60
|
+
# demodulized element ("AuthorRelationship" => "author_relationship"), which is
|
|
61
|
+
# the single source every DSL derivation reads. Nothing else in the gem uses
|
|
62
|
+
# the backing component's model_name, so this is safe and total: reader,
|
|
63
|
+
# has_one, and `includes_components` key all agree on `author_relationship`.
|
|
64
|
+
#
|
|
65
|
+
# ## Querying and preloading by name (RFC-0013 / ADR-0014)
|
|
66
|
+
#
|
|
67
|
+
# `with_related` / `without_related` / `includes_related` are the
|
|
68
|
+
# relationship-name equivalents of `with_component` / `without_component` /
|
|
69
|
+
# `includes_components`. They are thin sugar: each resolves the relationship
|
|
70
|
+
# name to its backing class and FK via metadata `relates_to` records, then
|
|
71
|
+
# delegates to the component verb — so `Post.with_related(:author, ada)`
|
|
72
|
+
# compiles to exactly `Post.with_component(Post::AuthorRelationship, author_id:
|
|
73
|
+
# ada.id)` and inherits its entity-model scoping and EXISTS correctness
|
|
74
|
+
# (ADR-0011). The backing `*Relationship` class never appears in app code.
|
|
75
|
+
module Relationships
|
|
76
|
+
# One recorded relationship, held by NAME so it survives a Rails reload the
|
|
77
|
+
# same way the registry's Declaration does (RFC-0002): #backing_class and
|
|
78
|
+
# #target_class resolve via constantize on read, so a metadata entry taken
|
|
79
|
+
# before a reload still resolves to the post-reload constants.
|
|
80
|
+
RelationshipMeta = Struct.new(:name, :backing_class_name, :foreign_key, :target_class_name) do
|
|
81
|
+
def backing_class
|
|
82
|
+
backing_class_name.constantize
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def target_class
|
|
86
|
+
target_class_name.constantize
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Sentinel for with_related's optional target: distinguishes "no target
|
|
91
|
+
# given" (filter to any backing row) from an explicit value. RFC-0013 only
|
|
92
|
+
# needs the no-arg form, but a sentinel — rather than a nil default — keeps
|
|
93
|
+
# "unset" from ever being confused with a legitimate id or entity.
|
|
94
|
+
ANY_TARGET = Object.new
|
|
95
|
+
private_constant :ANY_TARGET
|
|
96
|
+
|
|
97
|
+
# Declares a cross-entity link named `name` at `target_class`.
|
|
98
|
+
#
|
|
99
|
+
# `target_class` must be a concrete EcsRails::Entity; otherwise
|
|
100
|
+
# InvalidRelationship (a subclass of InvalidComponent — see errors.rb). `name`
|
|
101
|
+
# must not collide with an existing reader or delegated method on the entity;
|
|
102
|
+
# otherwise DelegationConflict, naming `name` (RFC-0012). Subclasses inherit
|
|
103
|
+
# the declaration exactly as they inherit `component` (the backing const lives
|
|
104
|
+
# on the declaring entity and resolves through ordinary constant lookup).
|
|
105
|
+
#
|
|
106
|
+
# Returns the Registry::Declaration for the backing component.
|
|
107
|
+
def relates_to(name, target_class)
|
|
108
|
+
name = name.to_sym
|
|
109
|
+
|
|
110
|
+
# Target first: this must fire before any name/table derivation, so that
|
|
111
|
+
# `Class.new(ApplicationEntity).relates_to(:x, String)` raises on the bad
|
|
112
|
+
# target rather than on the anonymous entity's blank model_name.
|
|
113
|
+
validate_relationship_target!(name, target_class)
|
|
114
|
+
|
|
115
|
+
# Then the name, with a relationship-shaped message. Left to `component`,
|
|
116
|
+
# a re-declared relationship trips the registry's DuplicateComponent, whose
|
|
117
|
+
# message names the CamelCase backing class ("Post::AuthorRelationship")
|
|
118
|
+
# rather than the relationship the developer wrote (`:author`) — and it
|
|
119
|
+
# would also warn on the doubled const_set. Catching it here keeps the
|
|
120
|
+
# message about the thing the developer typed.
|
|
121
|
+
detect_relationship_collision!(name)
|
|
122
|
+
|
|
123
|
+
backing = build_relationship_component(name, target_class)
|
|
124
|
+
declaration = component(backing)
|
|
125
|
+
|
|
126
|
+
# RFC-0013 / ADR-0014: record the relationship metadata the `*_related`
|
|
127
|
+
# query verbs resolve against. Recorded here, at declaration, so there is
|
|
128
|
+
# one source of truth for the backing class + FK rather than a second
|
|
129
|
+
# place that re-derives the naming convention (ADR-0014). On reload the
|
|
130
|
+
# entity class body reruns on a fresh class, repopulating this from empty.
|
|
131
|
+
record_relationship_meta(name, backing, target_class)
|
|
132
|
+
|
|
133
|
+
declaration
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The recorded metadata for relationship `name` on this entity (RFC-0013),
|
|
137
|
+
# or nil if there is none. Walks the entity ancestry, so a subclass sees its
|
|
138
|
+
# parents' relationships — the same way #component_declarations does.
|
|
139
|
+
def relationship_meta(name)
|
|
140
|
+
relationship_declarations[name.to_sym]
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The declared relationship names for this entity, ancestry included. Used
|
|
144
|
+
# in the InvalidRelationship message and handy for introspection.
|
|
145
|
+
def relationship_names
|
|
146
|
+
relationship_declarations.keys
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Entities whose `name` relationship points at `target` (RFC-0013 / ADR-0014).
|
|
150
|
+
#
|
|
151
|
+
# `target` may be an entity (its id is used) or a bare id. With no `target`,
|
|
152
|
+
# filters to entities that merely HAVE the relationship set (a backing row
|
|
153
|
+
# exists). Sugar for `with_component(backing, foreign_key => id)` — or
|
|
154
|
+
# `with_component(backing)` with no target — so it inherits that verb's
|
|
155
|
+
# entity-model scoping and correlated EXISTS (ADR-0011): no cross-entity leak.
|
|
156
|
+
#
|
|
157
|
+
# Returns a chainable ActiveRecord::Relation. Available on the class and on a
|
|
158
|
+
# relation, like the component verbs, because it builds on them (they run on
|
|
159
|
+
# `all`, the current default-scoped relation).
|
|
160
|
+
def with_related(name, target = ANY_TARGET)
|
|
161
|
+
meta = ecs_resolve_relationship!(name)
|
|
162
|
+
|
|
163
|
+
return with_component(meta.backing_class) if ANY_TARGET.equal?(target)
|
|
164
|
+
|
|
165
|
+
id = target.respond_to?(:id) ? target.id : target
|
|
166
|
+
with_component(meta.backing_class, meta.foreign_key => id)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Entities with NO backing row for `name` (RFC-0013). Sugar for
|
|
170
|
+
# `without_component(backing)`; inherits its NULL-safe NOT EXISTS (ADR-0011).
|
|
171
|
+
def without_related(name)
|
|
172
|
+
without_component(ecs_resolve_relationship!(name).backing_class)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Preloads each named relationship's backing component AND its target entity
|
|
176
|
+
# — one hop — so `entity.author` costs no extra query (RFC-0013 / ADR-0014).
|
|
177
|
+
#
|
|
178
|
+
# For `:author` that is `preload(author_relationship: :author)`: the backing
|
|
179
|
+
# reader is `<name>_relationship` (the backing's model_name.singular, pinned
|
|
180
|
+
# by ADR-0013) and the target association on the backing is the `<name>`
|
|
181
|
+
# belongs_to. Does NOT preload the target's own components (ADR-0014 non-goal).
|
|
182
|
+
#
|
|
183
|
+
# Chainable; returns a relation. Built from `all`, like Preloading, so it
|
|
184
|
+
# keeps any prior scope and the entity-model default scope.
|
|
185
|
+
def includes_related(*names)
|
|
186
|
+
preloads = names.map do |name|
|
|
187
|
+
meta = ecs_resolve_relationship!(name)
|
|
188
|
+
{ :"#{meta.name}_relationship" => meta.name }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
all.preload(*preloads)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
private
|
|
195
|
+
|
|
196
|
+
# Records one relationship's metadata on THIS class, by name and by class
|
|
197
|
+
# NAMES (strings), never Class objects (reload safety — see RelationshipMeta).
|
|
198
|
+
def record_relationship_meta(name, backing, target_class)
|
|
199
|
+
ecs_own_relationships[name] = RelationshipMeta.new(
|
|
200
|
+
name, # :author
|
|
201
|
+
backing.name, # "Post::AuthorRelationship"
|
|
202
|
+
:"#{name}_id", # :author_id
|
|
203
|
+
target_class.name # "User"
|
|
204
|
+
)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# This class's OWN relationships (not inherited). A per-class hash, so a fresh
|
|
208
|
+
# class object after a reload starts empty and `relates_to` repopulates it.
|
|
209
|
+
# Instance variables are not inherited, which is exactly what lets
|
|
210
|
+
# #relationship_declarations do the ancestry walk explicitly.
|
|
211
|
+
def ecs_own_relationships
|
|
212
|
+
@ecs_relationships ||= {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Every relationship declared on this entity, ancestors' before its own —
|
|
216
|
+
# merged across #entity_ancestry the same way #component_declarations walks
|
|
217
|
+
# it, so a subclass inherits its parents' relationships. Base-first merge
|
|
218
|
+
# means a nearer class would win a name clash, mirroring method lookup.
|
|
219
|
+
def relationship_declarations
|
|
220
|
+
entity_ancestry.each_with_object({}) do |klass, merged|
|
|
221
|
+
own = klass.instance_variable_get(:@ecs_relationships)
|
|
222
|
+
merged.merge!(own) if own
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Resolves `name` to its metadata or raises the fail-loud InvalidRelationship
|
|
227
|
+
# (RFC-0013) — naming the relationship and this entity's declared ones, the
|
|
228
|
+
# same component-shaped stance as the rest of the DSL.
|
|
229
|
+
def ecs_resolve_relationship!(name)
|
|
230
|
+
meta = relationship_meta(name)
|
|
231
|
+
return meta if meta
|
|
232
|
+
|
|
233
|
+
declared = relationship_names
|
|
234
|
+
known = declared.empty? ? "none" : declared.map { |n| ":#{n}" }.join(", ")
|
|
235
|
+
raise InvalidRelationship,
|
|
236
|
+
"#{self.name} has no relationship named :#{name}. " \
|
|
237
|
+
"#{self.name} relates to: #{known}."
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Dynamically defines the backing component class and returns it. Named
|
|
241
|
+
# before anything reads its name: the registry keys by name and rejects
|
|
242
|
+
# anonymous classes (RFC-0002), and `model_name` is derived from the const —
|
|
243
|
+
# so const_set, which both installs the nested constant and gives the class
|
|
244
|
+
# its name, must come first.
|
|
245
|
+
def build_relationship_component(name, target_class)
|
|
246
|
+
const_name = :"#{name.to_s.camelize}Relationship" # :AuthorRelationship
|
|
247
|
+
table = "#{model_name.singular}_#{name.to_s.pluralize}" # "post_authors"
|
|
248
|
+
foreign_key = :"#{name}_id" # :author_id
|
|
249
|
+
|
|
250
|
+
backing = Class.new(EcsRails::Component)
|
|
251
|
+
const_set(const_name, backing)
|
|
252
|
+
|
|
253
|
+
# table_name is set explicitly (ADR-0013): the class name and the table
|
|
254
|
+
# name are decoupled, so `Post::AuthorRelationship` reads `post_authors`.
|
|
255
|
+
backing.table_name = table
|
|
256
|
+
|
|
257
|
+
# Pin model_name to the demodulized element, so the DSL-derived reader is
|
|
258
|
+
# `author_relationship` and not `post_author_relationship` — see the module
|
|
259
|
+
# comment. Closed over `const_name`; ActiveModel::Name.new(self, nil,
|
|
260
|
+
# "AuthorRelationship") gives singular "author_relationship".
|
|
261
|
+
element = const_name.to_s
|
|
262
|
+
backing.define_singleton_method(:model_name) do
|
|
263
|
+
@ecs_relationship_model_name ||= ActiveModel::Name.new(self, nil, element)
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
# The one sanctioned place a component names an entity class (ADR-0006):
|
|
267
|
+
# optional, so a post with no author — or a nullified one — is valid.
|
|
268
|
+
backing.belongs_to name,
|
|
269
|
+
class_name: target_class.name,
|
|
270
|
+
foreign_key: foreign_key,
|
|
271
|
+
optional: true
|
|
272
|
+
|
|
273
|
+
backing
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# RFC-0012: the target must be a concrete entity. A component, an abstract
|
|
277
|
+
# entity, or a plain class is rejected with a relationship-shaped message.
|
|
278
|
+
def validate_relationship_target!(name, target_class)
|
|
279
|
+
unless target_class.is_a?(Class) && target_class < EcsRails::Entity
|
|
280
|
+
raise InvalidRelationship,
|
|
281
|
+
"relates_to :#{name} expected a concrete EcsRails::Entity as its " \
|
|
282
|
+
"target, got #{target_class.inspect}. A relationship points at an " \
|
|
283
|
+
"entity, not a component or a plain class."
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
return unless target_class.abstract_class?
|
|
287
|
+
|
|
288
|
+
raise InvalidRelationship,
|
|
289
|
+
"relates_to :#{name} target #{target_class.name} is abstract and owns " \
|
|
290
|
+
"no rows; relate to a concrete entity subclass."
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# RFC-0012: `name` must not already be a reader or a delegated method on this
|
|
294
|
+
# entity — two `relates_to :author`, or `:author` clashing with a component
|
|
295
|
+
# that already exposes `author`. Checked here, before the backing const is
|
|
296
|
+
# created, so the message names `:author` (the relationship) rather than the
|
|
297
|
+
# backing class, and no doubled const_set warning is printed.
|
|
298
|
+
#
|
|
299
|
+
# Reuses the DSL's own reader/delegation resolution (#reader_name_for,
|
|
300
|
+
# #delegated_method_names), so "what names does this entity already answer"
|
|
301
|
+
# is computed the one way the gem computes it everywhere else.
|
|
302
|
+
def detect_relationship_collision!(name)
|
|
303
|
+
taken = component_declarations.flat_map do |declaration|
|
|
304
|
+
component = declaration.component_class
|
|
305
|
+
[reader_name_for(component)] + delegated_method_names(component, declaration.options)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
return unless taken.include?(name)
|
|
309
|
+
|
|
310
|
+
raise DelegationConflict,
|
|
311
|
+
"relates_to :#{name} on #{self.name} collides with an existing " \
|
|
312
|
+
"##{name} method — a component reader or another relationship already " \
|
|
313
|
+
"owns that name. Choose a different relationship name."
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# Validation error merging: `entity.valid?` reflects its components' validity,
|
|
5
|
+
# and `entity.errors` reads naturally in a Rails form.
|
|
6
|
+
#
|
|
7
|
+
# Implements RFC-0007. Closes the gap RFC-0006 left explicit (see its "Status"
|
|
8
|
+
# section): the save/save! contract was already atomic, but it held *by
|
|
9
|
+
# accident* — the after_save cascade's `component.save!` raised, and `save`
|
|
10
|
+
# rescued it. `valid?` itself did not yet know a dirty component was bad.
|
|
11
|
+
#
|
|
12
|
+
# user = User.create!
|
|
13
|
+
# user.email.address = "not-an-email"
|
|
14
|
+
# user.valid? # => false (now, not just on save)
|
|
15
|
+
# user.errors[:"email.address"] # => ["is invalid"]
|
|
16
|
+
# user.errors.full_messages # => ["Email address is invalid"]
|
|
17
|
+
# user.save # => false, and inserts nothing
|
|
18
|
+
#
|
|
19
|
+
# With this in place, `entity.save` returns false because `valid?` is false
|
|
20
|
+
# *before* the cascade ever runs — the cascade's bang becomes belt-and-braces,
|
|
21
|
+
# exactly as ActiveRecord's autosave is. See RFC-0006's `#save_dirty_components`.
|
|
22
|
+
module Validations
|
|
23
|
+
# Mixed into EcsRails::Entity.
|
|
24
|
+
module Entity
|
|
25
|
+
extend ActiveSupport::Concern
|
|
26
|
+
|
|
27
|
+
included do
|
|
28
|
+
# A single `validate` callback that walks the memo and merges. No `on:`,
|
|
29
|
+
# so it runs in both the create and update contexts: an entity has no
|
|
30
|
+
# mutable fields of its own (architecture.md §1), so the money path is
|
|
31
|
+
# always a persisted-entity save whose only real work is a component's,
|
|
32
|
+
# and validation must fire there too.
|
|
33
|
+
validate :ecs_merge_component_errors
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Class-level hooks. `human_attribute_name` is a class method, and
|
|
37
|
+
# `ActiveModel::Error.full_message` reaches it via `base.class`, so the
|
|
38
|
+
# override that decouples the human label from the error key has to live
|
|
39
|
+
# here rather than on the instance.
|
|
40
|
+
module ClassMethods
|
|
41
|
+
# Turns a component-namespaced error key into a readable label, so that
|
|
42
|
+
# the *key* and the *full message* can diverge (RFC-0007):
|
|
43
|
+
#
|
|
44
|
+
# errors[:"email.address"] # machine-readable, namespaced by reader
|
|
45
|
+
# full_messages # => "Email address is invalid" (human)
|
|
46
|
+
#
|
|
47
|
+
# ActiveModel couples these: `full_message` is
|
|
48
|
+
# "%{human_attribute_name(attribute)} %{message}", and the stock
|
|
49
|
+
# `human_attribute_name("email.address")` rpartitions on the dot and
|
|
50
|
+
# returns just "Address" — giving "Address is invalid", which drops the
|
|
51
|
+
# component entirely. Humanising the *whole* dotted key instead
|
|
52
|
+
# ("email.address" -> "email_address" -> "Email address") keeps the
|
|
53
|
+
# component as a word and reads as one sentence, only the first word
|
|
54
|
+
# capitalised — which is exactly the RFC's expected message and, notably,
|
|
55
|
+
# *not* "Email Address" (deferring to the component's own
|
|
56
|
+
# `human_attribute_name` would capitalise both).
|
|
57
|
+
#
|
|
58
|
+
# Guarded to component reader keys only, so this never hijacks an
|
|
59
|
+
# unrelated dotted attribute a host app might introduce.
|
|
60
|
+
def human_attribute_name(attribute, options = {})
|
|
61
|
+
key = attribute.to_s
|
|
62
|
+
reader, dot, rest = key.partition(".")
|
|
63
|
+
|
|
64
|
+
return super if dot.empty? || rest.empty?
|
|
65
|
+
return super unless ecs_component_reader?(reader)
|
|
66
|
+
|
|
67
|
+
key.tr(".", "_").humanize
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Is `reader` the name of a component reader on this entity?
|
|
71
|
+
#
|
|
72
|
+
# Deliberately `method_defined?` and not the registry (`components`).
|
|
73
|
+
# The registry is a mutable process-wide singleton that the Railtie
|
|
74
|
+
# clears and repopulates on reload, and that sibling specs clear outright
|
|
75
|
+
# — so `components` can transiently be empty for a fully-composed entity.
|
|
76
|
+
# The generated reader is the durable artifact: the DSL defines it into an
|
|
77
|
+
# included module at declaration time, and it survives a registry clear
|
|
78
|
+
# (the same reason delegation keeps working across one). Since
|
|
79
|
+
# `human_attribute_name` is a hot method every form field hits, this is
|
|
80
|
+
# also the cheaper check.
|
|
81
|
+
#
|
|
82
|
+
# It can only ever matter for a dotted key, and the only dotted keys the
|
|
83
|
+
# gem produces are component-namespaced error keys whose head is, by
|
|
84
|
+
# construction, a real reader — so the looseness (any instance method
|
|
85
|
+
# named `reader` would pass) is harmless.
|
|
86
|
+
def ecs_component_reader?(reader)
|
|
87
|
+
method_defined?(reader)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
# Merges the errors of every component that deserves validating onto the
|
|
94
|
+
# entity, namespaced by the component's reader (`email.address`).
|
|
95
|
+
#
|
|
96
|
+
# **Which components?** Exactly the ones in RFC-0006's memo — the ones the
|
|
97
|
+
# caller has actually read on *this* instance — and of those, only the ones
|
|
98
|
+
# that are dirty or already persisted (ADR-0003: a virtual, untouched
|
|
99
|
+
# component is not validated at all). This is deliberately *not* a walk of
|
|
100
|
+
# every declared component:
|
|
101
|
+
#
|
|
102
|
+
# - Reading an unread component to validate it would materialise it (a
|
|
103
|
+
# SELECT, or a fresh virtual instance) — a side effect `valid?` must not
|
|
104
|
+
# have, and it would validate a component the caller never touched.
|
|
105
|
+
# - A *persisted* component the caller has not loaded on this instance is
|
|
106
|
+
# therefore not validated here. "if this row exists it must be
|
|
107
|
+
# well-formed" (ADR-0003) is a save-time guarantee the row's own
|
|
108
|
+
# validations keep; `valid?` speaks only to what is in front of it.
|
|
109
|
+
#
|
|
110
|
+
# So `valid?` has no side effects: it inserts nothing, dirties nothing, and
|
|
111
|
+
# materialises no component that was never read. Pinned hard in
|
|
112
|
+
# validation_spec.rb.
|
|
113
|
+
#
|
|
114
|
+
# ActiveModel clears the entity's errors at the top of every `valid?`
|
|
115
|
+
# (`run_validations!` does `errors.clear`), and `component.valid?` clears
|
|
116
|
+
# the component's own — so merging twice cannot double-count or leak state
|
|
117
|
+
# across calls. Idempotent by construction.
|
|
118
|
+
def ecs_merge_component_errors
|
|
119
|
+
return unless @ecs_components
|
|
120
|
+
|
|
121
|
+
@ecs_components.each do |reader, component|
|
|
122
|
+
# Destroyed through the reader inside this same lifecycle — there is no
|
|
123
|
+
# row to be well-formed, and the reader hands out a fresh virtual next.
|
|
124
|
+
next if component.destroyed?
|
|
125
|
+
next unless component.ecs_dirty? || component.persisted?
|
|
126
|
+
|
|
127
|
+
# `valid?` (not `errors.any?`) so the component actually runs its
|
|
128
|
+
# validations, in the same create/update context a save would use.
|
|
129
|
+
next if component.valid?
|
|
130
|
+
|
|
131
|
+
merge_component_errors(reader, component)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Copies one component's errors onto the entity. Each error is re-added
|
|
136
|
+
# individually so the attribute can be re-namespaced (`address` ->
|
|
137
|
+
# `email.address`) while the message is carried across verbatim; the human
|
|
138
|
+
# label is then composed by the `human_attribute_name` override above.
|
|
139
|
+
#
|
|
140
|
+
# A component base error (`errors.add(:base, ...)`) has no attribute to
|
|
141
|
+
# namespace, so it lands under the bare reader key.
|
|
142
|
+
def merge_component_errors(reader, component)
|
|
143
|
+
component.errors.each do |error|
|
|
144
|
+
key = error.attribute == :base ? reader : :"#{reader}.#{error.attribute}"
|
|
145
|
+
errors.add(key, error.message)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
data/lib/ecs_rails.rb
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "active_support"
|
|
5
|
+
|
|
6
|
+
require "ecs_rails/version"
|
|
7
|
+
require "ecs_rails/config"
|
|
8
|
+
require "ecs_rails/errors"
|
|
9
|
+
require "ecs_rails/registry"
|
|
10
|
+
require "ecs_rails/lazy"
|
|
11
|
+
require "ecs_rails/presence"
|
|
12
|
+
require "ecs_rails/validations"
|
|
13
|
+
require "ecs_rails/dsl"
|
|
14
|
+
require "ecs_rails/relationships"
|
|
15
|
+
require "ecs_rails/querying"
|
|
16
|
+
require "ecs_rails/preloading"
|
|
17
|
+
require "ecs_rails/entity"
|
|
18
|
+
require "ecs_rails/component"
|
|
19
|
+
|
|
20
|
+
# ECS Rails — an Entity-Component-System reimagining of ActiveRecord.
|
|
21
|
+
#
|
|
22
|
+
# See docs/architecture.md for the invariants this library guarantees.
|
|
23
|
+
module EcsRails
|
|
24
|
+
class << self
|
|
25
|
+
# The process-wide component registry. See RFC-0002.
|
|
26
|
+
def registry
|
|
27
|
+
@registry ||= Registry.new
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The process-wide generator configuration (ADR-0010). Layout only — the
|
|
31
|
+
# runtime does not consult it; the generators and the initializer they emit
|
|
32
|
+
# do.
|
|
33
|
+
def config
|
|
34
|
+
@config ||= Config.new
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Yields the config for block-style setup, as a host app's
|
|
38
|
+
# config/initializers/ecs_rails.rb does:
|
|
39
|
+
#
|
|
40
|
+
# EcsRails.configure { |config| config.entities_path = "app/models" }
|
|
41
|
+
def configure
|
|
42
|
+
yield config
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
require "ecs_rails/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# See the note in install_generator.rb: rails/generators/active_record/migration
|
|
4
|
+
# references ActiveRecord::Migration without loading it.
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "rails/generators/named_base"
|
|
7
|
+
require "rails/generators/active_record/migration"
|
|
8
|
+
|
|
9
|
+
# Rails::Generators::GeneratedAttribute.parse calls String#remove, an
|
|
10
|
+
# ActiveSupport core extension that neither rails/generators nor active_record
|
|
11
|
+
# loads. A booted Rails app has active_support/all, so `rails g` works either
|
|
12
|
+
# way — but without this the attribute parsing below depends on some other file
|
|
13
|
+
# happening to have loaded the core ext first, which is not a dependency this
|
|
14
|
+
# generator should have.
|
|
15
|
+
require "active_support/core_ext/string/filters"
|
|
16
|
+
|
|
17
|
+
# ADR-0010: the generator reads EcsRails.config to place its model and spec.
|
|
18
|
+
# Require the library explicitly so the generator stands on its own requires
|
|
19
|
+
# (see RFC-0008's isolation note and generator_isolation_spec.rb).
|
|
20
|
+
require "ecs_rails"
|
|
21
|
+
|
|
22
|
+
module EcsRails
|
|
23
|
+
module Generators
|
|
24
|
+
# `rails g ecs_rails:component NAME [field:type ...]`
|
|
25
|
+
#
|
|
26
|
+
# Implements RFC-0008. The whole point of this generator is that the
|
|
27
|
+
# entity_id + UNIQUE index + ON DELETE CASCADE invariant (ADR-0005) becomes
|
|
28
|
+
# impossible to forget, and that every attribute gets an explicit default
|
|
29
|
+
# (RFC-0006).
|
|
30
|
+
#
|
|
31
|
+
# Attribute parsing is Rails' own: declaring an `attributes` argument makes
|
|
32
|
+
# Rails::Generators::NamedBase run parse_attributes!, which turns
|
|
33
|
+
# "address:string" into a Rails::Generators::GeneratedAttribute.
|
|
34
|
+
class ComponentGenerator < Rails::Generators::NamedBase
|
|
35
|
+
include ActiveRecord::Generators::Migration
|
|
36
|
+
|
|
37
|
+
source_root File.expand_path("templates", __dir__)
|
|
38
|
+
|
|
39
|
+
argument :attributes, type: :array, default: [], banner: "field:type field:type"
|
|
40
|
+
|
|
41
|
+
desc "Creates a component: its migration, its model, and its spec."
|
|
42
|
+
|
|
43
|
+
def create_migration_file
|
|
44
|
+
migration_template(
|
|
45
|
+
"migration.rb.tt",
|
|
46
|
+
File.join(db_migrate_path, "create_#{table_name}.rb")
|
|
47
|
+
)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# ADR-0010: the model lands under the configured components_path
|
|
51
|
+
# (entities_path/components), not app/models. class_path is preserved so a
|
|
52
|
+
# namespaced component (rails g ecs_rails:component Billing/Plan) still
|
|
53
|
+
# nests correctly.
|
|
54
|
+
def create_model_file
|
|
55
|
+
template "model.rb.tt",
|
|
56
|
+
File.join(EcsRails.config.components_path, class_path, "#{file_name}.rb")
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ADR-0010: the spec mirrors the layout under spec/entities/components. Its
|
|
60
|
+
# template declares `type: :model` explicitly, because rspec-rails only
|
|
61
|
+
# infers that from spec/models/ — which this path no longer matches.
|
|
62
|
+
def create_spec_file
|
|
63
|
+
template "component_spec.rb.tt",
|
|
64
|
+
File.join("spec/entities/components", class_path, "#{file_name}_spec.rb")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
# The explicit-default policy (RFC-0008 / RFC-0006).
|
|
70
|
+
#
|
|
71
|
+
# A virtual component — one with no database row — reports its column
|
|
72
|
+
# defaults. So a column with no default silently reports nil, and that
|
|
73
|
+
# choice should be visible in the migration rather than implied. Every
|
|
74
|
+
# attribute therefore gets a `default:` written out.
|
|
75
|
+
#
|
|
76
|
+
# boolean => default: false, null: false
|
|
77
|
+
# A three-valued boolean has no sensible virtual reading: `user.email
|
|
78
|
+
# .verified` should be false, not nil. Matches spec/support/schema.rb's
|
|
79
|
+
# `verified` column. null: false because the default removes any
|
|
80
|
+
# reason for the column to be nullable.
|
|
81
|
+
#
|
|
82
|
+
# everything else => default: nil
|
|
83
|
+
# There is no defensible universal default for a string or an integer,
|
|
84
|
+
# and inventing one (0, "") would be worse than nil. nil is written
|
|
85
|
+
# explicitly so the reader sees it was a decision.
|
|
86
|
+
#
|
|
87
|
+
# Override by hand in the generated migration when the domain has a real
|
|
88
|
+
# default — that edit is the point.
|
|
89
|
+
def column_options_for(attribute)
|
|
90
|
+
case attribute.type.to_s
|
|
91
|
+
when "boolean" then ", default: false, null: false"
|
|
92
|
+
else ", default: nil"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def migration_version
|
|
97
|
+
"#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails_helper"
|
|
4
|
+
|
|
5
|
+
<% module_namespacing do -%>
|
|
6
|
+
# type: :model is declared explicitly because this spec lives under
|
|
7
|
+
# spec/entities/components, not spec/models — and rspec-rails only infers
|
|
8
|
+
# :model metadata from spec/models/. See ADR-0010.
|
|
9
|
+
RSpec.describe <%= class_name %>, type: :model do
|
|
10
|
+
it "is virtual until dirtied" do
|
|
11
|
+
# A component with no row reports its defaults and does not persist.
|
|
12
|
+
# See RFC-0006.
|
|
13
|
+
skip "write a test for <%= class_name %>"
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
<% end -%>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Creates the <%= table_name %> component table.
|
|
4
|
+
#
|
|
5
|
+
# Every component table follows the same invariants (docs/architecture.md §2):
|
|
6
|
+
# a UUID primary key, a non-null entity_id with a UNIQUE index (ADR-0005) and an
|
|
7
|
+
# ON DELETE CASCADE foreign key, and an explicit default for every attribute
|
|
8
|
+
# (RFC-0006).
|
|
9
|
+
class Create<%= table_name.camelize %> < ActiveRecord::Migration[<%= migration_version %>]
|
|
10
|
+
def change
|
|
11
|
+
create_table :<%= table_name %>, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
|
12
|
+
t.uuid :entity_id, null: false
|
|
13
|
+
<% attributes.each do |attribute| -%>
|
|
14
|
+
t.<%= attribute.type %> :<%= attribute.name %><%= column_options_for(attribute) %>
|
|
15
|
+
<% end -%>
|
|
16
|
+
t.timestamps
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# ADR-0005: a component appears at most once per entity.
|
|
20
|
+
add_index :<%= table_name %>, :entity_id, unique: true
|
|
21
|
+
|
|
22
|
+
# Destroying an entity destroys its components, at the database level.
|
|
23
|
+
add_foreign_key :<%= table_name %>, :entities, column: :entity_id, on_delete: :cascade
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
<% module_namespacing do -%>
|
|
4
|
+
# A component: data and behaviour, owned by exactly one entity.
|
|
5
|
+
#
|
|
6
|
+
# Methods defined here execute with `self` bound to the component, never the
|
|
7
|
+
# entity (ADR-0001), and are delegated onto any entity that declares
|
|
8
|
+
# `component <%= class_name %>`.
|
|
9
|
+
#
|
|
10
|
+
# This class must never reference an entity subclass.
|
|
11
|
+
class <%= class_name %> < ApplicationComponent
|
|
12
|
+
end
|
|
13
|
+
<% end -%>
|