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,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# An immutable identity row. Carries no domain state.
|
|
5
|
+
#
|
|
6
|
+
# Implements RFC-0001. See docs/architecture.md §1 for the invariants and
|
|
7
|
+
# docs/adr/0002-single-entities-table.md for why the `model` column exists.
|
|
8
|
+
#
|
|
9
|
+
# Host apps subclass this once, as ApplicationEntity, then subclass that per
|
|
10
|
+
# entity type:
|
|
11
|
+
#
|
|
12
|
+
# class ApplicationEntity < EcsRails::Entity
|
|
13
|
+
# self.abstract_class = true
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# class User < ApplicationEntity
|
|
17
|
+
# end
|
|
18
|
+
#
|
|
19
|
+
# User.create!.model # => "users"
|
|
20
|
+
# User.all # => SELECT * FROM entities WHERE model = 'users'
|
|
21
|
+
# ApplicationEntity.all # => SELECT * FROM entities (no filter)
|
|
22
|
+
#
|
|
23
|
+
# All entity classes share the one `entities` table. There is no updated_at
|
|
24
|
+
# column: an entity is written once and never changes, so ActiveRecord's
|
|
25
|
+
# timestamp code — which only touches timestamp columns that actually exist —
|
|
26
|
+
# stamps created_at and nothing else. No configuration is needed for that.
|
|
27
|
+
class Entity < ActiveRecord::Base
|
|
28
|
+
# Makes assignment to a readonly attribute raise on a persisted record.
|
|
29
|
+
#
|
|
30
|
+
# ActiveRecord ships this behaviour, but `attr_readonly` only installs it
|
|
31
|
+
# when `ActiveRecord.raise_on_assign_to_attr_readonly` is true. That config
|
|
32
|
+
# defaults to *false* in bare ActiveRecord, and in a Rails app it is applied
|
|
33
|
+
# by the railtie only after this file has already been required — so by the
|
|
34
|
+
# time `attr_readonly` runs below, the host's setting is not yet visible and
|
|
35
|
+
# cannot be relied on either way. RFC-0001 requires the raise
|
|
36
|
+
# unconditionally, so we install our own guard rather than inherit a race.
|
|
37
|
+
#
|
|
38
|
+
# Harmless if ActiveRecord's own guard is also installed: both raise the
|
|
39
|
+
# same error.
|
|
40
|
+
module ImmutableIdentity
|
|
41
|
+
# Rejects writes to readonly attributes once the row exists. New records
|
|
42
|
+
# are untouched, so create — including ActiveRecord assigning the
|
|
43
|
+
# database-generated id — still works.
|
|
44
|
+
#
|
|
45
|
+
# Both writers are guarded because the public #write_attribute does not
|
|
46
|
+
# call #_write_attribute; it writes to the attribute set directly.
|
|
47
|
+
def write_attribute(attr_name, value)
|
|
48
|
+
guard_readonly_attribute!(attr_name)
|
|
49
|
+
super
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The internal writer, which every generated `attr=` method funnels into.
|
|
53
|
+
def _write_attribute(attr_name, value)
|
|
54
|
+
guard_readonly_attribute!(attr_name)
|
|
55
|
+
super
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def guard_readonly_attribute!(attr_name)
|
|
61
|
+
return if new_record?
|
|
62
|
+
return unless self.class.readonly_attributes.include?(attr_name.to_s)
|
|
63
|
+
|
|
64
|
+
raise ActiveRecord::ReadonlyAttributeError, attr_name
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
self.abstract_class = true
|
|
69
|
+
self.table_name = "entities"
|
|
70
|
+
|
|
71
|
+
include ImmutableIdentity
|
|
72
|
+
|
|
73
|
+
# Lazy / virtual components (RFC-0006): the memo behind every component
|
|
74
|
+
# reader, and the save cascade. The readers themselves are generated per
|
|
75
|
+
# component by the DSL, into generated_component_methods.
|
|
76
|
+
include Lazy::Entity
|
|
77
|
+
|
|
78
|
+
# Component presence (RFC-0009): add / has? / remove. Included after
|
|
79
|
+
# Lazy::Entity because it works through Lazy's memo (@ecs_components) and its
|
|
80
|
+
# reader — `add` persists the memoised instance, `remove` relies on
|
|
81
|
+
# Lazy::Component's after_destroy to reset it. The `<reader>?` predicate is
|
|
82
|
+
# generated per component by the DSL.
|
|
83
|
+
include Presence::Entity
|
|
84
|
+
|
|
85
|
+
# Validation error merging (RFC-0007): the `validate` callback that reflects
|
|
86
|
+
# a dirty component's validity onto the entity, and the human_attribute_name
|
|
87
|
+
# override that keeps `errors[:"email.address"]` machine-readable while
|
|
88
|
+
# `full_messages` reads "Email address is invalid". Included after
|
|
89
|
+
# Lazy::Entity because it walks Lazy's memo (@ecs_components).
|
|
90
|
+
include Validations::Entity
|
|
91
|
+
|
|
92
|
+
# The `component` DSL (RFC-0004). Extended rather than defined here: RFC-0001
|
|
93
|
+
# is about identity, and composition is a separate concern with its own file.
|
|
94
|
+
# Singleton methods are inherited, so every entity subclass answers it.
|
|
95
|
+
extend DSL
|
|
96
|
+
|
|
97
|
+
# The `relates_to` DSL (RFC-0012): cross-entity links, decided by ADR-0013.
|
|
98
|
+
# Extended after DSL because it is built on `component` — it dynamically
|
|
99
|
+
# defines a backing component class and declares it — and reuses DSL's own
|
|
100
|
+
# private reader/delegation resolution for its collision check. See
|
|
101
|
+
# EcsRails::Relationships.
|
|
102
|
+
extend Relationships
|
|
103
|
+
|
|
104
|
+
# The component query DSL (RFC-0010): with_component / without_component.
|
|
105
|
+
# Extended as class methods so every entity subclass answers them, and so
|
|
106
|
+
# ActiveRecord delegates them to relations (`Post.where(...).with_component`).
|
|
107
|
+
# They compile to correlated EXISTS / NOT EXISTS subqueries and rely on
|
|
108
|
+
# running against the class's own default-scoped relation — see
|
|
109
|
+
# docs/adr/0011-component-query-dsl.md.
|
|
110
|
+
extend Querying
|
|
111
|
+
|
|
112
|
+
# Component preloading (RFC-0011): includes_components. Extended as class
|
|
113
|
+
# methods, like Querying, so every entity subclass answers it and ActiveRecord
|
|
114
|
+
# delegates it to relations (`User.where(...).includes_components`). It is a
|
|
115
|
+
# thin wrapper over ActiveRecord's own `preload` — which already batches
|
|
116
|
+
# component has_one loads and, via RFC-0006's `super`-calling reader, still
|
|
117
|
+
# yields virtual instances — see docs/adr/0012-component-preloading.md.
|
|
118
|
+
extend Preloading
|
|
119
|
+
|
|
120
|
+
# Immutable identity (architecture.md §1). Beyond the guard above, this is
|
|
121
|
+
# what excludes id and model from any UPDATE statement.
|
|
122
|
+
attr_readonly :id, :model
|
|
123
|
+
|
|
124
|
+
# Filters each concrete subclass to its own discriminator.
|
|
125
|
+
#
|
|
126
|
+
# `default_scope` is the right mechanism here despite its usual reputation,
|
|
127
|
+
# for two reasons:
|
|
128
|
+
#
|
|
129
|
+
# 1. `build_default_scope` returns early for abstract classes, so the
|
|
130
|
+
# abstract base applies no filter and can query across all entities —
|
|
131
|
+
# exactly what RFC-0001 asks for, with no special-casing from us.
|
|
132
|
+
# 2. The block is instance_exec'd against the relation, so `klass` is
|
|
133
|
+
# whichever class is being queried. One declaration here covers every
|
|
134
|
+
# subclass, and a subclass of a subclass filters on its own plural.
|
|
135
|
+
#
|
|
136
|
+
# Its notorious leak into `new`/`create` is, unusually, wanted: scope_for_create
|
|
137
|
+
# means `User.new.model` is already "users" before save. That leak is a
|
|
138
|
+
# convenience, not the mechanism — #stamp_model below is what guarantees the
|
|
139
|
+
# discriminator is correct.
|
|
140
|
+
#
|
|
141
|
+
# Must use the same derivation as #stamp_model, or entities become
|
|
142
|
+
# unfindable by the very scope that is supposed to select them.
|
|
143
|
+
default_scope { where(model: klass.model_name.collection) }
|
|
144
|
+
|
|
145
|
+
# RFC-0001: model is set on create from the subclass's model_name.collection.
|
|
146
|
+
#
|
|
147
|
+
# Deliberately not left to the default scope's scope_for_create. The
|
|
148
|
+
# discriminator is derived from the class, never supplied by the caller, so
|
|
149
|
+
# it is stamped unconditionally: `User.create!(model: "posts")` yields a
|
|
150
|
+
# user, and `User.unscoped.create!` — which has no create scope to inherit —
|
|
151
|
+
# is still stamped rather than failing the NOT NULL constraint.
|
|
152
|
+
before_validation :stamp_model, on: :create
|
|
153
|
+
|
|
154
|
+
class << self
|
|
155
|
+
# Both hooks are private in ActiveRecord, and both are only ever called
|
|
156
|
+
# with an implicit receiver from within AR's own class methods. Matching
|
|
157
|
+
# that visibility keeps them out of the gem's public surface.
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
# Resolves a row's `model` discriminator back to the entity subclass that
|
|
161
|
+
# wrote it, so ApplicationEntity.find(id) returns a User. See ADR-0008.
|
|
162
|
+
#
|
|
163
|
+
# This is Rails' own STI resolution hook, applied to a column that is not
|
|
164
|
+
# `inheritance_column` — taking the one piece of machinery we want and
|
|
165
|
+
# none of the rest.
|
|
166
|
+
#
|
|
167
|
+
# Raises NameError for a discriminator that names no live constant (class
|
|
168
|
+
# deleted or renamed). ADR-0008 chooses this deliberately: it matches the
|
|
169
|
+
# registry's fail-loudly stance, and a silent nil would hand back a
|
|
170
|
+
# useless abstract instance.
|
|
171
|
+
#
|
|
172
|
+
# Note this is only ever reached via #instantiate_instance_of below — see
|
|
173
|
+
# the comment there for why the hook alone is not enough.
|
|
174
|
+
def discriminate_class_for_record(record)
|
|
175
|
+
model = record["model"]
|
|
176
|
+
return super if model.blank?
|
|
177
|
+
|
|
178
|
+
model.classify.constantize
|
|
179
|
+
rescue NameError => e
|
|
180
|
+
raise NameError, "EcsRails: entity row has model #{model.inspect}, " \
|
|
181
|
+
"which does not resolve to a class (#{e.message}). " \
|
|
182
|
+
"See ADR-0008.", e.backtrace
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Routes every read path through #discriminate_class_for_record.
|
|
186
|
+
#
|
|
187
|
+
# ADR-0008 says to override #discriminate_class_for_record and stop. That
|
|
188
|
+
# is not sufficient on its own: ActiveRecord only consults the hook when
|
|
189
|
+
# the result set contains the *inheritance_column*. From
|
|
190
|
+
# ActiveRecord::Querying#_load_from_sql (8.1):
|
|
191
|
+
#
|
|
192
|
+
# if result_set.includes_column?(inheritance_column)
|
|
193
|
+
# rows.map { |r| instantiate(r, ...) } # calls the hook
|
|
194
|
+
# else
|
|
195
|
+
# rows.map { |r| instantiate_instance_of(self, r, ...) } # does not
|
|
196
|
+
# end
|
|
197
|
+
#
|
|
198
|
+
# `inheritance_column` is "type", and `entities` has no such column, so
|
|
199
|
+
# entities always take the second branch and the hook is never called.
|
|
200
|
+
# Setting `inheritance_column = "model"` opens the gate but drags in the
|
|
201
|
+
# rest of STI — a type_condition that breaks sub-subclass scoping, and
|
|
202
|
+
# subclass_from_attributes, which turns User.create!(model: "posts") into
|
|
203
|
+
# a SubclassNotFound. Both are behaviours RFC-0001 has already specified
|
|
204
|
+
# otherwise, and both are exactly why ADR-0008 rejected Option B.
|
|
205
|
+
#
|
|
206
|
+
# Overriding here instead keeps #discriminate_class_for_record as the one
|
|
207
|
+
# decision point and leaves inheritance_column alone. Both callers of
|
|
208
|
+
# this method funnel through it — the fast path above, and #instantiate
|
|
209
|
+
# (which the eager-loading path uses) — so every read resolves, and the
|
|
210
|
+
# double call from #instantiate is idempotent.
|
|
211
|
+
def instantiate_instance_of(klass, attributes, column_types = {}, &block)
|
|
212
|
+
super(discriminate_class_for_record(attributes), attributes, column_types, &block)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
private
|
|
217
|
+
|
|
218
|
+
# Stamps the model discriminator from the class name. See ADR-0002, and
|
|
219
|
+
# ADR-0008 for why this is #collection and not #plural.
|
|
220
|
+
#
|
|
221
|
+
# #plural is lossy for namespaced classes: Blog::Post and BlogPost both give
|
|
222
|
+
# "blog_posts", so the mapping is not injective and no inverse can separate
|
|
223
|
+
# them — such an entity could be written but never read back as itself.
|
|
224
|
+
# #collection gives "blog/posts", which classifies cleanly back to
|
|
225
|
+
# Blog::Post, and is identical to #plural for every non-namespaced class
|
|
226
|
+
# ("users" either way) — so this needs no data migration.
|
|
227
|
+
#
|
|
228
|
+
# #default_scope above must use the same derivation.
|
|
229
|
+
def stamp_model
|
|
230
|
+
self.model = self.class.model_name.collection
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# Base class for every error the gem raises.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when `component` is given something that is not a EcsRails::Component.
|
|
8
|
+
# See RFC-0004.
|
|
9
|
+
class InvalidComponent < Error; end
|
|
10
|
+
|
|
11
|
+
# Raised when `relates_to` is given a target that is not a concrete
|
|
12
|
+
# EcsRails::Entity. See RFC-0012 / ADR-0013.
|
|
13
|
+
#
|
|
14
|
+
# A subclass of InvalidComponent on purpose. RFC-0012 left the choice open —
|
|
15
|
+
# reuse InvalidComponent, or add a dedicated class — and this gets both: the
|
|
16
|
+
# message is relationship-shaped ("relates_to :author expected ... an entity"),
|
|
17
|
+
# while any existing `rescue InvalidComponent` and the RFC's own contract test
|
|
18
|
+
# (`raise_error(EcsRails::InvalidComponent)`) still match, because a
|
|
19
|
+
# relationship *is* a component under the hood. See EcsRails::Relationships.
|
|
20
|
+
class InvalidRelationship < InvalidComponent; end
|
|
21
|
+
|
|
22
|
+
# Raised when an entity declares the same component twice. See RFC-0002.
|
|
23
|
+
class DuplicateComponent < Error; end
|
|
24
|
+
|
|
25
|
+
# Raised at declaration time when two components on one entity would delegate
|
|
26
|
+
# the same method name. See ADR-0004 and RFC-0005.
|
|
27
|
+
class DelegationConflict < Error; end
|
|
28
|
+
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# Lazy / virtual components: a component costs nothing until it holds
|
|
5
|
+
# something.
|
|
6
|
+
#
|
|
7
|
+
# Implements RFC-0006 and closes the gap RFC-0004 knowingly left in
|
|
8
|
+
# architecture.md §3 — `entity.email` now always returns an Email.
|
|
9
|
+
#
|
|
10
|
+
# user = User.create! # one INSERT, into entities. Nothing else.
|
|
11
|
+
# user.email # => #<Email address: nil, verified: false>
|
|
12
|
+
# user.email.persisted? # => false — no row, and none wanted
|
|
13
|
+
# user.save! # still no emails row
|
|
14
|
+
#
|
|
15
|
+
# user.email.address = "a@b.com"
|
|
16
|
+
# user.save! # *now* one INSERT into emails
|
|
17
|
+
#
|
|
18
|
+
# The whole feature is two questions: which component instance does the reader
|
|
19
|
+
# hand back (Entity#ecs_component), and does that instance deserve a row
|
|
20
|
+
# (Component#ecs_dirty?). Everything else here is bookkeeping in service of
|
|
21
|
+
# those two.
|
|
22
|
+
module Lazy
|
|
23
|
+
# Mixed into EcsRails::Entity. The reader itself is generated per component
|
|
24
|
+
# by the DSL, into generated_component_methods; this supplies the machinery
|
|
25
|
+
# it calls, and the save cascade.
|
|
26
|
+
module Entity
|
|
27
|
+
extend ActiveSupport::Concern
|
|
28
|
+
|
|
29
|
+
included do
|
|
30
|
+
# The cascade (RFC-0006: "entity.save cascades: it saves itself and
|
|
31
|
+
# every dirty component, in one transaction").
|
|
32
|
+
#
|
|
33
|
+
# after_save, not after_create/after_update, because it must run for
|
|
34
|
+
# both — and it does run on an unchanged persisted entity, which matters
|
|
35
|
+
# a great deal here: an entity has no mutable fields of its own
|
|
36
|
+
# (architecture.md §1), so `user.email.address = "x"; user.save!` is
|
|
37
|
+
# *always* a save with nothing of the entity's own to write. If this
|
|
38
|
+
# only fired when the entity itself changed, the money path would never
|
|
39
|
+
# fire at all.
|
|
40
|
+
#
|
|
41
|
+
# ActiveRecord wraps save in a transaction and runs after_save inside
|
|
42
|
+
# it, so "one transaction" needs no work from us.
|
|
43
|
+
after_save :save_dirty_components
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Drops the memo, so the next read goes back to the database.
|
|
47
|
+
#
|
|
48
|
+
# ActiveRecord's #reload clears the association cache; the memo is a
|
|
49
|
+
# second cache over the same rows and has to be cleared with it or reload
|
|
50
|
+
# would be a lie — the caller would get their stale virtual back.
|
|
51
|
+
def reload(*)
|
|
52
|
+
@ecs_components = nil
|
|
53
|
+
super
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# The instance `entity.email` hands back. Called by the readers the DSL
|
|
57
|
+
# generates; the block reaches the has_one reader underneath.
|
|
58
|
+
#
|
|
59
|
+
# Memoised per entity instance, which RFC-0006 requires and the money path
|
|
60
|
+
# depends on: `user.email.address = "x"; user.save!` only works if the
|
|
61
|
+
# instance the caller mutated is the instance the cascade later sees.
|
|
62
|
+
# Reading twice must not throw the first read's assignment away.
|
|
63
|
+
#
|
|
64
|
+
# The memo caches the *row* case too, not just the virtual one. It costs
|
|
65
|
+
# nothing (the association has its own cache, holding the same object) and
|
|
66
|
+
# it means the cascade can simply walk the memo, rather than interrogating
|
|
67
|
+
# ActiveRecord about which associations happen to be loaded.
|
|
68
|
+
#
|
|
69
|
+
# @api private
|
|
70
|
+
def ecs_component(name)
|
|
71
|
+
cache = (@ecs_components ||= {})
|
|
72
|
+
return cache[name] if cache.key?(name)
|
|
73
|
+
|
|
74
|
+
cache[name] = yield || ecs_build_component(name)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Forgets a component, so the next read rebuilds it as virtual.
|
|
78
|
+
#
|
|
79
|
+
# Public only because a component calls it on its entity, from across the
|
|
80
|
+
# object boundary — unlike #ecs_component, which the generated readers
|
|
81
|
+
# call on themselves.
|
|
82
|
+
#
|
|
83
|
+
# Called by a component's after_destroy (see Lazy::Component). Clearing
|
|
84
|
+
# the memo is not enough on its own: if the row was read through the
|
|
85
|
+
# reader then ActiveRecord's association is *also* holding it, and #super
|
|
86
|
+
# in the generated reader would hand the frozen, destroyed object straight
|
|
87
|
+
# back. Both caches have to go.
|
|
88
|
+
#
|
|
89
|
+
# The association is left marked loaded-with-nil rather than reset,
|
|
90
|
+
# because that is not a guess: the row was just deleted, and a component
|
|
91
|
+
# appears at most once per entity (ADR-0005), so there is provably nothing
|
|
92
|
+
# left to find. Resetting would buy an identical answer for one SELECT.
|
|
93
|
+
#
|
|
94
|
+
# @api private
|
|
95
|
+
def ecs_forget_component(name)
|
|
96
|
+
@ecs_components&.delete(name)
|
|
97
|
+
return unless self.class.reflect_on_association(name)
|
|
98
|
+
|
|
99
|
+
association(name).target = nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
# An in-memory component with every attribute at its default and entity_id
|
|
105
|
+
# set (RFC-0006). The defaults are ActiveRecord's own, read from the
|
|
106
|
+
# column definitions — so a virtual `user.email.address` and a bare
|
|
107
|
+
# `Email.new.address` agree by construction rather than by us copying a
|
|
108
|
+
# list of defaults about.
|
|
109
|
+
def ecs_build_component(name)
|
|
110
|
+
component = self.class.reflect_on_association(name).klass.new
|
|
111
|
+
# Not `component.entity_id = id` — the belongs_to writer also sets the
|
|
112
|
+
# association target, which is what makes `user.email.entity` return
|
|
113
|
+
# this very entity without a query (architecture.md §1: a system reaches
|
|
114
|
+
# the entity via component.entity). A virtual component has no row, so
|
|
115
|
+
# there is nothing for a query to walk back *from*; if the entity is not
|
|
116
|
+
# handed over here it can never be recovered.
|
|
117
|
+
#
|
|
118
|
+
# On a new entity `id` is still nil at this point — the UUID comes back
|
|
119
|
+
# from the INSERT. #save_dirty_components restamps the foreign key after
|
|
120
|
+
# the entity is saved, which is where that resolves.
|
|
121
|
+
component.entity = self
|
|
122
|
+
component
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Saves every dirty component the caller has touched, inside the
|
|
126
|
+
# transaction ActiveRecord has already opened around #save.
|
|
127
|
+
#
|
|
128
|
+
# Walks the memo, not the declared components: an untouched component was
|
|
129
|
+
# never read, has no instance, and must not be queried for. That absence
|
|
130
|
+
# is the feature — "components are free unless you use them" is a claim
|
|
131
|
+
# about SQL, and this method is where it is kept.
|
|
132
|
+
def save_dirty_components
|
|
133
|
+
return unless @ecs_components
|
|
134
|
+
|
|
135
|
+
@ecs_components.each_value do |component|
|
|
136
|
+
# Destroyed through the reader inside this same save. Saving it would
|
|
137
|
+
# resurrect the row the caller just asked to be rid of.
|
|
138
|
+
next if component.destroyed?
|
|
139
|
+
next unless component.ecs_dirty?
|
|
140
|
+
|
|
141
|
+
# The entity's UUID is assigned by the database, so on create it did
|
|
142
|
+
# not exist when the component was built. Restamped rather than
|
|
143
|
+
# assumed. Deliberately after the dirty check, which ignores entity_id
|
|
144
|
+
# for exactly this reason.
|
|
145
|
+
component.entity_id = id
|
|
146
|
+
|
|
147
|
+
# Bang, so a failure is loud. This is a deliberate, temporary wart:
|
|
148
|
+
# non-bang `entity.save` will raise RecordInvalid rather than return
|
|
149
|
+
# false when a dirty component is invalid.
|
|
150
|
+
#
|
|
151
|
+
# The alternative is ActiveRecord's own autosave idiom, `raise
|
|
152
|
+
# ActiveRecord::Rollback`. It is worse here. Rollback raised from an
|
|
153
|
+
# after_save is swallowed by the transaction that save itself opened,
|
|
154
|
+
# so `entity.save!` would return nil and raise *nothing at all* — a
|
|
155
|
+
# silent failure to write, which is the one outcome a bang method must
|
|
156
|
+
# never have. (`throw :abort` is not an option either: after_ callbacks
|
|
157
|
+
# cannot halt a chain, and it escapes as an UncaughtThrowError.)
|
|
158
|
+
#
|
|
159
|
+
# RFC-0007 is what fixes this properly: once a dirty component's errors
|
|
160
|
+
# merge onto the entity, `entity.valid?` is false and non-bang `save`
|
|
161
|
+
# returns false before ever reaching this callback — leaving the bang
|
|
162
|
+
# here as belt-and-braces, which is exactly the role the equivalent
|
|
163
|
+
# line plays in ActiveRecord's autosave.
|
|
164
|
+
component.save!
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Mixed into EcsRails::Component.
|
|
170
|
+
module Component
|
|
171
|
+
extend ActiveSupport::Concern
|
|
172
|
+
|
|
173
|
+
included do
|
|
174
|
+
# architecture.md §3: "entity.email.destroy deletes the row and resets
|
|
175
|
+
# the component to its virtual default state. entity.email still returns
|
|
176
|
+
# an instance afterwards."
|
|
177
|
+
#
|
|
178
|
+
# Nothing about destroying a row does that on its own — ActiveRecord
|
|
179
|
+
# leaves the object frozen, still holding the values it had — so the
|
|
180
|
+
# entity has to be told. See #reset_entity_component.
|
|
181
|
+
after_destroy :reset_entity_component
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Does this component deserve a row?
|
|
185
|
+
#
|
|
186
|
+
# RFC-0006 defines dirty as "at least one attribute differs from its
|
|
187
|
+
# default", explicitly *not* ActiveModel's "differs from the last saved
|
|
188
|
+
# value". Both halves of that matter, and neither is quite what it looks
|
|
189
|
+
# like:
|
|
190
|
+
#
|
|
191
|
+
# 1. ActiveModel's dirty cannot be used for a component with no row.
|
|
192
|
+
# Building a virtual component sets entity_id, and ActiveModel counts
|
|
193
|
+
# that as a change — so `user.email.changed?` is true for a component
|
|
194
|
+
# nobody has touched, and a cascade built on it would insert a row for
|
|
195
|
+
# every component ever read. That is the whole feature, inverted.
|
|
196
|
+
#
|
|
197
|
+
# 2. RFC-0006's own wording fails for the same reason, taken literally:
|
|
198
|
+
# entity_id differs from its column default (nil) on every virtual
|
|
199
|
+
# component too. The foreign key is identity, not state — it says which
|
|
200
|
+
# entity this is, never anything about it — so the comparison has to
|
|
201
|
+
# skip it, and the primary key with it. See #state_attribute?.
|
|
202
|
+
#
|
|
203
|
+
# 3. "Differs from default" is only the right question while there is no
|
|
204
|
+
# row. Once there is one, the question is ActiveModel's, because the
|
|
205
|
+
# row now has a value to differ *from*: clearing an attribute back to
|
|
206
|
+
# its default is an UPDATE, and answering "not dirty" would silently
|
|
207
|
+
# discard it. This is where the two definitions genuinely part company,
|
|
208
|
+
# and the RFC's destroy-then-reset case is the same split seen from the
|
|
209
|
+
# other side.
|
|
210
|
+
def ecs_dirty?
|
|
211
|
+
return changed? if persisted?
|
|
212
|
+
|
|
213
|
+
self.class.column_defaults.any? do |attribute, default|
|
|
214
|
+
next false unless state_attribute?(attribute)
|
|
215
|
+
|
|
216
|
+
read_attribute(attribute) != default
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
private
|
|
221
|
+
|
|
222
|
+
# Attributes that say something about the component, as opposed to which
|
|
223
|
+
# component it is. Only these can make it dirty.
|
|
224
|
+
def state_attribute?(attribute)
|
|
225
|
+
attribute != self.class.primary_key && attribute != "entity_id"
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Tells the owning entity to forget this component, so its reader reverts
|
|
229
|
+
# to a virtual one (architecture.md §3).
|
|
230
|
+
#
|
|
231
|
+
# Reads the association target rather than calling #entity, so a component
|
|
232
|
+
# destroyed without its entity loaded — `Email.first.destroy` — costs no
|
|
233
|
+
# query and simply has nobody to notify. Nothing is reset on *this*
|
|
234
|
+
# object: ActiveRecord freezes a destroyed record, and the entity is going
|
|
235
|
+
# to hand out a fresh instance anyway.
|
|
236
|
+
def reset_entity_component
|
|
237
|
+
owner = association(:entity).target
|
|
238
|
+
return unless owner.is_a?(EcsRails::Entity)
|
|
239
|
+
|
|
240
|
+
owner.ecs_forget_component(model_name.singular.to_sym)
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EcsRails
|
|
4
|
+
# Batch component loads so a list view issues a bounded number of queries
|
|
5
|
+
# instead of one per component per row.
|
|
6
|
+
#
|
|
7
|
+
# Implements RFC-0011, decided by ADR-0012. Surfaced by the demo, where the
|
|
8
|
+
# 2-post index fired 14 queries (one per component per row).
|
|
9
|
+
#
|
|
10
|
+
# Post.published.includes_components # all declared components
|
|
11
|
+
# User.includes_components(Name, Email) # a named subset
|
|
12
|
+
# Post.with_component(PublishState).includes_components(Title, Body)
|
|
13
|
+
#
|
|
14
|
+
# Extended into EcsRails::Entity, so these are class methods on every entity
|
|
15
|
+
# class. Like Querying, ActiveRecord delegates class methods to relations, so
|
|
16
|
+
# `User.where(...).includes_components(...)` chains: the method runs with `all`
|
|
17
|
+
# returning the current relation, which already carries the entity-model scope.
|
|
18
|
+
#
|
|
19
|
+
# ## The finding this rests on (ADR-0012)
|
|
20
|
+
#
|
|
21
|
+
# This method builds no preload machinery. Each component is a `has_one`
|
|
22
|
+
# (RFC-0004), and RFC-0006's lazy reader overrides that has_one but calls
|
|
23
|
+
# `super`, reaching it underneath — so ActiveRecord's own `preload` already
|
|
24
|
+
# batches component loads AND the lazy reader still returns a *virtual* instance
|
|
25
|
+
# for an entity with no row (the has_one preloads to nil-and-loaded, the reader
|
|
26
|
+
# builds the virtual). `includes_components` is a thin, discoverable wrapper
|
|
27
|
+
# over `preload(*association_names)`; the regression tests in
|
|
28
|
+
# spec/preloading_spec.rb pin the native path so it cannot silently break.
|
|
29
|
+
module Preloading
|
|
30
|
+
# Preloads the given components and returns a chainable relation.
|
|
31
|
+
#
|
|
32
|
+
# With no arguments, preloads *every* declared component of the entity —
|
|
33
|
+
# walking inherited declarations (RFC-0004) via #components — which is the one
|
|
34
|
+
# affordance the raw `preload(:a, :b, :c)` cannot offer.
|
|
35
|
+
#
|
|
36
|
+
# Takes component **classes**, not association symbols, consistent with
|
|
37
|
+
# `with_component` / `add` / `has?`. Each must be a component declared on the
|
|
38
|
+
# entity; otherwise EcsRails::InvalidComponent naming it, rather than leaking
|
|
39
|
+
# ActiveRecord's `AssociationNotFoundError` and the has_one abstraction the gem
|
|
40
|
+
# otherwise hides (ADR-0012).
|
|
41
|
+
#
|
|
42
|
+
# Uses `preload` (separate queries), never `includes`/`eager_load`: one extra
|
|
43
|
+
# query per component, predictable, and no surprise JOIN that changes row
|
|
44
|
+
# identity or interacts with `with_component`'s EXISTS. A developer who wants a
|
|
45
|
+
# JOIN calls `eager_load` on the association names directly.
|
|
46
|
+
#
|
|
47
|
+
# Built from `all` — like Querying — so it chains onto any prior scope and
|
|
48
|
+
# keeps the entity-model default scope (ADR-0002/ADR-0011).
|
|
49
|
+
def includes_components(*component_classes)
|
|
50
|
+
declared = components
|
|
51
|
+
targets = component_classes.empty? ? declared : component_classes
|
|
52
|
+
targets.each { |component_class| ecs_validate_declared_component!(component_class, declared) }
|
|
53
|
+
|
|
54
|
+
all.preload(*targets.map { |component_class| ecs_component_association_name(component_class) })
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# The has_one name for a component — the same derivation the DSL uses when it
|
|
60
|
+
# defines the association (EcsRails::DSL#define_component_association).
|
|
61
|
+
def ecs_component_association_name(component_class)
|
|
62
|
+
component_class.model_name.singular.to_sym
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# A preloadable component is one this entity *declares* — unlike the query DSL
|
|
66
|
+
# (RFC-0010), which accepts any concrete component. Preloading an undeclared
|
|
67
|
+
# component could only ever preload a has_one that does not exist, so it is a
|
|
68
|
+
# programming error, caught here with a component-shaped message (ADR-0012)
|
|
69
|
+
# before any database work. `declared` is passed in so #components is walked
|
|
70
|
+
# once per call, not once per argument.
|
|
71
|
+
def ecs_validate_declared_component!(component_class, declared)
|
|
72
|
+
unless component_class.is_a?(Class) && component_class < EcsRails::Component
|
|
73
|
+
raise InvalidComponent,
|
|
74
|
+
"#{component_class.inspect} is not an EcsRails::Component subclass"
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
return if declared.include?(component_class)
|
|
78
|
+
|
|
79
|
+
raise InvalidComponent,
|
|
80
|
+
"#{component_class.name} is not a component declared on #{name}. " \
|
|
81
|
+
"#{name} is composed from: #{declared.map(&:name).join(', ')}."
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|