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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bef233f6d1933696953db3058563f4e4178c67aeb6d56f6d2f241f64f23b81f3
4
+ data.tar.gz: a7806e71e299991ae122dd30ce78a009bf181ed55c11d8b43444be2a2f6ef1d8
5
+ SHA512:
6
+ metadata.gz: 7fd3624f55f3264bbaf3c84cb355a63cc5e0e5e66eabd43d04ad01c1d87bff202dd8d4e851d6006c66c383c742702da548115161ab33dd2ddc7d1f37b0e86295
7
+ data.tar.gz: 926baa1d0cfca36f1453d0cc4abb02d463a04d49b499b2a6eb72f54534a4486ccfe27e1452a6b0a2206f2589062cd4f53aa1b05ab07fed7b4479e19515b92ecd
data/CHANGELOG.md ADDED
@@ -0,0 +1,88 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
5
+ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.1] — 2026-07-23
10
+
11
+ First RubyGems release.
12
+
13
+ ### Changed
14
+
15
+ - The gem is published as **`ecs_on_rails`**. Both `ecs-rails` and `ecs_rails`
16
+ are unavailable: an unrelated `ecs-rails` gem already exists, and RubyGems
17
+ treats `-`, `_` and case as equivalent when comparing names, so every spelling
18
+ of "ecs rails" collides with it.
19
+ - **No API change.** The require path is still `ecs_rails`, the module is still
20
+ `EcsRails`, and the generators are still `ecs_rails:install`,
21
+ `ecs_rails:component` and `ecs_rails:relationship`. Only the name you put in
22
+ your Gemfile differs:
23
+
24
+ ```ruby
25
+ gem "ecs_on_rails" # Gemfile
26
+ require "ecs_rails" # everywhere else
27
+ ```
28
+
29
+ A `lib/ecs_on_rails.rb` shim requires `ecs_rails`, so a bare `gem
30
+ "ecs_on_rails"` works under `Bundler.require`.
31
+
32
+ ## [0.2.0] — 2026-07-22
33
+
34
+ Adds cross-entity relationships. Demo-validated by the bulletin-board app.
35
+
36
+ ### Added
37
+
38
+ - Relationship query & preload sugar (RFC-0013). `Entity.with_related(:author, user)` /
39
+ `without_related(:author)` / `includes_related(:author)` query and preload a
40
+ relationship by its declared name, so the backing component class never appears in
41
+ application code. Thin sugar over the component verbs.
42
+ - Relationship DSL (RFC-0012). `relates_to :author, User` on an entity declares a
43
+ cross-entity link with no relationship component file — the DSL defines the
44
+ backing component dynamically. `post.author` / `post.author =` reach the target;
45
+ `rails g ecs_rails:relationship Post author:User` emits the migration. Deleting
46
+ the target nullifies the link; deleting the owner cascades.
47
+
48
+ ## [0.1.0] — 2026-07-20
49
+
50
+ Feature-complete, demo-validated. Not yet published to RubyGems. See the
51
+ [v0.1 retrospective](../docs/retrospective-v0.1.md).
52
+
53
+ ### Added
54
+
55
+ - Component preloading (RFC-0011). `Entity.includes_components(*Components)` batches
56
+ component loads (all declared, or a named subset) so a list view issues a bounded
57
+ number of queries instead of one per component per row. A thin wrapper over
58
+ ActiveRecord's native `preload`, which already works with lazy components.
59
+ - Component query DSL (RFC-0010). `Entity.with_component(Component, **conditions)` /
60
+ `Entity.without_component(Component)` query entities by which components they
61
+ have, compiling to correlated `EXISTS` / `NOT EXISTS` subqueries that apply the
62
+ entity-model scope automatically (a shared component can't leak across entity
63
+ types). Chainable with ordinary ActiveRecord. Avoids `.with` (AR's CTEs).
64
+ - Component presence (RFC-0009). `entity.add(Component)` / `entity.has?(Component)` /
65
+ `entity.remove(Component)` and a generated `entity.<component>?` predicate, so
66
+ marker components (Moderator, Administrator) — which carry no state and so are
67
+ never persisted by the lazy save cascade — work.
68
+ - Validation error merging (RFC-0007). `entity.valid?` reflects its touched
69
+ components' validity; component errors merge under `entity.errors[:"email.address"]`
70
+ and read naturally in a form. A non-dirty virtual component is not validated.
71
+ - Method delegation (RFC-0005). Component methods and attribute accessors are
72
+ callable on the entity — `user.send_welcome_email`, `user.address = "x"`.
73
+ Name clashes between two components raise `DelegationConflict` at load time;
74
+ `except:`/`only:` are the escape hatch.
75
+ - Lazy / virtual components (RFC-0006). `entity.email` always returns an
76
+ `Email`, never `nil` — a missing row yields an in-memory component with every
77
+ attribute at its database default. `entity.save` cascades to the components
78
+ you touched, inserting a row only for those that are dirty, in one
79
+ transaction. Reading a component costs a `SELECT` and nothing else.
80
+ - The `component` DSL (RFC-0004). `component Name` on an entity declares what it
81
+ is composed from, generates the reader, and wires the `has_one`.
82
+ - `ApplicationComponent` (RFC-0003) and entity subclass resolution — a loaded
83
+ entity comes back as its real subclass (`User`, not `ApplicationEntity`).
84
+ - `ApplicationEntity` (RFC-0001) — immutable identity rows in one shared
85
+ `entities` table, discriminated by `model`.
86
+ - The component registry (RFC-0002), reload-safe by keying on class name.
87
+ - `ecs_rails:install` and `ecs_rails:component` generators (RFC-0008).
88
+ - Gem scaffold, MIT licence, and RSpec + PostgreSQL test harness.
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Jason Hutchens
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,128 @@
1
+ # ECS Rails
2
+
3
+ An Entity–Component–System reimagining of ActiveRecord that stays idiomatic to
4
+ Ruby on Rails.
5
+
6
+ > The full API below is implemented and tested (534 examples on real
7
+ > PostgreSQL). A companion bulletin-board app is built entirely on it and runs
8
+ > live at **[ecs-rails.kranzky.com](https://ecs-rails.kranzky.com)**. See the
9
+ > [v0.1 retrospective](https://github.com/kranzky/ecs_rails/blob/main/docs/retrospective-v0.1.md)
10
+ > for the full story of how it was designed.
11
+
12
+ ## The idea
13
+
14
+ Replace one-table-per-model with one-table-per-component. An entity is a
15
+ lightweight identity row; all state and behaviour live in small, reusable
16
+ components that are composed onto it.
17
+
18
+ ```ruby
19
+ class User < ApplicationEntity
20
+ component Name
21
+ component Email
22
+ component Avatar
23
+ component Moderator # a marker: no data, presence is the meaning
24
+ end
25
+
26
+ class Email < ApplicationComponent
27
+ validates :address, presence: true
28
+
29
+ def send_welcome_email
30
+ # self is the Email, never the User
31
+ end
32
+ end
33
+ ```
34
+
35
+ ```ruby
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
+ user.send_welcome_email # delegated to the Email component
42
+ user.errors[:"email.address"] # component errors merge onto the entity
43
+ ```
44
+
45
+ Every v0.1 capability, working today:
46
+
47
+ ```ruby
48
+ # Lazy components — no row until a value differs from its default.
49
+ user.avatar.persisted? # => false, costs no INSERT
50
+
51
+ # Presence / markers — a user IS a moderator when the row exists.
52
+ user.add(Moderator); user.moderator? # => true
53
+ user.remove(Moderator)
54
+
55
+ # Query by composition — avoids AR's .with (CTEs); scopes to the entity model.
56
+ Post.with_component(PublishState, state: "published")
57
+ User.without_component(Avatar)
58
+
59
+ # Preload to bound the query count on a list view.
60
+ Post.with_component(PublishState).includes_components(Title, Body, Likes)
61
+ ```
62
+
63
+ Components are shared by *type*, so `Likes` behaves identically on a `Post` and
64
+ a `Comment` — reuse without STI and without polymorphic associations.
65
+
66
+ ## Getting started
67
+
68
+ ```ruby
69
+ # Gemfile — note the packaging name differs from the require path (see Names)
70
+ gem "ecs_on_rails"
71
+ ```
72
+
73
+ ```sh
74
+ bundle install
75
+ rails g ecs_rails:install
76
+ rails g ecs_rails:component Email address:string verified:boolean
77
+ ```
78
+
79
+ Entities go in `app/entities`, components in `app/entities/components`
80
+ ([configurable](https://github.com/kranzky/ecs_rails/blob/main/docs/adr/0010-entity-component-directory-layout.md)); the
81
+ install generator wires the autoloading.
82
+
83
+ ## Documentation
84
+
85
+ - **[Architecture](https://github.com/kranzky/ecs_rails/blob/main/docs/architecture.md)** — the invariants. Start here.
86
+ - **[v0.1 retrospective](https://github.com/kranzky/ecs_rails/blob/main/docs/retrospective-v0.1.md)** — what was built, what
87
+ the demo found, what's next.
88
+ - **[ADRs](https://github.com/kranzky/ecs_rails/tree/main/docs/adr)** — why the design is the way it is (14 decisions,
89
+ several amended by their own demo).
90
+ - **[RFCs](https://github.com/kranzky/ecs_rails/tree/main/docs/rfc)** — the 13 features, each one commit.
91
+ - **[Backlog](https://github.com/kranzky/ecs_rails/blob/main/docs/backlog.md)** — what deliberately isn't built yet.
92
+ - **[Friction log](https://github.com/kranzky/ecs_rails/blob/main/docs/friction-log.md)** — the demo's running verdict on
93
+ the API.
94
+
95
+ ## Development
96
+
97
+ Requires Ruby >= 3.2 and a running PostgreSQL.
98
+
99
+ ```sh
100
+ createdb ecs_rails_test
101
+ bundle install
102
+ bundle exec rspec
103
+ ```
104
+
105
+ Set `DATABASE_URL` to point the suite at a different database.
106
+
107
+ ## Names
108
+
109
+ `ecs_rails` everywhere except the Gemfile — see
110
+ [ADR-0007](https://github.com/kranzky/ecs_rails/blob/main/docs/adr/0007-monorepo-and-licensing.md#three-different-names).
111
+
112
+ | | |
113
+ |---|---|
114
+ | GitHub repo | [`ecs_rails`](https://github.com/kranzky/ecs_rails) |
115
+ | RubyGems gem | `ecs_on_rails` |
116
+ | Ruby module | `EcsRails` |
117
+ | `require` | `ecs_rails` |
118
+ | Generators | `ecs_rails:install`, `:component`, `:relationship` |
119
+
120
+ Only the published gem name differs. RubyGems collapses `-`, `_` and case when
121
+ comparing names, so `ecs-rails`, `ecs_rails` and `ecsrails` are one name — and
122
+ it belongs to an unrelated, still-maintained gem. `ecs_on_rails` keeps the
123
+ `rails` keyword without the `rails-` prefix that convention reserves for Rails
124
+ Core Team gems.
125
+
126
+ ## Licence
127
+
128
+ MIT. See [LICENSE.txt](LICENSE.txt).
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The gem is published as "ecs_on_rails", but its canonical require path and
4
+ # module are "ecs_rails" / EcsRails. Bundler requires a gem by its own name, so
5
+ # `Bundler.require` (i.e. a bare `gem "ecs_on_rails"` in a Gemfile) attempts
6
+ # `require "ecs_on_rails"` and would raise LoadError without this file.
7
+ #
8
+ # Requiring "ecs_rails" directly is equivalent and is what the documentation
9
+ # uses throughout.
10
+ require "ecs_rails"
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # An ordinary ActiveRecord model that belongs to exactly one entity.
5
+ #
6
+ # Implements RFC-0003. See docs/architecture.md §1 for the invariants.
7
+ #
8
+ # Host apps subclass this once, as ApplicationComponent, then subclass that
9
+ # per component type:
10
+ #
11
+ # class ApplicationComponent < EcsRails::Component
12
+ # self.abstract_class = true
13
+ # end
14
+ #
15
+ # class Email < ApplicationComponent
16
+ # validates :address, presence: true
17
+ # end
18
+ #
19
+ # Email.where(verified: false) # queried directly; no join to entities
20
+ # Email.first.entity # => #<User> — the real subclass (ADR-0008)
21
+ #
22
+ # A component owns its own table and knows nothing about entity subclasses.
23
+ # Scopes, validations, callbacks and associations all work as normal: this is
24
+ # a plain AR model that happens to hang off an entity.
25
+ #
26
+ # The one sanctioned exception to "knows nothing about entity subclasses" is a
27
+ # relationship component's `class_name:` — see ADR-0006.
28
+ class Component < ActiveRecord::Base
29
+ self.abstract_class = true
30
+
31
+ # Lazy / virtual components (RFC-0006), from the component's side: what
32
+ # "dirty" means — the question that decides whether this component is worth
33
+ # a row — and the after_destroy that resets its entity's reader back to a
34
+ # virtual instance (architecture.md §3).
35
+ include Lazy::Component
36
+
37
+ # Every component belongs to exactly one entity (architecture.md §1).
38
+ #
39
+ # The association targets the abstract ApplicationEntity, which has no table
40
+ # of its own — but EcsRails::Entity sets table_name to "entities", so the
41
+ # generated query is an ordinary select against that table. The loaded row's
42
+ # `model` column then decides which subclass to instantiate, via
43
+ # Entity.discriminate_class_for_record. That is what makes `email.entity`
44
+ # return a User rather than an ApplicationEntity (RFC-0003, ADR-0008).
45
+ #
46
+ # `optional: false` is explicit rather than inherited from
47
+ # `belongs_to_required_by_default`. That config is applied by the Rails
48
+ # railtie via load_defaults, so its value depends on the host app's
49
+ # configured defaults and is not visible in bare ActiveRecord. RFC-0003
50
+ # requires entity_id to be required unconditionally, so we state it here
51
+ # rather than inherit a host's setting. (Same reasoning as RFC-0001's
52
+ # readonly guard in entity.rb.)
53
+ #
54
+ # Deliberately no `dependent:` option. Cascade is owned by the database:
55
+ # every component table has an ON DELETE CASCADE foreign key to
56
+ # entities(id), so entity.destroy removes the component rows
57
+ # (architecture.md §3). Declaring `dependent: :destroy` on this side would
58
+ # invert the ownership — destroying a component would destroy its entity,
59
+ # and with it every sibling component. That is the wrong direction and
60
+ # destructive; the DB layer already has the right one.
61
+ belongs_to :entity, class_name: "ApplicationEntity", optional: false
62
+
63
+ # The unique index on entity_id (ADR-0005) is the real enforcement, and a
64
+ # uniqueness validation here would cost a SELECT on every save while still
65
+ # racing. RFC-0003 asserts RecordNotUnique from the database, so the guard
66
+ # is left where it is enforced.
67
+ end
68
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # Generator configuration for the directory layout (ADR-0010).
5
+ #
6
+ # The gem RUNTIME never reads this: entities and components are keyed by class
7
+ # name (RFC-0002) and by the `model` discriminator (ADR-0002), so the registry
8
+ # does not care where a class file lives. This object exists purely so the
9
+ # generators (RFC-0008) know where to write, and so the initializer they emit
10
+ # can echo the chosen path.
11
+ #
12
+ # `entities_path` is the single knob. Components always live in a `components`
13
+ # subdirectory of it, which is why `components_path` is derived rather than
14
+ # separately settable — ADR-0010 deliberately exposes one path, not two.
15
+ class Config
16
+ # 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.
18
+ attr_accessor :entities_path
19
+
20
+ def initialize
21
+ @entities_path = "app/entities"
22
+ end
23
+
24
+ # Components live in a `components` subdirectory of the entities path. The
25
+ # generated initializer collapses this directory so Zeitwerk treats the
26
+ # `components/` segment as transparent (ADR-0010 "How it works").
27
+ def components_path
28
+ "#{entities_path}/components"
29
+ end
30
+ end
31
+ end