active_record_doctor 1.8.0 → 1.9.0.rc1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +246 -48
- data/lib/active_record_doctor/config/default.rb +59 -0
- data/lib/active_record_doctor/config/loader.rb +137 -0
- data/lib/active_record_doctor/config.rb +14 -0
- data/lib/active_record_doctor/detectors/base.rb +110 -19
- data/lib/active_record_doctor/detectors/extraneous_indexes.rb +63 -37
- data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +32 -23
- data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +70 -34
- data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
- data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +32 -23
- data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +40 -28
- data/lib/active_record_doctor/detectors/missing_presence_validation.rb +28 -21
- data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +40 -30
- data/lib/active_record_doctor/detectors/short_primary_key_type.rb +41 -0
- data/lib/active_record_doctor/detectors/undefined_table_references.rb +19 -20
- data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +44 -18
- data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +31 -20
- data/lib/active_record_doctor/detectors.rb +12 -4
- data/lib/active_record_doctor/errors.rb +226 -0
- data/lib/active_record_doctor/help.rb +39 -0
- data/lib/active_record_doctor/rake/task.rb +78 -0
- data/lib/active_record_doctor/runner.rb +41 -0
- data/lib/active_record_doctor/version.rb +1 -1
- data/lib/active_record_doctor.rb +7 -3
- data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +34 -21
- data/lib/tasks/active_record_doctor.rake +9 -18
- data/test/active_record_doctor/config/loader_test.rb +120 -0
- data/test/active_record_doctor/config_test.rb +116 -0
- data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +131 -8
- data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +48 -5
- data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +190 -12
- data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
- data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +50 -4
- data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +138 -24
- data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +74 -13
- data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +57 -8
- data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +64 -0
- data/test/active_record_doctor/detectors/undefined_table_references_test.rb +34 -21
- data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +112 -8
- data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +56 -4
- data/test/active_record_doctor/runner_test.rb +42 -0
- data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
- data/test/model_factory.rb +73 -23
- data/test/setup.rb +62 -72
- metadata +40 -9
- data/lib/active_record_doctor/printers/io_printer.rb +0 -133
- data/lib/active_record_doctor/task.rb +0 -28
- data/test/active_record_doctor/printers/io_printer_test.rb +0 -33
@@ -4,39 +4,51 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
!table_exists?(model.table_name)
|
19
|
-
end.map do |model|
|
20
|
-
[
|
21
|
-
model.table_name,
|
22
|
-
connection.columns(model.table_name).select do |column|
|
23
|
-
validator_needed?(model, column) &&
|
24
|
-
has_mandatory_presence_validator?(model, column) &&
|
25
|
-
column.null
|
26
|
-
end.map(&:name)
|
27
|
-
]
|
28
|
-
end.reject do |_model_name, columns|
|
29
|
-
columns.empty?
|
30
|
-
end))
|
31
|
-
end
|
7
|
+
class MissingNonNullConstraint < Base # :nodoc:
|
8
|
+
@description = "detect columns whose presence is always validated but isn't enforced via a non-NULL constraint"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose columns should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "columns, written as table.column, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
32
18
|
|
33
19
|
private
|
34
20
|
|
35
|
-
def
|
36
|
-
|
21
|
+
def message(column:, table:)
|
22
|
+
"add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
|
23
|
+
end
|
24
|
+
|
25
|
+
def detect
|
26
|
+
table_models = models.group_by(&:table_name)
|
27
|
+
table_models.delete_if { |table| table.nil? || !table_exists?(table) }
|
28
|
+
|
29
|
+
table_models.each do |table, models|
|
30
|
+
next if config(:ignore_tables).include?(table)
|
31
|
+
|
32
|
+
concrete_models = models.reject do |model|
|
33
|
+
model.abstract_class? || sti_base_model?(model)
|
34
|
+
end
|
35
|
+
|
36
|
+
connection.columns(table).each do |column|
|
37
|
+
next if config(:ignore_columns).include?("#{table}.#{column.name}")
|
38
|
+
next if !column.null
|
39
|
+
next if !concrete_models.all? { |model| non_null_needed?(model, column) }
|
40
|
+
|
41
|
+
problem!(column: column.name, table: table)
|
42
|
+
end
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def sti_base_model?(model)
|
47
|
+
model.base_class == model &&
|
48
|
+
model.columns_hash.include?(model.inheritance_column.to_s)
|
37
49
|
end
|
38
50
|
|
39
|
-
def
|
51
|
+
def non_null_needed?(model, column)
|
40
52
|
# A foreign key can be validates via the column name (e.g. company_id)
|
41
53
|
# or the association name (e.g. company). We collect the allowed names
|
42
54
|
# in an array to check for their presence in the validator definition
|
@@ -4,31 +4,38 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
@
|
7
|
+
class MissingPresenceValidation < Base # :nodoc:
|
8
|
+
@description = "detect non-NULL columns without a corresponding presence validator"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose underlying tables' columns should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_attributes: {
|
15
|
+
description: "specific attributes, written as Model.attribute, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
10
18
|
|
11
|
-
|
12
|
-
eager_load!
|
19
|
+
private
|
13
20
|
|
14
|
-
|
15
|
-
|
16
|
-
model.table_name == "schema_migrations" ||
|
17
|
-
!table_exists?(model.table_name)
|
18
|
-
end.map do |model|
|
19
|
-
[
|
20
|
-
model.name,
|
21
|
-
connection.columns(model.table_name).select do |column|
|
22
|
-
validator_needed?(model, column) &&
|
23
|
-
!validator_present?(model, column)
|
24
|
-
end.map(&:name)
|
25
|
-
]
|
26
|
-
end.reject do |_model_name, columns|
|
27
|
-
columns.empty?
|
28
|
-
end))
|
21
|
+
def message(column:, model:)
|
22
|
+
"add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
|
29
23
|
end
|
30
24
|
|
31
|
-
|
25
|
+
def detect
|
26
|
+
models(except: config(:ignore_models)).each do |model|
|
27
|
+
next if model.table_name.nil?
|
28
|
+
next unless table_exists?(model.table_name)
|
29
|
+
|
30
|
+
connection.columns(model.table_name).each do |column|
|
31
|
+
next unless validator_needed?(model, column)
|
32
|
+
next if validator_present?(model, column)
|
33
|
+
next if config(:ignore_attributes).include?("#{model}.#{column.name}")
|
34
|
+
|
35
|
+
problem!(column: column.name, model: model.name)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
32
39
|
|
33
40
|
def validator_needed?(model, column)
|
34
41
|
![model.primary_key, "created_at", "updated_at"].include?(column.name) &&
|
@@ -4,39 +4,49 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
[
|
19
|
-
model.table_name,
|
20
|
-
model.validators.select do |validator|
|
21
|
-
table_name = model.table_name
|
22
|
-
scope = validator.options.fetch(:scope, [])
|
23
|
-
|
24
|
-
validator.is_a?(ActiveRecord::Validations::UniquenessValidator) &&
|
25
|
-
supported_validator?(validator) &&
|
26
|
-
!unique_index?(table_name, validator.attributes, scope)
|
27
|
-
end.map do |validator|
|
28
|
-
scope = Array(validator.options.fetch(:scope, []))
|
29
|
-
attributes = validator.attributes
|
30
|
-
(scope + attributes).map(&:to_s)
|
31
|
-
end
|
32
|
-
]
|
33
|
-
end.reject do |_table_name, indexes|
|
34
|
-
indexes.empty?
|
35
|
-
end))
|
36
|
-
end
|
7
|
+
class MissingUniqueIndexes < Base # :nodoc:
|
8
|
+
@description = "detect uniqueness validators not backed by a database constraint"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose uniqueness validators should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "specific validators, written as Model(column1, column2, ...), that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
37
18
|
|
38
19
|
private
|
39
20
|
|
21
|
+
def message(table:, columns:)
|
22
|
+
# rubocop:disable Layout/LineLength
|
23
|
+
"add a unique index on #{table}(#{columns.join(', ')}) - validating uniqueness in the model without an index can lead to duplicates"
|
24
|
+
# rubocop:enable Layout/LineLength
|
25
|
+
end
|
26
|
+
|
27
|
+
def detect
|
28
|
+
ignore_columns = config(:ignore_columns).map do |column|
|
29
|
+
column.gsub(" ", "")
|
30
|
+
end
|
31
|
+
|
32
|
+
models(except: config(:ignore_models)).each do |model|
|
33
|
+
next if model.table_name.nil?
|
34
|
+
|
35
|
+
model.validators.each do |validator|
|
36
|
+
scope = Array(validator.options.fetch(:scope, []))
|
37
|
+
|
38
|
+
next unless validator.is_a?(ActiveRecord::Validations::UniquenessValidator)
|
39
|
+
next unless supported_validator?(validator)
|
40
|
+
next if unique_index?(model.table_name, validator.attributes, scope)
|
41
|
+
|
42
|
+
columns = (scope + validator.attributes).map(&:to_s)
|
43
|
+
next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
|
44
|
+
|
45
|
+
problem!(table: model.table_name, columns: columns)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
40
50
|
def supported_validator?(validator)
|
41
51
|
validator.options[:if].nil? &&
|
42
52
|
validator.options[:unless].nil? &&
|
@@ -0,0 +1,41 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "active_record_doctor/detectors/base"
|
4
|
+
|
5
|
+
module ActiveRecordDoctor
|
6
|
+
module Detectors
|
7
|
+
class ShortPrimaryKeyType < Base # :nodoc:
|
8
|
+
@description = "detect primary keys with short integer types"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose primary keys should not be checked",
|
12
|
+
global: true
|
13
|
+
}
|
14
|
+
}
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def message(table:, column:)
|
19
|
+
"change the type of #{table}.#{column} to bigint"
|
20
|
+
end
|
21
|
+
|
22
|
+
def detect
|
23
|
+
tables(except: config(:ignore_tables)).each do |table|
|
24
|
+
column = primary_key(table)
|
25
|
+
next if column.nil?
|
26
|
+
next if bigint?(column)
|
27
|
+
|
28
|
+
problem!(table: table, column: column.name)
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def bigint?(column)
|
33
|
+
if column.respond_to?(:bigint?)
|
34
|
+
column.bigint?
|
35
|
+
else
|
36
|
+
/\Abigint\b/.match?(column.sql_type)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
41
|
+
end
|
@@ -4,30 +4,29 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
@
|
7
|
+
class UndefinedTableReferences < Base # :nodoc:
|
8
|
+
@description = "detect models referencing undefined tables or views"
|
9
|
+
@config = {
|
10
|
+
ignore_models: {
|
11
|
+
description: "models whose underlying tables should not be checked for existence",
|
12
|
+
global: true
|
13
|
+
}
|
14
|
+
}
|
10
15
|
|
11
|
-
|
12
|
-
eager_load!
|
16
|
+
private
|
13
17
|
|
14
|
-
|
15
|
-
#
|
16
|
-
|
17
|
-
existing_views = views
|
18
|
+
def message(model:, table:)
|
19
|
+
"#{model} references a non-existent table or view named #{table}"
|
20
|
+
end
|
18
21
|
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
!existing_views.include?(model.table_name)
|
25
|
-
)
|
26
|
-
end.map do |model|
|
27
|
-
[model.name, model.table_name]
|
28
|
-
end
|
22
|
+
def detect
|
23
|
+
models(except: config(:ignore_models)).each do |model|
|
24
|
+
next if model.table_name.nil?
|
25
|
+
next if tables.include?(model.table_name)
|
26
|
+
next if tables_and_views.include?(model.table_name)
|
29
27
|
|
30
|
-
|
28
|
+
problem!(model: model.name, table: model.table_name)
|
29
|
+
end
|
31
30
|
end
|
32
31
|
end
|
33
32
|
end
|
@@ -4,25 +4,51 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
7
|
+
class UnindexedDeletedAt < Base # :nodoc:
|
8
|
+
@description = "detect indexes that exclude deletion timestamp columns"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose indexes should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "specific columns, written as table.column, that should not be reported as unindexed"
|
16
|
+
},
|
17
|
+
ignore_indexes: {
|
18
|
+
description: "specific indexes that should not be reported as excluding a timestamp column"
|
19
|
+
},
|
20
|
+
column_names: {
|
21
|
+
description: "deletion timestamp column names"
|
22
|
+
}
|
23
|
+
}
|
24
|
+
|
25
|
+
private
|
26
|
+
|
27
|
+
def message(index:, column_name:)
|
28
|
+
# rubocop:disable Layout/LineLength
|
29
|
+
"consider adding `WHERE #{column_name} IS NULL` to #{index} - a partial index can speed lookups of soft-deletable models"
|
30
|
+
# rubocop:enable Layout/LineLength
|
31
|
+
end
|
32
|
+
|
33
|
+
def detect
|
34
|
+
tables(except: config(:ignore_tables)).each do |table|
|
35
|
+
timestamp_columns = connection.columns(table).reject do |column|
|
36
|
+
config(:ignore_columns).include?("#{table}.#{column.name}")
|
37
|
+
end.select do |column|
|
38
|
+
config(:column_names).include?(column.name)
|
39
|
+
end
|
40
|
+
|
41
|
+
next if timestamp_columns.empty?
|
42
|
+
|
43
|
+
timestamp_columns.each do |timestamp_column|
|
44
|
+
indexes(table, except: config(:ignore_indexes)).each do |index|
|
45
|
+
# TODO: whole word
|
46
|
+
next if index.where =~ /\b#{timestamp_column.name}\s+IS\s+NULL\b/i
|
47
|
+
|
48
|
+
problem!(index: index.name, column_name: timestamp_column.name)
|
49
|
+
end
|
24
50
|
end
|
25
|
-
end
|
51
|
+
end
|
26
52
|
end
|
27
53
|
end
|
28
54
|
end
|
@@ -4,29 +4,40 @@ require "active_record_doctor/detectors/base"
|
|
4
4
|
|
5
5
|
module ActiveRecordDoctor
|
6
6
|
module Detectors
|
7
|
-
|
8
|
-
|
9
|
-
@
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
foreign_key?(column) &&
|
19
|
-
!indexed?(table, column) &&
|
20
|
-
!indexed_as_polymorphic?(table, column)
|
21
|
-
end.map(&:name)
|
22
|
-
]
|
23
|
-
end.reject do |_table, columns|
|
24
|
-
columns.empty?
|
25
|
-
end))
|
26
|
-
end
|
7
|
+
class UnindexedForeignKeys < Base # :nodoc:
|
8
|
+
@description = "detect unindexed foreign keys"
|
9
|
+
@config = {
|
10
|
+
ignore_tables: {
|
11
|
+
description: "tables whose foreign keys should not be checked",
|
12
|
+
global: true
|
13
|
+
},
|
14
|
+
ignore_columns: {
|
15
|
+
description: "columns, written as table.column, that should not be checked"
|
16
|
+
}
|
17
|
+
}
|
27
18
|
|
28
19
|
private
|
29
20
|
|
21
|
+
def message(table:, column:)
|
22
|
+
# rubocop:disable Layout/LineLength
|
23
|
+
"add an index on #{table}.#{column} - foreign keys are often used in database lookups and should be indexed for performance reasons"
|
24
|
+
# rubocop:enable Layout/LineLength
|
25
|
+
end
|
26
|
+
|
27
|
+
def detect
|
28
|
+
tables(except: config(:ignore_tables)).each do |table|
|
29
|
+
connection.columns(table).each do |column|
|
30
|
+
next if config(:ignore_columns).include?("#{table}.#{column.name}")
|
31
|
+
|
32
|
+
next unless foreign_key?(column)
|
33
|
+
next if indexed?(table, column)
|
34
|
+
next if indexed_as_polymorphic?(table, column)
|
35
|
+
|
36
|
+
problem!(table: table, column: column.name)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
30
41
|
def foreign_key?(column)
|
31
42
|
column.name.end_with?("_id")
|
32
43
|
end
|
@@ -3,11 +3,19 @@
|
|
3
3
|
require "active_support"
|
4
4
|
require "active_support/core_ext/class/subclasses"
|
5
5
|
|
6
|
-
module ActiveRecordDoctor
|
6
|
+
module ActiveRecordDoctor # :nodoc:
|
7
|
+
def self.detectors
|
8
|
+
@detectors ||=
|
9
|
+
begin
|
10
|
+
detectors = {}
|
11
|
+
ActiveRecordDoctor::Detectors::Base.subclasses.each do |detector|
|
12
|
+
detectors[detector.underscored_name] = detector
|
13
|
+
end
|
14
|
+
detectors
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
7
18
|
# Container module for all detectors, implemented as separate classes.
|
8
19
|
module Detectors
|
9
|
-
def self.all
|
10
|
-
ActiveRecordDoctor::Detectors::Base.subclasses
|
11
|
-
end
|
12
20
|
end
|
13
21
|
end
|