database_consistency 3.0.1 → 3.0.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 530670a1dff61ea7ba5cbdd22628c2c2151448af0df367afb4f4f204577f3134
4
- data.tar.gz: b42ffbd20ccc21a292362a30f905fe2829c91635dfc347d8475d9772d2159fec
3
+ metadata.gz: 3778602ed94cd95bc312d659eb256f62b264812105dd7c680c773976b921659a
4
+ data.tar.gz: b94531ee4ada7bed7d18b1bda935cb1c316b171ad68eee0280318476344239cf
5
5
  SHA512:
6
- metadata.gz: 6b2d3fef5f27beb7e78c10863cccaef2a53d1d5018284fea8bd0d359e6c51b9cc4d0121060ae49a0ce190d2be1616b94271836a851b5ff16f1d48bf48ced1848
7
- data.tar.gz: 15c12a901db6176972dbac03ea900035c07f76ea748f4fe8e2cd45aae2c5046c2d047997b455f155c48c2aa3ca8c315ff956f6cabeae1edd108e09cee662886e
6
+ metadata.gz: e10aec4c79837cf87cad9e696699e8bf2e351c75cc606570d3dd322a579373be519fc3ae71e2f77279e21e9b9fbc8859f24fdfb8ef596742e58e7719d3abcf71
7
+ data.tar.gz: c5a31c8a838aab2226454ffe2d3aa82941b4f759f884f2cc7b2a113575eef4259a55c0c343e09d88e015f3e175ac30984d8ea645f5c3851f219d26148984e3a9
@@ -35,7 +35,7 @@ module DatabaseConsistency
35
35
  def validator_matches?(validator)
36
36
  validator.attributes.any? do |attribute|
37
37
  sorted_index_columns == Helper.sorted_uniqueness_validator_columns(attribute, validator, model) &&
38
- Helper.conditions_match_index?(model, validator.options[:conditions], index.where)
38
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
39
39
  end
40
40
  end
41
41
 
@@ -53,7 +53,7 @@ module DatabaseConsistency
53
53
  def index_matches?(index)
54
54
  index.unique &&
55
55
  Helper.extract_index_columns(index.columns).sort == sorted_uniqueness_validator_columns &&
56
- Helper.conditions_match_index?(model, validator.options[:conditions], index.where)
56
+ Helper.conditions_match_index?(model, attribute, validator, index.where)
57
57
  end
58
58
 
59
59
  def primary_key_covers_validation?
@@ -23,14 +23,15 @@ module DatabaseConsistency
23
23
  end
24
24
 
25
25
  def source_file_path(mod)
26
- return unless (name = mod.name)
26
+ name = mod.name
27
+ return unless name
27
28
 
28
29
  file, = Module.const_source_location(name)
29
30
  return unless file && File.exist?(file)
30
31
  return if excluded_source_file?(file)
31
32
 
32
33
  file
33
- rescue NameError, ArgumentError
34
+ rescue StandardError, ScriptError
34
35
  nil
35
36
  end
36
37
 
@@ -151,29 +151,182 @@ module DatabaseConsistency
151
151
  where_part = sql.split(/\bWHERE\b/i, 2).last
152
152
  return nil unless where_part
153
153
 
154
- normalize_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
154
+ normalize_condition_sql(where_part.gsub("#{model.quoted_table_name}.", '').gsub('"', ''))
155
155
  rescue StandardError
156
156
  nil
157
157
  end
158
158
 
159
+ # Builds the effective uniqueness constraint enforced by a validator by
160
+ # combining its explicit `conditions` proc with implicit guards such as
161
+ # `allow_nil` / `allow_blank`.
162
+ def uniqueness_validator_where_sql(model, attribute, validator)
163
+ conditions_sql = conditions_where_sql(model, validator.options[:conditions])
164
+ guard_sql = uniqueness_validator_guard_sql(model, attribute, validator)
165
+
166
+ sql_parts = [conditions_sql, guard_sql].reject { |part| part.nil? || part == '' }
167
+ return nil if sql_parts.empty?
168
+
169
+ normalize_condition_sql(sql_parts.join(' AND '))
170
+ end
171
+
159
172
  # Returns true when validator conditions and index WHERE clause are a valid
160
173
  # pairing: both absent means a match; exactly one present means no match;
161
174
  # when both present the normalized SQL is compared.
162
- def conditions_match_index?(model, conditions, index_where)
163
- return true if conditions.nil? && index_where.blank?
164
- return false if conditions.nil? || index_where.blank?
175
+ def conditions_match_index?(model, attribute, validator, index_where)
176
+ validator_where = uniqueness_validator_where_sql(model, attribute, validator)
177
+ return true if validator_where.nil? && index_where.blank?
178
+ return true if index_where.blank? && validator_guard_only?(model, attribute, validator)
179
+ return false if validator_where.nil? || index_where.blank?
180
+
181
+ normalized_where = normalize_condition_sql(index_where)
182
+ validator_where.casecmp?(normalized_where)
183
+ end
165
184
 
166
- conditions_sql = conditions_where_sql(model, conditions)
167
- # Strip one level of outer parentheses that some databases (e.g. PostgreSQL)
168
- # add when storing/returning the index WHERE clause.
169
- normalized_where = normalize_sql(index_where.sub(/\A\s*\((.+)\)\s*\z/m, '\1'))
170
- conditions_sql&.casecmp?(normalized_where)
185
+ # Normalizes SQL predicates into a canonical form so semantically equivalent
186
+ # Rails validators and database partial indexes can be compared safely.
187
+ def normalize_condition_sql(sql)
188
+ sql
189
+ .to_s
190
+ .then { |value| strip_outer_parentheses(value) }
191
+ .then { |value| normalize_sql(value) }
192
+ .then { |value| normalize_boolean_predicates(value) }
193
+ .then { |value| normalize_array_any_predicates(value) }
194
+ .then { |value| normalize_negated_blank_or_nil_predicates(value) }
195
+ .then { |value| sort_and_clauses(value) }
171
196
  end
172
197
 
198
+ # Applies lightweight SQL normalization without changing the logical meaning.
173
199
  def normalize_sql(sql)
174
- sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0')
175
- .gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0')
176
- .strip
200
+ # `/::\w+/` removes PostgreSQL casts like `column::text`.
201
+ normalized_sql = sql.gsub(/::\w+/, '')
202
+ # `/\(([a-z_][\w.]*)\)/i` unwraps a bare identifier surrounded by
203
+ # parentheses, e.g. `(internal_name)` -> `internal_name`.
204
+ normalized_sql = normalized_sql.gsub(/\(([a-z_][\w.]*)\)/i, '\1')
205
+ # `/\bTRUE\b/i` and `/\bFALSE\b/i` normalize boolean literals to `1` / `0`
206
+ # so they match SQL generated by Active Record on some adapters.
207
+ normalized_sql = normalized_sql.gsub(/\bTRUE\b/i, '1').gsub(/\bFALSE\b/i, '0')
208
+ # `/\s*<>\s*/` rewrites the SQL inequality operator `<>` to `!=`.
209
+ normalized_sql = normalized_sql.gsub(/\s*<>\s*/, ' != ')
210
+ # `/\bIS\s+NOT\s+NULL\b/i` normalizes `IS NOT NULL` spacing and casing.
211
+ normalized_sql = normalized_sql.gsub(/\bIS\s+NOT\s+NULL\b/i, ' IS NOT NULL')
212
+ # `/\bIS\s+NULL\b/i` normalizes `IS NULL` spacing and casing.
213
+ normalized_sql = normalized_sql.gsub(/\bIS\s+NULL\b/i, ' IS NULL')
214
+ # `/ = 't'/` and `/ = 'f'/` normalize PostgreSQL boolean literals stored
215
+ # as `'t'` / `'f'` inside comparisons.
216
+ normalized_sql = normalized_sql.gsub(/ = 't'/, ' = 1').gsub(/ = 'f'/, ' = 0')
217
+ # `/\s+/` collapses any run of whitespace to a single space.
218
+ normalized_sql = normalized_sql.gsub(/\s+/, ' ')
219
+ normalized_sql.strip
220
+ end
221
+
222
+ # Repeatedly removes one wrapping layer of parentheses when the whole SQL
223
+ # fragment is enclosed, e.g. `((foo))` -> `foo`.
224
+ def strip_outer_parentheses(sql)
225
+ stripped_sql = sql.strip
226
+
227
+ stripped_sql = stripped_sql[1..-2].strip while wrapped_with_parentheses?(stripped_sql)
228
+
229
+ stripped_sql
230
+ end
231
+
232
+ # Returns true only when the string is entirely wrapped by one outer pair of
233
+ # parentheses, not when parentheses close earlier inside the expression.
234
+ def wrapped_with_parentheses?(sql)
235
+ return false unless sql.start_with?('(') && sql.end_with?(')')
236
+
237
+ depth = 0
238
+
239
+ sql[1..-2].each_char do |char|
240
+ depth = parenthesis_depth(depth, char)
241
+ return false if depth.negative?
242
+ end
243
+
244
+ depth.zero?
245
+ end
246
+
247
+ # Tracks parenthesis nesting depth character by character.
248
+ def parenthesis_depth(depth, char)
249
+ case char
250
+ when '('
251
+ depth + 1
252
+ when ')'
253
+ depth - 1
254
+ else
255
+ depth
256
+ end
257
+ end
258
+
259
+ # Rewrites shorthand boolean predicates into explicit comparisons so
260
+ # `flag` and `NOT flag` line up with `flag = true/false`.
261
+ def normalize_boolean_predicates(sql)
262
+ normalized_sql = sql.dup
263
+
264
+ # Matches a bare negated boolean predicate such as `NOT archived`
265
+ # appearing at the start of an expression, after `AND` / `OR`, or after
266
+ # an opening parenthesis, and rewrites it to `archived = 0`.
267
+ normalized_sql.gsub!(
268
+ /(^|(?:\bAND\b|\bOR\b|\())\s*NOT\s+([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
269
+ ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 0" }
270
+
271
+ # Matches a bare boolean predicate such as `most_recent` appearing in the
272
+ # same structural positions, and rewrites it to `most_recent = 1`.
273
+ normalized_sql.gsub!(
274
+ /(^|(?:\bAND\b|\bOR\b|\())\s*([a-z_][\w.]*)\s*(?=$|(?:\bAND\b|\bOR\b|\)))/i
275
+ ) { "#{Regexp.last_match(1)} #{Regexp.last_match(2)} = 1" }
276
+
277
+ normalized_sql.gsub(/\s+/, ' ').strip
278
+ end
279
+
280
+ # Rewrites PostgreSQL's `= ANY (ARRAY[...])` form into an `IN (...)` form
281
+ # so it matches the SQL Active Record typically generates for arrays.
282
+ def normalize_array_any_predicates(sql)
283
+ sql.gsub(
284
+ # Matches `column = ANY (ARRAY[...])`, capturing the column name and the
285
+ # full array payload so it can be converted to `column IN (...)`.
286
+ /([a-z_][\w.]*)\s*=\s*ANY\s*\(ARRAY\[(.*?)\]\)/i
287
+ ) { "#{Regexp.last_match(1)} IN (#{Regexp.last_match(2).gsub(/\s+/, ' ').strip})" }
288
+ end
289
+
290
+ # Rewrites negated "blank or nil" predicates into the same shape used by
291
+ # `allow_blank`-derived guards: `IS NOT NULL AND != ''`.
292
+ def normalize_negated_blank_or_nil_predicates(sql)
293
+ sql.gsub(
294
+ # Matches SQL like `NOT (column = '' OR column IS NULL)` while enforcing
295
+ # the same column name on both sides via backreference `\1`.
296
+ /NOT\s+\(\s*\(?([a-z_][\w.]*)\s*=\s*''\s+OR\s+\1\s+IS\s+NULL\)?\s*\)/i
297
+ ) { "#{Regexp.last_match(1)} IS NOT NULL AND #{Regexp.last_match(1)} != ''" }
298
+ end
299
+
300
+ # Sorts simple `AND` clauses so `a AND b` and `b AND a` normalize to the
301
+ # same string before comparison.
302
+ def sort_and_clauses(sql)
303
+ # Matches `AND` with surrounding whitespace and splits the expression into
304
+ # comparable clause fragments.
305
+ clauses = sql.split(/\s+AND\s+/i)
306
+ return sql if clauses.length == 1
307
+
308
+ clauses.map! { |clause| strip_outer_parentheses(clause) }
309
+ clauses.sort.join(' AND ')
310
+ end
311
+
312
+ # Builds the implicit SQL guard introduced by validator options that skip
313
+ # nil or blank values instead of validating them.
314
+ def uniqueness_validator_guard_sql(model, attribute, validator)
315
+ attribute_name = foreign_key_or_attribute(model, attribute).to_s
316
+
317
+ if validator.options[:allow_blank]
318
+ "#{attribute_name} IS NOT NULL AND #{attribute_name} != ''"
319
+ elsif validator.options[:allow_nil]
320
+ "#{attribute_name} IS NOT NULL"
321
+ end
322
+ end
323
+
324
+ # A validator with only `allow_nil` / `allow_blank` and no explicit
325
+ # conditions is still satisfied by a full unique index, because the database
326
+ # constraint is stricter than the validator.
327
+ def validator_guard_only?(model, attribute, validator)
328
+ uniqueness_validator_guard_sql(model, attribute, validator).present? &&
329
+ validator.options[:conditions].nil?
177
330
  end
178
331
 
179
332
  # @return [String]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DatabaseConsistency
4
- VERSION = '3.0.1'
4
+ VERSION = '3.0.3'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: database_consistency
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.1
4
+ version: 3.0.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Evgeniy Demin
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-03-07 00:00:00.000000000 Z
11
+ date: 2026-04-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -275,7 +275,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
275
275
  requirements:
276
276
  - - ">="
277
277
  - !ruby/object:Gem::Version
278
- version: 2.4.0
278
+ version: 2.6.0
279
279
  required_rubygems_version: !ruby/object:Gem::Requirement
280
280
  requirements:
281
281
  - - ">="