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,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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ VERSION = "0.2.1"
5
+ 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 -%>