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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/CHANGELOG.md +14 -0
  4. data/EXAMPLES.md +408 -0
  5. data/Gemfile.lock +1 -1
  6. data/README.md +43 -1
  7. data/VERSION +1 -1
  8. data/anchormodel.gemspec +3 -3
  9. data/bin/test +9 -0
  10. data/doc/Anchormodel/ActiveModelTypeValueMulti.html +204 -42
  11. data/doc/Anchormodel/ActiveModelTypeValueSingle.html +243 -54
  12. data/doc/Anchormodel/Attribute.html +60 -37
  13. data/doc/Anchormodel/ModelMixin.html +166 -16
  14. data/doc/Anchormodel/SimpleFormInputs/Helpers/AnchormodelInputsCommon.html +82 -21
  15. data/doc/Anchormodel/SimpleFormInputs/Helpers.html +2 -11
  16. data/doc/Anchormodel/SimpleFormInputs.html +2 -11
  17. data/doc/Anchormodel/Util.html +497 -38
  18. data/doc/Anchormodel/Version.html +2 -20
  19. data/doc/Anchormodel.html +536 -129
  20. data/doc/AnchormodelCheckBoxesInput.html +22 -1
  21. data/doc/AnchormodelGenerator.html +64 -13
  22. data/doc/AnchormodelInput.html +24 -1
  23. data/doc/AnchormodelRadioButtonsInput.html +22 -1
  24. data/doc/_index.html +16 -1
  25. data/doc/class_list.html +1 -1
  26. data/doc/file.README.html +40 -2
  27. data/doc/index.html +40 -2
  28. data/doc/method_list.html +57 -17
  29. data/doc/top-level-namespace.html +1 -1
  30. data/lib/anchormodel/active_model_type_value_multi.rb +31 -5
  31. data/lib/anchormodel/active_model_type_value_single.rb +34 -6
  32. data/lib/anchormodel/attribute.rb +20 -8
  33. data/lib/anchormodel/model_mixin.rb +43 -8
  34. data/lib/anchormodel/simple_form_inputs/anchormodel_check_boxes_input.rb +7 -0
  35. data/lib/anchormodel/simple_form_inputs/anchormodel_input.rb +9 -0
  36. data/lib/anchormodel/simple_form_inputs/anchormodel_radio_buttons_input.rb +7 -0
  37. data/lib/anchormodel/simple_form_inputs/helpers/anchormodel_inputs_common.rb +14 -0
  38. data/lib/anchormodel/util.rb +102 -17
  39. data/lib/anchormodel.rb +109 -15
  40. data/lib/generators/anchormodel/anchormodel_generator.rb +8 -0
  41. data/test/active_record_model/user_test.rb +221 -10
  42. data/test/dummy/app/anchormodels/animal.rb +1 -0
  43. metadata +3 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 96363c644701666a3392626bef0c55f86ae83e7761c4b50cbf543b6dc89d036e
4
- data.tar.gz: 1b35c56261e01962b52fdc7420f5f3c4a5a9976de48fc85021c5a107a75e5acf
3
+ metadata.gz: 39f55bf66ab5a3a2c1ad56640b24a4616e17f9b69ac931c0f1a6dfc5ebd9b92f
4
+ data.tar.gz: f9572c99c3f9e1841d69871fe76a453cabf16e1eb02ab11bf75c8c2d2931af84
5
5
  SHA512:
6
- metadata.gz: dcb23e9e6e9faa80f1d5b9516a187ef29f0b05ffed2b39e9ecb027e69f014ddd292c6bd92ef08be6fdebfb0e5246a0e7537375969ef08d3b034bf68ce5612132
7
- data.tar.gz: 44892a2758b72039713326978eaa96d46fc360a23b0b6f72fbabc270af889dc16cf8b44112f91d3cd527c7ab95d70d18a0ace1ad82b5ed82be8505effd68b6f7
6
+ metadata.gz: 6847ebb6f464aa4090dfcbdb9c6d5a5b4321959716c075d3bfc812f6ad8711b33d004bc5ba2b7a0d1f3b42451bea3bbc52fdc65f7e1bd3bcc399bace9d33bd94
7
+ data.tar.gz: a44b7f56b88fb7e97967eda5014e8b9d53b94135043d656510d6dd9ce69ea243ecfc23e0f7ef4d4814f361912fb35479e666b643f4628c23dd4c7914f0ee3d65
data/.ruby-version CHANGED
@@ -1 +1 @@
1
- 3.2.2
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
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- anchormodel (0.3.1.edge)
4
+ anchormodel (0.3.2.edge)
5
5
  rails (>= 7.0)
6
6
 
7
7
  GEM
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 from the String and find its privilege level
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.3.1
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.3.1 ruby lib
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.3.1".freeze
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 "$@"