Module: Anchormodel::Util
- Defined in:
- lib/anchormodel/util.rb
Overview
Internal utilities used by the ModelMixin#belongs_to_anchormodel and ModelMixin#belongs_to_anchormodels macros. The public-facing entry points are the macros themselves; methods here are the wiring that installs the generated readers, writers, scopes, type casters, and helper scopes.
Class Method Summary collapse
-
.csv_contains_like(attribute, key) ⇒ Array(String, *String)
Builds a
WHEREfragment matching rows whose CSV-storedattributecolumn containskey. -
.escape_like(str) ⇒ String
Escapes
_,%, and!so the string can be used as a literal inside a SQLLIKEpattern paired withESCAPE '!'. -
.install_methods_in_model(model_class, attribute_name, anchormodel_class = nil, optional: false, multiple: false, model_readers: true, model_writers: true, model_scopes: true, model_methods: nil) ⇒ void
Installs an anchormodel attribute in a model class.
-
.normalize_anchormodel_keys(keys, anchormodel_class) ⇒ Array<String>
Coerces a list of mixed-type key inputs into validated key Strings, ready for SQL binding.
Class Method Details
.csv_contains_like(attribute, key) ⇒ Array(String, *String)
Builds a WHERE fragment matching rows whose CSV-stored attribute column contains
key. Generates four predicates OR'd together to cover the cases where key appears
at the start, end, middle, or as the sole entry in the CSV.
_ and % characters in key are escaped via ESCAPE '!' so that LIKE wildcards
in the key (e.g. :big_cat) are treated literally instead of cross-matching arbitrary
column values like "bigXcat,foo".
214 215 216 217 218 219 220 |
# File 'lib/anchormodel/util.rb', line 214 def self.csv_contains_like(attribute, key) escaped = escape_like(key.to_s) sql = "#{attribute} = ? OR #{attribute} LIKE ? ESCAPE '!' " \ "OR #{attribute} LIKE ? ESCAPE '!' OR #{attribute} LIKE ? ESCAPE '!'" binds = [key.to_s, "#{escaped},%", "%,#{escaped}", "%,#{escaped},%"] [sql, *binds] end |
.escape_like(str) ⇒ String
Escapes _, %, and ! so the string can be used as a literal inside a SQL LIKE
pattern paired with ESCAPE '!'. Uses ! rather than the conventional \ to avoid
the cross-DB ambiguity of backslash inside SQL string literals (SQLite vs MySQL vs
PostgreSQL all differ).
231 232 233 |
# File 'lib/anchormodel/util.rb', line 231 def self.escape_like(str) str.to_s.gsub(/[!%_]/) { |c| "!#{c}" } end |
.install_methods_in_model(model_class, attribute_name, anchormodel_class = nil, optional: false, multiple: false, model_readers: true, model_writers: true, model_scopes: true, model_methods: nil) ⇒ void
This method returns an undefined value.
Installs an anchormodel attribute in a model class. Wires up the AR attribute
type cast, the custom reader/writer, the per-key readers/writers/scopes, and (for
belongs_to_anchormodels) the bulk-key with_any_<attr> / with_all_<attr> scopes.
Called from ModelMixin#belongs_to_anchormodel and ModelMixin#belongs_to_anchormodels. Not normally invoked directly.
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/anchormodel/util.rb', line 27 def self.install_methods_in_model(model_class, attribute_name, anchormodel_class = nil, optional: false, multiple: false, model_readers: true, model_writers: true, model_scopes: true, model_methods: nil) optional = true if multiple anchormodel_class ||= attribute_name.to_s.classify.constantize attribute_name = attribute_name.to_sym attribute = Anchormodel::Attribute.new(model_class, attribute_name, anchormodel_class, optional, multiple) # Mass configurations if model_methods was specfied unless model_methods.nil? model_readers = model_methods model_writers = model_methods model_scopes = model_methods end # Register attribute model_class.anchormodel_attributes = model_class.anchormodel_attributes.merge({ attribute_name => attribute }).freeze # Add presence validation if required unless optional model_class.validates attribute_name, presence: true end # Make casting work # Define serializer/deserializer active_model_type_value = (multiple ? Anchormodel::ActiveModelTypeValueMulti : Anchormodel::ActiveModelTypeValueSingle).new(attribute) # Overwrite reader to force building anchors at every retrieval model_class.define_method(attribute_name.to_s) do # This patch fixes an issue in combination with the active_type gem, causing virtual models to always read nil on anchormodel attributes. raw_data = if defined?(ActiveType::Object) && is_a?(ActiveType::Object) virtual_attributes[attribute_name.to_s] else read_attribute_before_type_cast(attribute_name) end result = active_model_type_value.deserialize(raw_data) # If this attribute holds multiple anchormodels (`belongs_to_anchormodels`), patch the Array before returning in order to implement collection modifiers: if multiple model = self # fetching the model in order to pass it to the implementation of the following via reflection to make it available for storage single_tv = Anchormodel::ActiveModelTypeValueSingle.new(attribute) # Used for casting inputs to properly compare them to the set. # Adding result.define_singleton_method('add') do |value_to_add| super(single_tv.cast(value_to_add)) model.write_attribute(attribute_name, active_model_type_value.serialize(self)) next self end result.define_singleton_method('<<') { |value_to_add| add(single_tv.cast(value_to_add)) } # Deleting result.define_singleton_method('delete') do |value_to_delete| super(single_tv.cast(value_to_delete)) model.write_attribute(attribute_name, active_model_type_value.serialize(self)) next self end # Clearing result.define_singleton_method('clear') do super() model.write_attribute(attribute_name, active_model_type_value.serialize(self)) next self end # In the future, further methods could be supported. e.g. delete_if, subtract etc. end return result end # Override writer to fail early when an invalid target value is specified model_class.define_method("#{attribute_name}=") do |new_value| write_attribute(attribute_name, active_model_type_value.serialize(new_value)) end # Supply serializer and deserializer model_class.attribute attribute_name, active_model_type_value # Create ActiveRecord::Enum style reader directly in the model if asked to do so # For a model User with anchormodel Role with keys :admin and :guest, this creates user.admin? and user.guest? (returning true iff role is admin/guest) if model_readers anchormodel_class.all.each do |entry| if model_class.respond_to?(:"#{entry.key}?") fail("Anchormodel reader #{entry.key}? already defined for #{self}, add `model_readers: false` to `belongs_to_anchormodel :#{attribute_name}`.") end model_class.define_method(:"#{entry.key}?") do if multiple public_send(attribute_name.to_s).include?(entry) else public_send(attribute_name.to_s) == entry end end end end # Create ActiveRecord::Enum style writer directly in the model if asked to do so # 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) if model_writers anchormodel_class.all.each do |entry| if model_class.respond_to?(:"#{entry.key}!") fail("Anchormodel writer #{entry.key}! already defined for #{self}, add `model_writers: false` to `belongs_to_anchormodel :#{attribute_name}`.") end model_class.define_method(:"#{entry.key}!") do if multiple public_send(attribute_name.to_s) << entry else public_send(:"#{attribute_name}=", entry) end end end end # Create ActiveRecord::Enum style scope directly in the model class if asked to do so # For a model User with anchormodel Role with keys :admin and :guest, this creates User.admin and User.guest scopes if model_scopes anchormodel_class.all.each do |entry| if model_class.respond_to?(entry.key) fail("Anchormodel scope #{entry.key} already defined for #{self}, add `model_scopes: false` to `belongs_to_anchormodel :#{attribute_name}`.") end if multiple sql, *binds = Anchormodel::Util.csv_contains_like(attribute_name, entry.key) model_class.scope(entry.key, -> { where(sql, *binds) }) else model_class.scope(entry.key, -> { where(attribute_name => entry.key) }) end end end # For `belongs_to_anchormodels`, add bulk-key scopes since `where(col: array)` cannot # match CSV-in-column storage. Defined regardless of `model_scopes` — they are bulk # query helpers, not per-key scopes. if multiple model_class.scope(:"with_any_#{attribute_name}", lambda do |*keys| keys = Anchormodel::Util.normalize_anchormodel_keys(keys, anchormodel_class) next none if keys.empty? clauses = keys.map { |k| Anchormodel::Util.csv_contains_like(attribute_name, k) } sql = clauses.map { |c| "(#{c.first})" }.join(' OR ') binds = clauses.flat_map { |c| c.drop(1) } where(sql, *binds) end) model_class.scope(:"with_all_#{attribute_name}", lambda do |*keys| keys = Anchormodel::Util.normalize_anchormodel_keys(keys, anchormodel_class) next all if keys.empty? keys.reduce(all) { |rel, k| rel.merge(public_send(:"with_any_#{attribute_name}", k)) } end) end end |
.normalize_anchormodel_keys(keys, anchormodel_class) ⇒ Array<String>
Coerces a list of mixed-type key inputs into validated key Strings, ready for SQL binding. Accepts Strings, Symbols, Anchormodel instances, and arbitrarily nested arrays of those.
193 194 195 196 197 |
# File 'lib/anchormodel/util.rb', line 193 def self.normalize_anchormodel_keys(keys, anchormodel_class) keys = keys.flatten.map { |k| k.respond_to?(:key) ? k.key.to_s : k.to_s } keys.each { |k| anchormodel_class.find(k) } keys end |