ecs_on_rails 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +88 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +128 -0
  5. data/lib/ecs_on_rails.rb +10 -0
  6. data/lib/ecs_rails/component.rb +68 -0
  7. data/lib/ecs_rails/config.rb +31 -0
  8. data/lib/ecs_rails/dsl.rb +516 -0
  9. data/lib/ecs_rails/entity.rb +233 -0
  10. data/lib/ecs_rails/errors.rb +28 -0
  11. data/lib/ecs_rails/lazy.rb +244 -0
  12. data/lib/ecs_rails/preloading.rb +84 -0
  13. data/lib/ecs_rails/presence.rb +125 -0
  14. data/lib/ecs_rails/querying.rb +102 -0
  15. data/lib/ecs_rails/railtie.rb +14 -0
  16. data/lib/ecs_rails/registry.rb +152 -0
  17. data/lib/ecs_rails/relationships.rb +316 -0
  18. data/lib/ecs_rails/validations.rb +150 -0
  19. data/lib/ecs_rails/version.rb +5 -0
  20. data/lib/ecs_rails.rb +47 -0
  21. data/lib/generators/ecs_rails/component/component_generator.rb +101 -0
  22. data/lib/generators/ecs_rails/component/templates/component_spec.rb.tt +16 -0
  23. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +25 -0
  24. data/lib/generators/ecs_rails/component/templates/model.rb.tt +13 -0
  25. data/lib/generators/ecs_rails/install/install_generator.rb +83 -0
  26. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +16 -0
  27. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +15 -0
  28. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +19 -0
  29. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +20 -0
  30. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +94 -0
  31. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +32 -0
  32. metadata +138 -0
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # Component presence as a first-class operation: add / has? / remove.
5
+ #
6
+ # Implements RFC-0009, decided by ADR-0009. Surfaced by the demo: a marker
7
+ # component (Moderator, Administrator) carries no state, so it is never
8
+ # `ecs_dirty?` (ADR-0003 excludes entity_id as identity), and RFC-0006's save
9
+ # cascade therefore never writes it. The natural path does nothing at all:
10
+ #
11
+ # user.moderator # a virtual Moderator
12
+ # user.save! # writes no moderators row — a marker is never dirty
13
+ # user.moderator.persisted? # => false, forever
14
+ #
15
+ # Presence cannot be inferred from lazy state (reading must stay free, RFC-0006)
16
+ # and a marker has nothing to dirty, so presence is its own verb:
17
+ #
18
+ # user.add(Moderator) # persist the row now (idempotent, validated)
19
+ # user.has?(Moderator) # => true — a row exists
20
+ # user.moderator? # => the same question, per-component sugar (DSL)
21
+ # user.remove(Moderator) # destroy the row (idempotent)
22
+ #
23
+ # These are useful for *every* component — `has?(Email)`, `remove(Avatar)` —
24
+ # not just empty ones; a marker is simply the case where presence is the only
25
+ # state (ADR-0009). Mirrors Flecs' add/has/remove vocabulary.
26
+ #
27
+ # This module is the entity side. The `<reader>?` predicate is generated per
28
+ # component by the DSL, into generated_component_methods (see
29
+ # EcsRails::DSL#define_component_predicate); it is just `has?(ThatComponent)`.
30
+ module Presence
31
+ module Entity
32
+ extend ActiveSupport::Concern
33
+
34
+ # Ensures a row exists for `component_class` on this entity and returns the
35
+ # now-persisted instance (RFC-0009).
36
+ #
37
+ # Immediate, not deferred to the next save: the whole reason `add` exists
38
+ # is that the save cascade never persists a marker. Idempotent: an existing
39
+ # row is returned untouched, never a second INSERT — the unique entity_id
40
+ # index (ADR-0005) would forbid one, and correctness should not depend on
41
+ # catching that. Validated: it uses `save!`, so `add(Email)` raises
42
+ # RecordInvalid (address is required) while `add(Moderator)` always
43
+ # succeeds. You cannot add an empty required component (ADR-0009).
44
+ def add(component_class)
45
+ name = ecs_presence_reader(component_class)
46
+
47
+ # The reader goes through RFC-0006's memo. When no row exists on this
48
+ # instance it loads one (a SELECT) or builds a virtual; when a row does
49
+ # exist — including one this instance already added — it is already the
50
+ # persisted instance, so we return it and no second INSERT is attempted.
51
+ component = public_send(name)
52
+ return component if component.persisted?
53
+
54
+ # A virtual instance: persist it. entity_id is restamped for the same
55
+ # reason the cascade does (RFC-0006) — on a freshly built virtual for a
56
+ # new entity it may not have been set yet. The memo now holds this same,
57
+ # persisted instance, so a later `entity.moderator` answers from it with
58
+ # no further query.
59
+ component.entity_id = id
60
+ component.save!
61
+ component
62
+ end
63
+
64
+ # True iff a row exists for `component_class` on this entity (RFC-0009).
65
+ #
66
+ # Side-effect-free, like `valid?` (RFC-0007): it never materialises,
67
+ # persists, or dirties anything. It consults RFC-0006's memo first — a
68
+ # component dirtied-and-saved on this instance is present without a fresh
69
+ # query — and otherwise issues a bare existence check (SELECT ... EXISTS),
70
+ # a load of nothing.
71
+ def has?(component_class)
72
+ name = ecs_presence_reader(component_class)
73
+
74
+ # Memo first: if this instance already loaded (and persisted) the
75
+ # component, the answer is known without touching the database. A merely
76
+ # virtual memo entry is *not* proof of absence — a row could have been
77
+ # written elsewhere — so it falls through to the existence check rather
78
+ # than answering false from memory.
79
+ cached = @ecs_components&.[](name)
80
+ return true if cached&.persisted?
81
+
82
+ # Bare existence check: no row is instantiated, so nothing is loaded and
83
+ # nothing is dirtied. One query per component, as everywhere else in the
84
+ # gem (architecture.md §7 non-goal: query optimisation).
85
+ component_class.where(entity_id: id).exists?
86
+ end
87
+
88
+ # Destroys the row for `component_class` if present, and resets the reader
89
+ # to a virtual default instance — exactly RFC-0006's `component.destroy`
90
+ # semantics. Idempotent when absent. Returns the entity (RFC-0009).
91
+ def remove(component_class)
92
+ name = ecs_presence_reader(component_class)
93
+
94
+ # Reach the component through the reader so a present row is loaded with
95
+ # its callbacks intact. `component.destroy` fires Lazy::Component's
96
+ # after_destroy, which calls #ecs_forget_component here — dropping the
97
+ # memo and nilling the association, so the next read rebuilds a virtual.
98
+ # When nothing is there to destroy the read leaves a virtual in the memo,
99
+ # which is already the reset state, so remove is idempotent.
100
+ component = public_send(name)
101
+ component.destroy if component.persisted?
102
+ self
103
+ end
104
+
105
+ private
106
+
107
+ # Resolves a declared component class to its reader name, or raises.
108
+ #
109
+ # `add`/`has?`/`remove` accept only a component the entity actually
110
+ # declares (RFC-0009); anything else — an undeclared component, or a class
111
+ # that is not a component at all — is InvalidComponent, checked before any
112
+ # database work. The declared set already accounts for inheritance
113
+ # (DSL#components walks the ancestry), so a subclass sees its parents'
114
+ # components too.
115
+ def ecs_presence_reader(component_class)
116
+ unless self.class.components.include?(component_class)
117
+ raise InvalidComponent,
118
+ "#{component_class.inspect} is not a component of #{self.class.name}"
119
+ end
120
+
121
+ component_class.model_name.singular.to_sym
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ # The class/relation-level query DSL: filter entities by which components they
5
+ # have.
6
+ #
7
+ # Implements RFC-0010, decided by ADR-0011. Surfaced by the demo, where every
8
+ # list view hand-rolled a cross-component subquery whose correctness silently
9
+ # rode the entity's default_scope.
10
+ #
11
+ # Post.with_component(PublishState, state: "published") # posts that HAVE a
12
+ # # matching row
13
+ # User.without_component(Avatar) # users with NO avatar
14
+ # Post.with_component(Name).with_component(Avatar) # AND — chainable
15
+ #
16
+ # Extended into EcsRails::Entity, so these are class methods on every entity
17
+ # class. ActiveRecord delegates class methods to relations (via `scoping`), so
18
+ # `Post.where(...).with_component(...)` chains too: the method runs with `all`
19
+ # returning the current relation, which already carries the entity-model scope.
20
+ #
21
+ # ## Why EXISTS, not JOIN (ADR-0011)
22
+ #
23
+ # Each call compiles to a correlated `EXISTS` / `NOT EXISTS` subquery rather
24
+ # than a join: EXISTS matches an entity once (no duplicate rows), N calls are N
25
+ # independent `AND EXISTS` clauses that compose without table aliasing, and
26
+ # `NOT EXISTS` is the natural, NULL-safe form of "without" (unlike `NOT IN` or a
27
+ # `LEFT JOIN ... IS NULL`).
28
+ #
29
+ # ## Why the entity-model scope is correct for free (ADR-0011)
30
+ #
31
+ # A component table is blind to entity type — `Name` has rows for Users *and*
32
+ # Posts. The scope that keeps `Post.with_component(Name)` from returning Users
33
+ # is NOT added by this DSL. It falls out of the method running on `all`, which
34
+ # is Post's own default-scoped relation (`model = 'posts'`, ADR-0002). The DSL
35
+ # only appends the `EXISTS` clause. Building from `unscoped` or from
36
+ # `ApplicationEntity` would drop that scope and leak — so we build from `all`.
37
+ module Querying
38
+ # Entities that HAVE a row for `component_class` (RFC-0010). With
39
+ # `conditions`, the row must also match them (hash equality, like `where`).
40
+ #
41
+ # Returns an ActiveRecord::Relation, chainable with `where`, `order`, `limit`,
42
+ # and further `with_component` / `without_component` calls (which AND).
43
+ def with_component(component_class, **conditions)
44
+ all.where(ecs_component_exists_sql(component_class, conditions, negate: false))
45
+ end
46
+
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).
51
+ def without_component(component_class)
52
+ all.where(ecs_component_exists_sql(component_class, {}, negate: true))
53
+ end
54
+
55
+ private
56
+
57
+ # Builds the `EXISTS (...)` / `NOT EXISTS (...)` fragment for one component.
58
+ #
59
+ # The subquery is built from `component_class.where(conditions)` so that
60
+ # ActiveRecord sanitises the condition values — they are treated as data,
61
+ # never SQL, closing the injection hole (ADR-0011). The correlation is an
62
+ # Arel column comparison, `component.entity_id = entities.id`, so it renders
63
+ # as properly quoted identifiers against the OUTER entities table — never a
64
+ # hand-built string.
65
+ #
66
+ # `arel_table` here is the entity class's own table (`entities`); the outer
67
+ # query the fragment is appended to selects from that same table, so `id`
68
+ # correlates to each candidate entity row.
69
+ def ecs_component_exists_sql(component_class, conditions, negate:)
70
+ ecs_validate_queryable_component!(component_class)
71
+
72
+ subquery = component_class
73
+ .where(conditions)
74
+ .where(component_class.arel_table[:entity_id].eq(arel_table[primary_key]))
75
+ .select("1")
76
+
77
+ keyword = negate ? "NOT EXISTS" : "EXISTS"
78
+ "#{keyword} (#{subquery.to_sql})"
79
+ end
80
+
81
+ # A queryable component is any concrete EcsRails::Component. Unlike the
82
+ # presence API (RFC-0009), it need NOT be declared on this entity: querying
83
+ # `Post.with_component(Avatar)` when Post has no Avatar is a valid,
84
+ # always-empty query, not an error (RFC-0010) — and keeps the DSL from
85
+ # needing the registry. Anything that is not a concrete component raises
86
+ # InvalidComponent, before any database work.
87
+ def ecs_validate_queryable_component!(component_class)
88
+ unless component_class.is_a?(Class) && component_class < EcsRails::Component
89
+ raise InvalidComponent,
90
+ "#{component_class.inspect} is not an EcsRails::Component subclass"
91
+ end
92
+
93
+ # An abstract component owns no table (architecture.md §1), so its EXISTS
94
+ # subquery could never resolve. Fail here rather than at the database.
95
+ return unless component_class.abstract_class?
96
+
97
+ raise InvalidComponent,
98
+ "#{component_class.name} is abstract and owns no table; " \
99
+ "query a concrete component"
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module EcsRails
6
+ # Hooks the gem into a host Rails application.
7
+ class Railtie < ::Rails::Railtie
8
+ # The registry keys entries by class name and resolves lazily, so it
9
+ # survives development-mode reloading. See RFC-0002.
10
+ config.to_prepare do
11
+ EcsRails.registry.clear! if EcsRails.registry.respond_to?(:clear!)
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Required explicitly rather than relying on ActiveRecord having pulled them in:
4
+ # #constantize is load-bearing for reload safety.
5
+ require "active_support/core_ext/string/inflections"
6
+ require "active_support/core_ext/string/filters"
7
+
8
+ module EcsRails
9
+ # Records which components each entity declares. See RFC-0002.
10
+ #
11
+ # The registry is a process-wide singleton (`EcsRails.registry`) populated by the
12
+ # `component` DSL (RFC-0004) at class-load time, and read by generators,
13
+ # delegation and — later — systems.
14
+ #
15
+ # Reload safety is the whole design constraint. In development Rails does not
16
+ # mutate a reloaded class in place: it removes the constant and autoloads a
17
+ # brand-new Class object under the same name. A registry that held class
18
+ # objects would pin the old, orphaned constants forever, and every lookup
19
+ # would hand back classes the rest of the app has already forgotten. So
20
+ # nothing here stores a Class: entries are keyed by class *name*, and names are
21
+ # resolved back to live constants via #constantize at read time.
22
+ class Registry
23
+ # One `component Foo` declaration on one entity class.
24
+ #
25
+ # A value object over *names*. #entity_class / #component_class resolve on
26
+ # every call, so a Declaration handed out before a reload still resolves to
27
+ # the post-reload constants.
28
+ class Declaration
29
+ attr_reader :entity_class_name, :component_class_name, :options
30
+
31
+ def initialize(entity_class_name:, component_class_name:, options: {})
32
+ @entity_class_name = entity_class_name
33
+ @component_class_name = component_class_name
34
+ @options = options.dup.freeze
35
+ freeze
36
+ end
37
+
38
+ # Raises NameError if the constant has gone away. See #components_for.
39
+ def entity_class
40
+ entity_class_name.constantize
41
+ end
42
+
43
+ def component_class
44
+ component_class_name.constantize
45
+ end
46
+
47
+ def ==(other)
48
+ other.is_a?(Declaration) &&
49
+ entity_class_name == other.entity_class_name &&
50
+ component_class_name == other.component_class_name &&
51
+ options == other.options
52
+ end
53
+ alias eql? ==
54
+
55
+ def hash
56
+ [self.class, entity_class_name, component_class_name, options].hash
57
+ end
58
+
59
+ def inspect
60
+ "#<#{self.class} #{entity_class_name} => #{component_class_name} #{options.inspect}>"
61
+ end
62
+ end
63
+
64
+ def initialize
65
+ clear!
66
+ end
67
+
68
+ # Records one declaration. Returns the Declaration.
69
+ #
70
+ # Raises DuplicateComponent if this entity already declares this component —
71
+ # per ADR-0005 a component appears at most once per entity, and RFC-0004
72
+ # relies on the raise to catch a doubled `component` line at class-load time.
73
+ def register(entity_class:, component_class:, options: {})
74
+ entity_name = name_for(entity_class)
75
+ component_name = name_for(component_class)
76
+
77
+ declarations = (@declarations[entity_name] ||= [])
78
+
79
+ if declarations.any? { |declaration| declaration.component_class_name == component_name }
80
+ raise DuplicateComponent,
81
+ "#{entity_name} already declares #{component_name}"
82
+ end
83
+
84
+ declaration = Declaration.new(
85
+ entity_class_name: entity_name,
86
+ component_class_name: component_name,
87
+ options: options
88
+ )
89
+ declarations << declaration
90
+ declaration
91
+ end
92
+
93
+ # The declarations for an entity, in declaration order.
94
+ #
95
+ # Resolution is lazy, so this never raises for a stale entry; asking the
96
+ # returned Declaration for #component_class does. That is deliberate: a
97
+ # dangling name means the registry has drifted out of sync with the app, and
98
+ # silently dropping the entry would make generators emit an incomplete schema
99
+ # and delegation quietly stop working. #clear! is the supported way to drop
100
+ # entries, and the Railtie calls it on every reload.
101
+ def components_for(entity_class)
102
+ declarations = @declarations[name_for(entity_class)]
103
+ declarations ? declarations.dup : []
104
+ end
105
+
106
+ # Every entity class declaring this component, as live class objects.
107
+ def entities_for(component_class)
108
+ component_name = name_for(component_class)
109
+
110
+ @declarations.each_value.with_object([]) do |declarations, entities|
111
+ declarations.each do |declaration|
112
+ entities << declaration.entity_class if declaration.component_class_name == component_name
113
+ end
114
+ end
115
+ end
116
+
117
+ # Resets the registry. Used between tests and by the Railtie's `to_prepare`.
118
+ def clear!
119
+ @declarations = {}
120
+ self
121
+ end
122
+
123
+ # An opaque snapshot of the current declarations, for save/restore around a
124
+ # block that mutates the registry — chiefly tests that `clear!` the
125
+ # process-wide singleton and would otherwise wipe declarations that
126
+ # host/app classes made at load time. `Declaration` is frozen and holds only
127
+ # strings, so a shallow dup of the arrays is a safe, cheap copy.
128
+ def snapshot
129
+ @declarations.transform_values(&:dup)
130
+ end
131
+
132
+ # Replaces the declarations with a previously taken #snapshot.
133
+ def restore(snapshot)
134
+ @declarations = snapshot.transform_values(&:dup)
135
+ self
136
+ end
137
+
138
+ private
139
+
140
+ # The one place a Class is turned into a String — and the only thing the
141
+ # registry ever retains.
142
+ def name_for(klass)
143
+ raise ArgumentError, "expected a Class, got #{klass.inspect}" unless klass.is_a?(Module)
144
+
145
+ klass.name || raise(ArgumentError, <<~MESSAGE.squish)
146
+ cannot register an anonymous class: the registry keys entries by class
147
+ name so they survive Rails reloading. Assign the class to a constant
148
+ before declaring components on it.
149
+ MESSAGE
150
+ end
151
+ end
152
+ end