active_record_doctor 1.15.0 → 2.0.1
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/README.md +47 -24
- data/lib/active_record_doctor/config/default.rb +6 -2
- data/lib/active_record_doctor/detectors/base.rb +9 -10
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +3 -7
- data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +20 -4
- data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +14 -29
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +16 -1
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +141 -56
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +2 -3
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +2 -0
- data/lib/active_record_doctor/detectors/table_without_primary_key.rb +1 -1
- data/lib/active_record_doctor/detectors/table_without_timestamps.rb +38 -0
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +1 -5
- data/lib/active_record_doctor/runner.rb +1 -1
- data/lib/active_record_doctor/utils.rb +2 -4
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/active_record_doctor.rb +2 -1
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +1 -9
- metadata +35 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 94e700a1c78ee5e9657d1107578c2932a84acba7f592ab6a416e5c063520a311
|
4
|
+
data.tar.gz: 8496adb8a597250ec95276fc9fbad50397667f7de2336f80405203b3a3c2a459
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 79803ef2db49147fdf2daf5983a3f36fb895704d150347442bebdc192a6980ecb8cd438c23fe335941284e0a39aee8c3e1a725bfe3845c7a0d3dde03830068dc
|
7
|
+
data.tar.gz: 2feb5b594c369dc3d39e0f85540724474e5caa7573e0d0f18a5dcddd63d12113ec4c670ef374b475f237bd3d75fe7281bca0b0d219aac6582cbddbe59bdc2340
|
data/README.md
CHANGED
@@ -16,6 +16,7 @@ can detect:
|
|
16
16
|
* primary keys having short integer types - [`active_record_doctor:short_primary_key_type`](#detecting-primary-keys-having-short-integer-types)
|
17
17
|
* mismatched foreign key types - [`active_record_doctor:mismatched_foreign_key_type`](#detecting-mismatched-foreign-key-types)
|
18
18
|
* tables without primary keys - [`active_record_doctor:table_without_primary_key`](#detecting-tables-without-primary-keys)
|
19
|
+
* tables without timestamps - [`active_record_doctor:table_without_timestamps`](#detecting-tables-without-timestamps)
|
19
20
|
|
20
21
|
It can also:
|
21
22
|
|
@@ -160,28 +161,26 @@ obtained via the help mechanism described in the previous section.
|
|
160
161
|
|
161
162
|
### Regexp-Based Ignores
|
162
163
|
|
163
|
-
Settings like `ignore_tables`, `ignore_indexes`, and so on
|
164
|
-
identifiers to ignore. These can be either:
|
164
|
+
Settings like `ignore_tables`, `ignore_indexes`, `ignore_models` and so on
|
165
|
+
accept list of identifiers to ignore. These can be either:
|
165
166
|
|
166
167
|
1. Strings - in which case an exact match is needed.
|
167
168
|
2. Regexps - which are matched against object names, and matching ones are
|
168
169
|
excluded from output.
|
169
170
|
|
170
|
-
For example, to ignore all tables starting with `legacy_`
|
171
|
+
For example, to ignore all tables starting with `legacy_` and all models under
|
172
|
+
the `Legacy::` namespace you can write:
|
171
173
|
|
172
174
|
```ruby
|
173
175
|
ActiveRecordDoctor.configure do
|
174
176
|
global :ignore_tables, [
|
175
|
-
# Ignore internal Rails-related tables.
|
176
|
-
"ar_internal_metadata",
|
177
|
-
"schema_migrations",
|
178
|
-
"active_storage_blobs",
|
179
|
-
"active_storage_attachments",
|
180
|
-
"action_text_rich_texts",
|
181
|
-
|
182
177
|
# Ignore all legacy tables.
|
183
178
|
/^legacy_/
|
184
179
|
]
|
180
|
+
global :ignore_models, [
|
181
|
+
# Ignore all legacy models.
|
182
|
+
/^Legacy::/
|
183
|
+
]
|
185
184
|
end
|
186
185
|
```
|
187
186
|
|
@@ -270,7 +269,7 @@ Supported configuration options:
|
|
270
269
|
|
271
270
|
- `enabled` - set to `false` to disable the detector altogether
|
272
271
|
- `ignore_tables` - tables whose indexes should never be reported as extraneous.
|
273
|
-
- `
|
272
|
+
- `ignore_indexes` - indexes that should never be reported as extraneous.
|
274
273
|
|
275
274
|
### Detecting Unindexed `deleted_at` Columns
|
276
275
|
|
@@ -303,16 +302,15 @@ Supported configuration options:
|
|
303
302
|
|
304
303
|
### Detecting Missing Foreign Key Constraints
|
305
304
|
|
306
|
-
If `
|
307
|
-
|
308
|
-
|
309
|
-
|
310
|
-
level.
|
305
|
+
If `User` defines a `belongs_to` association to `Profile` then the underlying
|
306
|
+
column (`users.profile_id` by convention) should be marked as a foreign key in
|
307
|
+
the database. If it's not then it's possible to end up in a situation where a
|
308
|
+
user is referencing a non-existent profile.
|
311
309
|
|
312
310
|
`active_record_doctor` can automatically detect foreign keys that could benefit
|
313
311
|
from a foreign key constraint (a future version will generate a migration that
|
314
|
-
add the constraint; for now, it's your job). You can obtain the list of
|
315
|
-
|
312
|
+
add the constraint; for now, it's your job). You can obtain the list of missing
|
313
|
+
foreign key constraints with the following command:
|
316
314
|
|
317
315
|
```bash
|
318
316
|
bundle exec rake active_record_doctor:missing_foreign_keys
|
@@ -332,9 +330,9 @@ end
|
|
332
330
|
Supported configuration options:
|
333
331
|
|
334
332
|
- `enabled` - set to `false` to disable the detector altogether
|
335
|
-
- `
|
336
|
-
- `
|
337
|
-
checked.
|
333
|
+
- `ignore_models` - models whose associations should not be checked.
|
334
|
+
- `ignore_associations` - associations, written as Model.association, that
|
335
|
+
should not be checked.
|
338
336
|
|
339
337
|
### Detecting Models Referencing Undefined Tables
|
340
338
|
|
@@ -403,7 +401,8 @@ Supported configuration options:
|
|
403
401
|
|
404
402
|
If there's an unconditional presence validation on a column then it should be
|
405
403
|
marked as non-`NULL`-able at the database level or should have a `IS NOT NULL`
|
406
|
-
constraint.
|
404
|
+
constraint. Timestamp columns are also expected to be made `NOT NULL` as they're
|
405
|
+
managed automatically by Active Record.
|
407
406
|
|
408
407
|
In order to detect columns whose presence is required but that are marked
|
409
408
|
`null: true` in the database run the following command:
|
@@ -433,7 +432,8 @@ Supported configuration options:
|
|
433
432
|
### Detecting Missing Presence Validations
|
434
433
|
|
435
434
|
If a column is marked as `null: false` then it's likely it should have the
|
436
|
-
corresponding presence validator
|
435
|
+
corresponding presence validator or an appropriately configured inclusion or
|
436
|
+
exclusion validation.
|
437
437
|
|
438
438
|
In order to detect models lacking these validations run:
|
439
439
|
|
@@ -640,7 +640,30 @@ add a primary key to companies
|
|
640
640
|
Supported configuration options:
|
641
641
|
|
642
642
|
- `enabled` - set to `false` to disable the detector altogether
|
643
|
-
- `ignore_tables` - tables whose primary key
|
643
|
+
- `ignore_tables` - tables whose primary key existence should not be checked
|
644
|
+
|
645
|
+
### Detecting Tables Without Timestamps
|
646
|
+
|
647
|
+
Tables should have timestamp columns (`created_at`/`updated_at`). Otherwise, it becomes problematic
|
648
|
+
to easily find when the record was created/updated, if the table is active or can be removed,
|
649
|
+
automatic Rails cache expiration after record updates is not possible.
|
650
|
+
|
651
|
+
Running the command below will list all tables without default timestamp columns:
|
652
|
+
|
653
|
+
```
|
654
|
+
bundle exec rake active_record_doctor:table_without_timestamps
|
655
|
+
```
|
656
|
+
|
657
|
+
The output of the command looks like this:
|
658
|
+
|
659
|
+
```
|
660
|
+
add a created_at column to companies
|
661
|
+
```
|
662
|
+
|
663
|
+
Supported configuration options:
|
664
|
+
|
665
|
+
- `enabled` - set to `false` to disable the detector altogether
|
666
|
+
- `ignore_tables` - tables whose timestamp columns existence should not be checked
|
644
667
|
|
645
668
|
## Ruby and Rails Compatibility Policy
|
646
669
|
|
@@ -36,8 +36,8 @@ ActiveRecordDoctor.configure do
|
|
36
36
|
|
37
37
|
detector :missing_foreign_keys,
|
38
38
|
enabled: true,
|
39
|
-
|
40
|
-
|
39
|
+
ignore_models: [],
|
40
|
+
ignore_associations: []
|
41
41
|
|
42
42
|
detector :missing_non_null_constraint,
|
43
43
|
enabled: true,
|
@@ -64,6 +64,10 @@ ActiveRecordDoctor.configure do
|
|
64
64
|
enabled: true,
|
65
65
|
ignore_tables: []
|
66
66
|
|
67
|
+
detector :table_without_timestamps,
|
68
|
+
enabled: true,
|
69
|
+
ignore_tables: []
|
70
|
+
|
67
71
|
detector :undefined_table_references,
|
68
72
|
enabled: true,
|
69
73
|
ignore_models: []
|
@@ -136,8 +136,7 @@ module ActiveRecordDoctor
|
|
136
136
|
end
|
137
137
|
|
138
138
|
def check_constraints(table_name)
|
139
|
-
|
140
|
-
if connection.respond_to?(:supports_check_constraints?) && connection.supports_check_constraints?
|
139
|
+
if connection.supports_check_constraints?
|
141
140
|
connection.check_constraints(table_name).select(&:validated?).map(&:expression)
|
142
141
|
elsif Utils.postgresql?(connection)
|
143
142
|
definitions =
|
@@ -245,6 +244,13 @@ module ActiveRecordDoctor
|
|
245
244
|
end
|
246
245
|
end
|
247
246
|
|
247
|
+
def looks_like_foreign_key?(column)
|
248
|
+
type = column.type.to_s
|
249
|
+
|
250
|
+
column.name.end_with?("_id") &&
|
251
|
+
(type == "integer" || type.include?("uuid"))
|
252
|
+
end
|
253
|
+
|
248
254
|
def each_foreign_key(table_name)
|
249
255
|
log("Iterating over foreign keys on #{table_name}") do
|
250
256
|
connection.foreign_keys(table_name).each do |foreign_key|
|
@@ -256,15 +262,8 @@ module ActiveRecordDoctor
|
|
256
262
|
end
|
257
263
|
|
258
264
|
def each_table(except: [])
|
259
|
-
tables =
|
260
|
-
if ActiveRecord::VERSION::STRING >= "5.1"
|
261
|
-
connection.tables
|
262
|
-
else
|
263
|
-
connection.data_sources
|
264
|
-
end
|
265
|
-
|
266
265
|
log("Iterating over tables") do
|
267
|
-
tables.each do |table|
|
266
|
+
connection.tables.each do |table|
|
268
267
|
case
|
269
268
|
when ignored?(table, except)
|
270
269
|
log("#{table} - ignored via the configuration; skipping")
|
@@ -58,7 +58,7 @@ module ActiveRecordDoctor
|
|
58
58
|
# model lacks the next leg in the :through relationship. For
|
59
59
|
# instance, if user has many comments through posts then a nil
|
60
60
|
# source_reflection means that Post doesn't define +has_many :comments+.
|
61
|
-
if
|
61
|
+
if association.through_reflection? && association.source_reflection.nil?
|
62
62
|
log("through association with nil source_reflection")
|
63
63
|
|
64
64
|
through_association = model.reflect_on_association(association.options.fetch(:through))
|
@@ -77,14 +77,14 @@ module ActiveRecordDoctor
|
|
77
77
|
associated_models: [through_association.klass.name],
|
78
78
|
associated_models_type: "join"
|
79
79
|
)
|
80
|
-
next
|
81
80
|
end
|
81
|
+
next
|
82
82
|
end
|
83
83
|
|
84
84
|
associated_models, associated_models_type =
|
85
85
|
if association.polymorphic?
|
86
86
|
[models_having_association_with_options(as: association.name), nil]
|
87
|
-
elsif
|
87
|
+
elsif association.through_reflection?
|
88
88
|
[[association.source_reflection.active_record], "join"]
|
89
89
|
else
|
90
90
|
[[association.klass], nil]
|
@@ -159,10 +159,6 @@ module ActiveRecordDoctor
|
|
159
159
|
end
|
160
160
|
end
|
161
161
|
|
162
|
-
def through?(reflection)
|
163
|
-
reflection.is_a?(ActiveRecord::Reflection::ThroughReflection)
|
164
|
-
end
|
165
|
-
|
166
162
|
def defines_destroy_callbacks?(model)
|
167
163
|
# Destroying an associated model involves loading it first hence
|
168
164
|
# initialize and find are present. If they are defined on the model
|
@@ -33,21 +33,23 @@ module ActiveRecordDoctor
|
|
33
33
|
def detect
|
34
34
|
each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
|
35
35
|
each_attribute(model, except: config(:ignore_attributes), type: [:string, :text]) do |column|
|
36
|
-
model_maximum =
|
37
|
-
|
36
|
+
model_maximum = maximum_allowed_by_length_validation(model, column.name.to_sym)
|
37
|
+
database_maximum = column.limit
|
38
|
+
next if model_maximum == database_maximum
|
39
|
+
next if database_maximum && covered_by_inclusion_validation?(model, column.name.to_sym, database_maximum)
|
38
40
|
|
39
41
|
problem!(
|
40
42
|
model: model.name,
|
41
43
|
attribute: column.name,
|
42
44
|
table: model.table_name,
|
43
|
-
database_maximum:
|
45
|
+
database_maximum: database_maximum,
|
44
46
|
model_maximum: model_maximum
|
45
47
|
)
|
46
48
|
end
|
47
49
|
end
|
48
50
|
end
|
49
51
|
|
50
|
-
def
|
52
|
+
def maximum_allowed_by_length_validation(model, column)
|
51
53
|
length_validator = model.validators.find do |validator|
|
52
54
|
validator.kind == :length &&
|
53
55
|
validator.options.include?(:maximum) &&
|
@@ -55,6 +57,20 @@ module ActiveRecordDoctor
|
|
55
57
|
end
|
56
58
|
length_validator ? length_validator.options[:maximum] : nil
|
57
59
|
end
|
60
|
+
|
61
|
+
def covered_by_inclusion_validation?(model, column, limit)
|
62
|
+
inclusion_validator = model.validators.find do |validator|
|
63
|
+
validator.kind == :inclusion &&
|
64
|
+
validator.attributes.include?(column) &&
|
65
|
+
[:if, :unless].none? { |option| validator.options.key?(option) } &&
|
66
|
+
[:allow_nil, :allow_blank].none? { |option| validator.options[option] == true }
|
67
|
+
end
|
68
|
+
|
69
|
+
return false if !inclusion_validator
|
70
|
+
|
71
|
+
values = inclusion_validator.options[:in] || inclusion_validator.options[:within]
|
72
|
+
values.is_a?(Array) && values.all? { |value| value.is_a?(String) && value.size <= limit }
|
73
|
+
end
|
58
74
|
end
|
59
75
|
end
|
60
76
|
end
|
@@ -7,12 +7,12 @@ module ActiveRecordDoctor
|
|
7
7
|
class MissingForeignKeys < Base # :nodoc:
|
8
8
|
@description = "detect foreign-key-like columns lacking an actual foreign key constraint"
|
9
9
|
@config = {
|
10
|
-
|
11
|
-
description: "
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose columns should not be checked",
|
12
12
|
global: true
|
13
13
|
},
|
14
|
-
|
15
|
-
description: "
|
14
|
+
ignore_associations: {
|
15
|
+
description: "associations, written as Model.association, that should not be checked"
|
16
16
|
}
|
17
17
|
}
|
18
18
|
|
@@ -23,34 +23,19 @@ module ActiveRecordDoctor
|
|
23
23
|
end
|
24
24
|
|
25
25
|
def detect
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
# multiple tables but a foreign key constraint can reference
|
30
|
-
# a single predefined table.
|
31
|
-
next unless named_like_foreign_key?(column)
|
32
|
-
next if foreign_key?(table, column)
|
33
|
-
next if polymorphic_foreign_key?(table, column)
|
34
|
-
|
35
|
-
problem!(table: table, column: column.name)
|
36
|
-
end
|
37
|
-
end
|
38
|
-
end
|
26
|
+
each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
|
27
|
+
foreign_keys = connection.foreign_keys(model.table_name)
|
28
|
+
foreign_key_columns = foreign_keys.map { |key| key.options[:column] }
|
39
29
|
|
40
|
-
|
41
|
-
|
42
|
-
|
30
|
+
each_association(model, type: :belongs_to) do |association|
|
31
|
+
next if ignored?("#{model.name}.#{association.name}", config(:ignore_associations))
|
32
|
+
next if association.options[:polymorphic]
|
43
33
|
|
44
|
-
|
45
|
-
|
46
|
-
foreign_key.options[:column] == column.name
|
47
|
-
end
|
48
|
-
end
|
34
|
+
has_foreign_key = foreign_key_columns.include?(association.foreign_key)
|
35
|
+
next if has_foreign_key
|
49
36
|
|
50
|
-
|
51
|
-
|
52
|
-
connection.columns(table).any? do |another_column|
|
53
|
-
another_column.name == type_column_name
|
37
|
+
problem!(table: model.table_name, column: association.foreign_key)
|
38
|
+
end
|
54
39
|
end
|
55
40
|
end
|
56
41
|
end
|
@@ -18,15 +18,27 @@ module ActiveRecordDoctor
|
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
+
TIMESTAMPS = ["created_at", "created_on", "updated_at", "updated_on"].freeze
|
22
|
+
|
21
23
|
def message(column:, table:)
|
22
|
-
|
24
|
+
if TIMESTAMPS.include?(column)
|
25
|
+
<<~WARN.squish
|
26
|
+
add `NOT NULL` to #{table}.#{column} - timestamp columns are set
|
27
|
+
automatically by Active Record and allowing NULL may lead to
|
28
|
+
inconsistencies introduced by bulk operations
|
29
|
+
WARN
|
30
|
+
else
|
31
|
+
"add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
|
32
|
+
end
|
23
33
|
end
|
24
34
|
|
25
35
|
def detect
|
26
36
|
table_models = models.select(&:table_exists?).group_by(&:table_name)
|
37
|
+
views = connection.views
|
27
38
|
|
28
39
|
table_models.each do |table, models|
|
29
40
|
next if ignored?(table, config(:ignore_tables))
|
41
|
+
next if views.include?(table)
|
30
42
|
|
31
43
|
concrete_models = models.reject do |model|
|
32
44
|
model.abstract_class? || sti_base_model?(model)
|
@@ -50,6 +62,8 @@ module ActiveRecordDoctor
|
|
50
62
|
end
|
51
63
|
|
52
64
|
def non_null_needed?(model, column)
|
65
|
+
return true if TIMESTAMPS.include?(column.name)
|
66
|
+
|
53
67
|
belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
|
54
68
|
reflection.foreign_key == column.name ||
|
55
69
|
(reflection.polymorphic? && reflection.foreign_type == column.name)
|
@@ -67,6 +81,7 @@ module ActiveRecordDoctor
|
|
67
81
|
model.validators.select do |validator|
|
68
82
|
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
69
83
|
!validator.options[:allow_nil] &&
|
84
|
+
validator.options[:on].blank? &&
|
70
85
|
(rails_belongs_to_presence_validator?(validator) || !conditional_validator?(validator))
|
71
86
|
end
|
72
87
|
end
|
@@ -21,79 +21,164 @@ module ActiveRecordDoctor
|
|
21
21
|
|
22
22
|
private
|
23
23
|
|
24
|
-
def message(column:, model:)
|
25
|
-
|
24
|
+
def message(type:, column:, reflection:, model:)
|
25
|
+
case type
|
26
|
+
when :missing_validator
|
27
|
+
"add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
|
28
|
+
when :optional_association
|
29
|
+
"add `optional: false` to #{model}.#{reflection.name} - the foreign key #{reflection.foreign_key} is NOT NULL"
|
30
|
+
when :optional_polymorphic_association
|
31
|
+
"add `optional: false` to #{model}.#{reflection.name} - the foreign key #{reflection.foreign_key} or type #{reflection.foreign_type} are NOT NULL" # rubocop:disable Layout/LineLength
|
32
|
+
end
|
26
33
|
end
|
27
34
|
|
28
35
|
def detect
|
29
36
|
each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
37
|
+
# List all columns and then remove those that don't need or don't have
|
38
|
+
# a missing validator.
|
39
|
+
problematic_columns = connection.columns(model.table_name)
|
40
|
+
problematic_columns.reject! do |column|
|
41
|
+
# The primary key, timestamps, and counter caches are special
|
42
|
+
# columns that are automatically managed by Rails and don't need
|
43
|
+
# an explicit presence validator.
|
44
|
+
column.name == model.primary_key ||
|
45
|
+
["created_at", "updated_at", "created_on", "updated_on"].include?(column.name) ||
|
46
|
+
counter_cache_column?(model, column) ||
|
47
|
+
|
48
|
+
# NULL-able columns don't need a presence validator as they can be
|
49
|
+
# set to NULL after all. A check constraint (column IS NOT NULL)
|
50
|
+
# is an alternative approach and the absence of such constraint is
|
51
|
+
# tested below.
|
52
|
+
(column.null && !not_null_check_constraint_exists?(model.table_name, column)) ||
|
53
|
+
|
54
|
+
# If requested, columns with a default value don't need presence
|
55
|
+
# validation as they'd have the default value substituted automatically.
|
56
|
+
(config(:ignore_columns_with_default) && (column.default || column.default_function)) ||
|
57
|
+
|
58
|
+
# Explicitly ignored columns should be skipped.
|
59
|
+
ignored?("#{model.name}.#{column.name}", config(:ignore_attributes))
|
35
60
|
end
|
36
|
-
end
|
37
|
-
end
|
38
61
|
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
62
|
+
# At this point the only columns that are left are those that DO
|
63
|
+
# need presence validation in the model. Let's iterate over all
|
64
|
+
# validators to see which columns are actually validated, but before
|
65
|
+
# we do that let's define a map for quickly translating foreign key
|
66
|
+
# names to belongs_to association names.
|
67
|
+
column_name_to_association_name = {}
|
68
|
+
model.reflect_on_all_associations(:belongs_to).each do |reflection|
|
69
|
+
column_name_to_association_name[reflection.foreign_key] = reflection.name
|
70
|
+
if reflection.polymorphic?
|
71
|
+
column_name_to_association_name[reflection.foreign_type] = reflection.name
|
72
|
+
end
|
73
|
+
end
|
44
74
|
|
45
|
-
|
46
|
-
|
47
|
-
|
75
|
+
# We're now ready to iterate over the validators and remove columns
|
76
|
+
# that are validated directly or via an association name.
|
77
|
+
model.validators.each do |validator|
|
78
|
+
problematic_columns.reject! do |column|
|
79
|
+
attribute_names = [
|
80
|
+
column.name.to_sym,
|
81
|
+
column_name_to_association_name[column.name]
|
82
|
+
].compact
|
83
|
+
|
84
|
+
case validator
|
85
|
+
|
86
|
+
# A regular presence validator is enough if the column name is
|
87
|
+
# listed among the attributes it's validating.
|
88
|
+
when ActiveRecord::Validations::PresenceValidator
|
89
|
+
(validator.attributes & attribute_names).present?
|
90
|
+
|
91
|
+
# An inclusion validator ensures the column is not nil if it covers
|
92
|
+
# the column and nil is NOT one of the values it allows.
|
93
|
+
when ActiveModel::Validations::InclusionValidator
|
94
|
+
validator_items = inclusion_or_exclusion_validator_items(validator)
|
95
|
+
(validator.attributes & attribute_names).present? &&
|
96
|
+
(validator_items.is_a?(Proc) || validator_items.exclude?(nil))
|
97
|
+
|
98
|
+
# An exclusion validator ensures the column is not nil if it covers
|
99
|
+
# the column and excludes nil as an allowed value explicitly.
|
100
|
+
when ActiveModel::Validations::ExclusionValidator
|
101
|
+
validator_items = inclusion_or_exclusion_validator_items(validator)
|
102
|
+
(validator.attributes & attribute_names).present? &&
|
103
|
+
(validator_items.is_a?(Proc) || validator_items.include?(nil))
|
104
|
+
|
105
|
+
end
|
106
|
+
end
|
107
|
+
end
|
48
108
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
109
|
+
# Associations need to be checked whether they're marked optional
|
110
|
+
# while the underlying foreign key or type columns are marked NOT NULL.
|
111
|
+
problematic_associations = []
|
112
|
+
problematic_polymorphic_associations = []
|
113
|
+
|
114
|
+
model.reflect_on_all_associations(:belongs_to).each do |reflection|
|
115
|
+
foreign_key_column = problematic_columns.find { |column| column.name == reflection.foreign_key }
|
116
|
+
if reflection.polymorphic?
|
117
|
+
# If the foreign key and type are not one of the columns that lack
|
118
|
+
# a validator then it means the association added a validator and
|
119
|
+
# is configured correctly.
|
120
|
+
foreign_type_column = problematic_columns.find { |column| column.name == reflection.foreign_type }
|
121
|
+
next if foreign_key_column.nil? && foreign_type_column.nil?
|
122
|
+
|
123
|
+
# Otherwise, don't report errors about missing validators on the
|
124
|
+
# foreign key or type, but instead ...
|
125
|
+
problematic_columns.delete(foreign_key_column)
|
126
|
+
problematic_columns.delete(foreign_type_column)
|
127
|
+
|
128
|
+
# ... report an error about an incorrectly configured polymorphic
|
129
|
+
# association.
|
130
|
+
problematic_polymorphic_associations << reflection
|
131
|
+
else
|
132
|
+
# If the foreign key is not one of the columns that lack a
|
133
|
+
# validator then it means the association added a validator and is
|
134
|
+
# configured correctly.
|
135
|
+
next if foreign_key_column.nil?
|
136
|
+
|
137
|
+
# Otherwise, don't report an error about a missing validator on
|
138
|
+
# the foreign key, but instead ...
|
139
|
+
problematic_columns.delete(foreign_key_column)
|
140
|
+
|
141
|
+
# ... report an error about an incorrectly configured association.
|
142
|
+
problematic_associations << reflection
|
143
|
+
end
|
144
|
+
end
|
57
145
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
146
|
+
# Finally, regular and polymorphic associations that are explicitly
|
147
|
+
# ignored should be removed from the output. It's NOT enough to skip
|
148
|
+
# processing them in the loop above because their underlying foreign
|
149
|
+
# key and type columns must be removed from output, too.
|
150
|
+
problematic_associations.reject! do |reflection|
|
151
|
+
config(:ignore_attributes).include?("#{model.name}.#{reflection.name}")
|
152
|
+
end
|
153
|
+
problematic_polymorphic_associations.reject! do |reflection|
|
154
|
+
config(:ignore_attributes).include?("#{model.name}.#{reflection.name}")
|
155
|
+
end
|
62
156
|
|
63
|
-
|
64
|
-
|
65
|
-
!
|
157
|
+
# Job is done and all accumulated errors can be reported.
|
158
|
+
problematic_polymorphic_associations.each do |reflection|
|
159
|
+
problem!(type: :optional_polymorphic_association, column: nil, reflection: reflection, model: model.name)
|
160
|
+
end
|
161
|
+
problematic_associations.each do |reflection|
|
162
|
+
problem!(type: :optional_association, column: nil, reflection: reflection, model: model.name)
|
163
|
+
end
|
164
|
+
problematic_columns.each do |column|
|
165
|
+
problem!(type: :missing_validator, column: column.name, reflection: nil, model: model.name)
|
166
|
+
end
|
66
167
|
end
|
67
168
|
end
|
68
169
|
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
return true if validator_items.is_a?(Proc)
|
73
|
-
|
74
|
-
validator.is_a?(ActiveModel::Validations::ExclusionValidator) &&
|
75
|
-
validator.attributes.include?(column.name.to_sym) &&
|
76
|
-
validator_items.include?(nil)
|
77
|
-
end
|
170
|
+
# Normalizes the list of values passed to an inclusion or exclusion validator.
|
171
|
+
def inclusion_or_exclusion_validator_items(validator)
|
172
|
+
validator.options[:in] || validator.options[:within] || []
|
78
173
|
end
|
79
174
|
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
reflection.
|
85
|
-
end
|
86
|
-
allowed_attributes << belongs_to.name.to_sym if belongs_to
|
87
|
-
|
88
|
-
model.validators.any? do |validator|
|
89
|
-
validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
|
90
|
-
(validator.attributes & allowed_attributes).present?
|
175
|
+
# Determines whether the given column is used as a counter cache column by
|
176
|
+
# a has_many association on the model.
|
177
|
+
def counter_cache_column?(model, column)
|
178
|
+
model.reflect_on_all_associations(:has_many).any? do |reflection|
|
179
|
+
reflection.has_cached_counter? && reflection.counter_cache_column == column.name
|
91
180
|
end
|
92
181
|
end
|
93
|
-
|
94
|
-
def inclusion_validator_items(validator)
|
95
|
-
validator.options[:in] || validator.options[:within] || []
|
96
|
-
end
|
97
182
|
end
|
98
183
|
end
|
99
184
|
end
|
@@ -65,9 +65,8 @@ module ActiveRecordDoctor
|
|
65
65
|
# put true literally.
|
66
66
|
case_sensitive = validator.options.fetch(:case_sensitive, true)
|
67
67
|
|
68
|
-
#
|
69
|
-
|
70
|
-
next if !case_sensitive && Utils.expression_indexes_unsupported?
|
68
|
+
# Avoid a false positive if expression indexes are unsupported.
|
69
|
+
next if !case_sensitive && !connection.supports_expression_index?
|
71
70
|
|
72
71
|
validator.attributes.each do |attribute|
|
73
72
|
columns = resolve_attributes(model, scope + [attribute])
|
@@ -8,7 +8,7 @@ module ActiveRecordDoctor
|
|
8
8
|
@description = "detect tables without primary keys"
|
9
9
|
@config = {
|
10
10
|
ignore_tables: {
|
11
|
-
description: "tables whose primary key
|
11
|
+
description: "tables whose primary key existence should not be checked",
|
12
12
|
global: true
|
13
13
|
}
|
14
14
|
}
|
@@ -0,0 +1,38 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
4
|
+
|
5
|
+
module ActiveRecordDoctor
|
6
|
+
module Detectors
|
7
|
+
class TableWithoutTimestamps < Base # :nodoc:
|
8
|
+
@description = "detect tables without created_at/updated_at columns"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose timestamp columns existence should not be checked",
|
12
|
+
global: true
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
TIMESTAMPS = {
|
19
|
+
"created_at" => "created_on",
|
20
|
+
"updated_at" => "updated_on"
|
21
|
+
}.freeze
|
22
|
+
|
23
|
+
def message(table:, column:)
|
24
|
+
"add a #{column} column to #{table}"
|
25
|
+
end
|
26
|
+
|
27
|
+
def detect
|
28
|
+
each_table(except: config(:ignore_tables)) do |table|
|
29
|
+
TIMESTAMPS.each do |column, alternative_column|
|
30
|
+
unless column(table, column) || column(table, alternative_column)
|
31
|
+
problem!(table: table, column: column)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
@@ -27,7 +27,7 @@ module ActiveRecordDoctor
|
|
27
27
|
def detect
|
28
28
|
each_table(except: config(:ignore_tables)) do |table|
|
29
29
|
each_column(table, except: config(:ignore_columns)) do |column|
|
30
|
-
next unless
|
30
|
+
next unless looks_like_foreign_key?(column) || foreign_key?(table, column)
|
31
31
|
next if indexed?(table, column)
|
32
32
|
next if indexed_as_polymorphic?(table, column)
|
33
33
|
next if connection.primary_key(table) == column.name
|
@@ -46,10 +46,6 @@ module ActiveRecordDoctor
|
|
46
46
|
end
|
47
47
|
end
|
48
48
|
|
49
|
-
def named_like_foreign_key?(column)
|
50
|
-
column.name.end_with?("_id")
|
51
|
-
end
|
52
|
-
|
53
49
|
def foreign_key?(table, column)
|
54
50
|
connection.foreign_keys(table).any? do |foreign_key|
|
55
51
|
foreign_key.column == column.name
|
@@ -27,7 +27,7 @@ module ActiveRecordDoctor # :nodoc:
|
|
27
27
|
# We can't use #all? because of its short-circuit behavior - it stops
|
28
28
|
# iteration and returns false upon the first falsey value. This
|
29
29
|
# prevents other detectors from running if there's a failure.
|
30
|
-
ActiveRecordDoctor.detectors.
|
30
|
+
ActiveRecordDoctor.detectors.each_key do |name|
|
31
31
|
success = false if !run_one(name)
|
32
32
|
end
|
33
33
|
|
@@ -11,10 +11,8 @@ module ActiveRecordDoctor
|
|
11
11
|
connection.adapter_name == "Mysql2"
|
12
12
|
end
|
13
13
|
|
14
|
-
def
|
15
|
-
|
16
|
-
# Active Record is unable to correctly parse expression indexes for MySQL.
|
17
|
-
(mysql?(connection) && ActiveRecord::VERSION::STRING < "7.1")
|
14
|
+
def sqlite?(connection = ActiveRecord::Base.connection)
|
15
|
+
connection.adapter_name == "SQLite"
|
18
16
|
end
|
19
17
|
end
|
20
18
|
end
|
data/lib/active_record_doctor.rb
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "active_record_doctor/railtie" if defined?(Rails
|
3
|
+
require "active_record_doctor/railtie" if defined?(Rails::Railtie)
|
4
4
|
require "active_record_doctor/utils"
|
5
5
|
require "active_record_doctor/logger"
|
6
6
|
require "active_record_doctor/logger/dummy"
|
@@ -21,6 +21,7 @@ require "active_record_doctor/detectors/incorrect_dependent_option"
|
|
21
21
|
require "active_record_doctor/detectors/short_primary_key_type"
|
22
22
|
require "active_record_doctor/detectors/mismatched_foreign_key_type"
|
23
23
|
require "active_record_doctor/detectors/table_without_primary_key"
|
24
|
+
require "active_record_doctor/detectors/table_without_timestamps"
|
24
25
|
require "active_record_doctor/errors"
|
25
26
|
require "active_record_doctor/help"
|
26
27
|
require "active_record_doctor/runner"
|
@@ -47,7 +47,7 @@ module ActiveRecordDoctor
|
|
47
47
|
# rubocop rule below.
|
48
48
|
|
49
49
|
<<MIGRATION
|
50
|
-
class IndexForeignKeysIn#{table.camelize} < ActiveRecord::Migration#{
|
50
|
+
class IndexForeignKeysIn#{table.camelize} < ActiveRecord::Migration[#{ActiveRecord::Migration.current_version}]
|
51
51
|
def change
|
52
52
|
#{add_indexes(table, indexes)}
|
53
53
|
end
|
@@ -71,13 +71,5 @@ MIGRATION
|
|
71
71
|
" add_index :#{table}, #{columns.inspect}"
|
72
72
|
end
|
73
73
|
end
|
74
|
-
|
75
|
-
def migration_version
|
76
|
-
if ActiveRecord::VERSION::STRING >= "5.1"
|
77
|
-
"[#{ActiveRecord::Migration.current_version}]"
|
78
|
-
else
|
79
|
-
""
|
80
|
-
end
|
81
|
-
end
|
82
74
|
end
|
83
75
|
end
|
metadata
CHANGED
@@ -1,14 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: active_record_doctor
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.0.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Greg Navis
|
8
|
-
autorequire:
|
9
8
|
bindir: bin
|
10
9
|
cert_chain: []
|
11
|
-
date:
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
12
11
|
dependencies:
|
13
12
|
- !ruby/object:Gem::Dependency
|
14
13
|
name: activerecord
|
@@ -16,14 +15,14 @@ dependencies:
|
|
16
15
|
requirements:
|
17
16
|
- - ">="
|
18
17
|
- !ruby/object:Gem::Version
|
19
|
-
version:
|
18
|
+
version: 7.0.0
|
20
19
|
type: :runtime
|
21
20
|
prerelease: false
|
22
21
|
version_requirements: !ruby/object:Gem::Requirement
|
23
22
|
requirements:
|
24
23
|
- - ">="
|
25
24
|
- !ruby/object:Gem::Version
|
26
|
-
version:
|
25
|
+
version: 7.0.0
|
27
26
|
- !ruby/object:Gem::Dependency
|
28
27
|
name: minitest-fork_executor
|
29
28
|
requirement: !ruby/object:Gem::Requirement
|
@@ -44,85 +43,98 @@ dependencies:
|
|
44
43
|
requirements:
|
45
44
|
- - "~>"
|
46
45
|
- !ruby/object:Gem::Version
|
47
|
-
version: 0.5.
|
46
|
+
version: 0.5.6
|
48
47
|
type: :development
|
49
48
|
prerelease: false
|
50
49
|
version_requirements: !ruby/object:Gem::Requirement
|
51
50
|
requirements:
|
52
51
|
- - "~>"
|
53
52
|
- !ruby/object:Gem::Version
|
54
|
-
version: 0.5.
|
53
|
+
version: 0.5.6
|
55
54
|
- !ruby/object:Gem::Dependency
|
56
55
|
name: pg
|
57
56
|
requirement: !ruby/object:Gem::Requirement
|
58
57
|
requirements:
|
59
58
|
- - "~>"
|
60
59
|
- !ruby/object:Gem::Version
|
61
|
-
version: 1.5.
|
60
|
+
version: 1.5.9
|
62
61
|
type: :development
|
63
62
|
prerelease: false
|
64
63
|
version_requirements: !ruby/object:Gem::Requirement
|
65
64
|
requirements:
|
66
65
|
- - "~>"
|
67
66
|
- !ruby/object:Gem::Version
|
68
|
-
version: 1.5.
|
67
|
+
version: 1.5.9
|
69
68
|
- !ruby/object:Gem::Dependency
|
70
69
|
name: railties
|
71
70
|
requirement: !ruby/object:Gem::Requirement
|
72
71
|
requirements:
|
73
72
|
- - ">="
|
74
73
|
- !ruby/object:Gem::Version
|
75
|
-
version:
|
74
|
+
version: 7.0.0
|
76
75
|
type: :development
|
77
76
|
prerelease: false
|
78
77
|
version_requirements: !ruby/object:Gem::Requirement
|
79
78
|
requirements:
|
80
79
|
- - ">="
|
81
80
|
- !ruby/object:Gem::Version
|
82
|
-
version:
|
81
|
+
version: 7.0.0
|
83
82
|
- !ruby/object:Gem::Dependency
|
84
83
|
name: rake
|
85
84
|
requirement: !ruby/object:Gem::Requirement
|
86
85
|
requirements:
|
87
86
|
- - "~>"
|
88
87
|
- !ruby/object:Gem::Version
|
89
|
-
version:
|
88
|
+
version: 13.2.1
|
90
89
|
type: :development
|
91
90
|
prerelease: false
|
92
91
|
version_requirements: !ruby/object:Gem::Requirement
|
93
92
|
requirements:
|
94
93
|
- - "~>"
|
95
94
|
- !ruby/object:Gem::Version
|
96
|
-
version:
|
95
|
+
version: 13.2.1
|
97
96
|
- !ruby/object:Gem::Dependency
|
98
|
-
name:
|
97
|
+
name: rubocop
|
99
98
|
requirement: !ruby/object:Gem::Requirement
|
100
99
|
requirements:
|
101
100
|
- - "~>"
|
102
101
|
- !ruby/object:Gem::Version
|
103
|
-
version:
|
102
|
+
version: 1.68.0
|
104
103
|
type: :development
|
105
104
|
prerelease: false
|
106
105
|
version_requirements: !ruby/object:Gem::Requirement
|
107
106
|
requirements:
|
108
107
|
- - "~>"
|
109
108
|
- !ruby/object:Gem::Version
|
110
|
-
version:
|
109
|
+
version: 1.68.0
|
111
110
|
- !ruby/object:Gem::Dependency
|
112
|
-
name:
|
111
|
+
name: sqlite3
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
113
|
+
requirements:
|
114
|
+
- - "~>"
|
115
|
+
- !ruby/object:Gem::Version
|
116
|
+
version: 2.2.0
|
117
|
+
type: :development
|
118
|
+
prerelease: false
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
120
|
+
requirements:
|
121
|
+
- - "~>"
|
122
|
+
- !ruby/object:Gem::Version
|
123
|
+
version: 2.2.0
|
124
|
+
- !ruby/object:Gem::Dependency
|
125
|
+
name: transient_record
|
113
126
|
requirement: !ruby/object:Gem::Requirement
|
114
127
|
requirements:
|
115
128
|
- - "~>"
|
116
129
|
- !ruby/object:Gem::Version
|
117
|
-
version:
|
130
|
+
version: 3.0.0
|
118
131
|
type: :development
|
119
132
|
prerelease: false
|
120
133
|
version_requirements: !ruby/object:Gem::Requirement
|
121
134
|
requirements:
|
122
135
|
- - "~>"
|
123
136
|
- !ruby/object:Gem::Version
|
124
|
-
version:
|
125
|
-
description:
|
137
|
+
version: 3.0.0
|
126
138
|
email:
|
127
139
|
- contact@gregnavis.com
|
128
140
|
executables: []
|
@@ -148,6 +160,7 @@ files:
|
|
148
160
|
- lib/active_record_doctor/detectors/missing_unique_indexes.rb
|
149
161
|
- lib/active_record_doctor/detectors/short_primary_key_type.rb
|
150
162
|
- lib/active_record_doctor/detectors/table_without_primary_key.rb
|
163
|
+
- lib/active_record_doctor/detectors/table_without_timestamps.rb
|
151
164
|
- lib/active_record_doctor/detectors/undefined_table_references.rb
|
152
165
|
- lib/active_record_doctor/detectors/unindexed_deleted_at.rb
|
153
166
|
- lib/active_record_doctor/detectors/unindexed_foreign_keys.rb
|
@@ -170,7 +183,6 @@ licenses:
|
|
170
183
|
- MIT
|
171
184
|
metadata:
|
172
185
|
rubygems_mfa_required: 'true'
|
173
|
-
post_install_message:
|
174
186
|
rdoc_options: []
|
175
187
|
require_paths:
|
176
188
|
- lib
|
@@ -178,15 +190,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
178
190
|
requirements:
|
179
191
|
- - ">="
|
180
192
|
- !ruby/object:Gem::Version
|
181
|
-
version: 2.
|
193
|
+
version: 2.7.0
|
182
194
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
183
195
|
requirements:
|
184
196
|
- - ">="
|
185
197
|
- !ruby/object:Gem::Version
|
186
198
|
version: '0'
|
187
199
|
requirements: []
|
188
|
-
rubygems_version: 3.
|
189
|
-
signing_key:
|
200
|
+
rubygems_version: 3.6.7
|
190
201
|
specification_version: 4
|
191
202
|
summary: Identify database issues before they hit production.
|
192
203
|
test_files: []
|