ecs_on_rails 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +128 -0
  5. data/lib/ecs_on_rails.rb +10 -0
  6. data/lib/ecs_rails/component.rb +68 -0
  7. data/lib/ecs_rails/config.rb +31 -0
  8. data/lib/ecs_rails/dsl.rb +516 -0
  9. data/lib/ecs_rails/entity.rb +233 -0
  10. data/lib/ecs_rails/errors.rb +28 -0
  11. data/lib/ecs_rails/lazy.rb +244 -0
  12. data/lib/ecs_rails/preloading.rb +84 -0
  13. data/lib/ecs_rails/presence.rb +125 -0
  14. data/lib/ecs_rails/querying.rb +102 -0
  15. data/lib/ecs_rails/railtie.rb +14 -0
  16. data/lib/ecs_rails/registry.rb +152 -0
  17. data/lib/ecs_rails/relationships.rb +316 -0
  18. data/lib/ecs_rails/validations.rb +150 -0
  19. data/lib/ecs_rails/version.rb +5 -0
  20. data/lib/ecs_rails.rb +47 -0
  21. data/lib/generators/ecs_rails/component/component_generator.rb +101 -0
  22. data/lib/generators/ecs_rails/component/templates/component_spec.rb.tt +16 -0
  23. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +25 -0
  24. data/lib/generators/ecs_rails/component/templates/model.rb.tt +13 -0
  25. data/lib/generators/ecs_rails/install/install_generator.rb +83 -0
  26. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +16 -0
  27. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +15 -0
  28. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +19 -0
  29. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +20 -0
  30. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +94 -0
  31. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +32 -0
  32. metadata +138 -0
@@ -0,0 +1,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