active_record_doctor 1.15.0 → 2.0.0
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 +38 -14
- data/lib/active_record_doctor/adapters/database.rb +17 -0
- data/lib/active_record_doctor/adapters/table.rb +18 -0
- data/lib/active_record_doctor/adapters/validators.rb +16 -0
- data/lib/active_record_doctor/config/default.rb +4 -0
- 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 -5
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +16 -1
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +139 -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 +38 -24
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 8bae986a04fe63835ce7e190f32471cc06a370972161c487cac195ef33f73269
|
4
|
+
data.tar.gz: c5a2f92f0b31ec1faedfd65fc5abe9ef656f75b9fa933719e57f180b4fb0f28d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7759e1effdfcea2382966f8973c2c132bda909b847a14914fb7a46b795a7d0407f9605efa39dfece9d889ee65b35f334f78e6664928fbbe03e349f792845ae7f
|
7
|
+
data.tar.gz: c563de0bbf91966325eddd2bafedb3dadcc07ca39e7c27a529fdb544113eadba4f2373e9d618a074205a8716687f7684f232a212711fcf5d50fc1484999b1074
|
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
|
|
@@ -403,7 +402,8 @@ Supported configuration options:
|
|
403
402
|
|
404
403
|
If there's an unconditional presence validation on a column then it should be
|
405
404
|
marked as non-`NULL`-able at the database level or should have a `IS NOT NULL`
|
406
|
-
constraint.
|
405
|
+
constraint. Timestamp columns are also expected to be made `NOT NULL` as they're
|
406
|
+
managed automatically by Active Record.
|
407
407
|
|
408
408
|
In order to detect columns whose presence is required but that are marked
|
409
409
|
`null: true` in the database run the following command:
|
@@ -433,7 +433,8 @@ Supported configuration options:
|
|
433
433
|
### Detecting Missing Presence Validations
|
434
434
|
|
435
435
|
If a column is marked as `null: false` then it's likely it should have the
|
436
|
-
corresponding presence validator
|
436
|
+
corresponding presence validator or an appropriately configured inclusion or
|
437
|
+
exclusion validation.
|
437
438
|
|
438
439
|
In order to detect models lacking these validations run:
|
439
440
|
|
@@ -640,7 +641,30 @@ add a primary key to companies
|
|
640
641
|
Supported configuration options:
|
641
642
|
|
642
643
|
- `enabled` - set to `false` to disable the detector altogether
|
643
|
-
- `ignore_tables` - tables whose primary key
|
644
|
+
- `ignore_tables` - tables whose primary key existence should not be checked
|
645
|
+
|
646
|
+
### Detecting Tables Without Timestamps
|
647
|
+
|
648
|
+
Tables should have timestamp columns (`created_at`/`updated_at`). Otherwise, it becomes problematic
|
649
|
+
to easily find when the record was created/updated, if the table is active or can be removed,
|
650
|
+
automatic Rails cache expiration after record updates is not possible.
|
651
|
+
|
652
|
+
Running the command below will list all tables without default timestamp columns:
|
653
|
+
|
654
|
+
```
|
655
|
+
bundle exec rake active_record_doctor:table_without_timestamps
|
656
|
+
```
|
657
|
+
|
658
|
+
The output of the command looks like this:
|
659
|
+
|
660
|
+
```
|
661
|
+
add a created_at column to companies
|
662
|
+
```
|
663
|
+
|
664
|
+
Supported configuration options:
|
665
|
+
|
666
|
+
- `enabled` - set to `false` to disable the detector altogether
|
667
|
+
- `ignore_tables` - tables whose timestamp columns existence should not be checked
|
644
668
|
|
645
669
|
## Ruby and Rails Compatibility Policy
|
646
670
|
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module ActiveRecordDoctor
|
2
|
+
module Adapters
|
3
|
+
class Table
|
4
|
+
def initialize(connection, table_name)
|
5
|
+
@connection = connection
|
6
|
+
@table_name = table_name
|
7
|
+
end
|
8
|
+
|
9
|
+
def columns
|
10
|
+
connection.columns(table_name)
|
11
|
+
end
|
12
|
+
|
13
|
+
private
|
14
|
+
|
15
|
+
attr_reader :connection, :table_name
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -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
|
@@ -28,19 +28,16 @@ module ActiveRecordDoctor
|
|
28
28
|
# We need to skip polymorphic associations as they can reference
|
29
29
|
# multiple tables but a foreign key constraint can reference
|
30
30
|
# a single predefined table.
|
31
|
-
next unless
|
31
|
+
next unless looks_like_foreign_key?(column)
|
32
32
|
next if foreign_key?(table, column)
|
33
33
|
next if polymorphic_foreign_key?(table, column)
|
34
|
+
next if model_destroyed_async?(table, column)
|
34
35
|
|
35
36
|
problem!(table: table, column: column.name)
|
36
37
|
end
|
37
38
|
end
|
38
39
|
end
|
39
40
|
|
40
|
-
def named_like_foreign_key?(column)
|
41
|
-
column.name.end_with?("_id")
|
42
|
-
end
|
43
|
-
|
44
41
|
def foreign_key?(table, column)
|
45
42
|
connection.foreign_keys(table).any? do |foreign_key|
|
46
43
|
foreign_key.options[:column] == column.name
|
@@ -53,6 +50,18 @@ module ActiveRecordDoctor
|
|
53
50
|
another_column.name == type_column_name
|
54
51
|
end
|
55
52
|
end
|
53
|
+
|
54
|
+
def model_destroyed_async?(table, column)
|
55
|
+
# Check if there are any models having `has_many ..., dependent: :destroy_async`
|
56
|
+
# referencing the specified table.
|
57
|
+
models.any? do |model|
|
58
|
+
model.reflect_on_all_associations(:has_many).any? do |reflection|
|
59
|
+
reflection.options[:dependent] == :destroy_async &&
|
60
|
+
reflection.foreign_key == column.name &&
|
61
|
+
reflection.klass.table_name == table
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
56
65
|
end
|
57
66
|
end
|
58
67
|
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,162 @@ module ActiveRecordDoctor
|
|
21
21
|
|
22
22
|
private
|
23
23
|
|
24
|
-
def message(
|
25
|
-
|
24
|
+
def message(type:, column_or_association:, model:)
|
25
|
+
case type
|
26
|
+
when :missing_validator
|
27
|
+
"add a `presence` validator to #{model}.#{column_or_association} - it's NOT NULL but lacks a validator"
|
28
|
+
when :optional_association
|
29
|
+
"add `optional: false` to #{model}.#{column_or_association} - the foreign key #{column_or_association}_id is NOT NULL" # rubocop:disable Layout/LineLength
|
30
|
+
when :optional_polymorphic_association
|
31
|
+
"add `optional: false` to #{model}.#{column_or_association} - the foreign key #{column_or_association}_id or type #{column_or_association}_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
|
+
config(:ignore_attributes).include?("#{model.name}.#{column.name}")
|
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
|
+
# Translate a foreign key or type to the association name.
|
80
|
+
attribute = column_name_to_association_name[column.name] || column.name.to_sym
|
81
|
+
|
82
|
+
case validator
|
83
|
+
|
84
|
+
# A regular presence validator is enough if the column name is
|
85
|
+
# listed among the attributes it's validating.
|
86
|
+
when ActiveRecord::Validations::PresenceValidator
|
87
|
+
validator.attributes.include?(attribute)
|
88
|
+
|
89
|
+
# An inclusion validator ensures the column is not nil if it covers
|
90
|
+
# the column and nil is NOT one of the values it allows.
|
91
|
+
when ActiveModel::Validations::InclusionValidator
|
92
|
+
validator_items = inclusion_or_exclusion_validator_items(validator)
|
93
|
+
validator.attributes.include?(attribute) &&
|
94
|
+
(validator_items.is_a?(Proc) || validator_items.exclude?(nil))
|
95
|
+
|
96
|
+
# An exclusion validator ensures the column is not nil if it covers
|
97
|
+
# the column and excludes nil as an allowed value explicitly.
|
98
|
+
when ActiveModel::Validations::ExclusionValidator
|
99
|
+
validator_items = inclusion_or_exclusion_validator_items(validator)
|
100
|
+
validator.attributes.include?(attribute) &&
|
101
|
+
(validator_items.is_a?(Proc) || validator_items.include?(nil))
|
102
|
+
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
48
106
|
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
107
|
+
# Associations need to be checked whether they're marked optional
|
108
|
+
# while the underlying foreign key or type columns are marked NOT NULL.
|
109
|
+
problematic_associations = []
|
110
|
+
problematic_polymorphic_associations = []
|
111
|
+
|
112
|
+
model.reflect_on_all_associations.each do |reflection|
|
113
|
+
foreign_key_column = problematic_columns.find { |column| column.name == reflection.foreign_key }
|
114
|
+
if reflection.polymorphic?
|
115
|
+
# If the foreign key and type are not one of the columns that lack
|
116
|
+
# a validator then it means the association added a validator and
|
117
|
+
# is configured correctly.
|
118
|
+
foreign_type_column = problematic_columns.find { |column| column.name == reflection.foreign_type }
|
119
|
+
next if foreign_key_column.nil? && foreign_type_column.nil?
|
120
|
+
|
121
|
+
# Otherwise, don't report errors about missing validators on the
|
122
|
+
# foreign key or type, but instead ...
|
123
|
+
problematic_columns.delete(foreign_key_column)
|
124
|
+
problematic_columns.delete(foreign_type_column)
|
125
|
+
|
126
|
+
# ... report an error about an incorrectly configured polymorphic
|
127
|
+
# association.
|
128
|
+
problematic_polymorphic_associations << reflection.name
|
129
|
+
else
|
130
|
+
# If the foreign key is not one of the columns that lack a
|
131
|
+
# validator then it means the association added a validator and is
|
132
|
+
# configured correctly.
|
133
|
+
next if foreign_key_column.nil?
|
134
|
+
|
135
|
+
# Otherwise, don't report an error about a missing validator on
|
136
|
+
# the foreign key, but instead ...
|
137
|
+
problematic_columns.delete(foreign_key_column)
|
138
|
+
|
139
|
+
# ... report an error about an incorrectly configured association.
|
140
|
+
problematic_associations << reflection.name
|
141
|
+
end
|
142
|
+
end
|
57
143
|
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
144
|
+
# Finally, regular and polymorphic associations that are explicitly
|
145
|
+
# ignored should be removed from the output. It's NOT enough to skip
|
146
|
+
# processing them in the loop above because their underlying foreign
|
147
|
+
# key and type columns must be removed from output, too.
|
148
|
+
problematic_associations.reject! do |name|
|
149
|
+
config(:ignore_attributes).include?("#{model.name}.#{name}")
|
150
|
+
end
|
151
|
+
problematic_polymorphic_associations.reject! do |name|
|
152
|
+
config(:ignore_attributes).include?("#{model.name}.#{name}")
|
153
|
+
end
|
62
154
|
|
63
|
-
|
64
|
-
|
65
|
-
!
|
155
|
+
# Job is done and all accumulated errors can be reported.
|
156
|
+
problematic_polymorphic_associations.each do |name|
|
157
|
+
problem!(type: :optional_polymorphic_association, column_or_association: name, model: model.name)
|
158
|
+
end
|
159
|
+
problematic_associations.each do |name|
|
160
|
+
problem!(type: :optional_association, column_or_association: name, model: model.name)
|
161
|
+
end
|
162
|
+
problematic_columns.each do |column|
|
163
|
+
problem!(type: :missing_validator, column_or_association: column.name, model: model.name)
|
164
|
+
end
|
66
165
|
end
|
67
166
|
end
|
68
167
|
|
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
|
168
|
+
# Normalizes the list of values passed to an inclusion or exclusion validator.
|
169
|
+
def inclusion_or_exclusion_validator_items(validator)
|
170
|
+
validator.options[:in] || validator.options[:within] || []
|
78
171
|
end
|
79
172
|
|
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?
|
173
|
+
# Determines whether the given column is used as a counter cache column by
|
174
|
+
# a has_many association on the model.
|
175
|
+
def counter_cache_column?(model, column)
|
176
|
+
model.reflect_on_all_associations(:has_many).any? do |reflection|
|
177
|
+
reflection.has_cached_counter? && reflection.counter_cache_column == column.name
|
91
178
|
end
|
92
179
|
end
|
93
|
-
|
94
|
-
def inclusion_validator_items(validator)
|
95
|
-
validator.options[:in] || validator.options[:within] || []
|
96
|
-
end
|
97
180
|
end
|
98
181
|
end
|
99
182
|
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.0
|
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: []
|
@@ -132,6 +144,9 @@ files:
|
|
132
144
|
- MIT-LICENSE.txt
|
133
145
|
- README.md
|
134
146
|
- lib/active_record_doctor.rb
|
147
|
+
- lib/active_record_doctor/adapters/database.rb
|
148
|
+
- lib/active_record_doctor/adapters/table.rb
|
149
|
+
- lib/active_record_doctor/adapters/validators.rb
|
135
150
|
- lib/active_record_doctor/config.rb
|
136
151
|
- lib/active_record_doctor/config/default.rb
|
137
152
|
- lib/active_record_doctor/config/loader.rb
|
@@ -148,6 +163,7 @@ files:
|
|
148
163
|
- lib/active_record_doctor/detectors/missing_unique_indexes.rb
|
149
164
|
- lib/active_record_doctor/detectors/short_primary_key_type.rb
|
150
165
|
- lib/active_record_doctor/detectors/table_without_primary_key.rb
|
166
|
+
- lib/active_record_doctor/detectors/table_without_timestamps.rb
|
151
167
|
- lib/active_record_doctor/detectors/undefined_table_references.rb
|
152
168
|
- lib/active_record_doctor/detectors/unindexed_deleted_at.rb
|
153
169
|
- lib/active_record_doctor/detectors/unindexed_foreign_keys.rb
|
@@ -170,7 +186,6 @@ licenses:
|
|
170
186
|
- MIT
|
171
187
|
metadata:
|
172
188
|
rubygems_mfa_required: 'true'
|
173
|
-
post_install_message:
|
174
189
|
rdoc_options: []
|
175
190
|
require_paths:
|
176
191
|
- lib
|
@@ -178,15 +193,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
178
193
|
requirements:
|
179
194
|
- - ">="
|
180
195
|
- !ruby/object:Gem::Version
|
181
|
-
version: 2.
|
196
|
+
version: 2.7.0
|
182
197
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
183
198
|
requirements:
|
184
199
|
- - ">="
|
185
200
|
- !ruby/object:Gem::Version
|
186
201
|
version: '0'
|
187
202
|
requirements: []
|
188
|
-
rubygems_version: 3.
|
189
|
-
signing_key:
|
203
|
+
rubygems_version: 3.6.7
|
190
204
|
specification_version: 4
|
191
205
|
summary: Identify database issues before they hit production.
|
192
206
|
test_files: []
|