ecs_on_rails 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yardopts +12 -0
- data/CHANGELOG.md +28 -0
- data/lib/ecs_rails/config.rb +14 -1
- data/lib/ecs_rails/dsl.rb +50 -1
- data/lib/ecs_rails/preloading.rb +14 -0
- data/lib/ecs_rails/presence.rb +51 -0
- data/lib/ecs_rails/querying.rb +38 -6
- data/lib/ecs_rails/registry.rb +52 -2
- data/lib/ecs_rails/relationships.rb +105 -31
- data/lib/ecs_rails/version.rb +4 -1
- data/lib/ecs_rails.rb +44 -3
- data/lib/generators/ecs_rails/component/component_generator.rb +5 -0
- data/lib/generators/ecs_rails/install/install_generator.rb +10 -0
- data/lib/generators/ecs_rails/relationship/relationship_generator.rb +5 -0
- metadata +5 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: dc72bfa8de8f997d618f95ab47f65718fa60ba8bf81828ea8befa0501d8be45a
|
|
4
|
+
data.tar.gz: 96c83b903c33e4e490a8dd607f37fd8c4cfb87d1da0031fcef9d6d41888a5c60
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a0125c3bad8212cf1aa1273ba18897afcf87fcf27f12acabd0586b5b62c8ba1f6b74c8a3a238ece5b707580d001dab95d68601bb16cd3ef9b235283e15e830d
|
|
7
|
+
data.tar.gz: 61f118b33194050f9f6eaa9976e43fef7d194074333307163052c24e388947c786dd17f9fa93451e368fe5af70b8a71928e8ef213cd0ece9fa8bbff4fd62cf09
|
data/.yardopts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
--markup markdown
|
|
2
|
+
--markup-provider kramdown
|
|
3
|
+
--readme README.md
|
|
4
|
+
--title "ECS Rails - an Entity-Component-System reimagining of ActiveRecord"
|
|
5
|
+
--charset utf-8
|
|
6
|
+
--no-private
|
|
7
|
+
--hide-void-return
|
|
8
|
+
--embed-mixins
|
|
9
|
+
lib/**/*.rb
|
|
10
|
+
-
|
|
11
|
+
CHANGELOG.md
|
|
12
|
+
LICENSE.txt
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,34 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] — 2026-07-23
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **YARD API documentation across the whole public API** — `@param`, `@return`,
|
|
14
|
+
`@raise`, `@example` and `@see` tags on every public module, class, attribute
|
|
15
|
+
and method (100% documented, verified by `yard stats`). The existing prose
|
|
16
|
+
rationale is kept; the tags are additive, so
|
|
17
|
+
[rubydoc.info/gems/ecs_on_rails](https://rubydoc.info/gems/ecs_on_rails) now
|
|
18
|
+
renders a real API reference rather than bare comments.
|
|
19
|
+
- A `.yardopts`, shipped in the gem so rubydoc.info honours it. Notably
|
|
20
|
+
`--embed-mixins`, without which the DSL that `EcsRails::Entity` gains by
|
|
21
|
+
`extend`ing {EcsRails::DSL}, `Querying`, `Preloading` and `Relationships`
|
|
22
|
+
would not appear on `Entity` at all — which is where users look for it.
|
|
23
|
+
- `documentation_uri` in the gemspec, pointing at the rubydoc.info API
|
|
24
|
+
reference. The narrative documentation (architecture, ADRs, RFCs) is reached
|
|
25
|
+
via the homepage and `source_code_uri`.
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- `changelog_uri` in the gemspec pointed at `<repo>/blob/main/CHANGELOG.md`,
|
|
30
|
+
which 404s: the gem lives in `gem/` of a monorepo and there is no changelog at
|
|
31
|
+
the repo root, so the Changelog link on the 0.2.1 RubyGems page was broken.
|
|
32
|
+
Gem metadata is immutable once published, so this needed a release to correct.
|
|
33
|
+
- `source_code_uri` now points at the `gem/` subtree rather than the repo root.
|
|
34
|
+
|
|
35
|
+
No code change — 0.2.2 is identical to 0.2.1 apart from gemspec metadata.
|
|
36
|
+
|
|
9
37
|
## [0.2.1] — 2026-07-23
|
|
10
38
|
|
|
11
39
|
First RubyGems release.
|
data/lib/ecs_rails/config.rb
CHANGED
|
@@ -12,9 +12,16 @@ module EcsRails
|
|
|
12
12
|
# `entities_path` is the single knob. Components always live in a `components`
|
|
13
13
|
# subdirectory of it, which is why `components_path` is derived rather than
|
|
14
14
|
# separately settable — ADR-0010 deliberately exposes one path, not two.
|
|
15
|
+
# @example Restoring the pre-ADR-0010 single-directory layout
|
|
16
|
+
# # config/initializers/ecs_rails.rb
|
|
17
|
+
# EcsRails.configure { |config| config.entities_path = "app/models" }
|
|
18
|
+
#
|
|
19
|
+
# @see EcsRails.configure
|
|
15
20
|
class Config
|
|
16
21
|
# Entities land here; the default is the ADR-0010 layout. Set it to
|
|
17
|
-
# "app/models" to restore the pre-ADR-0010 single-directory layout.
|
|
22
|
+
# `"app/models"` to restore the pre-ADR-0010 single-directory layout.
|
|
23
|
+
#
|
|
24
|
+
# @return [String] the directory entities are generated into
|
|
18
25
|
attr_accessor :entities_path
|
|
19
26
|
|
|
20
27
|
def initialize
|
|
@@ -24,6 +31,12 @@ module EcsRails
|
|
|
24
31
|
# Components live in a `components` subdirectory of the entities path. The
|
|
25
32
|
# generated initializer collapses this directory so Zeitwerk treats the
|
|
26
33
|
# `components/` segment as transparent (ADR-0010 "How it works").
|
|
34
|
+
#
|
|
35
|
+
# Derived rather than separately settable: ADR-0010 deliberately exposes one
|
|
36
|
+
# path, not two.
|
|
37
|
+
#
|
|
38
|
+
# @return [String] the directory components are generated into,
|
|
39
|
+
# e.g. `"app/entities/components"`
|
|
27
40
|
def components_path
|
|
28
41
|
"#{entities_path}/components"
|
|
29
42
|
end
|
data/lib/ecs_rails/dsl.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
module EcsRails
|
|
4
4
|
# The class-level DSL that composes an entity out of components.
|
|
5
5
|
#
|
|
6
|
-
# Implements RFC-0004. Extended into EcsRails::Entity, so every entity class —
|
|
6
|
+
# Implements RFC-0004. Extended into {EcsRails::Entity}, so every entity class —
|
|
7
7
|
# and every subclass of one — answers `component`.
|
|
8
8
|
#
|
|
9
9
|
# class User < ApplicationEntity
|
|
@@ -53,6 +53,36 @@ module EcsRails
|
|
|
53
53
|
# an anonymous class on either side.
|
|
54
54
|
#
|
|
55
55
|
# Returns the Registry::Declaration.
|
|
56
|
+
#
|
|
57
|
+
# @example Composition, and the reader it generates
|
|
58
|
+
# class User < ApplicationEntity
|
|
59
|
+
# component Email
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# User.new.email # => #<Email> — never nil
|
|
63
|
+
#
|
|
64
|
+
# @example Resolving a delegation conflict
|
|
65
|
+
# component Group, except: [:title]
|
|
66
|
+
#
|
|
67
|
+
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
68
|
+
# @param only [Array<Symbol>, Symbol, nil] delegate only these methods.
|
|
69
|
+
# Attribute aware: `:title` covers `#title` and `#title=`.
|
|
70
|
+
# Mutually exclusive with `except:`.
|
|
71
|
+
# @param except [Array<Symbol>, Symbol, nil] delegate everything but these.
|
|
72
|
+
# Attribute aware, as `only:` is. Mutually exclusive with `only:`.
|
|
73
|
+
# @return [EcsRails::Registry::Declaration] the recorded declaration
|
|
74
|
+
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
75
|
+
# {EcsRails::Component} subclass, or is abstract and so owns no table
|
|
76
|
+
# @raise [EcsRails::DuplicateComponent] if this entity, or one it inherits
|
|
77
|
+
# from, already declares this component (ADR-0005)
|
|
78
|
+
# @raise [EcsRails::DelegationConflict] if a delegated name is already
|
|
79
|
+
# delegated by a sibling component, or collides with a component reader
|
|
80
|
+
# (ADR-0004)
|
|
81
|
+
# @raise [ArgumentError] if both `only:` and `except:` are given, if either
|
|
82
|
+
# names a method the component does not delegate, or if either class is
|
|
83
|
+
# anonymous
|
|
84
|
+
# @see #components
|
|
85
|
+
# @see EcsRails::Presence::Entity#has? the `<reader>?` predicate this generates
|
|
56
86
|
def component(component_class, only: nil, except: nil)
|
|
57
87
|
validate_component_class!(component_class)
|
|
58
88
|
options = normalized_delegation_options(only: only, except: except)
|
|
@@ -92,6 +122,16 @@ module EcsRails
|
|
|
92
122
|
end
|
|
93
123
|
|
|
94
124
|
# Every component this entity is composed from, nearest ancestor last.
|
|
125
|
+
#
|
|
126
|
+
# Inherited components are included: a subclass is composed from its parents'
|
|
127
|
+
# components as well as its own.
|
|
128
|
+
#
|
|
129
|
+
# @example
|
|
130
|
+
# User.components # => [Name, Email, Group]
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<Class<EcsRails::Component>>] the component classes, resolved
|
|
133
|
+
# live from the registry (so a reloaded constant is picked up)
|
|
134
|
+
# @see #component_declarations
|
|
95
135
|
def components
|
|
96
136
|
component_declarations.map(&:component_class)
|
|
97
137
|
end
|
|
@@ -109,6 +149,11 @@ module EcsRails
|
|
|
109
149
|
#
|
|
110
150
|
# So EcsRails.registry.components_for(Admin) is only Admin's own, by design,
|
|
111
151
|
# and this is the method that answers "what is an Admin made of".
|
|
152
|
+
#
|
|
153
|
+
# @return [Array<EcsRails::Registry::Declaration>] declarations, ancestors'
|
|
154
|
+
# first, in declaration order. Carries the `only:`/`except:` options that
|
|
155
|
+
# {#components} discards.
|
|
156
|
+
# @see #components
|
|
112
157
|
def component_declarations
|
|
113
158
|
entity_ancestry.flat_map { |klass| EcsRails.registry.components_for(klass) }
|
|
114
159
|
end
|
|
@@ -134,6 +179,10 @@ module EcsRails
|
|
|
134
179
|
# of DSL calls that can invert it. That is an ActiveRecord internal, so the
|
|
135
180
|
# ordering is pinned by tests ("the generated methods module" in
|
|
136
181
|
# spec/dsl_spec.rb) and an upgrade that changed it would fail loudly.
|
|
182
|
+
#
|
|
183
|
+
# @return [Module] the per-class module holding generated readers, delegated
|
|
184
|
+
# methods and presence predicates
|
|
185
|
+
# @api private
|
|
137
186
|
def generated_component_methods
|
|
138
187
|
@generated_component_methods ||= begin
|
|
139
188
|
mod = const_set(:GeneratedComponentMethods, Module.new)
|
data/lib/ecs_rails/preloading.rb
CHANGED
|
@@ -46,6 +46,20 @@ module EcsRails
|
|
|
46
46
|
#
|
|
47
47
|
# Built from `all` — like Querying — so it chains onto any prior scope and
|
|
48
48
|
# keeps the entity-model default scope (ADR-0002/ADR-0011).
|
|
49
|
+
#
|
|
50
|
+
# @example Every declared component
|
|
51
|
+
# Post.published.includes_components
|
|
52
|
+
#
|
|
53
|
+
# @example A named subset, chained onto a component filter
|
|
54
|
+
# Post.with_component(PublishState).includes_components(Title, Body)
|
|
55
|
+
#
|
|
56
|
+
# @param component_classes [Array<Class<EcsRails::Component>>] components to
|
|
57
|
+
# preload; empty preloads every component the entity declares
|
|
58
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
59
|
+
# @raise [EcsRails::InvalidComponent] if any argument is not a component, or
|
|
60
|
+
# is not declared on this entity — unlike {EcsRails::Querying#with_component},
|
|
61
|
+
# which accepts any concrete component
|
|
62
|
+
# @see EcsRails::Relationships#includes_related for relationships
|
|
49
63
|
def includes_components(*component_classes)
|
|
50
64
|
declared = components
|
|
51
65
|
targets = component_classes.empty? ? declared : component_classes
|
data/lib/ecs_rails/presence.rb
CHANGED
|
@@ -28,6 +28,16 @@ module EcsRails
|
|
|
28
28
|
# component by the DSL, into generated_component_methods (see
|
|
29
29
|
# EcsRails::DSL#define_component_predicate); it is just `has?(ThatComponent)`.
|
|
30
30
|
module Presence
|
|
31
|
+
# The entity side of presence: `add` / `has?` / `remove`.
|
|
32
|
+
#
|
|
33
|
+
# Included into {EcsRails::Entity}, so every entity answers these. The
|
|
34
|
+
# per-component `<reader>?` predicate (`user.moderator?`) is generated by
|
|
35
|
+
# {EcsRails::DSL} and is exactly {#has?}.
|
|
36
|
+
#
|
|
37
|
+
# @example
|
|
38
|
+
# user.add(Moderator) # persist the row now (idempotent, validated)
|
|
39
|
+
# user.has?(Moderator) # => true
|
|
40
|
+
# user.remove(Moderator) # destroy the row (idempotent)
|
|
31
41
|
module Entity
|
|
32
42
|
extend ActiveSupport::Concern
|
|
33
43
|
|
|
@@ -41,6 +51,22 @@ module EcsRails
|
|
|
41
51
|
# catching that. Validated: it uses `save!`, so `add(Email)` raises
|
|
42
52
|
# RecordInvalid (address is required) while `add(Moderator)` always
|
|
43
53
|
# succeeds. You cannot add an empty required component (ADR-0009).
|
|
54
|
+
#
|
|
55
|
+
# @example A marker, which the save cascade would never write
|
|
56
|
+
# user.add(Moderator) # INSERTs the row now
|
|
57
|
+
# user.moderator? # => true
|
|
58
|
+
#
|
|
59
|
+
# @example Validation still applies
|
|
60
|
+
# user.add(Email) # raises: Email#address is required
|
|
61
|
+
#
|
|
62
|
+
# @param component_class [Class<EcsRails::Component>] a component this
|
|
63
|
+
# entity declares
|
|
64
|
+
# @return [EcsRails::Component] the persisted component instance
|
|
65
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it
|
|
66
|
+
# @raise [ActiveRecord::RecordInvalid] if the component is invalid — you
|
|
67
|
+
# cannot add an empty required component (ADR-0009)
|
|
68
|
+
# @see #has?
|
|
69
|
+
# @see #remove
|
|
44
70
|
def add(component_class)
|
|
45
71
|
name = ecs_presence_reader(component_class)
|
|
46
72
|
|
|
@@ -68,6 +94,20 @@ module EcsRails
|
|
|
68
94
|
# component dirtied-and-saved on this instance is present without a fresh
|
|
69
95
|
# query — and otherwise issues a bare existence check (SELECT ... EXISTS),
|
|
70
96
|
# a load of nothing.
|
|
97
|
+
#
|
|
98
|
+
# The DSL also generates a per-component predicate, so `user.moderator?`
|
|
99
|
+
# asks exactly this question.
|
|
100
|
+
#
|
|
101
|
+
# @example
|
|
102
|
+
# user.has?(Moderator) # => true
|
|
103
|
+
# user.moderator? # => the same question, generated per component
|
|
104
|
+
#
|
|
105
|
+
# @param component_class [Class<EcsRails::Component>] a component this
|
|
106
|
+
# entity declares
|
|
107
|
+
# @return [Boolean] whether a persisted row exists
|
|
108
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it
|
|
109
|
+
# @see #add
|
|
110
|
+
# @see #remove
|
|
71
111
|
def has?(component_class)
|
|
72
112
|
name = ecs_presence_reader(component_class)
|
|
73
113
|
|
|
@@ -88,6 +128,17 @@ module EcsRails
|
|
|
88
128
|
# Destroys the row for `component_class` if present, and resets the reader
|
|
89
129
|
# to a virtual default instance — exactly RFC-0006's `component.destroy`
|
|
90
130
|
# semantics. Idempotent when absent. Returns the entity (RFC-0009).
|
|
131
|
+
#
|
|
132
|
+
# @example
|
|
133
|
+
# user.remove(Moderator) # DELETEs the row; no-op if there was none
|
|
134
|
+
# user.moderator # => #<Moderator> — virtual again, not nil
|
|
135
|
+
#
|
|
136
|
+
# @param component_class [Class<EcsRails::Component>] a component this
|
|
137
|
+
# entity declares
|
|
138
|
+
# @return [self] the entity, for chaining
|
|
139
|
+
# @raise [EcsRails::InvalidComponent] if the entity does not declare it
|
|
140
|
+
# @see #add
|
|
141
|
+
# @see #has?
|
|
91
142
|
def remove(component_class)
|
|
92
143
|
name = ecs_presence_reader(component_class)
|
|
93
144
|
|
data/lib/ecs_rails/querying.rb
CHANGED
|
@@ -38,16 +38,48 @@ module EcsRails
|
|
|
38
38
|
# Entities that HAVE a row for `component_class` (RFC-0010). With
|
|
39
39
|
# `conditions`, the row must also match them (hash equality, like `where`).
|
|
40
40
|
#
|
|
41
|
-
#
|
|
42
|
-
#
|
|
41
|
+
# Compiles to a correlated `EXISTS` subquery, so an entity matches once —
|
|
42
|
+
# never duplicated as a join would. Condition values are sanitised by
|
|
43
|
+
# ActiveRecord and treated as data, never SQL (ADR-0011).
|
|
44
|
+
#
|
|
45
|
+
# The component need *not* be declared on this entity: querying a component
|
|
46
|
+
# the entity never declares is a valid, always-empty query, not an error.
|
|
47
|
+
#
|
|
48
|
+
# @example Filtering by a component's attributes
|
|
49
|
+
# Post.with_component(PublishState, state: "published")
|
|
50
|
+
#
|
|
51
|
+
# @example Chaining — each call ANDs
|
|
52
|
+
# Post.with_component(Title).with_component(Body).order(created_at: :desc)
|
|
53
|
+
#
|
|
54
|
+
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
55
|
+
# @param conditions [Hash] optional attribute equality the row must match
|
|
56
|
+
# @return [ActiveRecord::Relation] chainable, and still scoped to this
|
|
57
|
+
# entity's own `model` discriminator (ADR-0002)
|
|
58
|
+
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
59
|
+
# component, or is abstract and so owns no table
|
|
60
|
+
# @see #without_component
|
|
61
|
+
# @see EcsRails::Relationships#with_related the relationship-name equivalent
|
|
43
62
|
def with_component(component_class, **conditions)
|
|
44
63
|
all.where(ecs_component_exists_sql(component_class, conditions, negate: false))
|
|
45
64
|
end
|
|
46
65
|
|
|
47
|
-
# Entities that have NO row for `component_class` (RFC-0010).
|
|
48
|
-
#
|
|
49
|
-
#
|
|
50
|
-
#
|
|
66
|
+
# Entities that have NO row for `component_class` (RFC-0010).
|
|
67
|
+
#
|
|
68
|
+
# Compiles to `NOT EXISTS`, which is the NULL-safe form of "without" — unlike
|
|
69
|
+
# `NOT IN` or a `LEFT JOIN ... IS NULL`.
|
|
70
|
+
#
|
|
71
|
+
# There is deliberately no conditions form: "without a *matching* row" is
|
|
72
|
+
# ambiguous (see the RFC's Non-goals). A virtual/lazy component has no row, so
|
|
73
|
+
# it counts as absent — the intuitive reading of "without" (ADR-0009).
|
|
74
|
+
#
|
|
75
|
+
# @example
|
|
76
|
+
# User.without_component(Avatar)
|
|
77
|
+
#
|
|
78
|
+
# @param component_class [Class<EcsRails::Component>] a concrete component
|
|
79
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
80
|
+
# @raise [EcsRails::InvalidComponent] if `component_class` is not a concrete
|
|
81
|
+
# component, or is abstract and so owns no table
|
|
82
|
+
# @see #with_component
|
|
51
83
|
def without_component(component_class)
|
|
52
84
|
all.where(ecs_component_exists_sql(component_class, {}, negate: true))
|
|
53
85
|
end
|
data/lib/ecs_rails/registry.rb
CHANGED
|
@@ -26,8 +26,17 @@ module EcsRails
|
|
|
26
26
|
# every call, so a Declaration handed out before a reload still resolves to
|
|
27
27
|
# the post-reload constants.
|
|
28
28
|
class Declaration
|
|
29
|
+
# @!attribute [r] entity_class_name
|
|
30
|
+
# @return [String] the declaring entity's class name
|
|
31
|
+
# @!attribute [r] component_class_name
|
|
32
|
+
# @return [String] the declared component's class name
|
|
33
|
+
# @!attribute [r] options
|
|
34
|
+
# @return [Hash] the frozen `only:`/`except:` delegation options
|
|
29
35
|
attr_reader :entity_class_name, :component_class_name, :options
|
|
30
36
|
|
|
37
|
+
# @param entity_class_name [String] the declaring entity's class name
|
|
38
|
+
# @param component_class_name [String] the declared component's class name
|
|
39
|
+
# @param options [Hash] `only:`/`except:` delegation options
|
|
31
40
|
def initialize(entity_class_name:, component_class_name:, options: {})
|
|
32
41
|
@entity_class_name = entity_class_name
|
|
33
42
|
@component_class_name = component_class_name
|
|
@@ -35,15 +44,25 @@ module EcsRails
|
|
|
35
44
|
freeze
|
|
36
45
|
end
|
|
37
46
|
|
|
38
|
-
#
|
|
47
|
+
# Resolved live, so a reloaded constant is picked up.
|
|
48
|
+
#
|
|
49
|
+
# @return [Class<EcsRails::Entity>] the declaring entity class
|
|
50
|
+
# @raise [NameError] if the constant has gone away. See {Registry#components_for}.
|
|
39
51
|
def entity_class
|
|
40
52
|
entity_class_name.constantize
|
|
41
53
|
end
|
|
42
54
|
|
|
55
|
+
# Resolved live, so a reloaded constant is picked up.
|
|
56
|
+
#
|
|
57
|
+
# @return [Class<EcsRails::Component>] the declared component class
|
|
58
|
+
# @raise [NameError] if the constant has gone away
|
|
43
59
|
def component_class
|
|
44
60
|
component_class_name.constantize
|
|
45
61
|
end
|
|
46
62
|
|
|
63
|
+
# @param other [Object]
|
|
64
|
+
# @return [Boolean] true if both declare the same component on the same
|
|
65
|
+
# entity with the same options
|
|
47
66
|
def ==(other)
|
|
48
67
|
other.is_a?(Declaration) &&
|
|
49
68
|
entity_class_name == other.entity_class_name &&
|
|
@@ -52,10 +71,13 @@ module EcsRails
|
|
|
52
71
|
end
|
|
53
72
|
alias eql? ==
|
|
54
73
|
|
|
74
|
+
# @return [Integer] a hash consistent with {#==}, so Declarations work as
|
|
75
|
+
# Hash keys and in Sets
|
|
55
76
|
def hash
|
|
56
77
|
[self.class, entity_class_name, component_class_name, options].hash
|
|
57
78
|
end
|
|
58
79
|
|
|
80
|
+
# @return [String] e.g. `#<...Declaration User => Email {}>`
|
|
59
81
|
def inspect
|
|
60
82
|
"#<#{self.class} #{entity_class_name} => #{component_class_name} #{options.inspect}>"
|
|
61
83
|
end
|
|
@@ -70,6 +92,15 @@ module EcsRails
|
|
|
70
92
|
# Raises DuplicateComponent if this entity already declares this component —
|
|
71
93
|
# per ADR-0005 a component appears at most once per entity, and RFC-0004
|
|
72
94
|
# relies on the raise to catch a doubled `component` line at class-load time.
|
|
95
|
+
#
|
|
96
|
+
# @param entity_class [Class<EcsRails::Entity>] the declaring entity
|
|
97
|
+
# @param component_class [Class<EcsRails::Component>] the declared component
|
|
98
|
+
# @param options [Hash] `only:`/`except:` delegation options
|
|
99
|
+
# @return [Declaration] the recorded declaration
|
|
100
|
+
# @raise [EcsRails::DuplicateComponent] if this entity already declares this
|
|
101
|
+
# component (ADR-0005)
|
|
102
|
+
# @raise [ArgumentError] if either class is anonymous, since the registry
|
|
103
|
+
# keys entries by class name
|
|
73
104
|
def register(entity_class:, component_class:, options: {})
|
|
74
105
|
entity_name = name_for(entity_class)
|
|
75
106
|
component_name = name_for(component_class)
|
|
@@ -98,12 +129,22 @@ module EcsRails
|
|
|
98
129
|
# silently dropping the entry would make generators emit an incomplete schema
|
|
99
130
|
# and delegation quietly stop working. #clear! is the supported way to drop
|
|
100
131
|
# entries, and the Railtie calls it on every reload.
|
|
132
|
+
#
|
|
133
|
+
# Only the entity's *own* declarations — inheritance is walked on read by
|
|
134
|
+
# {EcsRails::DSL#component_declarations}, not copied down here.
|
|
135
|
+
#
|
|
136
|
+
# @param entity_class [Class<EcsRails::Entity>] the entity to look up
|
|
137
|
+
# @return [Array<Declaration>] its declarations; empty if none
|
|
101
138
|
def components_for(entity_class)
|
|
102
139
|
declarations = @declarations[name_for(entity_class)]
|
|
103
140
|
declarations ? declarations.dup : []
|
|
104
141
|
end
|
|
105
142
|
|
|
106
143
|
# Every entity class declaring this component, as live class objects.
|
|
144
|
+
#
|
|
145
|
+
# @param component_class [Class<EcsRails::Component>] the component
|
|
146
|
+
# @return [Array<Class<EcsRails::Entity>>] the entities composed from it
|
|
147
|
+
# @raise [NameError] if a recorded entity constant has gone away
|
|
107
148
|
def entities_for(component_class)
|
|
108
149
|
component_name = name_for(component_class)
|
|
109
150
|
|
|
@@ -115,6 +156,8 @@ module EcsRails
|
|
|
115
156
|
end
|
|
116
157
|
|
|
117
158
|
# Resets the registry. Used between tests and by the Railtie's `to_prepare`.
|
|
159
|
+
#
|
|
160
|
+
# @return [self]
|
|
118
161
|
def clear!
|
|
119
162
|
@declarations = {}
|
|
120
163
|
self
|
|
@@ -125,11 +168,18 @@ module EcsRails
|
|
|
125
168
|
# process-wide singleton and would otherwise wipe declarations that
|
|
126
169
|
# host/app classes made at load time. `Declaration` is frozen and holds only
|
|
127
170
|
# strings, so a shallow dup of the arrays is a safe, cheap copy.
|
|
171
|
+
#
|
|
172
|
+
# @return [Hash] an opaque snapshot to hand back to {#restore}
|
|
173
|
+
# @see #restore
|
|
128
174
|
def snapshot
|
|
129
175
|
@declarations.transform_values(&:dup)
|
|
130
176
|
end
|
|
131
177
|
|
|
132
|
-
# Replaces the declarations with a previously taken #snapshot.
|
|
178
|
+
# Replaces the declarations with a previously taken {#snapshot}.
|
|
179
|
+
#
|
|
180
|
+
# @param snapshot [Hash] a value previously returned by {#snapshot}
|
|
181
|
+
# @return [self]
|
|
182
|
+
# @see #snapshot
|
|
133
183
|
def restore(snapshot)
|
|
134
184
|
@declarations = snapshot.transform_values(&:dup)
|
|
135
185
|
self
|
|
@@ -74,14 +74,29 @@ module EcsRails
|
|
|
74
74
|
# (ADR-0011). The backing `*Relationship` class never appears in app code.
|
|
75
75
|
module Relationships
|
|
76
76
|
# One recorded relationship, held by NAME so it survives a Rails reload the
|
|
77
|
-
# same way the registry's Declaration does (RFC-0002):
|
|
78
|
-
# #target_class resolve via constantize on read, so a
|
|
79
|
-
# before a reload still resolves to the post-reload
|
|
77
|
+
# same way the registry's {EcsRails::Registry::Declaration} does (RFC-0002):
|
|
78
|
+
# {#backing_class} and {#target_class} resolve via `constantize` on read, so a
|
|
79
|
+
# metadata entry taken before a reload still resolves to the post-reload
|
|
80
|
+
# constants.
|
|
81
|
+
#
|
|
82
|
+
# @!attribute [rw] name
|
|
83
|
+
# @return [Symbol] the relationship name, e.g. `:author`
|
|
84
|
+
# @!attribute [rw] backing_class_name
|
|
85
|
+
# @return [String] the dynamically defined backing component,
|
|
86
|
+
# e.g. `"Post::AuthorRelationship"`
|
|
87
|
+
# @!attribute [rw] foreign_key
|
|
88
|
+
# @return [Symbol] the FK on the backing component, e.g. `:author_id`
|
|
89
|
+
# @!attribute [rw] target_class_name
|
|
90
|
+
# @return [String] the entity pointed at, e.g. `"User"`
|
|
80
91
|
RelationshipMeta = Struct.new(:name, :backing_class_name, :foreign_key, :target_class_name) do
|
|
92
|
+
# @return [Class<EcsRails::Component>] the backing component class
|
|
93
|
+
# @raise [NameError] if the constant no longer exists
|
|
81
94
|
def backing_class
|
|
82
95
|
backing_class_name.constantize
|
|
83
96
|
end
|
|
84
97
|
|
|
98
|
+
# @return [Class<EcsRails::Entity>] the entity this relationship points at
|
|
99
|
+
# @raise [NameError] if the constant no longer exists
|
|
85
100
|
def target_class
|
|
86
101
|
target_class_name.constantize
|
|
87
102
|
end
|
|
@@ -96,14 +111,40 @@ module EcsRails
|
|
|
96
111
|
|
|
97
112
|
# Declares a cross-entity link named `name` at `target_class`.
|
|
98
113
|
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
#
|
|
114
|
+
# Writes no relationship component file: it defines the backing component
|
|
115
|
+
# dynamically and declares it with {EcsRails::DSL#component}, so the whole
|
|
116
|
+
# stack — registry, lazy reader, delegation, querying, presence, preloading —
|
|
117
|
+
# applies for free.
|
|
118
|
+
#
|
|
119
|
+
# Subclasses inherit the declaration exactly as they inherit `component`.
|
|
120
|
+
#
|
|
121
|
+
# @example Declaring and using a relationship
|
|
122
|
+
# class Post < ApplicationEntity
|
|
123
|
+
# relates_to :author, User
|
|
124
|
+
# end
|
|
125
|
+
#
|
|
126
|
+
# post.author = user # writer, delegated from the backing belongs_to
|
|
127
|
+
# post.author # => #<User>
|
|
128
|
+
# post.author_relationship # => the backing component row
|
|
105
129
|
#
|
|
106
|
-
#
|
|
130
|
+
# @example Several relationships on one entity
|
|
131
|
+
# class Membership < ApplicationEntity
|
|
132
|
+
# relates_to :user, User
|
|
133
|
+
# relates_to :team, Team
|
|
134
|
+
# component Role
|
|
135
|
+
# end
|
|
136
|
+
#
|
|
137
|
+
# @param name [Symbol, String] the relationship name. Becomes the delegated
|
|
138
|
+
# accessor (`#author`, `#author=`), the `<name>_id` foreign key, and the
|
|
139
|
+
# `<name>_relationship` backing reader.
|
|
140
|
+
# @param target_class [Class<EcsRails::Entity>] a concrete entity to point at
|
|
141
|
+
# @return [EcsRails::Registry::Declaration] the backing component's declaration
|
|
142
|
+
# @raise [EcsRails::InvalidRelationship] if `target_class` is not a concrete
|
|
143
|
+
# entity — a component, an abstract entity, or a plain class
|
|
144
|
+
# @raise [EcsRails::DelegationConflict] if `name` collides with an existing
|
|
145
|
+
# component reader, delegated method, or another relationship
|
|
146
|
+
# @see #with_related
|
|
147
|
+
# @see #includes_related
|
|
107
148
|
def relates_to(name, target_class)
|
|
108
149
|
name = name.to_sym
|
|
109
150
|
|
|
@@ -133,30 +174,49 @@ module EcsRails
|
|
|
133
174
|
declaration
|
|
134
175
|
end
|
|
135
176
|
|
|
136
|
-
# The recorded metadata for relationship `name` on this entity (RFC-0013)
|
|
137
|
-
#
|
|
138
|
-
#
|
|
177
|
+
# The recorded metadata for relationship `name` on this entity (RFC-0013).
|
|
178
|
+
#
|
|
179
|
+
# Walks the entity ancestry, so a subclass sees its parents' relationships —
|
|
180
|
+
# the same way {EcsRails::DSL#component_declarations} does.
|
|
181
|
+
#
|
|
182
|
+
# @param name [Symbol, String] the relationship name
|
|
183
|
+
# @return [RelationshipMeta, nil] the metadata, or nil if undeclared
|
|
184
|
+
# @see #relationship_names
|
|
139
185
|
def relationship_meta(name)
|
|
140
186
|
relationship_declarations[name.to_sym]
|
|
141
187
|
end
|
|
142
188
|
|
|
143
|
-
# The declared relationship names for this entity, ancestry included.
|
|
144
|
-
#
|
|
189
|
+
# The declared relationship names for this entity, ancestry included.
|
|
190
|
+
#
|
|
191
|
+
# @example
|
|
192
|
+
# Membership.relationship_names # => [:user, :team]
|
|
193
|
+
#
|
|
194
|
+
# @return [Array<Symbol>] declared relationship names
|
|
195
|
+
# @see #relationship_meta
|
|
145
196
|
def relationship_names
|
|
146
197
|
relationship_declarations.keys
|
|
147
198
|
end
|
|
148
199
|
|
|
149
200
|
# Entities whose `name` relationship points at `target` (RFC-0013 / ADR-0014).
|
|
150
201
|
#
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
155
|
-
#
|
|
202
|
+
# Sugar over {EcsRails::Querying#with_component}, so it inherits that verb's
|
|
203
|
+
# entity-model scoping and correlated EXISTS (ADR-0011) — no cross-entity
|
|
204
|
+
# leak. The backing `*Relationship` class never appears in app code.
|
|
205
|
+
#
|
|
206
|
+
# @example Posts by a given author
|
|
207
|
+
# Post.with_related(:author, ada)
|
|
208
|
+
# Post.with_related(:author, ada.id) # a bare id works too
|
|
156
209
|
#
|
|
157
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
210
|
+
# @example Posts that have *any* author set
|
|
211
|
+
# Post.with_related(:author)
|
|
212
|
+
#
|
|
213
|
+
# @param name [Symbol, String] a declared relationship name
|
|
214
|
+
# @param target [EcsRails::Entity, Integer, String] the target entity or its
|
|
215
|
+
# id. Omit to match entities that merely have the relationship set.
|
|
216
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
217
|
+
# @raise [EcsRails::InvalidRelationship] if `name` is not declared
|
|
218
|
+
# @see #without_related
|
|
219
|
+
# @see #includes_related
|
|
160
220
|
def with_related(name, target = ANY_TARGET)
|
|
161
221
|
meta = ecs_resolve_relationship!(name)
|
|
162
222
|
|
|
@@ -166,8 +226,18 @@ module EcsRails
|
|
|
166
226
|
with_component(meta.backing_class, meta.foreign_key => id)
|
|
167
227
|
end
|
|
168
228
|
|
|
169
|
-
# Entities with NO backing row for `name` (RFC-0013).
|
|
170
|
-
#
|
|
229
|
+
# Entities with NO backing row for `name` (RFC-0013).
|
|
230
|
+
#
|
|
231
|
+
# Sugar over {EcsRails::Querying#without_component}; inherits its NULL-safe
|
|
232
|
+
# `NOT EXISTS` (ADR-0011).
|
|
233
|
+
#
|
|
234
|
+
# @example Orphaned posts
|
|
235
|
+
# Post.without_related(:author)
|
|
236
|
+
#
|
|
237
|
+
# @param name [Symbol, String] a declared relationship name
|
|
238
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
239
|
+
# @raise [EcsRails::InvalidRelationship] if `name` is not declared
|
|
240
|
+
# @see #with_related
|
|
171
241
|
def without_related(name)
|
|
172
242
|
without_component(ecs_resolve_relationship!(name).backing_class)
|
|
173
243
|
end
|
|
@@ -175,13 +245,17 @@ module EcsRails
|
|
|
175
245
|
# Preloads each named relationship's backing component AND its target entity
|
|
176
246
|
# — one hop — so `entity.author` costs no extra query (RFC-0013 / ADR-0014).
|
|
177
247
|
#
|
|
178
|
-
# For `:author` that is `preload(author_relationship: :author)
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
248
|
+
# For `:author` that is `preload(author_relationship: :author)`. Does **not**
|
|
249
|
+
# preload the target's own components (ADR-0014 non-goal) — chain
|
|
250
|
+
# {EcsRails::Preloading#includes_components} on the target for that.
|
|
251
|
+
#
|
|
252
|
+
# @example
|
|
253
|
+
# Post.published.includes_related(:author).each { |p| p.author.name }
|
|
182
254
|
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
255
|
+
# @param names [Array<Symbol, String>] declared relationship names
|
|
256
|
+
# @return [ActiveRecord::Relation] chainable, entity-model scoped
|
|
257
|
+
# @raise [EcsRails::InvalidRelationship] if any name is not declared
|
|
258
|
+
# @see #with_related
|
|
185
259
|
def includes_related(*names)
|
|
186
260
|
preloads = names.map do |name|
|
|
187
261
|
meta = ecs_resolve_relationship!(name)
|
data/lib/ecs_rails/version.rb
CHANGED
data/lib/ecs_rails.rb
CHANGED
|
@@ -19,10 +19,44 @@ require "ecs_rails/component"
|
|
|
19
19
|
|
|
20
20
|
# ECS Rails — an Entity-Component-System reimagining of ActiveRecord.
|
|
21
21
|
#
|
|
22
|
-
#
|
|
22
|
+
# Replaces one-table-per-model with one-table-per-component. An {EcsRails::Entity}
|
|
23
|
+
# is a lightweight identity row; all state and behaviour live in small, reusable
|
|
24
|
+
# {EcsRails::Component}s composed onto it.
|
|
25
|
+
#
|
|
26
|
+
# The gem is published as **`ecs_on_rails`**, but the require path and module are
|
|
27
|
+
# `ecs_rails` / `EcsRails` — see the README for why.
|
|
28
|
+
#
|
|
29
|
+
# @example Composing an entity from components
|
|
30
|
+
# class User < ApplicationEntity
|
|
31
|
+
# component Name
|
|
32
|
+
# component Email
|
|
33
|
+
# component Moderator # a marker: no data, presence is the meaning
|
|
34
|
+
# end
|
|
35
|
+
#
|
|
36
|
+
# user = User.create! # one row in `entities`, no component rows
|
|
37
|
+
# user.email # => #<Email> — virtual, not persisted
|
|
38
|
+
# user.email.address = "a@b.com"
|
|
39
|
+
# user.save! # now `emails` gets a row
|
|
40
|
+
#
|
|
41
|
+
# @see EcsRails::Entity Identity, and the class-level DSL entry point
|
|
42
|
+
# @see EcsRails::Component The ActiveRecord model a component is
|
|
43
|
+
# @see EcsRails::DSL `component` — composition
|
|
44
|
+
# @see EcsRails::Relationships `relates_to` — cross-entity links
|
|
45
|
+
# @see EcsRails::Querying `with_component` / `without_component`
|
|
46
|
+
# @see EcsRails::Preloading `includes_components`
|
|
47
|
+
# @see EcsRails::Presence::Entity `add` / `has?` / `remove`
|
|
48
|
+
# @see https://github.com/kranzky/ecs_rails/blob/main/docs/architecture.md
|
|
49
|
+
# architecture.md — the invariants this library guarantees
|
|
23
50
|
module EcsRails
|
|
24
51
|
class << self
|
|
25
|
-
# The process-wide component registry
|
|
52
|
+
# The process-wide component registry, populated by the {EcsRails::DSL#component}
|
|
53
|
+
# DSL at class-load time.
|
|
54
|
+
#
|
|
55
|
+
# Keyed by class *name* rather than class object, so it survives Rails
|
|
56
|
+
# reloading (RFC-0002).
|
|
57
|
+
#
|
|
58
|
+
# @return [EcsRails::Registry] the singleton registry
|
|
59
|
+
# @see EcsRails::Registry
|
|
26
60
|
def registry
|
|
27
61
|
@registry ||= Registry.new
|
|
28
62
|
end
|
|
@@ -30,14 +64,21 @@ module EcsRails
|
|
|
30
64
|
# The process-wide generator configuration (ADR-0010). Layout only — the
|
|
31
65
|
# runtime does not consult it; the generators and the initializer they emit
|
|
32
66
|
# do.
|
|
67
|
+
#
|
|
68
|
+
# @return [EcsRails::Config] the singleton configuration
|
|
69
|
+
# @see #configure
|
|
33
70
|
def config
|
|
34
71
|
@config ||= Config.new
|
|
35
72
|
end
|
|
36
73
|
|
|
37
74
|
# Yields the config for block-style setup, as a host app's
|
|
38
|
-
# config/initializers/ecs_rails.rb does
|
|
75
|
+
# `config/initializers/ecs_rails.rb` does.
|
|
39
76
|
#
|
|
77
|
+
# @example Restoring the pre-ADR-0010 single-directory layout
|
|
40
78
|
# EcsRails.configure { |config| config.entities_path = "app/models" }
|
|
79
|
+
#
|
|
80
|
+
# @yieldparam config [EcsRails::Config] the process-wide configuration
|
|
81
|
+
# @return [EcsRails::Config] the configuration, after the block has run
|
|
41
82
|
def configure
|
|
42
83
|
yield config
|
|
43
84
|
end
|
|
@@ -40,6 +40,11 @@ module EcsRails
|
|
|
40
40
|
|
|
41
41
|
desc "Creates a component: its migration, its model, and its spec."
|
|
42
42
|
|
|
43
|
+
# Emits the component's table migration.
|
|
44
|
+
#
|
|
45
|
+
# A Thor task: invoked as a generator step, not called directly.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
43
48
|
def create_migration_file
|
|
44
49
|
migration_template(
|
|
45
50
|
"migration.rb.tt",
|
|
@@ -17,6 +17,11 @@ require "rails/generators/active_record/migration"
|
|
|
17
17
|
require "ecs_rails"
|
|
18
18
|
|
|
19
19
|
module EcsRails
|
|
20
|
+
# The Rails generators (RFC-0008): `ecs_rails:install`, `ecs_rails:component`
|
|
21
|
+
# and `ecs_rails:relationship`.
|
|
22
|
+
#
|
|
23
|
+
# They read {EcsRails.config} to place their files (ADR-0010). The gem runtime
|
|
24
|
+
# never consults that config — only these do.
|
|
20
25
|
module Generators
|
|
21
26
|
# `rails g ecs_rails:install`
|
|
22
27
|
#
|
|
@@ -34,6 +39,11 @@ module EcsRails
|
|
|
34
39
|
desc "Creates the entities migration and the ApplicationEntity / " \
|
|
35
40
|
"ApplicationComponent base classes."
|
|
36
41
|
|
|
42
|
+
# Emits the `entities` table migration.
|
|
43
|
+
#
|
|
44
|
+
# A Thor task: invoked as a generator step, not called directly.
|
|
45
|
+
#
|
|
46
|
+
# @return [void]
|
|
37
47
|
def create_migration_file
|
|
38
48
|
migration_template(
|
|
39
49
|
"migration.rb.tt",
|
|
@@ -43,6 +43,11 @@ module EcsRails
|
|
|
43
43
|
|
|
44
44
|
desc "Creates the migration for a relationship's backing table (no model)."
|
|
45
45
|
|
|
46
|
+
# Emits the backing relationship table migration.
|
|
47
|
+
#
|
|
48
|
+
# A Thor task: invoked as a generator step, not called directly.
|
|
49
|
+
#
|
|
50
|
+
# @return [void]
|
|
46
51
|
def create_migration_file
|
|
47
52
|
parse_relationship!
|
|
48
53
|
migration_template(
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: ecs_on_rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.2.
|
|
4
|
+
version: 0.2.2
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jason Hutchens
|
|
@@ -80,6 +80,7 @@ executables: []
|
|
|
80
80
|
extensions: []
|
|
81
81
|
extra_rdoc_files: []
|
|
82
82
|
files:
|
|
83
|
+
- ".yardopts"
|
|
83
84
|
- CHANGELOG.md
|
|
84
85
|
- LICENSE.txt
|
|
85
86
|
- README.md
|
|
@@ -114,9 +115,10 @@ homepage: https://github.com/kranzky/ecs_rails
|
|
|
114
115
|
licenses:
|
|
115
116
|
- MIT
|
|
116
117
|
metadata:
|
|
117
|
-
source_code_uri: https://github.com/kranzky/ecs_rails
|
|
118
|
-
changelog_uri: https://github.com/kranzky/ecs_rails/blob/main/CHANGELOG.md
|
|
118
|
+
source_code_uri: https://github.com/kranzky/ecs_rails/tree/main/gem
|
|
119
|
+
changelog_uri: https://github.com/kranzky/ecs_rails/blob/main/gem/CHANGELOG.md
|
|
119
120
|
bug_tracker_uri: https://github.com/kranzky/ecs_rails/issues
|
|
121
|
+
documentation_uri: https://rubydoc.info/gems/ecs_on_rails
|
|
120
122
|
rubygems_mfa_required: 'true'
|
|
121
123
|
rdoc_options: []
|
|
122
124
|
require_paths:
|