ecs_on_rails 0.2.1 → 0.3.0

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.yardopts +12 -0
  3. data/CHANGELOG.md +332 -0
  4. data/README.md +365 -50
  5. data/lib/ecs_rails/catalogue/address.rb +39 -0
  6. data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
  7. data/lib/ecs_rails/catalogue/counter.rb +46 -0
  8. data/lib/ecs_rails/catalogue/discard.rb +36 -0
  9. data/lib/ecs_rails/catalogue/email.rb +35 -0
  10. data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
  11. data/lib/ecs_rails/catalogue/identifier.rb +23 -0
  12. data/lib/ecs_rails/catalogue/image.rb +28 -0
  13. data/lib/ecs_rails/catalogue/link.rb +27 -0
  14. data/lib/ecs_rails/catalogue/marker.rb +38 -0
  15. data/lib/ecs_rails/catalogue/money.rb +86 -0
  16. data/lib/ecs_rails/catalogue/name.rb +37 -0
  17. data/lib/ecs_rails/catalogue/password.rb +50 -0
  18. data/lib/ecs_rails/catalogue/period.rb +57 -0
  19. data/lib/ecs_rails/catalogue/phone.rb +30 -0
  20. data/lib/ecs_rails/catalogue/position.rb +18 -0
  21. data/lib/ecs_rails/catalogue/rating.rb +25 -0
  22. data/lib/ecs_rails/catalogue/relationship.rb +159 -0
  23. data/lib/ecs_rails/catalogue/role.rb +23 -0
  24. data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
  25. data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
  26. data/lib/ecs_rails/catalogue/state.rb +71 -0
  27. data/lib/ecs_rails/catalogue/tags.rb +64 -0
  28. data/lib/ecs_rails/catalogue/text.rb +29 -0
  29. data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
  30. data/lib/ecs_rails/catalogue/token.rb +77 -0
  31. data/lib/ecs_rails/catalogue.rb +405 -0
  32. data/lib/ecs_rails/component.rb +13 -7
  33. data/lib/ecs_rails/config.rb +45 -10
  34. data/lib/ecs_rails/dsl.rb +503 -88
  35. data/lib/ecs_rails/entity.rb +21 -5
  36. data/lib/ecs_rails/errors.rb +3 -0
  37. data/lib/ecs_rails/inverses.rb +398 -0
  38. data/lib/ecs_rails/lazy.rb +86 -23
  39. data/lib/ecs_rails/markers.rb +197 -0
  40. data/lib/ecs_rails/preloading.rb +33 -8
  41. data/lib/ecs_rails/presence.rb +124 -20
  42. data/lib/ecs_rails/querying.rb +195 -17
  43. data/lib/ecs_rails/railtie.rb +10 -0
  44. data/lib/ecs_rails/registry.rb +113 -18
  45. data/lib/ecs_rails/relationships.rb +241 -159
  46. data/lib/ecs_rails/slots.rb +183 -0
  47. data/lib/ecs_rails/validations.rb +3 -5
  48. data/lib/ecs_rails/version.rb +4 -1
  49. data/lib/ecs_rails.rb +51 -6
  50. data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
  51. data/lib/generators/ecs_rails/component/component_generator.rb +5 -0
  52. data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
  53. data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
  54. data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
  55. data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
  56. data/lib/generators/ecs_rails/install/install_generator.rb +35 -8
  57. data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
  58. data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
  59. data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
  60. data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
  61. data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
  62. data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
  63. data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
  64. data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
  65. data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
  66. data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
  67. metadata +65 -6
  68. data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
  69. data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -94
  70. data/lib/generators/ecs_rails/relationship/templates/migration.rb.tt +0 -32
data/README.md CHANGED
@@ -1,93 +1,408 @@
1
1
  # ECS Rails
2
2
 
3
- An Entity–Component–System reimagining of ActiveRecord that stays idiomatic to
4
- Ruby on Rails.
3
+ Compose ordinary Rails models from reusable components. Install their tables
4
+ once; add entity types, labelled slots, relationships and markers in Ruby.
5
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.
6
+ > This guide is for **0.3.0**, the zero-migrations release. Upgrading from
7
+ > 0.2.x? See [Upgrading an existing installation](#upgrading-an-existing-installation)
8
+ > and the [changelog](https://github.com/kranzky/ecs_rails/blob/main/gem/CHANGELOG.md).
11
9
 
12
- ## The idea
10
+ ## Quickstart: a contacts directory
13
11
 
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.
12
+ You need Ruby 3.2 or newer, Bundler, Rails 7.1–8.x and a running PostgreSQL
13
+ server. Your PostgreSQL role must be able to create databases. Use a new app
14
+ and database for this tutorial; the script creates example records each run.
17
15
 
16
+ From a directory where you keep projects:
17
+
18
+ ```sh
19
+ rails new contacts --minimal --database=postgresql
20
+ cd contacts
21
+ bundle add ecs_on_rails --version "~> 0.3.0"
22
+ ```
23
+
24
+ To follow unreleased work on `main` instead, clone the repository and use
25
+ `bundle add ecs_on_rails --path ../ecs_rails/gem`.
26
+
27
+ Install the catalogue, then create and migrate the app's database:
28
+
29
+ <!-- quickstart:commands install -->
30
+ ```sh
31
+ bin/rails generate ecs_rails:install --sets core commerce
32
+ bin/rails db:create db:migrate
33
+ ```
34
+
35
+ The generator writes `ApplicationEntity`, `ApplicationComponent`, catalogue
36
+ classes and an initializer under `app/entities` and `config/initializers`.
37
+ Components are top-level constants even though their files live in
38
+ `app/entities/components`. The install migration creates the selected catalogue
39
+ tables. This tutorial selects `core commerce` to include Money; omitting
40
+ `--sets` installs just `core`.
41
+
42
+ Generate the three entity classes from installed components:
43
+
44
+ <!-- quickstart:commands entities -->
45
+ ```sh
46
+ bin/rails generate ecs_rails:entity Contact name email home:address avatar:image
47
+ bin/rails generate ecs_rails:entity Company name:text email
48
+ bin/rails generate ecs_rails:entity Note body:text
49
+ ```
50
+
51
+ The generator writes only Ruby classes. Add the marker and inverse relationship
52
+ to **app/entities/contact.rb**, so its declarations read:
53
+
54
+ <!-- quickstart:file app/entities/contact.rb -->
18
55
  ```ruby
19
- class User < ApplicationEntity
56
+ class Contact < ApplicationEntity
20
57
  component Name
21
58
  component Email
22
- component Avatar
23
- component Moderator # a marker: no data, presence is the meaning
59
+ component Address, prefix: :home
60
+ component Image, prefix: :avatar
61
+ marker :featured
62
+ has_many :notes, via: :author
63
+ end
64
+ ```
65
+
66
+ The generated **app/entities/company.rb** is ready to use. A company's name is a labelled Text, while
67
+ Name holds a person's given/family names:
68
+
69
+ <!-- quickstart:file app/entities/company.rb -->
70
+ ```ruby
71
+ class Company < ApplicationEntity
72
+ component Text, prefix: :name
73
+ component Email
24
74
  end
75
+ ```
76
+
77
+ Add `relates_to :author, Contact` to **app/entities/note.rb**. Links use an
78
+ installed shared table:
79
+
80
+ <!-- quickstart:file app/entities/note.rb -->
81
+ ```ruby
82
+ class Note < ApplicationEntity
83
+ component Text, prefix: :body
84
+ relates_to :author, Contact
85
+ end
86
+ ```
25
87
 
26
- class Email < ApplicationComponent
27
- validates :address, presence: true
88
+ Create **app/services/email_directory.rb** (create the directory too). A system
89
+ is just Ruby operating on components. This one reads addresses from any entity
90
+ type without knowing Contact or Company:
28
91
 
29
- def send_welcome_email
30
- # self is the Email, never the User
92
+ <!-- quickstart:file app/services/email_directory.rb -->
93
+ ```ruby
94
+ class EmailDirectory
95
+ def self.call
96
+ Email.where.not(address: nil).order(:address).distinct.pluck(:address)
31
97
  end
32
98
  end
33
99
  ```
34
100
 
101
+ Create **script/quickstart.rb**:
102
+
103
+ <!-- quickstart:file script/quickstart.rb -->
35
104
  ```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
105
+ contact = Contact.create!
106
+ puts contact.email.persisted? # false: a virtual component
107
+ puts contact.email.verified # false: its column default
40
108
 
41
- user.send_welcome_email # delegated to the Email component
42
- user.errors[:"email.address"] # component errors merge onto the entity
109
+ contact.update!(name_given: "Ada", name_family: "Lovelace",
110
+ email_address: "ada@example.test", home_address_country: "AU")
111
+ contact.add(:featured)
112
+ contact.notes.create!(body: "Met at Ruby meetup")
113
+ Company.create!(name: "Analytical Engines", email_address: "hello@example.test")
114
+
115
+ puts contact.name.initials # AL: behavior on the component
116
+ puts contact.notes.sole.body # Met at Ruby meetup
117
+ puts Contact.with_component(Address, prefix: :home, country: "AU").count # 1
118
+ puts Contact.with_marker(:featured).count # 1
119
+ puts EmailDirectory.call # ada@example.test, then hello@example.test
120
+ ```
121
+
122
+ Run it:
123
+
124
+ <!-- quickstart:commands run -->
125
+ ```sh
126
+ bin/rails runner script/quickstart.rb
127
+ bin/rails zeitwerk:check
43
128
  ```
44
129
 
45
- Every v0.1 capability, working today:
130
+ There is still one migration. `name_given` delegates to `name.given`;
131
+ `home_address_country` delegates to the labelled Address. A primary attribute
132
+ also gets the bare slot name: `note.body` is the string, `note.body_text` is
133
+ its component. The relationship reader `note.author` returns a Contact or nil.
46
134
 
135
+ ### Render the directory
136
+
137
+ Create **app/controllers/contacts_controller.rb**:
138
+
139
+ <!-- quickstart:file app/controllers/contacts_controller.rb -->
47
140
  ```ruby
48
- # Lazy components — no row until a value differs from its default.
49
- user.avatar.persisted? # => false, costs no INSERT
141
+ class ContactsController < ApplicationController
142
+ def index
143
+ @contacts = Contact.order(:id).preload(:name, :email)
144
+ end
145
+ end
146
+ ```
147
+
148
+ Create **app/views/contacts/index.html.erb**:
50
149
 
51
- # Presence / markers — a user IS a moderator when the row exists.
52
- user.add(Moderator); user.moderator? # => true
53
- user.remove(Moderator)
150
+ <!-- quickstart:file app/views/contacts/index.html.erb -->
151
+ ```erb
152
+ <h1>Contacts</h1>
153
+ <% @contacts.each do |contact| %>
154
+ <p><%= contact.name_given %>: <%= contact.email_address %></p>
155
+ <% end %>
156
+ ```
54
157
 
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)
158
+ Replace **config/routes.rb**:
58
159
 
59
- # Preload to bound the query count on a list view.
60
- Post.with_component(PublishState).includes_components(Title, Body, Likes)
160
+ <!-- quickstart:file config/routes.rb -->
161
+ ```ruby
162
+ Rails.application.routes.draw do
163
+ root "contacts#index"
164
+ end
61
165
  ```
62
166
 
63
- Components are shared by *type*, so `Likes` behaves identically on a `Post` and
64
- a `Comment` — reuse without STI and without polymorphic associations.
167
+ Run `bin/rails server` and open <http://localhost:3000>. You should see
168
+ **Ada: ada@example.test**. For a larger working application, follow the
169
+ [demo setup](https://github.com/kranzky/ecs_rails/blob/main/demo/README.md).
170
+
171
+ ## Generating more entities
65
172
 
66
- ## Getting started
173
+ After installing and migrating, this command writes `app/entities/person.rb`:
174
+
175
+ <!-- entity:commands person -->
176
+ ```sh
177
+ bin/rails generate ecs_rails:entity Person name email mobile:phone work:phone home:address
178
+ ```
67
179
 
68
180
  ```ruby
69
- # Gemfile — note the packaging name differs from the require path (see Names)
70
- gem "ecs_on_rails"
181
+ class Person < ApplicationEntity
182
+ component Name
183
+ component Email
184
+ component Phone, prefix: :mobile
185
+ component Phone, prefix: :work
186
+ component Address, prefix: :home
187
+ end
71
188
  ```
72
189
 
190
+ References name **existing component classes**, not database column types.
191
+ `name` or `Name` selects the default slot; `home:address` selects an Address
192
+ labelled `home`. Use `/` or `::` for namespaces, such as `CRM/Person` and
193
+ `billing/contact_email`. The entity goes under the configured `entities_path`;
194
+ components resolve through the app's normal autoloading, including renamed or
195
+ bespoke classes. Namespaced output uses absolute component references.
196
+
197
+ Missing components produce install/upgrade/`--sets` guidance and the bespoke
198
+ component escape hatch. Invalid references and duplicate component/slot pairs
199
+ fail before writing. Other method/delegation conflicts are reported by the DSL
200
+ when the class loads; edit ordinary Ruby for `only:`, `except:`, slot options,
201
+ relationships and markers. Run `bin/rails zeitwerk:check` after editing.
202
+
203
+ Existing files use Rails/Thor's conflict handling; `--skip` preserves them,
204
+ `--force` overwrites, and `--pretend` previews. `bin/rails destroy ecs_rails:entity
205
+ Person` removes the generated file, not its database records. The generator emits
206
+ no migration, component file or empty spec.
207
+
208
+ ## Presence, values and query costs
209
+
210
+ An absent component reader returns a virtual object with database column
211
+ defaults. **A first read on a saved entity may issue a SELECT even when no row
212
+ exists.** The reader caches the result; preloading can avoid per-entity reads.
213
+ `contact.avatar_image.persisted?` is false until that slot has a stored row.
214
+ `contact.has?(Image, prefix: :avatar)` checks persisted presence; a virtual
215
+ object's Ruby truthiness does not imply presence.
216
+
217
+ Saving an entity persists touched components whose values differ from defaults.
218
+ An untouched virtual component skips validation; touched components validate and
219
+ merge errors onto the entity. `save` returns false for invalid input, and `save!`
220
+ raises `ActiveRecord::RecordInvalid`. Blank strings differ from nil defaults;
221
+ normalize optional form fields with `.presence` when blank should mean absent.
222
+ Changing a persisted component back to defaults does not automatically delete it.
223
+
224
+ `with_component` and `without_component` filter **stored rows**, not virtual
225
+ values. For example, a virtual Counter reads zero but does not match a query for
226
+ a stored Counter with `count: 0`. Component queries need not be declared on the
227
+ entity and can match independently stored rows. Markers make presence explicit:
228
+ `add(:featured)`, `remove(:featured)` and `featured?`.
229
+
230
+ Use ordinary Rails association preloads for the slots a page reads, as the
231
+ controller above does. `includes_components(Address)` loads all declared Address
232
+ slots. Relationship targets can be nested with
233
+ `Note.preload(author_relationship: { target: :name })`. See the
234
+ [performance comparison](https://github.com/kranzky/ecs_rails/blob/main/docs/design/performance-comparison.md)
235
+ for measured costs.
236
+
237
+ ## Updating, replacing and deleting
238
+
239
+ Prefer reader updates followed by an entity save:
240
+
241
+ ```ruby
242
+ contact.email.assign_attributes(address: "new@example.test")
243
+ contact.save!
244
+ # Equivalent delegated update:
245
+ contact.update!(email_address: "new@example.test")
246
+ ```
247
+
248
+ | Component operation | Behavior |
249
+ | --- | --- |
250
+ | `contact.email` | Returns a cached component, possibly virtual. |
251
+ | `contact.email = Email.new(address: "replacement@example.test")` | Replaces atomically and immediately on a saved owner; waits for owner save on a new owner. Default-only replacements remain virtual. |
252
+ | `contact.email = nil` | Removes the row; the next reader returns a virtual component. |
253
+ | `contact.reload_email` / `contact.reset_email` | Discard both component and association caches. Reload reads immediately; reset defers the read. |
254
+ | `contact.build_email`, `create_email`, `create_email!` | Intentionally raise `EcsRails::InvalidComponent`; use the reader or delegated update. |
255
+
256
+ Replacement preserves the original row if validation fails, raising
257
+ `ActiveRecord::RecordInvalid` on a saved owner. Wrong types, another owner's
258
+ component and moving a persisted component between slots are rejected. After
259
+ an outer transaction rolls back, reload the owner before using its cached state.
260
+ Raw association mutation and validation-bypassing writes are outside this contract.
261
+
262
+ **Relationships.** `note.author = Contact.new(...)` saves that new target, its
263
+ touched components and the link in the owner's transaction when `note.save!`
264
+ runs. An invalid target prevents the save. Wrong-type objects raise on assignment;
265
+ wrong-type or missing IDs fail validation, exposed under
266
+ `note.errors["author_relationship.target"]`. Nil is allowed; reading an unset
267
+ link or assigning then clearing a new target creates no target or link row.
268
+ Targets accept subclasses of the declared class. The foreign key protects
269
+ existence, but writes bypassing validation also bypass the Ruby target-type check.
270
+
271
+ **Deletion.** Destroying an entity deletes its component rows through PostgreSQL
272
+ `ON DELETE CASCADE`, bypassing those components' destroy callbacks. Explicit
273
+ `contact.email.destroy` runs component destroy callbacks and resets the owner
274
+ reader to virtual defaults. Replacement also runs the old component's destroy
275
+ callbacks, then makes the replacement available through the reader. Destroying a relationship's
276
+ target nullifies `target_id`; reload a previously cached owner to observe it.
277
+ The referring entity survives.
278
+
279
+ Inverse collection APIs such as `contact.notes.create!` are supported Rails
280
+ associations. Deleting from an inverse collection removes the **link**, not the
281
+ child entity. `dependent: :destroy` / `:delete_all` on an inverse also applies to
282
+ link rows. To destroy the notes themselves, explicitly call
283
+ `contact.notes.each(&:destroy)` before destroying the contact. Choose this in the
284
+ application: invoices, for example, may need to survive their former owner.
285
+
286
+ ## When another migration is needed
287
+
288
+ “Zero migrations” means **composition from the installed catalogue**. New slots,
289
+ markers and entity types reuse those tables. New catalogue schema versions and
290
+ bespoke storage still need migrations.
291
+
292
+ For a bespoke temperature reading, run:
293
+
294
+ <!-- bespoke:commands install -->
295
+ ```sh
296
+ bin/rails generate ecs_rails:component Temperature celsius:decimal
297
+ bin/rails db:migrate
298
+ ```
299
+
300
+ Inspect the generated migration before running it; decimal precision, scale and
301
+ defaults are application choices. The generator also writes a model and an
302
+ RSpec example (running that spec requires an RSpec Rails setup). Create
303
+ **app/entities/weather_station.rb**:
304
+
305
+ <!-- bespoke:file app/entities/weather_station.rb -->
306
+ ```ruby
307
+ class WeatherStation < ApplicationEntity
308
+ component Temperature
309
+ end
310
+ ```
311
+
312
+ Create **script/temperature.rb**:
313
+
314
+ <!-- bespoke:file script/temperature.rb -->
315
+ ```ruby
316
+ station = WeatherStation.create!(temperature_celsius: 21.5)
317
+ puts station.reload.temperature.celsius # 21.5
318
+ ```
319
+
320
+ <!-- bespoke:commands run -->
321
+ ```sh
322
+ bin/rails runner script/temperature.rb
323
+ bin/rails zeitwerk:check
324
+ ```
325
+
326
+ This app now has a second migration and a dedicated `temperatures` table.
327
+
328
+ ## Upgrading an existing installation
329
+
330
+ Back up the database and try the upgrade on a copy first. From 0.2.x, change
331
+ the Gemfile entry to `gem "ecs_on_rails", "~> 0.3.0"`, then run `bundle update
332
+ ecs_on_rails`. Use development (without eager loading) for generation: an old
333
+ app needs the new generated classes before it can fully boot. Set
334
+ `RAILS_ENV=development` in your shell, then run:
335
+
336
+ <!-- upgrade:commands generate -->
337
+ ```sh
338
+ bin/rails generate ecs_rails:upgrade
339
+ ```
340
+
341
+ Choose `--sets core commerce` if the app needs Money. Review the generated
342
+ migrations, then run:
343
+
344
+ <!-- upgrade:commands migrate -->
345
+ ```sh
346
+ bin/rails db:migrate
347
+ ```
348
+
349
+ The upgrade moves old relationship backing tables and marker rows into shared
350
+ tables, preserving their IDs and links. Replace old marker declarations such as
351
+ `component Moderator` with `marker :moderator`, update `add(Moderator)` /
352
+ `remove(Moderator)` calls to symbols, and remove the obsolete marker class file
353
+ after migration. Review the generator's output for your app's names. Bespoke
354
+ components remain; do not remove their classes.
355
+
356
+ <!-- upgrade:commands verify -->
357
+ ```sh
358
+ bin/rails zeitwerk:check
359
+ bin/rails generate ecs_rails:upgrade
360
+ ```
361
+
362
+ Also run your application's tests. Repeating the generator on the completed
363
+ schema should produce no new migration. Upgrade checks column definitions,
364
+ unique/partial indexes and foreign keys before writing files. Incompatible
365
+ structures need an explicit repair/backfill migration; the generator does not
366
+ convert values or silently replace constraints. New constraints validate existing
367
+ rows when the migration runs. See the
368
+ [upgrade design](https://github.com/kranzky/ecs_rails/blob/main/docs/rfc/0017-catalogue.md).
369
+
370
+ ## Compatibility checks
371
+
372
+ The gem requires Ruby >= 3.2 and Rails >= 7.1, < 9. The representative CI matrix
373
+ covers Ruby/Rails 3.2/7.1, 3.2/7.2, 3.3/8.0, 3.2/8.1, 3.4/8.1 and 4.0/8.1,
374
+ resolving current patches within each Rails minor. It runs PostgreSQL gem specs
375
+ and packaged fresh-install/0.2.2-upgrade checks for each entry. The package
376
+ check extracts the marked code and Rails command blocks from this README,
377
+ then verifies the tutorial's stored results, rendered page and bespoke component. The demo suite,
378
+ eager loading and a 100% public-API documentation gate run separately.
379
+
380
+ JSON is constrained to version 2 because supported Rails decoders still use
381
+ positional option hashes incompatible with JSON 3. This is a runtime dependency,
382
+ so a packaged consumer receives the same constraint as the test suite.
383
+
384
+ To reproduce a Rails series locally from `gem/`:
385
+
73
386
  ```sh
387
+ export BUNDLE_GEMFILE="$PWD/gemfiles/rails_7.1.gemfile"
74
388
  bundle install
75
- rails g ecs_rails:install
76
- rails g ecs_rails:component Email address:string verified:boolean
389
+ DATABASE_URL=postgresql:///ecs_rails_test bundle exec rspec
390
+ DATABASE_URL=postgresql:///ecs_rails_test bundle exec ruby script/package_smoke.rb
77
391
  ```
78
392
 
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.
393
+ The package smoke script creates its own uniquely named PostgreSQL databases;
394
+ its connection role needs permission to create databases. It removes only those
395
+ databases after the run. CI uploads each built gem with its resolved dependency
396
+ lockfile. This tests released versions, not future Ruby/Rails releases.
82
397
 
83
398
  ## Documentation
84
399
 
85
- - **[Architecture](https://github.com/kranzky/ecs_rails/blob/main/docs/architecture.md)** — the invariants. Start here.
400
+ - **[Architecture](https://github.com/kranzky/ecs_rails/blob/main/docs/architecture.md)** — the invariants and design background.
86
401
  - **[v0.1 retrospective](https://github.com/kranzky/ecs_rails/blob/main/docs/retrospective-v0.1.md)** — what was built, what
87
402
  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.
403
+ - **[Source walkthrough](https://github.com/kranzky/ecs_rails/blob/main/docs/source-walkthrough.md)** — follow composition, validation, checkout and indexing through the code.
404
+ - **[ADRs](https://github.com/kranzky/ecs_rails/tree/main/docs/adr)** — why the design is the way it is.
405
+ - **[RFCs](https://github.com/kranzky/ecs_rails/tree/main/docs/rfc)** — feature contracts and their amendments.
91
406
  - **[Backlog](https://github.com/kranzky/ecs_rails/blob/main/docs/backlog.md)** — what deliberately isn't built yet.
92
407
  - **[Friction log](https://github.com/kranzky/ecs_rails/blob/main/docs/friction-log.md)** — the demo's running verdict on
93
408
  the API.
@@ -115,7 +430,7 @@ Set `DATABASE_URL` to point the suite at a different database.
115
430
  | RubyGems gem | `ecs_on_rails` |
116
431
  | Ruby module | `EcsRails` |
117
432
  | `require` | `ecs_rails` |
118
- | Generators | `ecs_rails:install`, `:component`, `:relationship` |
433
+ | Generators | `ecs_rails:install`, `:component`, `:entity`, `:upgrade` |
119
434
 
120
435
  Only the published gem name differs. RubyGems collapses `-`, `_` and case when
121
436
  comparing names, so `ecs-rails`, `ecs_rails` and `ecsrails` are one name — and
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A postal address (schema.org `PostalAddress`), multi-role by nature:
6
+ # `component Address, prefix: :shipping`, `prefix: :billing`.
7
+ #
8
+ # Named `Address`, not `PostalAddress`: ADR-0016 freed the short name
9
+ # (`Email#address` delegates as `email_address`), and the slot reader rule
10
+ # (`#{prefix}_#{singular}`) needs it to read `billing_address`.
11
+ module Address
12
+ extend Definition
13
+
14
+ table "addresses"
15
+ schema do |t|
16
+ t.string :line1, default: nil
17
+ t.string :line2, default: nil
18
+ t.string :locality, default: nil
19
+ t.string :region, default: nil
20
+ t.string :postcode, default: nil
21
+ t.string :country, default: nil, limit: 2
22
+ end
23
+
24
+ included do
25
+ validates :country, format: { with: /\A[A-Z]{2}\z/, allow_blank: true }
26
+ end
27
+
28
+ # @return [String] the address on one line, comma separated
29
+ def one_line
30
+ [line1, line2, locality, region, postcode, country].compact_blank.join(", ")
31
+ end
32
+
33
+ # @return [Array<String>] the address as lines, for a label
34
+ def lines
35
+ [line1, line2, [locality, region, postcode].compact_blank.join(" "), country].compact_blank
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A calendar date: a birthday, a due date. A date is not a timestamp — it has
6
+ # no time zone. Named so as never to shadow Ruby's `Date`.
7
+ module CalendarDate
8
+ extend Definition
9
+
10
+ table "calendar_dates"
11
+ primary_attribute :date
12
+ schema do |t|
13
+ t.date :date, default: nil
14
+ end
15
+
16
+ # @return [Boolean] whether the date is set and before today
17
+ def past?
18
+ date.present? && date < ::Date.current
19
+ end
20
+
21
+ # @return [Boolean]
22
+ def today?
23
+ date == ::Date.current
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # A count: likes, stock, quantity — one slot each. `increment!` and
6
+ # `decrement!` write an atomic `UPDATE ... SET count = count + n` once the
7
+ # row exists.
8
+ module Counter
9
+ extend Definition
10
+
11
+ table "counters"
12
+ primary_attribute :count
13
+ schema do |t|
14
+ t.integer :count, default: 0, null: false
15
+ end
16
+
17
+ # Adds `by` and saves. Atomic on a persisted row.
18
+ #
19
+ # @param by [Integer]
20
+ # @return [Integer] the new count
21
+ def increment!(by = 1)
22
+ if persisted?
23
+ self.class.where(id: id).update_all(["count = count + ?", by])
24
+ reload
25
+ else
26
+ self.count = count + by
27
+ save!
28
+ end
29
+ count
30
+ end
31
+
32
+ # Subtracts `by` and saves.
33
+ #
34
+ # @param by [Integer]
35
+ # @return [Integer] the new count
36
+ def decrement!(by = 1)
37
+ increment!(-by)
38
+ end
39
+
40
+ # @return [Boolean]
41
+ def zero?
42
+ count.zero?
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # Soft delete as presence: an entity is discarded when its Discard row
6
+ # exists with a time. `Post.without_component(Discard)` is "kept".
7
+ module Discard
8
+ extend Definition
9
+
10
+ table "discards"
11
+ schema do |t|
12
+ t.datetime :discarded_at, default: nil
13
+ end
14
+
15
+ # Stamps `discarded_at` and saves.
16
+ #
17
+ # @return [Time]
18
+ def discard!
19
+ update!(discarded_at: Time.current)
20
+ discarded_at
21
+ end
22
+
23
+ # Destroys the row, so the entity reads as kept again.
24
+ #
25
+ # @return [void]
26
+ def undiscard!
27
+ destroy if persisted?
28
+ end
29
+
30
+ # @return [Boolean]
31
+ def discarded?
32
+ discarded_at.present?
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EcsRails
4
+ module Catalogue
5
+ # An email address with a verification flag.
6
+ #
7
+ # Format is a regex, not a delivery check; `verified` is yours to flip
8
+ # (`verify!`) when a confirmation link is followed.
9
+ module Email
10
+ extend Definition
11
+
12
+ table "emails"
13
+ schema do |t|
14
+ t.string :address, default: nil
15
+ t.boolean :verified, default: false, null: false
16
+ end
17
+
18
+ included do
19
+ validates :address, format: { with: URI::MailTo::EMAIL_REGEXP, allow_blank: true }
20
+ end
21
+
22
+ # Marks the address verified and saves.
23
+ #
24
+ # @return [true]
25
+ def verify!
26
+ update!(verified: true)
27
+ end
28
+
29
+ # @return [String, nil] the domain part, e.g. "example.com"
30
+ def domain
31
+ address&.split("@", 2)&.last
32
+ end
33
+ end
34
+ end
35
+ end