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.
- checksums.yaml +4 -4
- data/.gitignore +3 -1
- data/.ruby-version +1 -1
- data/CHANGELOG.md +19 -0
- data/EXAMPLES.md +408 -0
- data/Gemfile.lock +8 -8
- data/README.md +43 -1
- data/VERSION +1 -1
- data/anchormodel.gemspec +4 -4
- data/bin/test +9 -0
- data/doc/Anchormodel/ActiveModelTypeValueMulti.html +204 -42
- data/doc/Anchormodel/ActiveModelTypeValueSingle.html +245 -92
- 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 +51 -13
- 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 +256 -6
- data/test/dummy/app/anchormodels/animal.rb +1 -0
- metadata +4 -2
data/lib/anchormodel/util.rb
CHANGED
|
@@ -1,16 +1,29 @@
|
|
|
1
|
-
#
|
|
2
|
-
#
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# @param
|
|
12
|
-
# @param
|
|
13
|
-
# @param
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
#
|
|
2
|
-
#
|
|
3
|
-
#
|
|
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
|
-
#
|
|
14
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
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
|
-
#
|
|
39
|
-
#
|
|
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] ||
|
|
101
|
+
return entries_hash[key.to_sym] || raise(InvalidKey, "Retrieved undefined anchor model key #{key.inspect} for #{inspect}.")
|
|
43
102
|
end
|
|
44
103
|
|
|
45
|
-
#
|
|
46
|
-
#
|
|
47
|
-
#
|
|
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
|
|
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
|