anchormodel 0.3.0 → 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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +3 -1
  3. data/.ruby-version +1 -1
  4. data/CHANGELOG.md +19 -0
  5. data/EXAMPLES.md +408 -0
  6. data/Gemfile.lock +8 -8
  7. data/README.md +43 -1
  8. data/VERSION +1 -1
  9. data/anchormodel.gemspec +4 -4
  10. data/bin/test +9 -0
  11. data/doc/Anchormodel/ActiveModelTypeValueMulti.html +204 -42
  12. data/doc/Anchormodel/ActiveModelTypeValueSingle.html +245 -92
  13. data/doc/Anchormodel/Attribute.html +60 -37
  14. data/doc/Anchormodel/ModelMixin.html +166 -16
  15. data/doc/Anchormodel/SimpleFormInputs/Helpers/AnchormodelInputsCommon.html +82 -21
  16. data/doc/Anchormodel/SimpleFormInputs/Helpers.html +2 -11
  17. data/doc/Anchormodel/SimpleFormInputs.html +2 -11
  18. data/doc/Anchormodel/Util.html +497 -38
  19. data/doc/Anchormodel/Version.html +2 -20
  20. data/doc/Anchormodel.html +536 -129
  21. data/doc/AnchormodelCheckBoxesInput.html +22 -1
  22. data/doc/AnchormodelGenerator.html +64 -13
  23. data/doc/AnchormodelInput.html +24 -1
  24. data/doc/AnchormodelRadioButtonsInput.html +22 -1
  25. data/doc/_index.html +16 -1
  26. data/doc/class_list.html +1 -1
  27. data/doc/file.README.html +40 -2
  28. data/doc/index.html +40 -2
  29. data/doc/method_list.html +57 -17
  30. data/doc/top-level-namespace.html +1 -1
  31. data/lib/anchormodel/active_model_type_value_multi.rb +31 -5
  32. data/lib/anchormodel/active_model_type_value_single.rb +51 -13
  33. data/lib/anchormodel/attribute.rb +20 -8
  34. data/lib/anchormodel/model_mixin.rb +43 -8
  35. data/lib/anchormodel/simple_form_inputs/anchormodel_check_boxes_input.rb +7 -0
  36. data/lib/anchormodel/simple_form_inputs/anchormodel_input.rb +9 -0
  37. data/lib/anchormodel/simple_form_inputs/anchormodel_radio_buttons_input.rb +7 -0
  38. data/lib/anchormodel/simple_form_inputs/helpers/anchormodel_inputs_common.rb +14 -0
  39. data/lib/anchormodel/util.rb +102 -17
  40. data/lib/anchormodel.rb +109 -15
  41. data/lib/generators/anchormodel/anchormodel_generator.rb +8 -0
  42. data/test/active_record_model/user_test.rb +256 -6
  43. data/test/dummy/app/anchormodels/animal.rb +1 -0
  44. metadata +4 -2
@@ -1,16 +1,29 @@
1
- # @api description
2
- # A swiss army knife for common functionality
1
+ # Internal utilities used by the {Anchormodel::ModelMixin#belongs_to_anchormodel} and
2
+ # {Anchormodel::ModelMixin#belongs_to_anchormodels} macros. The public-facing entry
3
+ # points are the macros themselves; methods here are the wiring that installs the
4
+ # generated readers, writers, scopes, type casters, and helper scopes.
3
5
  module Anchormodel::Util
4
- # Installs an anchormodel attribute in a model class
5
- # @param model_class [ActiveRecord::Base] Internal only. The model class that the attribute should be installed to.
6
- # @param attribute_name [String,Symbol] The name and database column of the attribute
7
- # @param anchormodel_class [Class] Class of the Anchormodel (omit if attribute `:foo_bar` holds a `FooBar`)
8
- # @param optional [Boolean] If false, a presence validation is added to the model. Forced to true if multiple is true.
9
- # @param multiple [Boolean] Internal only. Distinguishes between `belongs_to_anchormodel` and `belongs_to_anchormodels`.
10
- # @param model_readers [Boolean] If true, the model is given an ActiveRecord::Enum style method `my_model.my_key?` reader for each key in the anchormodel
11
- # @param model_writers [Boolean] If true, the model is given an ActiveRecord::Enum style method `my_model.my_key!` writer for each key in the anchormodel
12
- # @param model_scopes [Boolean] If true, the model is given an ActiveRecord::Enum style scope `MyModel.mykey` for each key in the anchormodel
13
- # @param model_methods [Boolean, NilClass] If non-nil, this mass-assigns and overrides `model_readers`, `model_writers` and `model_scopes`
6
+ # Installs an anchormodel attribute in a model class. Wires up the AR `attribute`
7
+ # type cast, the custom reader/writer, the per-key readers/writers/scopes, and (for
8
+ # `belongs_to_anchormodels`) the bulk-key `with_any_<attr>` / `with_all_<attr>` scopes.
9
+ #
10
+ # Called from {Anchormodel::ModelMixin#belongs_to_anchormodel} and
11
+ # {Anchormodel::ModelMixin#belongs_to_anchormodels}. Not normally invoked directly.
12
+ #
13
+ # @param model_class [Class] Internal only. The model class the attribute is installed on.
14
+ # @param attribute_name [String,Symbol] The name and database column of the attribute.
15
+ # @param anchormodel_class [Class,nil] The anchormodel class. If omitted, inferred from
16
+ # `attribute_name` (`:foo_bar` → `FooBar`).
17
+ # @param optional [Boolean] If false, a presence validation is added to the model. Forced
18
+ # to true when `multiple` is true.
19
+ # @param multiple [Boolean] Internal only. Distinguishes between `belongs_to_anchormodel`
20
+ # (false) and `belongs_to_anchormodels` (true).
21
+ # @param model_readers [Boolean] If true, generates `model.key?` readers per anchormodel key.
22
+ # @param model_writers [Boolean] If true, generates `model.key!` writers per anchormodel key.
23
+ # @param model_scopes [Boolean] If true, generates `Model.key` scopes per anchormodel key.
24
+ # @param model_methods [Boolean,nil] If non-nil, mass-overrides `model_readers`/`model_writers`/`model_scopes`.
25
+ # @return [void]
26
+ # @raise [RuntimeError] if a generated method or scope name collides with an existing one.
14
27
  def self.install_methods_in_model(model_class, attribute_name, anchormodel_class = nil,
15
28
  optional: false,
16
29
  multiple: false,
@@ -129,21 +142,93 @@ module Anchormodel::Util
129
142
  end
130
143
 
131
144
  # Create ActiveRecord::Enum style scope directly in the model class if asked to do so
132
- # For a model User with anchormodel Role with keys :admin and :guest, this creates user.admin! and user.guest! (setting the role to admin/guest)
145
+ # For a model User with anchormodel Role with keys :admin and :guest, this creates User.admin and User.guest scopes
133
146
  if model_scopes
134
147
  anchormodel_class.all.each do |entry|
135
148
  if model_class.respond_to?(entry.key)
136
149
  fail("Anchormodel scope #{entry.key} already defined for #{self}, add `model_scopes: false` to `belongs_to_anchormodel :#{attribute_name}`.")
137
150
  end
138
151
  if multiple
139
- model_class.scope(entry.key, lambda {
140
- where("#{attribute_name} LIKE ? OR #{attribute_name} LIKE ? OR #{attribute_name} LIKE ? OR #{attribute_name} LIKE ?",
141
- "%#{entry.key},%", "%#{entry.key}", "#{entry.key},%", entry.key.to_s)
142
- })
152
+ sql, *binds = Anchormodel::Util.csv_contains_like(attribute_name, entry.key)
153
+ model_class.scope(entry.key, -> { where(sql, *binds) })
143
154
  else
144
155
  model_class.scope(entry.key, -> { where(attribute_name => entry.key) })
145
156
  end
146
157
  end
147
158
  end
159
+
160
+ # For `belongs_to_anchormodels`, add bulk-key scopes since `where(col: array)` cannot
161
+ # match CSV-in-column storage. Defined regardless of `model_scopes` — they are bulk
162
+ # query helpers, not per-key scopes.
163
+ if multiple
164
+ model_class.scope(:"with_any_#{attribute_name}", lambda do |*keys|
165
+ keys = Anchormodel::Util.normalize_anchormodel_keys(keys, anchormodel_class)
166
+ next none if keys.empty?
167
+
168
+ clauses = keys.map { |k| Anchormodel::Util.csv_contains_like(attribute_name, k) }
169
+ sql = clauses.map { |c| "(#{c.first})" }.join(' OR ')
170
+ binds = clauses.flat_map { |c| c.drop(1) }
171
+ where(sql, *binds)
172
+ end)
173
+
174
+ model_class.scope(:"with_all_#{attribute_name}", lambda do |*keys|
175
+ keys = Anchormodel::Util.normalize_anchormodel_keys(keys, anchormodel_class)
176
+ next all if keys.empty?
177
+
178
+ keys.reduce(all) { |rel, k| rel.merge(public_send(:"with_any_#{attribute_name}", k)) }
179
+ end)
180
+ end
181
+ end
182
+
183
+ # Coerces a list of mixed-type key inputs into validated key Strings, ready for SQL binding.
184
+ # Accepts Strings, Symbols, Anchormodel instances, and arbitrarily nested arrays of those.
185
+ #
186
+ # @param keys [Array] Input list (flattened internally).
187
+ # @param anchormodel_class [Class] The anchormodel class against which keys are validated.
188
+ # @return [Array<String>] Flattened, stringified, validated keys.
189
+ # @raise [Anchormodel::InvalidKey] if any element is not a registered key.
190
+ # @example
191
+ # Anchormodel::Util.normalize_anchormodel_keys([:cat, 'dog', Animal.find(:horse)], Animal)
192
+ # # => ["cat", "dog", "horse"]
193
+ def self.normalize_anchormodel_keys(keys, anchormodel_class)
194
+ keys = keys.flatten.map { |k| k.respond_to?(:key) ? k.key.to_s : k.to_s }
195
+ keys.each { |k| anchormodel_class.find(k) }
196
+ keys
197
+ end
198
+
199
+ # Builds a `WHERE` fragment matching rows whose CSV-stored `attribute` column contains
200
+ # `key`. Generates four predicates OR'd together to cover the cases where `key` appears
201
+ # at the start, end, middle, or as the sole entry in the CSV.
202
+ #
203
+ # `_` and `%` characters in `key` are escaped via `ESCAPE '!'` so that LIKE wildcards
204
+ # in the key (e.g. `:big_cat`) are treated literally instead of cross-matching arbitrary
205
+ # column values like `"bigXcat,foo"`.
206
+ #
207
+ # @param attribute [String,Symbol] Column name to query.
208
+ # @param key [String,Symbol] Key to search for inside the CSV column value.
209
+ # @return [Array(String, *String)] `[sql_fragment, *bind_values]`, suitable for splatting
210
+ # into `Model.where(sql, *binds)`.
211
+ # @example
212
+ # sql, *binds = Anchormodel::Util.csv_contains_like(:animals, :cat)
213
+ # User.where(sql, *binds)
214
+ def self.csv_contains_like(attribute, key)
215
+ escaped = escape_like(key.to_s)
216
+ sql = "#{attribute} = ? OR #{attribute} LIKE ? ESCAPE '!' " \
217
+ "OR #{attribute} LIKE ? ESCAPE '!' OR #{attribute} LIKE ? ESCAPE '!'"
218
+ binds = [key.to_s, "#{escaped},%", "%,#{escaped}", "%,#{escaped},%"]
219
+ [sql, *binds]
220
+ end
221
+
222
+ # Escapes `_`, `%`, and `!` so the string can be used as a literal inside a SQL `LIKE`
223
+ # pattern paired with `ESCAPE '!'`. Uses `!` rather than the conventional `\` to avoid
224
+ # the cross-DB ambiguity of backslash inside SQL string literals (SQLite vs MySQL vs
225
+ # PostgreSQL all differ).
226
+ #
227
+ # @param str [String,Symbol] Input to escape.
228
+ # @return [String] Escaped string.
229
+ # @example
230
+ # Anchormodel::Util.escape_like('big_cat') # => "big!_cat"
231
+ def self.escape_like(str)
232
+ str.to_s.gsub(/[!%_]/) { |c| "!#{c}" }
148
233
  end
149
234
  end
data/lib/anchormodel.rb CHANGED
@@ -1,8 +1,39 @@
1
- # @api description
2
- # Inherit from this class and place your Anchormodel under `app/anchormodels/your_anchor_model.rb`. Use it like `YourAnchorModel`.
3
- # Refer to the README for usage.
1
+ # An Anchormodel is a registry of named constants that behave like first-class objects.
2
+ # It is a richer alternative to Rails Enums: each entry is a real Ruby instance that can
3
+ # carry behavior and per-key attributes while still being persistable to a String column
4
+ # in the database.
5
+ #
6
+ # Place anchormodel subclasses under `app/anchormodels/your_anchor_model.rb` so Rails
7
+ # autoloading picks them up. Refer to the README for usage.
8
+ #
9
+ # @example Define an anchormodel
10
+ # class Role < Anchormodel
11
+ # include Comparable
12
+ # attr_reader :privilege_level
13
+ #
14
+ # def <=>(other) = privilege_level <=> other.privilege_level
15
+ #
16
+ # new :guest, privilege_level: 0
17
+ # new :manager, privilege_level: 1
18
+ # new :admin, privilege_level: 2
19
+ # end
20
+ #
21
+ # @example Use it from an ActiveRecord model
22
+ # class User < ApplicationRecord
23
+ # belongs_to_anchormodel :role
24
+ # end
25
+ #
26
+ # user = User.create!(role: :admin)
27
+ # user.role # => #<Role<admin>:...>
28
+ # user.role.privilege_level # => 2
29
+ # user.admin? # => true
4
30
  class Anchormodel
31
+ # @!attribute [r] key
32
+ # @return [Symbol] The key under which this entry was registered.
5
33
  attr_reader :key
34
+
35
+ # @!attribute [r] index
36
+ # @return [Integer] Zero-based declaration order within the subclass.
6
37
  attr_reader :index
7
38
 
8
39
  class_attribute :setup_completed, default: false
@@ -10,8 +41,14 @@ class Anchormodel
10
41
  class_attribute :entries_hash, default: {} # For quick access
11
42
  class_attribute :valid_keys, default: Set.new
12
43
 
13
- # When a descendant of Anchormodel is first used, it must overwrite the class_attributes
14
- # to prevent cross-class pollution.
44
+ # Initializes the per-subclass registry on first use. Called automatically from
45
+ # the first `new` invocation. Each subclass gets its own duped copies of the
46
+ # registry class attributes to prevent cross-class pollution.
47
+ #
48
+ # You normally do not need to call this directly.
49
+ #
50
+ # @return [void]
51
+ # @raise [RuntimeError] if called twice for the same subclass.
15
52
  def self.setup!
16
53
  fail("`setup!` was called twice for Anchormodel subclass #{self}.") if setup_completed
17
54
  self.entries_list = entries_list.dup
@@ -20,31 +57,61 @@ class Anchormodel
20
57
  self.setup_completed = true
21
58
  end
22
59
 
23
- # Returns all possible values this Anchormodel can take.
60
+ # @return [Array<Anchormodel>] All registered entries of this subclass, in declaration order.
61
+ # @example
62
+ # Role.all # => [#<Role<guest>>, #<Role<manager>>, #<Role<admin>>]
24
63
  def self.all
25
64
  entries_list
26
65
  end
27
66
 
28
- # Shorthand to satisfy rubocop
67
+ # Shorthand for `all.first`. Provided so callers can avoid Rubocop's
68
+ # `Style/FirstElementInCollection`-style warnings on `Foo.all.first`.
69
+ # @return [Anchormodel,nil] The first registered entry, or `nil` if the registry is empty.
29
70
  def self.first
30
71
  all.first
31
72
  end
32
73
 
33
- # Returns an array of tuples [label, key] suitable for passing as a collection to some form input helpers
74
+ # Builds a `[label, key_string]` tuple list suitable for Rails form select helpers.
75
+ # @return [Array<Array(String,String)>]
76
+ # @example
77
+ # <%= form.select :role, Role.form_collection %>
34
78
  def self.form_collection
35
79
  entries_list.map { |el| [el.label, el.key.to_s] }
36
80
  end
37
81
 
38
- # Retrieves a particular value given the key. Fails if not found.
39
- # @param key [String,Symbol] The key of the value that should be retrieved.
82
+ # Raised when an anchormodel key is unknown to its class — i.e. no `new :that_key`
83
+ # call was made on the subclass.
84
+ #
85
+ # Inherits from `RuntimeError` so existing `rescue RuntimeError` blocks remain
86
+ # compatible while allowing the narrower `rescue Anchormodel::InvalidKey`.
87
+ class InvalidKey < RuntimeError; end
88
+
89
+ # Retrieves the entry registered under `key`.
90
+ #
91
+ # @param key [String,Symbol,nil] The key of the value that should be retrieved.
92
+ # @return [Anchormodel,nil] The matching entry, or `nil` if `key` is `nil`.
93
+ # @raise [Anchormodel::InvalidKey] if no entry with that key exists.
94
+ # @example
95
+ # Role.find(:admin) # => #<Role<admin>>
96
+ # Role.find('admin') # => #<Role<admin>> (same singleton instance)
97
+ # Role.find(nil) # => nil
98
+ # Role.find(:nope) # raises Anchormodel::InvalidKey
40
99
  def self.find(key)
41
100
  return nil if key.nil?
42
- return entries_hash[key.to_sym] || fail("Retreived undefined anchor model key #{key.inspect} for #{inspect}.")
101
+ return entries_hash[key.to_sym] || raise(InvalidKey, "Retrieved undefined anchor model key #{key.inspect} for #{inspect}.")
43
102
  end
44
103
 
45
- # Call this initializer directly in your Anchormodel class. To set `@foo=:bar` for anchor `:ter`, use `new(:ter, foo: :bar)`
46
- # @param key [String,Symbol] The key under which the entry should be stored.
47
- # @param attributes All named arguments to Anchormodel are made available as instance attributes.
104
+ # Registers a new entry. Called in the body of an Anchormodel subclass.
105
+ # All keyword arguments become instance attributes accessible via `attr_reader`.
106
+ #
107
+ # @param key [String,Symbol] The unique key under which the entry is registered.
108
+ # @param attributes [Hash] Arbitrary attributes exposed as instance variables.
109
+ # @raise [RuntimeError] if `key` is already registered for this subclass.
110
+ # @example
111
+ # class Role < Anchormodel
112
+ # attr_reader :privilege_level
113
+ # new :guest, privilege_level: 0
114
+ # end
48
115
  def initialize(key, **attributes)
49
116
  self.class.setup! unless self.class.setup_completed
50
117
 
@@ -70,23 +137,50 @@ class Anchormodel
70
137
  end
71
138
  end
72
139
 
140
+ # Two anchormodels are equal iff they have the same concrete class and key.
141
+ # Different subclasses sharing a key are not equal.
142
+ # @param other [Object]
143
+ # @return [Boolean]
73
144
  def ==(other)
74
145
  self.class == other.class && key == other.key
75
146
  end
147
+ alias eql? ==
148
+
149
+ # Hash matches `==` (class + key) so `Set` and `Hash` membership work correctly
150
+ # even for copies (`dup`, Marshal round-trip) of the singleton entries.
151
+ # @return [Integer]
152
+ def hash
153
+ [self.class, key].hash
154
+ end
76
155
 
77
- # Returns a Rails label that is compatible with the [Rails FastGettext](https://github.com/grosser/gettext_i18n_rails/) gem.
156
+ # Returns a translatable label for this entry, compatible with the
157
+ # [Rails FastGettext](https://github.com/grosser/gettext_i18n_rails/) gem.
158
+ # The translation key is `"<SubclassName>|<Humanized key>"`.
159
+ # @return [String]
160
+ # @example
161
+ # Role.find(:admin).label # => "Role|Admin" (or its I18n translation)
78
162
  def label
79
163
  I18n.t("#{self.class.name.demodulize}|#{key.to_s.humanize}")
80
164
  end
81
165
 
166
+ # @return [String] Debug representation like `"#<Role<admin>:HASH>"`.
82
167
  def inspect
83
168
  "#<#{self.class.name}<#{key}>:#{hash}>"
84
169
  end
85
170
 
171
+ # Same as {#inspect}. Anchormodel intentionally overrides `to_s` so string
172
+ # interpolation in templates is unambiguous; render `#label` or `#key` directly
173
+ # if you want a user-facing form.
174
+ # @return [String]
86
175
  def to_s
87
176
  inspect
88
177
  end
89
178
 
179
+ # JSON serialization returns the key as a String so anchormodels round-trip
180
+ # cleanly through JSON (e.g. for API payloads).
181
+ # @return [String]
182
+ # @example
183
+ # Role.find(:admin).as_json # => "admin"
90
184
  def as_json
91
185
  key.to_s
92
186
  end
@@ -1,6 +1,14 @@
1
+ # Rails generator for scaffolding a new anchormodel.
2
+ #
3
+ # @example
4
+ # rails generate anchormodel Role
5
+ # # → creates app/anchormodels/role.rb
1
6
  class AnchormodelGenerator < Rails::Generators::NamedBase
2
7
  source_root File.expand_path('templates', __dir__)
3
8
 
9
+ # Writes the new anchormodel file from the ERB template.
10
+ # @return [void]
11
+ # @raise [RuntimeError] if NAME is blank.
4
12
  def add_anchormodel
5
13
  fail('NAME must be present.') if name.blank?
6
14
  @klass = @name.camelize