database_consistency 3.0.2 → 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 +4 -4
- data/lib/database_consistency/checkers/index_checkers/unique_index_checker.rb +1 -1
- data/lib/database_consistency/checkers/validator_checkers/missing_unique_index_checker.rb +1 -1
- data/lib/database_consistency/helper.rb +165 -12
- data/lib/database_consistency/version.rb +1 -1
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3778602ed94cd95bc312d659eb256f62b264812105dd7c680c773976b921659a
|
|
4
|
+
data.tar.gz: b94531ee4ada7bed7d18b1bda935cb1c316b171ad68eee0280318476344239cf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
|
56
|
+
Helper.conditions_match_index?(model, attribute, validator, index.where)
|
|
57
57
|
end
|
|
58
58
|
|
|
59
59
|
def primary_key_covers_validation?
|
|
@@ -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
|
-
|
|
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,
|
|
163
|
-
|
|
164
|
-
return
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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]
|
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.
|
|
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-
|
|
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.
|
|
278
|
+
version: 2.6.0
|
|
279
279
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
280
280
|
requirements:
|
|
281
281
|
- - ">="
|