Module: DatabaseConsistency::Helper
- Defined in:
- lib/database_consistency/helper.rb
Overview
The module contains helper methods
Class Method Summary collapse
- .adapter ⇒ Object
- .btree_index?(index) ⇒ Boolean
- .check_inclusion?(array, element) ⇒ Boolean
-
.conditions_match_index?(model, attribute, validator, index_where) ⇒ Boolean
Returns true when validator conditions and index WHERE clause are a valid pairing: both absent means a match; exactly one present means no match; when both present the normalized SQL is compared.
-
.conditions_where_sql(model, conditions) ⇒ Object
Returns the normalized WHERE SQL produced by a conditions proc, or nil if it cannot be determined (complex proc, unsupported AR version, etc.).
- .connected?(klass) ⇒ Boolean
- .connection_config(klass) ⇒ Object
- .database_name(model) ⇒ Object
- .extract_columns(str) ⇒ Object
- .extract_index_columns(index_columns) ⇒ Array<String>
- .first_level_associations(model) ⇒ Object
- .foreign_key_or_attribute(model, attribute) ⇒ Object
- .inclusion_validator_values(validator) ⇒ Object
-
.models(configuration) ⇒ Object
Returns list of models to check.
-
.normalize_array_any_predicates(sql) ⇒ Object
Rewrites PostgreSQL's
= ANY (ARRAY[...])form into anIN (...)form so it matches the SQL Active Record typically generates for arrays. -
.normalize_boolean_predicates(sql) ⇒ Object
Rewrites shorthand boolean predicates into explicit comparisons so
flagandNOT flagline up withflag = true/false. -
.normalize_condition_sql(sql) ⇒ Object
Normalizes SQL predicates into a canonical form so semantically equivalent Rails validators and database partial indexes can be compared safely.
-
.normalize_negated_blank_or_nil_predicates(sql) ⇒ Object
Rewrites negated "blank or nil" predicates into the same shape used by
allow_blank-derived guards:IS NOT NULL AND != ''. -
.normalize_sql(sql) ⇒ Object
Applies lightweight SQL normalization without changing the logical meaning.
-
.parent_models(configuration) ⇒ Object
Return list of not inherited models.
-
.parenthesis_depth(depth, char) ⇒ Object
Tracks parenthesis nesting depth character by character.
- .postgresql? ⇒ Boolean
- .project_klass?(klass) ⇒ Boolean
- .project_models(configuration) ⇒ Object
- .scope_columns(validator, model) ⇒ Object
-
.sort_and_clauses(sql) ⇒ Object
Sorts simple
ANDclauses soa AND bandb AND anormalize to the same string before comparison. - .sorted_uniqueness_validator_columns(attribute, validator, model) ⇒ Object
-
.strip_outer_parentheses(sql) ⇒ Object
Repeatedly removes one wrapping layer of parentheses when the whole SQL fragment is enclosed, e.g.
- .uniqueness_validator_columns(attribute, validator, model) ⇒ Object
-
.uniqueness_validator_guard_sql(model, attribute, validator) ⇒ Object
Builds the implicit SQL guard introduced by validator options that skip nil or blank values instead of validating them.
-
.uniqueness_validator_where_sql(model, attribute, validator) ⇒ Object
Builds the effective uniqueness constraint enforced by a validator.
-
.validator_guard_only?(model, attribute, validator) ⇒ Boolean
A validator with only
allow_nil/allow_blankand no explicit conditions is still satisfied by a full unique index, because the database constraint is stricter than the validator. - .wrapped_attribute_name(attribute, validator, model) ⇒ String
-
.wrapped_with_parentheses?(sql) ⇒ Boolean
Returns true only when the string is entirely wrapped by one outer pair of parentheses, not when parentheses close earlier inside the expression.
Class Method Details
.adapter ⇒ Object
8 9 10 11 12 13 14 |
# File 'lib/database_consistency/helper.rb', line 8 def adapter if ActiveRecord::Base.respond_to?(:connection_db_config) ActiveRecord::Base.connection_db_config.configuration_hash[:adapter] else ActiveRecord::Base.connection_config[:adapter] end end |
.btree_index?(index) ⇒ Boolean
125 126 127 128 |
# File 'lib/database_consistency/helper.rb', line 125 def btree_index?(index) (index.type.nil? || index.type.to_s == 'btree') && (index.using.nil? || index.using.to_s == 'btree') end |
.check_inclusion?(array, element) ⇒ Boolean
75 76 77 |
# File 'lib/database_consistency/helper.rb', line 75 def check_inclusion?(array, element) array.include?(element.to_s) || array.include?(element.to_sym) end |
.conditions_match_index?(model, attribute, validator, index_where) ⇒ Boolean
Returns true when validator conditions and index WHERE clause are a valid pairing: both absent means a match; exactly one present means no match; when both present the normalized SQL is compared.
181 182 183 184 185 186 187 188 189 |
# File 'lib/database_consistency/helper.rb', line 181 def conditions_match_index?(model, attribute, validator, index_where) validator_where = uniqueness_validator_where_sql(model, attribute, validator) return true if validator_where.nil? && index_where.blank? return true if index_where.blank? && validator_guard_only?(model, attribute, validator) return false if validator_where.nil? || index_where.blank? normalized_where = normalize_condition_sql(index_where) validator_where.casecmp?(normalized_where) end |
.conditions_where_sql(model, conditions) ⇒ Object
Returns the normalized WHERE SQL produced by a conditions proc, or nil if it cannot be determined (complex proc, unsupported AR version, etc.).
149 150 151 152 153 154 155 156 157 |
# File 'lib/database_consistency/helper.rb', line 149 def conditions_where_sql(model, conditions) sql = model.unscoped.instance_exec(&conditions).to_sql where_part = sql.split(/\bWHERE\b/i, 2).last return nil unless where_part normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', '')) rescue StandardError nil end |
.connected?(klass) ⇒ Boolean
49 50 51 52 53 54 |
# File 'lib/database_consistency/helper.rb', line 49 def connected?(klass) klass.connection rescue ActiveRecord::ConnectionNotEstablished puts "#{klass} does not have an active connection, skipping" false end |
.connection_config(klass) ⇒ Object
24 25 26 27 28 29 30 |
# File 'lib/database_consistency/helper.rb', line 24 def connection_config(klass) if klass.respond_to?(:connection_db_config) klass.connection_db_config.configuration_hash else klass.connection_config end end |
.database_name(model) ⇒ Object
16 17 18 |
# File 'lib/database_consistency/helper.rb', line 16 def database_name(model) model.connection_db_config.name.to_s if model.respond_to?(:connection_db_config) end |
.extract_columns(str) ⇒ Object
130 131 132 133 134 135 136 137 138 139 140 141 |
# File 'lib/database_consistency/helper.rb', line 130 def extract_columns(str) case str when Array str.map(&:to_s) when String str.scan(/(\w+)/).flatten when Symbol [str.to_s] else raise "Unexpected type for columns: #{str.class} with value: #{str}" end end |
.extract_index_columns(index_columns) ⇒ Array<String>
91 92 93 94 95 96 97 98 99 |
# File 'lib/database_consistency/helper.rb', line 91 def extract_index_columns(index_columns) return index_columns unless index_columns.is_a?(String) index_columns.split(',') .map(&:strip) .map { |str| str.gsub(/lower\(/i, 'lower(') } .map { |str| str.gsub(/\(([^)]+)\)::\w+/, '\1') } .map { |str| str.gsub(/'([^)]+)'::\w+/, '\1') } end |
.first_level_associations(model) ⇒ Object
79 80 81 82 83 84 85 86 87 88 |
# File 'lib/database_consistency/helper.rb', line 79 def first_level_associations(model) associations = model.reflect_on_all_associations while model != ActiveRecord::Base && model.respond_to?(:reflect_on_all_associations) model = model.superclass associations -= model.reflect_on_all_associations end associations end |
.foreign_key_or_attribute(model, attribute) ⇒ Object
143 144 145 |
# File 'lib/database_consistency/helper.rb', line 143 def foreign_key_or_attribute(model, attribute) model._reflect_on_association(attribute)&.foreign_key || attribute end |
.inclusion_validator_values(validator) ⇒ Object
115 116 117 118 119 120 121 122 123 |
# File 'lib/database_consistency/helper.rb', line 115 def inclusion_validator_values(validator) value = validator.[:in] if value.is_a?(Proc) && value.arity.zero? value.call else Array.wrap(value) end end |
.models(configuration) ⇒ Object
Returns list of models to check
41 42 43 44 45 46 47 |
# File 'lib/database_consistency/helper.rb', line 41 def models(configuration) project_models(configuration).select do |klass| !klass.abstract_class? && klass.table_exists? && !klass.name.include?('HABTM_') end end |
.normalize_array_any_predicates(sql) ⇒ Object
Rewrites PostgreSQL's = ANY (ARRAY[...]) form into an IN (...) form
so it matches the SQL Active Record typically generates for arrays.
288 289 290 291 292 293 294 |
# File 'lib/database_consistency/helper.rb', line 288 def normalize_array_any_predicates(sql) sql.gsub( # Matches `column = ANY (ARRAY[...])`, capturing the column name and the # full array payload so it can be converted to `column IN (...)`. /([a-z_][\w.]*)\s*=\s*ANY\s*\(ARRAY\[(.*?)\]\)/i ) { "#{Regexp.last_match(1)} IN (#{Regexp.last_match(2).gsub(/\s+/, ' ').strip})" } end |
.normalize_boolean_predicates(sql) ⇒ Object
Rewrites shorthand boolean predicates into explicit comparisons so
flag and NOT flag line up with flag = true/false.
267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 |
# File 'lib/database_consistency/helper.rb', line 267 def normalize_boolean_predicates(sql) normalized_sql = sql.dup # Matches a bare negated boolean predicate such as `NOT archived` # appearing at the start of an expression, after `AND` / `OR`, or after # an opening parenthesis, and rewrites it to `archived = 0`. normalized_sql.gsub!( /(^|(?:\bAND\b|\bOR\b|\())\s*NOT\s+([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 0" } # Matches a bare boolean predicate such as `most_recent` appearing in the # same structural positions, and rewrites it to `most_recent = 1`. normalized_sql.gsub!( /(^|(?:\bAND\b|\bOR\b|\())\s*([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 1" } normalized_sql.gsub(/\s+/, ' ').strip end |
.normalize_condition_sql(sql) ⇒ Object
Normalizes SQL predicates into a canonical form so semantically equivalent Rails validators and database partial indexes can be compared safely.
193 194 195 196 197 198 199 200 201 202 |
# File 'lib/database_consistency/helper.rb', line 193 def normalize_condition_sql(sql) sql .to_s .then { |value| strip_outer_parentheses(value) } .then { |value| normalize_sql(value) } .then { |value| normalize_boolean_predicates(value) } .then { |value| normalize_array_any_predicates(value) } .then { |value| normalize_negated_blank_or_nil_predicates(value) } .then { |value| sort_and_clauses(value) } end |
.normalize_negated_blank_or_nil_predicates(sql) ⇒ Object
Rewrites negated "blank or nil" predicates into the same shape used by
allow_blank-derived guards: IS NOT NULL AND != ''.
298 299 300 301 302 303 304 |
# File 'lib/database_consistency/helper.rb', line 298 def normalize_negated_blank_or_nil_predicates(sql) sql.gsub( # Matches SQL like `NOT (column = '' OR column IS NULL)` while enforcing # the same column name on both sides via backreference `\1`. /NOT\s+\(\s*\(?([a-z_][\w.]*)\s*=\s*''\s+OR\s+\1\s+IS\s+NULL\)?\s*\)/i ) { "#{Regexp.last_match(1)} IS NOT NULL AND #{Regexp.last_match(1)} != ''" } end |
.normalize_sql(sql) ⇒ Object
Applies lightweight SQL normalization without changing the logical meaning.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 |
# File 'lib/database_consistency/helper.rb', line 205 def normalize_sql(sql) # `/::\w+/` removes PostgreSQL casts like `column::text`. normalized_sql = sql.gsub(/::\w+/, '') # `/\(([a-z_][\w.]*)\)/i` unwraps a bare identifier surrounded by # parentheses, e.g. `(internal_name)` -> `internal_name`. normalized_sql = normalized_sql.gsub(/\(([a-z_][\w.]*)\)/i, '\1') # `/\bTRUE\b/i` and `/\bFALSE\b/i` normalize boolean literals to `1` / `0` # so they match SQL generated by Active Record on some adapters. normalized_sql = normalized_sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0') # `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`. normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ') # `/\bIS\s+NOT\s+NULL\b/i` normalizes `IS NOT NULL` spacing and casing. normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+NULL\b/i, ' IS NOT NULL') # `/\bIS\s+NULL\b/i` normalizes `IS NULL` spacing and casing. normalized_sql = normalized_sql.gsub(/\bIS\s+NULL\b/i, ' IS NULL') # `/ = 't'/` and `/ = 'f'/` normalize PostgreSQL boolean literals stored # as `'t'` / `'f'` inside comparisons. normalized_sql = normalized_sql.gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0') # `/\s+/` collapses any run of whitespace to a single space. normalized_sql = normalized_sql.gsub(/\s+/, ' ') normalized_sql.strip end |
.parent_models(configuration) ⇒ Object
Return list of not inherited models
57 58 59 60 61 |
# File 'lib/database_consistency/helper.rb', line 57 def parent_models(configuration) models(configuration).group_by(&:table_name).each_value.flat_map do |models| models.reject { |model| models.include?(model.superclass) } end end |
.parenthesis_depth(depth, char) ⇒ Object
Tracks parenthesis nesting depth character by character.
254 255 256 257 258 259 260 261 262 263 |
# File 'lib/database_consistency/helper.rb', line 254 def parenthesis_depth(depth, char) case char when '(' depth + 1 when ')' depth - 1 else depth end end |
.postgresql? ⇒ Boolean
20 21 22 |
# File 'lib/database_consistency/helper.rb', line 20 def postgresql? adapter == 'postgresql' end |
.project_klass?(klass) ⇒ Boolean
66 67 68 69 70 71 72 |
# File 'lib/database_consistency/helper.rb', line 66 def project_klass?(klass) return true unless Module.respond_to?(:const_source_location) && defined?(Bundler) !Module.const_source_location(klass.to_s).first.to_s.include?(Bundler.bundle_path.to_s) rescue NameError false end |
.project_models(configuration) ⇒ Object
32 33 34 35 36 37 38 |
# File 'lib/database_consistency/helper.rb', line 32 def project_models(configuration) ActiveRecord::Base.descendants.select do |klass| next unless configuration.model_enabled?(klass) project_klass?(klass) && connected?(klass) end end |
.scope_columns(validator, model) ⇒ Object
109 110 111 112 113 |
# File 'lib/database_consistency/helper.rb', line 109 def scope_columns(validator, model) Array.wrap(validator.[:scope]).map do |scope_item| foreign_key_or_attribute(model, scope_item) end end |
.sort_and_clauses(sql) ⇒ Object
Sorts simple AND clauses so a AND b and b AND a normalize to the
same string before comparison.
308 309 310 311 312 313 314 315 316 |
# File 'lib/database_consistency/helper.rb', line 308 def sort_and_clauses(sql) # Matches `AND` with surrounding whitespace and splits the expression into # comparable clause fragments. clauses = sql.split(/\s+AND\s+/i) return sql if clauses.length == 1 clauses.map! { |clause| strip_outer_parentheses(clause) } clauses.sort.join(' AND ') end |
.sorted_uniqueness_validator_columns(attribute, validator, model) ⇒ Object
101 102 103 |
# File 'lib/database_consistency/helper.rb', line 101 def sorted_uniqueness_validator_columns(attribute, validator, model) uniqueness_validator_columns(attribute, validator, model).sort end |
.strip_outer_parentheses(sql) ⇒ Object
Repeatedly removes one wrapping layer of parentheses when the whole SQL
fragment is enclosed, e.g. ((foo)) -> foo.
230 231 232 233 234 235 236 |
# File 'lib/database_consistency/helper.rb', line 230 def strip_outer_parentheses(sql) stripped_sql = sql.strip stripped_sql = stripped_sql[1..-2].strip while wrapped_with_parentheses?(stripped_sql) stripped_sql end |
.uniqueness_validator_columns(attribute, validator, model) ⇒ Object
105 106 107 |
# File 'lib/database_consistency/helper.rb', line 105 def uniqueness_validator_columns(attribute, validator, model) ([wrapped_attribute_name(attribute, validator, model)] + scope_columns(validator, model)).map(&:to_s) end |
.uniqueness_validator_guard_sql(model, attribute, validator) ⇒ Object
Builds the implicit SQL guard introduced by validator options that skip nil or blank values instead of validating them.
320 321 322 323 324 325 326 327 328 |
# File 'lib/database_consistency/helper.rb', line 320 def uniqueness_validator_guard_sql(model, attribute, validator) attribute_name = foreign_key_or_attribute(model, attribute).to_s if validator.[:allow_blank] "#{attribute_name} IS NOT NULL AND #{attribute_name} != ''" elsif validator.[:allow_nil] "#{attribute_name} IS NOT NULL" end end |
.uniqueness_validator_where_sql(model, attribute, validator) ⇒ Object
Builds the effective uniqueness constraint enforced by a validator.
When the validator carries an explicit conditions proc, that proc is the
authoritative partial predicate. The implicit allow_nil / allow_blank
guard on the validated attribute is redundant against a unique index (which
already treats NULLs as distinct), so it is only used as a fallback when no
explicit conditions are present. Otherwise gems that always set allow_nil
(e.g. database_validations) would append a duplicate or extra
attribute IS NOT NULL clause and never match the partial index.
168 169 170 171 172 173 174 175 176 |
# File 'lib/database_consistency/helper.rb', line 168 def uniqueness_validator_where_sql(model, attribute, validator) conditions_sql = conditions_where_sql(model, validator.[:conditions]) guard_sql = conditions_sql ? nil : uniqueness_validator_guard_sql(model, attribute, validator) sql_parts = [conditions_sql, guard_sql].reject { |part| part.nil? || part == '' } return nil if sql_parts.empty? normalize_condition_sql(sql_parts.join(' AND ')) end |
.validator_guard_only?(model, attribute, validator) ⇒ Boolean
A validator with only allow_nil / allow_blank and no explicit
conditions is still satisfied by a full unique index, because the database
constraint is stricter than the validator.
333 334 335 336 |
# File 'lib/database_consistency/helper.rb', line 333 def validator_guard_only?(model, attribute, validator) uniqueness_validator_guard_sql(model, attribute, validator).present? && validator.[:conditions].nil? end |
.wrapped_attribute_name(attribute, validator, model) ⇒ String
339 340 341 342 343 344 345 346 347 |
# File 'lib/database_consistency/helper.rb', line 339 def wrapped_attribute_name(attribute, validator, model) attribute = foreign_key_or_attribute(model, attribute) if validator.[:case_sensitive].nil? || validator.[:case_sensitive] attribute else "lower(#{attribute})" end end |
.wrapped_with_parentheses?(sql) ⇒ Boolean
Returns true only when the string is entirely wrapped by one outer pair of parentheses, not when parentheses close earlier inside the expression.
240 241 242 243 244 245 246 247 248 249 250 251 |
# File 'lib/database_consistency/helper.rb', line 240 def wrapped_with_parentheses?(sql) return false unless sql.start_with?('(') && sql.end_with?(')') depth = 0 sql[1..-2].each_char do |char| depth = parenthesis_depth(depth, char) return false if depth.negative? end depth.zero? end |