anchormodel 0.3.1 → 0.4.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/.ruby-version +1 -1
- data/CHANGELOG.md +14 -0
- data/EXAMPLES.md +408 -0
- data/Gemfile.lock +1 -1
- data/README.md +43 -1
- data/VERSION +1 -1
- data/anchormodel.gemspec +3 -3
- data/bin/test +9 -0
- data/doc/Anchormodel/ActiveModelTypeValueMulti.html +204 -42
- data/doc/Anchormodel/ActiveModelTypeValueSingle.html +243 -54
- data/doc/Anchormodel/Attribute.html +60 -37
- data/doc/Anchormodel/ModelMixin.html +166 -16
- data/doc/Anchormodel/SimpleFormInputs/Helpers/AnchormodelInputsCommon.html +82 -21
- data/doc/Anchormodel/SimpleFormInputs/Helpers.html +2 -11
- data/doc/Anchormodel/SimpleFormInputs.html +2 -11
- data/doc/Anchormodel/Util.html +497 -38
- data/doc/Anchormodel/Version.html +2 -20
- data/doc/Anchormodel.html +536 -129
- data/doc/AnchormodelCheckBoxesInput.html +22 -1
- data/doc/AnchormodelGenerator.html +64 -13
- data/doc/AnchormodelInput.html +24 -1
- data/doc/AnchormodelRadioButtonsInput.html +22 -1
- data/doc/_index.html +16 -1
- data/doc/class_list.html +1 -1
- data/doc/file.README.html +40 -2
- data/doc/index.html +40 -2
- data/doc/method_list.html +57 -17
- data/doc/top-level-namespace.html +1 -1
- data/lib/anchormodel/active_model_type_value_multi.rb +31 -5
- data/lib/anchormodel/active_model_type_value_single.rb +34 -6
- data/lib/anchormodel/attribute.rb +20 -8
- data/lib/anchormodel/model_mixin.rb +43 -8
- data/lib/anchormodel/simple_form_inputs/anchormodel_check_boxes_input.rb +7 -0
- data/lib/anchormodel/simple_form_inputs/anchormodel_input.rb +9 -0
- data/lib/anchormodel/simple_form_inputs/anchormodel_radio_buttons_input.rb +7 -0
- data/lib/anchormodel/simple_form_inputs/helpers/anchormodel_inputs_common.rb +14 -0
- data/lib/anchormodel/util.rb +102 -17
- data/lib/anchormodel.rb +109 -15
- data/lib/generators/anchormodel/anchormodel_generator.rb +8 -0
- data/test/active_record_model/user_test.rb +221 -10
- data/test/dummy/app/anchormodels/animal.rb +1 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 39f55bf66ab5a3a2c1ad56640b24a4616e17f9b69ac931c0f1a6dfc5ebd9b92f
|
|
4
|
+
data.tar.gz: f9572c99c3f9e1841d69871fe76a453cabf16e1eb02ab11bf75c8c2d2931af84
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6847ebb6f464aa4090dfcbdb9c6d5a5b4321959716c075d3bfc812f6ad8711b33d004bc5ba2b7a0d1f3b42451bea3bbc52fdc65f7e1bd3bcc399bace9d33bd94
|
|
7
|
+
data.tar.gz: a44b7f56b88fb7e97967eda5014e8b9d53b94135043d656510d6dd9ce69ea243ecfc23e0f7ef4d4814f361912fb35479e666b643f4628c23dd4c7914f0ee3d65
|
data/.ruby-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.3.5
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
# 0.4.0
|
|
2
|
+
|
|
3
|
+
- Fix `Multi#serializable?` returning non-Boolean values (CSV String / Array) and rejecting `nil`
|
|
4
|
+
- Fix `Multi#cast(nil)` crashing with `NoMethodError` — now returns an empty `Set` (also fixes reader path on NULL DB column via `deserialize`)
|
|
5
|
+
- Fix `Multi#serialize` accepting unvalidated `String` input — invalid keys now raise on assignment instead of being stored verbatim and crashing on the next read
|
|
6
|
+
- Add `with_any_<attr>` / `with_all_<attr>` scopes for `belongs_to_anchormodels` to work around `where(col: array)` not matching CSV-in-column storage
|
|
7
|
+
- Fix `belongs_to_anchormodels` scopes treating `_` / `%` in keys as SQL `LIKE` wildcards — e.g. `:big_cat` no longer cross-matches column values like `"bigXcat,foo"`. Centralizes CSV-contains predicate in `Anchormodel::Util.csv_contains_like` with `ESCAPE '!'`
|
|
8
|
+
- Fix single-value anchormodel attributes silently accepting `Array` assignment (e.g. `u.role = %w[admin guest]`) — now raises immediately. Was a regression from the 0.3.1 `where(col: array)` fix; the Array branch turned out to be dead code since AR's `HomogeneousIn` serializes per element
|
|
9
|
+
- Define `Anchormodel#hash` and `#eql?` to match `#==`. `Set` / `Hash` membership previously relied on instance identity (only safe because entries are singletons via `entries_hash`); copies via `dup` or test doubles now compare correctly by class + key
|
|
10
|
+
- Introduce `Anchormodel::InvalidKey` exception class for unknown keys (raised from `find`, attribute assignment, and `with_any_<attr>` scopes). Inherits from `RuntimeError` so existing `rescue RuntimeError` blocks remain compatible while allowing narrower `rescue Anchormodel::InvalidKey`
|
|
11
|
+
- Update default ruby version to 3.3.5
|
|
12
|
+
- Improve documentation (yard, inline, readmes) for humans and AI usage
|
|
13
|
+
- Add various examples that proudly show various ways of using anchormodels in practice
|
|
14
|
+
|
|
1
15
|
# 0.3.1
|
|
2
16
|
|
|
3
17
|
- Fix `Model.where(anchormodel_col: array)` collapsing to `IN (NULL)` and matching zero rows
|
data/EXAMPLES.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
# Anchormodel in the wild
|
|
2
|
+
|
|
3
|
+
This document takes you on a tour of how to unleash the power of Anchormodel in a production codebase. Each pattern below shows what an Anchormodel adds over a plain Rails Enum and includes a short, anonymized example you can adapt.
|
|
4
|
+
|
|
5
|
+
If all you need is a simple key-to-label mapping, a Rails Enum is perfectly adequate. The real value of Anchormodel emerges as soon as the entries in your enum need to *do* something: carry data, hold behavior, transition between states, reference one another, dispatch to other classes, or register themselves at load time.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Table of contents
|
|
10
|
+
|
|
11
|
+
1. [Plain enumeration](#1-plain-enumeration)
|
|
12
|
+
2. [Ordered enumeration with `Comparable`](#2-ordered-enumeration-with-comparable)
|
|
13
|
+
3. [Rich behavior — calculations and helpers per entry](#3-rich-behavior--calculations-and-helpers-per-entry)
|
|
14
|
+
4. [State machine with allowed transitions](#4-state-machine-with-allowed-transitions)
|
|
15
|
+
5. [Multi-key collection attribute](#5-multi-key-collection-attribute)
|
|
16
|
+
6. [Cross-anchormodel references](#6-cross-anchormodel-references)
|
|
17
|
+
7. [Dynamic / conditional registration](#7-dynamic--conditional-registration)
|
|
18
|
+
8. [Polymorphic class registry](#8-polymorphic-class-registry)
|
|
19
|
+
9. [Mapping external-data payloads to anchormodels](#9-mapping-external-data-payloads-to-anchormodels)
|
|
20
|
+
10. [Gem-internal DSL with self-registration](#10-gem-internal-dsl-with-self-registration)
|
|
21
|
+
|
|
22
|
+
---
|
|
23
|
+
|
|
24
|
+
## 1. Plain enumeration
|
|
25
|
+
|
|
26
|
+
This is the simplest use of Anchormodel: a drop-in replacement for a Rails Enum when you want the keys to be stored as Strings in the database. There is no per-entry behavior — just a closed set of valid values.
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# app/anchormodels/shipment_status.rb
|
|
30
|
+
class ShipmentStatus < Anchormodel
|
|
31
|
+
new :pending
|
|
32
|
+
new :ready
|
|
33
|
+
new :in_transit
|
|
34
|
+
new :delivered
|
|
35
|
+
new :cancelled
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# app/models/order.rb
|
|
39
|
+
class Order < ApplicationRecord
|
|
40
|
+
belongs_to_anchormodel :shipment_status
|
|
41
|
+
end
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ruby
|
|
45
|
+
order.shipment_status # => #<ShipmentStatus<in_transit>>
|
|
46
|
+
order.delivered? # => false
|
|
47
|
+
Order.in_transit # => scope
|
|
48
|
+
Order.where(shipment_status: %i[ready in_transit]) # bulk where
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
You might wonder why you would reach for an Anchormodel here rather than a plain Rails Enum. There are three good reasons. First, the keys are stored as readable strings rather than as integer codes, which makes debugging in `psql` or `mysql` far easier. Second, adding behavior to the enum later requires no migration — you simply add methods to the class. And third, `ShipmentStatus.all` returns first-class Ruby objects that you can pass directly to form helpers, presenters, or service objects without writing translation layers.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## 2. Ordered enumeration with `Comparable`
|
|
56
|
+
|
|
57
|
+
When the entries in your enum have a natural ordering — permission levels, plan tiers, severity ranks — you can include `Comparable` and define `<=>` so that `<`, `>`, and `==` all work between instances.
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
# app/anchormodels/role.rb
|
|
61
|
+
class Role < Anchormodel
|
|
62
|
+
include Comparable
|
|
63
|
+
|
|
64
|
+
attr_reader :privilege_level
|
|
65
|
+
|
|
66
|
+
def <=>(other)
|
|
67
|
+
privilege_level <=> other.privilege_level
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
new :guest, privilege_level: 0
|
|
71
|
+
new :member, privilege_level: 1
|
|
72
|
+
new :moderator, privilege_level: 2
|
|
73
|
+
new :admin, privilege_level: 3
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
# Privilege checks now read like English:
|
|
79
|
+
def can_edit?(actor, target)
|
|
80
|
+
actor.role > target.role
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Sort users by privilege:
|
|
84
|
+
User.all.sort_by(&:role)
|
|
85
|
+
|
|
86
|
+
# Pick the highest-privileged user in a group:
|
|
87
|
+
group.members.max_by(&:role)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Trying to model this with a Rails Enum would force you to either compare integer codes directly (which is a leaky abstraction — the code that compares roles should never need to know that `:admin` happens to be backed by `3`) or to scatter helper methods like `admin?`, `at_least_moderator?`, and so on across the `User` model. The Anchormodel version keeps the ordering logic in exactly one place: the enum itself.
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## 3. Rich behavior — calculations and helpers per entry
|
|
95
|
+
|
|
96
|
+
When each entry needs to carry domain data *and* the methods that operate on that data, Anchormodel becomes a natural home for both. A classic example is a tax-rate enum where each rate knows how to convert between net and gross prices.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
# app/anchormodels/tax_rate.rb
|
|
100
|
+
class TaxRate < Anchormodel
|
|
101
|
+
attr_reader :percentage
|
|
102
|
+
|
|
103
|
+
new :exempt, percentage: 0.0
|
|
104
|
+
new :reduced, percentage: 2.5
|
|
105
|
+
new :standard, percentage: 8.1
|
|
106
|
+
|
|
107
|
+
def label
|
|
108
|
+
"#{super} (#{percentage}%)"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def factor
|
|
112
|
+
percentage / 100.0
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def gross_from_net(net)
|
|
116
|
+
net.to_f * (1 + factor)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def net_from_gross(gross)
|
|
120
|
+
gross.to_f / (1 + factor)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def tax_amount_from_gross(gross)
|
|
124
|
+
gross.to_f * factor / (1 + factor)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
product.tax_rate.gross_from_net(100) # => 108.1
|
|
131
|
+
invoice.line_items.sum { |li| li.tax_rate.tax_amount_from_gross(li.amount) }
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The arithmetic lives where the rate is *defined*, rather than in a separate `TaxCalculator` service that would need a long `case` statement on the rate key. Adding a new tax rate is a one-line change to the Anchormodel class, and none of the calling code has to be touched.
|
|
135
|
+
|
|
136
|
+
---
|
|
137
|
+
|
|
138
|
+
## 4. State machine with allowed transitions
|
|
139
|
+
|
|
140
|
+
When each enum entry represents a state in a workflow, Anchormodel lets you declare the allowed transitions as first-class data on each entry. The UI can then render the available next states directly, without duplicating workflow knowledge in views or controllers.
|
|
141
|
+
|
|
142
|
+
```ruby
|
|
143
|
+
# app/anchormodels/ticket_status.rb
|
|
144
|
+
class TicketStatus < Anchormodel
|
|
145
|
+
attr_reader :button_label_key
|
|
146
|
+
|
|
147
|
+
new :draft, allowed_next: %i[open], button_label_key: ''
|
|
148
|
+
new :open, allowed_next: %i[in_progress closed], button_label_key: N_('TicketStatus|Open|Button')
|
|
149
|
+
new :in_progress, allowed_next: %i[open closed], button_label_key: N_('TicketStatus|In progress|Button')
|
|
150
|
+
new :closed, allowed_next: %i[open], button_label_key: N_('TicketStatus|Closed|Button')
|
|
151
|
+
|
|
152
|
+
def allowed_next
|
|
153
|
+
@allowed_next.map { |key| self.class.find(key) }
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
```erb
|
|
159
|
+
<% ticket.status.allowed_next.each do |next_status| %>
|
|
160
|
+
<%= button_to next_status.button_label, transition_ticket_path(ticket, to: next_status.key) %>
|
|
161
|
+
<% end %>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The view does not need to know which transitions are allowed from a given state — the Anchormodel does. When the workflow changes, you edit one file. The UI follows automatically.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## 5. Multi-key collection attribute
|
|
169
|
+
|
|
170
|
+
When a record can hold *many* enum values at once, Anchormodel supports storing them as a CSV string in a single column. There is no join table to maintain and no `has_and_belongs_to_many` boilerplate. You declare the attribute with `belongs_to_anchormodels` and get back a `Set` of Anchormodel instances, plus a small collection of query scopes for free.
|
|
171
|
+
|
|
172
|
+
```ruby
|
|
173
|
+
# app/anchormodels/feature.rb
|
|
174
|
+
class Feature < Anchormodel
|
|
175
|
+
new :waterproof
|
|
176
|
+
new :bluetooth
|
|
177
|
+
new :noise_cancelling
|
|
178
|
+
new :wireless_charging
|
|
179
|
+
new :usb_c
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# app/models/product.rb
|
|
183
|
+
class Product < ApplicationRecord
|
|
184
|
+
belongs_to_anchormodels :features
|
|
185
|
+
end
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```ruby
|
|
189
|
+
product = Product.create!(features: %i[bluetooth usb_c])
|
|
190
|
+
product.features # => #<Set: {#<Feature<bluetooth>>, #<Feature<usb_c>>}>
|
|
191
|
+
product.bluetooth? # => true
|
|
192
|
+
product.features << :waterproof # mutates and persists
|
|
193
|
+
product.features.delete(:bluetooth)
|
|
194
|
+
|
|
195
|
+
# Bulk-key queries. (Plain `where(col: array)` is unsuitable here because the
|
|
196
|
+
# column stores a CSV string rather than one row per feature.)
|
|
197
|
+
Product.with_any_features(:waterproof, :bluetooth) # OR semantics
|
|
198
|
+
Product.with_all_features(:waterproof, :bluetooth) # AND semantics
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The backing column is a single `string`. There is no migration to add a join table, and adding or removing a feature key never touches the schema. The trade-off is that you lose database-level referential integrity on the keys — but since the keys are defined in code rather than in data, that is exactly the right trade-off.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## 6. Cross-anchormodel references
|
|
206
|
+
|
|
207
|
+
Anchormodels can reference other Anchormodels in their attributes, which lets you build a small static graph of related constants. This is useful for modeling things like locales, regions, or category hierarchies.
|
|
208
|
+
|
|
209
|
+
```ruby
|
|
210
|
+
# app/anchormodels/country.rb
|
|
211
|
+
class Country < Anchormodel
|
|
212
|
+
attr_reader :currency, :phone_prefix
|
|
213
|
+
|
|
214
|
+
new :ch, currency: Currency.find('CHF'), phone_prefix: '+41'
|
|
215
|
+
new :de, currency: Currency.find('EUR'), phone_prefix: '+49'
|
|
216
|
+
new :fr, currency: Currency.find('EUR'), phone_prefix: '+33'
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
# app/anchormodels/locale.rb
|
|
220
|
+
class Locale < Anchormodel
|
|
221
|
+
attr_reader :language, :country
|
|
222
|
+
|
|
223
|
+
new :'de-ch', language: Language.find(:de), country: Country.find(:ch)
|
|
224
|
+
new :'fr-ch', language: Language.find(:fr), country: Country.find(:ch)
|
|
225
|
+
new :'de-de', language: Language.find(:de), country: Country.find(:de)
|
|
226
|
+
new :'fr-fr', language: Language.find(:fr), country: Country.find(:fr)
|
|
227
|
+
|
|
228
|
+
# Find all locales available in a country
|
|
229
|
+
def self.in(country)
|
|
230
|
+
all.select { |l| l.country == country }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
```ruby
|
|
236
|
+
user.locale.country.currency.key # => :CHF
|
|
237
|
+
Locale.in(Country.find(:ch)) # => [<de-ch>, <fr-ch>]
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Every reference is resolved at class-load time. There are no per-request database lookups, no foreign keys, and no risk of an orphaned reference at runtime.
|
|
241
|
+
|
|
242
|
+
---
|
|
243
|
+
|
|
244
|
+
## 7. Dynamic / conditional registration
|
|
245
|
+
|
|
246
|
+
The list of registered entries does not have to be hard-coded. You can register entries conditionally based on environment variables, configuration, or values read from another library at load time. This is particularly useful when different deployments of the same codebase need to expose different subsets of an enum.
|
|
247
|
+
|
|
248
|
+
```ruby
|
|
249
|
+
# app/anchormodels/report_template.rb
|
|
250
|
+
class ReportTemplate < Anchormodel
|
|
251
|
+
ENABLED = ENV.fetch('REPORT_TEMPLATES', '').split(',').to_set
|
|
252
|
+
|
|
253
|
+
def self.new_if_enabled(key, **attrs)
|
|
254
|
+
return unless ENABLED.include?(key.to_s)
|
|
255
|
+
new(key, **attrs)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
attr_reader :layout, :template, :include_signature
|
|
259
|
+
|
|
260
|
+
new_if_enabled :sales_quote, layout: 'letter', template: 'sales/quote', include_signature: false
|
|
261
|
+
new_if_enabled :sales_invoice, layout: 'letter', template: 'sales/invoice', include_signature: true
|
|
262
|
+
new_if_enabled :delivery_note, layout: 'letter', template: 'logistics/dn', include_signature: true
|
|
263
|
+
end
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
A second variant of the pattern auto-populates its entries from another library's registry:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
# app/anchormodels/supported_locale.rb
|
|
270
|
+
class SupportedLocale < Anchormodel
|
|
271
|
+
new :en # make English the first entry
|
|
272
|
+
I18n.available_locales.each do |locale|
|
|
273
|
+
new locale.to_sym unless locale.to_sym == :en
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
The set of valid keys reflects the reality of the current deployment, which means you never carry around dead constants for features that are disabled in this build.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## 8. Polymorphic class registry
|
|
283
|
+
|
|
284
|
+
When each entry maps a key to a Ruby class (along with any per-key configuration), you can use the Anchormodel as a dispatch table. Calling code looks up the entry, asks it for the class, and delegates the rest.
|
|
285
|
+
|
|
286
|
+
```ruby
|
|
287
|
+
# app/anchormodels/block_type.rb
|
|
288
|
+
class BlockType < Anchormodel
|
|
289
|
+
attr_reader :component_class, :explanation_key
|
|
290
|
+
|
|
291
|
+
new :hero,
|
|
292
|
+
component_class: Blocks::Hero,
|
|
293
|
+
explanation_key: N_('BlockType|Hero|Explanation')
|
|
294
|
+
new :gallery,
|
|
295
|
+
component_class: Blocks::Gallery,
|
|
296
|
+
explanation_key: N_('BlockType|Gallery|Explanation')
|
|
297
|
+
new :testimonial,
|
|
298
|
+
component_class: Blocks::Testimonial,
|
|
299
|
+
explanation_key: N_('BlockType|Testimonial|Explanation')
|
|
300
|
+
|
|
301
|
+
def explanation
|
|
302
|
+
_(@explanation_key)
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
def render_block(block)
|
|
309
|
+
block_type = BlockType.find(block.type)
|
|
310
|
+
block_type.component_class.new(block).render
|
|
311
|
+
end
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
Adding a new block type is a one-line addition to the Anchormodel — the dispatch site does not change. This is the open/closed principle expressed on top of plain Ruby constants.
|
|
315
|
+
|
|
316
|
+
---
|
|
317
|
+
|
|
318
|
+
## 9. Mapping external-data payloads to anchormodels
|
|
319
|
+
|
|
320
|
+
When you import data from an external API or feed, it is good practice to parse the foreign representation into Anchormodel instances right at the boundary. The rest of the application then only ever deals with strongly-typed Anchormodel objects.
|
|
321
|
+
|
|
322
|
+
```ruby
|
|
323
|
+
class Feature < Anchormodel
|
|
324
|
+
new :elevator
|
|
325
|
+
new :ramp
|
|
326
|
+
new :lifting_platform
|
|
327
|
+
# ... etc.
|
|
328
|
+
|
|
329
|
+
# Translate from an upstream IDX-style feed into a list of Feature instances.
|
|
330
|
+
def self.from_external_payload(payload)
|
|
331
|
+
result = []
|
|
332
|
+
result << find(:elevator) if truthy?(payload[:prop_elevator])
|
|
333
|
+
result << find(:ramp) if truthy?(payload[:has_ramp])
|
|
334
|
+
result << find(:lifting_platform) if truthy?(payload[:lift_platform])
|
|
335
|
+
result
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def self.truthy?(value)
|
|
339
|
+
[1, '1', 'Y', 'y', true, 'true'].include?(value)
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
```ruby
|
|
345
|
+
listing.features = Feature.from_external_payload(idx_row)
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
The translation layer lives right next to the enum it produces, so there is no `FeatureMapper` class floating around in some unrelated directory. Unknown fields in the payload simply produce no feature, rather than crashing the import.
|
|
349
|
+
|
|
350
|
+
---
|
|
351
|
+
|
|
352
|
+
## 10. Gem-internal DSL with self-registration
|
|
353
|
+
|
|
354
|
+
Anchormodel is also useful *inside* gems, as a registry that other classes populate at load time. Each subclass of a DSL base class registers an Anchormodel entry pointing back at itself, so that the rest of the gem can discover and dispatch to those subclasses without a central manifest.
|
|
355
|
+
|
|
356
|
+
```ruby
|
|
357
|
+
# lib/workflow/step_kind.rb
|
|
358
|
+
class StepKind < Anchormodel
|
|
359
|
+
attr_reader :step_class, :next_step_keys
|
|
360
|
+
|
|
361
|
+
# Steps register themselves via `WorkflowStep.inherited` (see below).
|
|
362
|
+
def self.register(step_class, key:, next_step_keys: [])
|
|
363
|
+
new(key, step_class: step_class, next_step_keys: next_step_keys)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def next_steps
|
|
367
|
+
@next_step_keys.map { |k| self.class.find(k) }
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
# lib/workflow/step.rb
|
|
372
|
+
class WorkflowStep
|
|
373
|
+
def self.inherited(subclass)
|
|
374
|
+
super
|
|
375
|
+
subclass.singleton_class.attr_accessor :step_kind_key, :step_kind_next
|
|
376
|
+
|
|
377
|
+
subclass.define_singleton_method(:register_kind) do |key:, next_keys: []|
|
|
378
|
+
StepKind.register(subclass, key: key, next_step_keys: next_keys)
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
# config/initializers/load_workflow_steps.rb
|
|
384
|
+
Dir[Rails.root.join('app/workflow/**/*.rb')].each { |f| require f }
|
|
385
|
+
|
|
386
|
+
# app/workflow/steps/payment_step.rb
|
|
387
|
+
class PaymentStep < WorkflowStep
|
|
388
|
+
register_kind key: :payment, next_keys: %i[confirmation]
|
|
389
|
+
# ...
|
|
390
|
+
end
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
```ruby
|
|
394
|
+
# Anywhere in the app:
|
|
395
|
+
kind = StepKind.find(workflow.current_step_key)
|
|
396
|
+
kind.step_class.new(workflow).run
|
|
397
|
+
|
|
398
|
+
# Walk the DAG of allowed transitions:
|
|
399
|
+
kind.next_steps.each { |s| ... }
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
The registry is populated automatically as soon as the step classes are autoloaded. Adding a new step type is a one-file change, and there is no central manifest that needs to be updated alongside it.
|
|
403
|
+
|
|
404
|
+
---
|
|
405
|
+
|
|
406
|
+
## Mixing patterns
|
|
407
|
+
|
|
408
|
+
Real codebases tend to combine several of these patterns in a single Anchormodel. For example, a `Status` class might be ordered (`Comparable`), carry rendering behavior, declare its allowed state transitions, *and* reference another Anchormodel for related metadata. Anchormodel imposes no structure beyond the basic `new :key, **attrs` registration — what each entry holds and does is entirely up to you.
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -7,6 +7,10 @@ Enums](https://api.rubyonrails.org/v7.0/classes/ActiveRecord/Enum.html). In
|
|
|
7
7
|
contrast to regular Enums, Anchormodels can hold application logic, making them
|
|
8
8
|
ideal for tying code to database objects.
|
|
9
9
|
|
|
10
|
+
For a tour of real-world usage patterns — ordered enums with `Comparable`,
|
|
11
|
+
state machines, polymorphic class registries, multi-key collection attributes,
|
|
12
|
+
cross-anchormodel references, and more — see [EXAMPLES.md](EXAMPLES.md).
|
|
13
|
+
|
|
10
14
|
# Use case
|
|
11
15
|
|
|
12
16
|
Typically, a Rails application consists of three kinds of state:
|
|
@@ -101,8 +105,14 @@ You may now use the following methods:
|
|
|
101
105
|
# Retrieve all user roles:
|
|
102
106
|
Role.all
|
|
103
107
|
|
|
104
|
-
# Retrieve a specific role
|
|
108
|
+
# Retrieve a specific role. `find` accepts a String, a Symbol, or `nil` (returns nil).
|
|
105
109
|
Role.find(:guest).privilege_level
|
|
110
|
+
Role.find('guest').privilege_level # equivalent
|
|
111
|
+
|
|
112
|
+
# Assignment accepts a String, Symbol, or Anchormodel instance:
|
|
113
|
+
@user.role = 'admin'
|
|
114
|
+
@user.role = :admin
|
|
115
|
+
@user.role = Role.find(:admin)
|
|
106
116
|
|
|
107
117
|
# Implement a Rails helper that makes sure users can only edit other users that have a lower privilege level than themselves
|
|
108
118
|
def user_can_edit?(this_user, other_user)
|
|
@@ -116,6 +126,20 @@ puts("User #{@user.name} has role #{@user.role.label}")
|
|
|
116
126
|
@user.role.admin? # true if and only if the role is admin (false otherwise)
|
|
117
127
|
```
|
|
118
128
|
|
|
129
|
+
## Error handling
|
|
130
|
+
|
|
131
|
+
Anchormodel raises `Anchormodel::InvalidKey` whenever an unknown key is supplied — from `find`, from attribute writers, and from the bulk-key scopes shown below:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
begin
|
|
135
|
+
Role.find(:does_not_exist)
|
|
136
|
+
rescue Anchormodel::InvalidKey => e
|
|
137
|
+
# e.message: "Retrieved undefined anchor model key :does_not_exist for Role."
|
|
138
|
+
end
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`Anchormodel::InvalidKey` inherits from `RuntimeError`, so existing `rescue RuntimeError` blocks remain compatible while allowing the narrower `rescue Anchormodel::InvalidKey`.
|
|
142
|
+
|
|
119
143
|
Your form could look something like this:
|
|
120
144
|
|
|
121
145
|
```erb
|
|
@@ -264,6 +288,24 @@ u.roles.clear
|
|
|
264
288
|
|
|
265
289
|
Note that no other methods of Set are overwritten at this point - if you use any other methods mutating the underlying Set, your changes will not be applied.
|
|
266
290
|
|
|
291
|
+
## Querying a collection of Anchormodels
|
|
292
|
+
|
|
293
|
+
Because keys are stored as a CSV string in a single column, the standard Rails idiom `Model.where(col: array)` does **not** work for `belongs_to_anchormodels` attributes — it compiles to an `IN (...)` clause that compares against the full column value (e.g. `"cat,dog"`) rather than the individual entries.
|
|
294
|
+
|
|
295
|
+
Anchormodel provides two helper scopes for bulk-key queries:
|
|
296
|
+
|
|
297
|
+
```ruby
|
|
298
|
+
# Users that hold at least one of the given keys
|
|
299
|
+
User.with_any_animals(:cat, :dog)
|
|
300
|
+
|
|
301
|
+
# Users that hold every given key
|
|
302
|
+
User.with_all_animals(:cat, :dog)
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
Both scopes accept `String`, `Symbol`, or `Anchormodel` instances (and nested arrays), and raise `Anchormodel::InvalidKey` on invalid keys. They are always defined for every `belongs_to_anchormodels` attribute (independent of the `model_scopes` setting), named `with_any_<attribute_name>` / `with_all_<attribute_name>`.
|
|
306
|
+
|
|
307
|
+
For a single key, the per-key scope generated by `model_scopes` (e.g. `User.cat`) is the most concise option.
|
|
308
|
+
|
|
267
309
|
## Basic rails form for a collection of Anchormodels
|
|
268
310
|
|
|
269
311
|
```erb
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.
|
|
1
|
+
0.4.0
|
data/anchormodel.gemspec
CHANGED
|
@@ -2,17 +2,17 @@
|
|
|
2
2
|
# This file is auto-generated via: 'rake gemspec'.
|
|
3
3
|
|
|
4
4
|
# -*- encoding: utf-8 -*-
|
|
5
|
-
# stub: anchormodel 0.
|
|
5
|
+
# stub: anchormodel 0.4.0 ruby lib
|
|
6
6
|
|
|
7
7
|
Gem::Specification.new do |s|
|
|
8
8
|
s.name = "anchormodel".freeze
|
|
9
|
-
s.version = "0.
|
|
9
|
+
s.version = "0.4.0".freeze
|
|
10
10
|
|
|
11
11
|
s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version=
|
|
12
12
|
s.require_paths = ["lib".freeze]
|
|
13
13
|
s.authors = ["Sandro Kalbermatter".freeze]
|
|
14
14
|
s.date = "1980-01-02"
|
|
15
|
-
s.files = [".gitignore".freeze, ".ruby-version".freeze, ".yardopts".freeze, "CHANGELOG.md".freeze, "Gemfile".freeze, "Gemfile.lock".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "VERSION".freeze, "anchormodel.gemspec".freeze, "bin/rails".freeze, "doc/Anchormodel.html".freeze, "doc/Anchormodel/ActiveModelTypeValue.html".freeze, "doc/Anchormodel/ActiveModelTypeValueMulti.html".freeze, "doc/Anchormodel/ActiveModelTypeValueSingle.html".freeze, "doc/Anchormodel/Attribute.html".freeze, "doc/Anchormodel/ModelMixin.html".freeze, "doc/Anchormodel/SimpleFormInputs.html".freeze, "doc/Anchormodel/SimpleFormInputs/Helpers.html".freeze, "doc/Anchormodel/SimpleFormInputs/Helpers/AnchormodelInputsCommon.html".freeze, "doc/Anchormodel/Util.html".freeze, "doc/Anchormodel/Version.html".freeze, "doc/AnchormodelCheckBoxesInput.html".freeze, "doc/AnchormodelGenerator.html".freeze, "doc/AnchormodelInput.html".freeze, "doc/AnchormodelRadioButtonsInput.html".freeze, "doc/_index.html".freeze, "doc/class_list.html".freeze, "doc/css/common.css".freeze, "doc/css/full_list.css".freeze, "doc/css/style.css".freeze, "doc/file.README.html".freeze, "doc/file_list.html".freeze, "doc/frames.html".freeze, "doc/index.html".freeze, "doc/js/app.js".freeze, "doc/js/full_list.js".freeze, "doc/js/jquery.js".freeze, "doc/method_list.html".freeze, "doc/top-level-namespace.html".freeze, "lib/anchormodel.rb".freeze, "lib/anchormodel/active_model_type_value_multi.rb".freeze, "lib/anchormodel/active_model_type_value_single.rb".freeze, "lib/anchormodel/attribute.rb".freeze, "lib/anchormodel/model_mixin.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_check_boxes_input.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_input.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_radio_buttons_input.rb".freeze, "lib/anchormodel/simple_form_inputs/helpers/anchormodel_inputs_common.rb".freeze, "lib/anchormodel/util.rb".freeze, "lib/anchormodel/version.rb".freeze, "lib/generators/anchormodel/USAGE".freeze, "lib/generators/anchormodel/anchormodel_generator.rb".freeze, "lib/generators/anchormodel/templates/anchormodel.rb.erb".freeze, "logo.svg".freeze, "test/active_record_model/user_test.rb".freeze, "test/dummy/.gitignore".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/anchormodels/animal.rb".freeze, "test/dummy/app/anchormodels/locale.rb".freeze, "test/dummy/app/anchormodels/role.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/credentials.yml.enc".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/initializers/content_security_policy.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/permissions_policy.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/db/migrate/20230107173151_create_users.rb".freeze, "test/dummy/db/migrate/20240425182000_add_animals_to_users.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/db/seeds.rb".freeze, "test/dummy/lib/tasks/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/tmp/.keep".freeze, "test/dummy/tmp/pids/.keep".freeze, "test/test_helper.rb".freeze]
|
|
15
|
+
s.files = [".gitignore".freeze, ".ruby-version".freeze, ".yardopts".freeze, "CHANGELOG.md".freeze, "EXAMPLES.md".freeze, "Gemfile".freeze, "Gemfile.lock".freeze, "LICENSE".freeze, "README.md".freeze, "Rakefile".freeze, "VERSION".freeze, "anchormodel.gemspec".freeze, "bin/rails".freeze, "bin/test".freeze, "doc/Anchormodel.html".freeze, "doc/Anchormodel/ActiveModelTypeValue.html".freeze, "doc/Anchormodel/ActiveModelTypeValueMulti.html".freeze, "doc/Anchormodel/ActiveModelTypeValueSingle.html".freeze, "doc/Anchormodel/Attribute.html".freeze, "doc/Anchormodel/ModelMixin.html".freeze, "doc/Anchormodel/SimpleFormInputs.html".freeze, "doc/Anchormodel/SimpleFormInputs/Helpers.html".freeze, "doc/Anchormodel/SimpleFormInputs/Helpers/AnchormodelInputsCommon.html".freeze, "doc/Anchormodel/Util.html".freeze, "doc/Anchormodel/Version.html".freeze, "doc/AnchormodelCheckBoxesInput.html".freeze, "doc/AnchormodelGenerator.html".freeze, "doc/AnchormodelInput.html".freeze, "doc/AnchormodelRadioButtonsInput.html".freeze, "doc/_index.html".freeze, "doc/class_list.html".freeze, "doc/css/common.css".freeze, "doc/css/full_list.css".freeze, "doc/css/style.css".freeze, "doc/file.README.html".freeze, "doc/file_list.html".freeze, "doc/frames.html".freeze, "doc/index.html".freeze, "doc/js/app.js".freeze, "doc/js/full_list.js".freeze, "doc/js/jquery.js".freeze, "doc/method_list.html".freeze, "doc/top-level-namespace.html".freeze, "lib/anchormodel.rb".freeze, "lib/anchormodel/active_model_type_value_multi.rb".freeze, "lib/anchormodel/active_model_type_value_single.rb".freeze, "lib/anchormodel/attribute.rb".freeze, "lib/anchormodel/model_mixin.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_check_boxes_input.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_input.rb".freeze, "lib/anchormodel/simple_form_inputs/anchormodel_radio_buttons_input.rb".freeze, "lib/anchormodel/simple_form_inputs/helpers/anchormodel_inputs_common.rb".freeze, "lib/anchormodel/util.rb".freeze, "lib/anchormodel/version.rb".freeze, "lib/generators/anchormodel/USAGE".freeze, "lib/generators/anchormodel/anchormodel_generator.rb".freeze, "lib/generators/anchormodel/templates/anchormodel.rb.erb".freeze, "logo.svg".freeze, "test/active_record_model/user_test.rb".freeze, "test/dummy/.gitignore".freeze, "test/dummy/Rakefile".freeze, "test/dummy/app/anchormodels/animal.rb".freeze, "test/dummy/app/anchormodels/locale.rb".freeze, "test/dummy/app/anchormodels/role.rb".freeze, "test/dummy/app/helpers/application_helper.rb".freeze, "test/dummy/app/models/application_record.rb".freeze, "test/dummy/app/models/concerns/.keep".freeze, "test/dummy/app/models/user.rb".freeze, "test/dummy/bin/rails".freeze, "test/dummy/bin/rake".freeze, "test/dummy/bin/setup".freeze, "test/dummy/config.ru".freeze, "test/dummy/config/application.rb".freeze, "test/dummy/config/boot.rb".freeze, "test/dummy/config/credentials.yml.enc".freeze, "test/dummy/config/database.yml".freeze, "test/dummy/config/environment.rb".freeze, "test/dummy/config/environments/test.rb".freeze, "test/dummy/config/initializers/content_security_policy.rb".freeze, "test/dummy/config/initializers/filter_parameter_logging.rb".freeze, "test/dummy/config/initializers/inflections.rb".freeze, "test/dummy/config/initializers/permissions_policy.rb".freeze, "test/dummy/config/locales/en.yml".freeze, "test/dummy/config/puma.rb".freeze, "test/dummy/config/routes.rb".freeze, "test/dummy/db/migrate/20230107173151_create_users.rb".freeze, "test/dummy/db/migrate/20240425182000_add_animals_to_users.rb".freeze, "test/dummy/db/schema.rb".freeze, "test/dummy/db/seeds.rb".freeze, "test/dummy/lib/tasks/.keep".freeze, "test/dummy/log/.keep".freeze, "test/dummy/tmp/.keep".freeze, "test/dummy/tmp/pids/.keep".freeze, "test/test_helper.rb".freeze]
|
|
16
16
|
s.homepage = "https://github.com/kalsan/anchormodel".freeze
|
|
17
17
|
s.licenses = ["LGPL-3.0-or-later".freeze]
|
|
18
18
|
s.required_ruby_version = Gem::Requirement.new(">= 3.0.0".freeze)
|
data/bin/test
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
# Runs the gem's test suite via `bundle exec`. Uses `ruby -S rake` to bypass the
|
|
4
|
+
# rake binstub's hard-coded `#!/usr/bin/env ruby3.3` shebang, which fails on
|
|
5
|
+
# systems where the active Ruby is not symlinked to `ruby3.3`.
|
|
6
|
+
|
|
7
|
+
set -e
|
|
8
|
+
cd "$(dirname "$0")/.."
|
|
9
|
+
exec bundle exec ruby -S rake test "$@"
|