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.
- checksums.yaml +4 -4
- data/.yardopts +12 -0
- data/CHANGELOG.md +332 -0
- data/README.md +365 -50
- data/lib/ecs_rails/catalogue/address.rb +39 -0
- data/lib/ecs_rails/catalogue/calendar_date.rb +27 -0
- data/lib/ecs_rails/catalogue/counter.rb +46 -0
- data/lib/ecs_rails/catalogue/discard.rb +36 -0
- data/lib/ecs_rails/catalogue/email.rb +35 -0
- data/lib/ecs_rails/catalogue/geolocation.rb +53 -0
- data/lib/ecs_rails/catalogue/identifier.rb +23 -0
- data/lib/ecs_rails/catalogue/image.rb +28 -0
- data/lib/ecs_rails/catalogue/link.rb +27 -0
- data/lib/ecs_rails/catalogue/marker.rb +38 -0
- data/lib/ecs_rails/catalogue/money.rb +86 -0
- data/lib/ecs_rails/catalogue/name.rb +37 -0
- data/lib/ecs_rails/catalogue/password.rb +50 -0
- data/lib/ecs_rails/catalogue/period.rb +57 -0
- data/lib/ecs_rails/catalogue/phone.rb +30 -0
- data/lib/ecs_rails/catalogue/position.rb +18 -0
- data/lib/ecs_rails/catalogue/rating.rb +25 -0
- data/lib/ecs_rails/catalogue/relationship.rb +159 -0
- data/lib/ecs_rails/catalogue/role.rb +23 -0
- data/lib/ecs_rails/catalogue/schema_diff.rb +198 -0
- data/lib/ecs_rails/catalogue/search_vector.rb +38 -0
- data/lib/ecs_rails/catalogue/state.rb +71 -0
- data/lib/ecs_rails/catalogue/tags.rb +64 -0
- data/lib/ecs_rails/catalogue/text.rb +29 -0
- data/lib/ecs_rails/catalogue/timestamp.rb +34 -0
- data/lib/ecs_rails/catalogue/token.rb +77 -0
- data/lib/ecs_rails/catalogue.rb +405 -0
- data/lib/ecs_rails/component.rb +13 -7
- data/lib/ecs_rails/config.rb +45 -10
- data/lib/ecs_rails/dsl.rb +503 -88
- data/lib/ecs_rails/entity.rb +21 -5
- data/lib/ecs_rails/errors.rb +3 -0
- data/lib/ecs_rails/inverses.rb +398 -0
- data/lib/ecs_rails/lazy.rb +86 -23
- data/lib/ecs_rails/markers.rb +197 -0
- data/lib/ecs_rails/preloading.rb +33 -8
- data/lib/ecs_rails/presence.rb +124 -20
- data/lib/ecs_rails/querying.rb +195 -17
- data/lib/ecs_rails/railtie.rb +10 -0
- data/lib/ecs_rails/registry.rb +113 -18
- data/lib/ecs_rails/relationships.rb +241 -159
- data/lib/ecs_rails/slots.rb +183 -0
- data/lib/ecs_rails/validations.rb +3 -5
- data/lib/ecs_rails/version.rb +4 -1
- data/lib/ecs_rails.rb +51 -6
- data/lib/generators/ecs_rails/catalogue_options.rb +66 -0
- data/lib/generators/ecs_rails/component/component_generator.rb +5 -0
- data/lib/generators/ecs_rails/component/templates/migration.rb.tt +9 -5
- data/lib/generators/ecs_rails/component/templates/model.rb.tt +2 -1
- data/lib/generators/ecs_rails/entity/entity_generator.rb +93 -0
- data/lib/generators/ecs_rails/entity/templates/entity.rb.tt +9 -0
- data/lib/generators/ecs_rails/install/install_generator.rb +35 -8
- data/lib/generators/ecs_rails/install/templates/application_component.rb.tt +3 -2
- data/lib/generators/ecs_rails/install/templates/application_entity.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/initializer.rb.tt +2 -2
- data/lib/generators/ecs_rails/install/templates/install_migration.rb.tt +38 -0
- data/lib/generators/ecs_rails/templates/catalogue_class.rb.tt +10 -0
- data/lib/generators/ecs_rails/upgrade/templates/catalogue_migration.rb.tt +18 -0
- data/lib/generators/ecs_rails/upgrade/templates/markers_migration.rb.tt +31 -0
- data/lib/generators/ecs_rails/upgrade/templates/relationships_migration.rb.tt +33 -0
- data/lib/generators/ecs_rails/upgrade/templates/slots_migration.rb.tt +23 -0
- data/lib/generators/ecs_rails/upgrade/upgrade_generator.rb +281 -0
- metadata +65 -6
- data/lib/generators/ecs_rails/install/templates/migration.rb.tt +0 -20
- data/lib/generators/ecs_rails/relationship/relationship_generator.rb +0 -94
- 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
|
-
|
|
4
|
-
|
|
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
|
-
>
|
|
7
|
-
>
|
|
8
|
-
>
|
|
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
|
-
##
|
|
10
|
+
## Quickstart: a contacts directory
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
56
|
+
class Contact < ApplicationEntity
|
|
20
57
|
component Name
|
|
21
58
|
component Email
|
|
22
|
-
component
|
|
23
|
-
component
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
Post.with_component(PublishState, state: "published")
|
|
57
|
-
User.without_component(Avatar)
|
|
158
|
+
Replace **config/routes.rb**:
|
|
58
159
|
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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
|
-
- **[
|
|
89
|
-
|
|
90
|
-
- **[RFCs](https://github.com/kranzky/ecs_rails/tree/main/docs/rfc)** —
|
|
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`, `:
|
|
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
|