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 an `IN (…)` 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 ‘flag` and `NOT flag` line up with `flag = 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 ‘AND` clauses so `a AND b` and `b AND a` normalize 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 by combining its explicit ‘conditions` proc with implicit guards such as `allow_nil` / `allow_blank`.
-
.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.
- .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.
175 176 177 178 179 180 181 182 183 |
# File 'lib/database_consistency/helper.rb', line 175 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.
282 283 284 285 286 287 288 |
# File 'lib/database_consistency/helper.rb', line 282 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`.
261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 |
# File 'lib/database_consistency/helper.rb', line 261 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.
187 188 189 190 191 192 193 194 195 196 |
# File 'lib/database_consistency/helper.rb', line 187 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 != ”`.
292 293 294 295 296 297 298 |
# File 'lib/database_consistency/helper.rb', line 292 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.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/database_consistency/helper.rb', line 199 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.
248 249 250 251 252 253 254 255 256 257 |
# File 'lib/database_consistency/helper.rb', line 248 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.
302 303 304 305 306 307 308 309 310 |
# File 'lib/database_consistency/helper.rb', line 302 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`.
224 225 226 227 228 229 230 |
# File 'lib/database_consistency/helper.rb', line 224 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.
314 315 316 317 318 319 320 321 322 |
# File 'lib/database_consistency/helper.rb', line 314 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 by combining its explicit ‘conditions` proc with implicit guards such as `allow_nil` / `allow_blank`.
162 163 164 165 166 167 168 169 170 |
# File 'lib/database_consistency/helper.rb', line 162 def uniqueness_validator_where_sql(model, attribute, validator) conditions_sql = conditions_where_sql(model, validator.[:conditions]) guard_sql = 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.
327 328 329 330 |
# File 'lib/database_consistency/helper.rb', line 327 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
333 334 335 336 337 338 339 340 341 |
# File 'lib/database_consistency/helper.rb', line 333 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.
234 235 236 237 238 239 240 241 242 243 244 245 |
# File 'lib/database_consistency/helper.rb', line 234 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 |