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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: bef233f6d1933696953db3058563f4e4178c67aeb6d56f6d2f241f64f23b81f3
4
- data.tar.gz: a7806e71e299991ae122dd30ce78a009bf181ed55c11d8b43444be2a2f6ef1d8
3
+ metadata.gz: dc72bfa8de8f997d618f95ab47f65718fa60ba8bf81828ea8befa0501d8be45a
4
+ data.tar.gz: 96c83b903c33e4e490a8dd607f37fd8c4cfb87d1da0031fcef9d6d41888a5c60
5
5
  SHA512:
6
- metadata.gz: 7fd3624f55f3264bbaf3c84cb355a63cc5e0e5e66eabd43d04ad01c1d87bff202dd8d4e851d6006c66c383c742702da548115161ab33dd2ddc7d1f37b0e86295
7
- data.tar.gz: 926baa1d0cfca36f1453d0cc4abb02d463a04d49b499b2a6eb72f54534a4486ccfe27e1452a6b0a2206f2589062cd4f53aa1b05ab07fed7b4479e19515b92ecd
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.
@@ -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)
@@ -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
@@ -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
 
@@ -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
- # Returns an ActiveRecord::Relation, chainable with `where`, `order`, `limit`,
42
- # and further `with_component` / `without_component` calls (which AND).
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). No conditions
48
- # form — "without a *matching* row" is ambiguous and unneeded (see the RFC's
49
- # Non-goals). A virtual/lazy component has no row, so it counts as absent,
50
- # which is the intuitive reading of "without" (ADR-0009).
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
@@ -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
- # Raises NameError if the constant has gone away. See #components_for.
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): #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.
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
- # `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).
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
- # Returns the Registry::Declaration for the backing component.
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
- # or nil if there is none. Walks the entity ancestry, so a subclass sees its
138
- # parents' relationships the same way #component_declarations does.
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. Used
144
- # in the InvalidRelationship message and handy for introspection.
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
- # `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.
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
- # 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).
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). Sugar for
170
- # `without_component(backing)`; inherits its NULL-safe NOT EXISTS (ADR-0011).
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)`: 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).
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
- # Chainable; returns a relation. Built from `all`, like Preloading, so it
184
- # keeps any prior scope and the entity-model default scope.
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)
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module EcsRails
4
- VERSION = "0.2.1"
4
+ # The gem version. Published on RubyGems as `ecs_on_rails`.
5
+ #
6
+ # @return [String] the semantic version, e.g. `"0.2.2"`
7
+ VERSION = "0.2.2"
5
8
  end
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
- # See docs/architecture.md for the invariants this library guarantees.
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. See RFC-0002.
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.1
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: