Module: DatabaseConsistency::Helper

Defined in:
lib/database_consistency/helper.rb

Overview

The module contains helper methods

Class Method Summary collapse

Class Method Details

.adapterObject



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

Returns:

  • (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

Returns:

  • (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.

Returns:

  • (Boolean)


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

Returns:

  • (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>

Returns:

  • (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.options[: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

Returns:

  • (Boolean)


20
21
22
# File 'lib/database_consistency/helper.rb', line 20

def postgresql?
  adapter == 'postgresql'
end

.project_klass?(klass) ⇒ Boolean

Parameters:

  • klass (ActiveRecord::Base)

Returns:

  • (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.options[: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.options[:allow_blank]
    "#{attribute_name} IS NOT NULL AND #{attribute_name} != ''"
  elsif validator.options[: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.options[: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.

Returns:

  • (Boolean)


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.options[:conditions].nil?
end

.wrapped_attribute_name(attribute, validator, model) ⇒ String

Returns:

  • (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.options[:case_sensitive].nil? || validator.options[: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.

Returns:

  • (Boolean)


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