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,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ # active_record must be required explicitly: rails/generators/active_record/
4
+ # migration references ActiveRecord::Migration and ActiveRecord::VERSION but
5
+ # does not load them itself. A host Rails app happens to have ActiveRecord
6
+ # loaded already, so omitting this only breaks in isolation — which is exactly
7
+ # where it is hardest to notice.
8
+ require "active_record"
9
+ require "rails/generators/named_base"
10
+ require "rails/generators/active_record/migration"
11
+
12
+ # ADR-0010: the generator reads EcsRails.config to place its files and to fill in
13
+ # the initializer. Require the library explicitly so the generator stands on its
14
+ # own requires — a clean `rails g` process must reach EcsRails.config without
15
+ # depending on some other file having loaded it first (see RFC-0008's isolation
16
+ # note and spec/generators/generator_isolation_spec.rb).
17
+ require "ecs_rails"
18
+
19
+ module EcsRails
20
+ module Generators
21
+ # `rails g ecs_rails:install`
22
+ #
23
+ # Implements RFC-0008. Emits the `entities` migration and the two abstract
24
+ # base classes a host app subclasses from. The migration mirrors
25
+ # docs/architecture.md §2 exactly — if the two ever disagree, the
26
+ # architecture document wins.
27
+ #
28
+ # Inherits from Base rather than NamedBase: install takes no NAME argument.
29
+ class InstallGenerator < Rails::Generators::Base
30
+ include ActiveRecord::Generators::Migration
31
+
32
+ source_root File.expand_path("templates", __dir__)
33
+
34
+ desc "Creates the entities migration and the ApplicationEntity / " \
35
+ "ApplicationComponent base classes."
36
+
37
+ def create_migration_file
38
+ migration_template(
39
+ "migration.rb.tt",
40
+ File.join(db_migrate_path, "ecs_rails_create_entities.rb")
41
+ )
42
+ end
43
+
44
+ # ADR-0010: base classes land under the configured layout —
45
+ # ApplicationEntity at entities_path, ApplicationComponent at
46
+ # components_path (entities_path/components).
47
+ def create_base_models
48
+ template "application_entity.rb.tt",
49
+ File.join(EcsRails.config.entities_path, "application_entity.rb")
50
+ template "application_component.rb.tt",
51
+ File.join(EcsRails.config.components_path, "application_component.rb")
52
+ end
53
+
54
+ # ADR-0010: the generated initializer both records the chosen layout (so
55
+ # ecs_rails:component and the app agree on it) and collapses the nested
56
+ # components directory, so Zeitwerk maps app/entities/components/name.rb to
57
+ # the top-level `Name` rather than `Components::Name`.
58
+ #
59
+ # An initializer — not application.rb, not the gem's Railtie — is the right
60
+ # home for the collapse: it runs before eager_load under both lazy and
61
+ # eager modes, and it does not read entities_path before app initializers
62
+ # have had their chance to set it. See ADR-0010 "How it works".
63
+ def create_initializer
64
+ template "initializer.rb.tt", "config/initializers/ecs_rails.rb"
65
+ end
66
+
67
+ private
68
+
69
+ # The literal path written into the generated initializer's
70
+ # `entities_path =` line. Reflects whatever entities_path is at generation
71
+ # time, so a pre-configured layout carries through into the initializer.
72
+ def entities_path
73
+ EcsRails.config.entities_path
74
+ end
75
+
76
+ # The `ActiveRecord::Migration[x.y]` version stamp, tracking whatever
77
+ # ActiveRecord the host app is actually running.
78
+ def migration_version
79
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for every component in this application.
4
+ #
5
+ # A component is an ordinary ActiveRecord model that owns one table and belongs
6
+ # to exactly one entity. It may hold behaviour as well as data, and it must
7
+ # never reference an entity subclass.
8
+ #
9
+ # Generate one with:
10
+ #
11
+ # rails g ecs_rails:component Email address:string verified:boolean
12
+ #
13
+ # See docs/architecture.md §1.
14
+ class ApplicationComponent < EcsRails::Component
15
+ self.abstract_class = true
16
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The base class for every entity in this application.
4
+ #
5
+ # An entity is an immutable identity row: a UUID and a `model` discriminator,
6
+ # and nothing else. All state lives in components.
7
+ #
8
+ # class User < ApplicationEntity
9
+ # component Email
10
+ # end
11
+ #
12
+ # See docs/architecture.md §1.
13
+ class ApplicationEntity < EcsRails::Entity
14
+ self.abstract_class = true
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # ECS Rails directory layout (ADR-0010).
4
+ #
5
+ # entities_path is the single knob: entities live here, components live in its
6
+ # `components` subdirectory. Set it to "app/models" to restore the old
7
+ # single-directory layout (no collapse is needed then, since nothing nests).
8
+ EcsRails.configure do |config|
9
+ config.entities_path = "<%= entities_path %>"
10
+ end
11
+
12
+ # Rails auto-adds every immediate subdirectory of app/ as an autoload root, so
13
+ # app/entities works for free. The nested components/ directory is the exception:
14
+ # without this, Zeitwerk would namespace it as Components::Name. `collapse` makes
15
+ # the components/ segment transparent, exactly as Rails does for app/models/
16
+ # concerns — so app/entities/components/name.rb resolves to the top-level `Name`.
17
+ Rails.autoloaders.main.collapse(
18
+ Rails.root.join(EcsRails.config.entities_path, "components")
19
+ )
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Creates the single `entities` table every ECS Rails entity shares.
4
+ # See docs/architecture.md §2 and docs/adr/0002-single-entities-table.md.
5
+ class EcsRailsCreateEntities < ActiveRecord::Migration[<%= migration_version %>]
6
+ def change
7
+ # gen_random_uuid() lives in pgcrypto on PostgreSQL < 13; enabling it is
8
+ # harmless on 13+, where the function is built in.
9
+ enable_extension "pgcrypto" unless extension_enabled?("pgcrypto")
10
+
11
+ create_table :entities, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
12
+ # The entity subclass discriminator, e.g. "users". Indexed because
13
+ # User.all compiles to WHERE model = 'users'.
14
+ t.string :model, null: false, index: true
15
+ t.datetime :created_at, null: false
16
+ # No updated_at — an entity is written once and never changes.
17
+ # See RFC-0001 and docs/architecture.md §1.
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ # See the note in component_generator.rb: rails/generators/active_record/migration
4
+ # references ActiveRecord::Migration without loading it, so these are required
5
+ # explicitly for load-isolation (generator_isolation_spec.rb).
6
+ require "active_record"
7
+ require "rails/generators/named_base"
8
+ require "rails/generators/active_record/migration"
9
+
10
+ # ADR-0010: the generator reads EcsRails.config. Require the library explicitly so
11
+ # the generator stands on its own requires (RFC-0008's isolation note).
12
+ require "ecs_rails"
13
+
14
+ module EcsRails
15
+ module Generators
16
+ # `rails g ecs_rails:relationship OWNER name:Target`
17
+ #
18
+ # rails g ecs_rails:relationship Post author:User
19
+ #
20
+ # Implements RFC-0012 / ADR-0013. Unlike the component generator, this emits
21
+ # ONLY a migration — `relates_to` defines the backing component dynamically,
22
+ # so there is no model file to write. It prints a reminder to add the
23
+ # `relates_to` line to the entity.
24
+ #
25
+ # The migration creates the owner-scoped backing table `post_authors`:
26
+ # - uuid PK,
27
+ # - entity_id: not-null, UNIQUE index, ON DELETE CASCADE FK to entities —
28
+ # the owner side (a post has at most one author; destroying the post
29
+ # destroys the link),
30
+ # - author_id: indexed, ON DELETE **NULLIFY** FK to entities — the target
31
+ # side (destroying the target nullifies, does not cascade to the owner),
32
+ # - timestamps.
33
+ #
34
+ # The single `name:Target` argument does not fit Rails' GeneratedAttribute
35
+ # (there is no `:Target` column type), so it is parsed by hand — see
36
+ # #parse_relationship!.
37
+ class RelationshipGenerator < Rails::Generators::NamedBase
38
+ include ActiveRecord::Generators::Migration
39
+
40
+ source_root File.expand_path("templates", __dir__)
41
+
42
+ argument :relationship, type: :string, banner: "name:Target"
43
+
44
+ desc "Creates the migration for a relationship's backing table (no model)."
45
+
46
+ def create_migration_file
47
+ parse_relationship!
48
+ migration_template(
49
+ "migration.rb.tt",
50
+ File.join(db_migrate_path, "create_#{backing_table_name}.rb")
51
+ )
52
+ end
53
+
54
+ # RFC-0012: no component file. The DSL defines the backing component; the
55
+ # developer only has to declare the relationship on the entity.
56
+ def print_relates_to_reminder
57
+ entity_path = File.join(EcsRails.config.entities_path, class_path, "#{file_name}.rb")
58
+ say "Add the relationship to #{entity_path}:", :green
59
+ say " relates_to :#{relation_name}, #{target_class_name}"
60
+ end
61
+
62
+ private
63
+
64
+ # Splits the `name:Target` argument. `file_name` (from NamedBase) is the
65
+ # OWNER, e.g. "post"; `relationship` is "author:User".
66
+ def parse_relationship!
67
+ @relation_name, @target_class_name = relationship.split(":", 2)
68
+
69
+ return unless @relation_name.to_s.empty? || @target_class_name.to_s.empty?
70
+
71
+ raise Thor::Error,
72
+ "expected OWNER name:Target, e.g. " \
73
+ "`rails g ecs_rails:relationship Post author:User` (got #{relationship.inspect})"
74
+ end
75
+
76
+ attr_reader :relation_name, :target_class_name
77
+
78
+ # The owner-scoped backing table: #{owner.singular}_#{relation.plural} —
79
+ # `post_authors`. Mirrors the derivation in EcsRails::Relationships.
80
+ def backing_table_name
81
+ "#{singular_table_name}_#{relation_name.pluralize}"
82
+ end
83
+
84
+ # The target FK column: `author_id`. Mirrors EcsRails::Relationships.
85
+ def target_foreign_key
86
+ "#{relation_name}_id"
87
+ end
88
+
89
+ def migration_version
90
+ "#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Creates the <%= backing_table_name %> relationship backing table
4
+ # (RFC-0012 / ADR-0013). This is what `relates_to :<%= relation_name %>,
5
+ # <%= target_class_name %>` reads; there is no relationship component file — the
6
+ # DSL defines the backing component dynamically.
7
+ #
8
+ # The owner side (entity_id) and the target side (<%= target_foreign_key %>) are
9
+ # deliberately asymmetric:
10
+ # - entity_id: not-null, UNIQUE (ADR-0005: at most one per owner), ON DELETE
11
+ # CASCADE — destroying the owner destroys the link.
12
+ # - <%= target_foreign_key %>: nullable, ON DELETE NULLIFY — destroying the
13
+ # TARGET nullifies the link, it does NOT cascade to the owner.
14
+ class Create<%= backing_table_name.camelize %> < ActiveRecord::Migration[<%= migration_version %>]
15
+ def change
16
+ create_table :<%= backing_table_name %>, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
17
+ t.uuid :entity_id, null: false
18
+ t.uuid :<%= target_foreign_key %>, default: nil
19
+ t.timestamps
20
+ end
21
+
22
+ # ADR-0005: at most one relationship row per owner entity.
23
+ add_index :<%= backing_table_name %>, :entity_id, unique: true
24
+
25
+ # Destroying the owner destroys the link, at the database level.
26
+ add_foreign_key :<%= backing_table_name %>, :entities, column: :entity_id, on_delete: :cascade
27
+
28
+ # Destroying the TARGET nullifies the link — it does not cascade to the owner.
29
+ add_index :<%= backing_table_name %>, :<%= target_foreign_key %>
30
+ add_foreign_key :<%= backing_table_name %>, :entities, column: :<%= target_foreign_key %>, on_delete: :nullify
31
+ end
32
+ end
metadata ADDED
@@ -0,0 +1,138 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: ecs_on_rails
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.1
5
+ platform: ruby
6
+ authors:
7
+ - Jason Hutchens
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ - - "<"
20
+ - !ruby/object:Gem::Version
21
+ version: '9.0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ requirements:
26
+ - - ">="
27
+ - !ruby/object:Gem::Version
28
+ version: '7.1'
29
+ - - "<"
30
+ - !ruby/object:Gem::Version
31
+ version: '9.0'
32
+ - !ruby/object:Gem::Dependency
33
+ name: activesupport
34
+ requirement: !ruby/object:Gem::Requirement
35
+ requirements:
36
+ - - ">="
37
+ - !ruby/object:Gem::Version
38
+ version: '7.1'
39
+ - - "<"
40
+ - !ruby/object:Gem::Version
41
+ version: '9.0'
42
+ type: :runtime
43
+ prerelease: false
44
+ version_requirements: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '7.1'
49
+ - - "<"
50
+ - !ruby/object:Gem::Version
51
+ version: '9.0'
52
+ - !ruby/object:Gem::Dependency
53
+ name: railties
54
+ requirement: !ruby/object:Gem::Requirement
55
+ requirements:
56
+ - - ">="
57
+ - !ruby/object:Gem::Version
58
+ version: '7.1'
59
+ - - "<"
60
+ - !ruby/object:Gem::Version
61
+ version: '9.0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '7.1'
69
+ - - "<"
70
+ - !ruby/object:Gem::Version
71
+ version: '9.0'
72
+ description: |
73
+ ECS Rails extends ActiveRecord with an Entity-Component-System persistence
74
+ architecture inspired by Flecs, while remaining idiomatic Rails. Entities
75
+ are lightweight identity records composed from reusable, lazily persisted
76
+ components that encapsulate both data and behaviour.
77
+ email:
78
+ - jasonhutchens@gmail.com
79
+ executables: []
80
+ extensions: []
81
+ extra_rdoc_files: []
82
+ files:
83
+ - CHANGELOG.md
84
+ - LICENSE.txt
85
+ - README.md
86
+ - lib/ecs_on_rails.rb
87
+ - lib/ecs_rails.rb
88
+ - lib/ecs_rails/component.rb
89
+ - lib/ecs_rails/config.rb
90
+ - lib/ecs_rails/dsl.rb
91
+ - lib/ecs_rails/entity.rb
92
+ - lib/ecs_rails/errors.rb
93
+ - lib/ecs_rails/lazy.rb
94
+ - lib/ecs_rails/preloading.rb
95
+ - lib/ecs_rails/presence.rb
96
+ - lib/ecs_rails/querying.rb
97
+ - lib/ecs_rails/railtie.rb
98
+ - lib/ecs_rails/registry.rb
99
+ - lib/ecs_rails/relationships.rb
100
+ - lib/ecs_rails/validations.rb
101
+ - lib/ecs_rails/version.rb
102
+ - lib/generators/ecs_rails/component/component_generator.rb
103
+ - lib/generators/ecs_rails/component/templates/component_spec.rb.tt
104
+ - lib/generators/ecs_rails/component/templates/migration.rb.tt
105
+ - lib/generators/ecs_rails/component/templates/model.rb.tt
106
+ - lib/generators/ecs_rails/install/install_generator.rb
107
+ - lib/generators/ecs_rails/install/templates/application_component.rb.tt
108
+ - lib/generators/ecs_rails/install/templates/application_entity.rb.tt
109
+ - lib/generators/ecs_rails/install/templates/initializer.rb.tt
110
+ - lib/generators/ecs_rails/install/templates/migration.rb.tt
111
+ - lib/generators/ecs_rails/relationship/relationship_generator.rb
112
+ - lib/generators/ecs_rails/relationship/templates/migration.rb.tt
113
+ homepage: https://github.com/kranzky/ecs_rails
114
+ licenses:
115
+ - MIT
116
+ metadata:
117
+ source_code_uri: https://github.com/kranzky/ecs_rails
118
+ changelog_uri: https://github.com/kranzky/ecs_rails/blob/main/CHANGELOG.md
119
+ bug_tracker_uri: https://github.com/kranzky/ecs_rails/issues
120
+ rubygems_mfa_required: 'true'
121
+ rdoc_options: []
122
+ require_paths:
123
+ - lib
124
+ required_ruby_version: !ruby/object:Gem::Requirement
125
+ requirements:
126
+ - - ">="
127
+ - !ruby/object:Gem::Version
128
+ version: 3.2.0
129
+ required_rubygems_version: !ruby/object:Gem::Requirement
130
+ requirements:
131
+ - - ">="
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ requirements: []
135
+ rubygems_version: 4.0.17
136
+ specification_version: 4
137
+ summary: An Entity-Component-System reimagining of ActiveRecord.
138
+ test_files: []