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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de87f340b23468b20a336608acbb3adc75a1505845e9f03c03722199896a2582
4
- data.tar.gz: b98205be522fc4a97d21d835fcf81d285209cdbb07b5ac8072d7c912cb544eb0
3
+ metadata.gz: 8bae986a04fe63835ce7e190f32471cc06a370972161c487cac195ef33f73269
4
+ data.tar.gz: c5a2f92f0b31ec1faedfd65fc5abe9ef656f75b9fa933719e57f180b4fb0f28d
5
5
  SHA512:
6
- metadata.gz: 492104d7e80292d96b91760e453c27aeff140b83eff8f78092a90a34c83dc1e7f1b0defe5d47fa15408c08eeb48c7c0ee61280108dd2f31f3eeba4712f3093d2
7
- data.tar.gz: 2be286fd10dbbbf6b54eebe96ea44618e48906d6875fdfce0e2f1ae3ac5420b0ec95e7c1c25c378f26ba3ffb3c2a40267c4fa3daf14643410ea78e90484b5b91
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 accept list of
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_` you can write:
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
- - `ignore_columns` - indexes that should never be reported as extraneous.
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 existense should not be checked
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,17 @@
1
+ module ActiveRecordDoctor
2
+ module Adapters
3
+ class Database
4
+ def initialize(connection)
5
+ @connection = connection
6
+ end
7
+
8
+ def tables
9
+ connection.tables
10
+ end
11
+
12
+ private
13
+
14
+ attr_reader :connection
15
+ end
16
+ end
17
+ end
@@ -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
@@ -0,0 +1,16 @@
1
+ module ActiveRecordDoctor
2
+ module Adapters
3
+ class Validators
4
+ def initialize(model_class, kind: :any)
5
+ @model_class = model_class
6
+ end
7
+
8
+ def each
9
+ end
10
+
11
+ private
12
+
13
+ attr_reader :model_class
14
+ end
15
+ end
16
+ end
@@ -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
- # ActiveRecord 6.1+
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 through?(association) && association.source_reflection.nil?
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 through?(association)
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 = maximum_allowed_by_validations(model, column.name.to_sym)
37
- next if model_maximum == column.limit
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: column.limit,
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 maximum_allowed_by_validations(model, column)
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 named_like_foreign_key?(column)
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
- "add `NOT NULL` to #{table}.#{column} - models validates its presence but it's not non-NULL in the database"
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(column:, model:)
25
- "add a `presence` validator to #{model}.#{column} - it's NOT NULL but lacks a validator"
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
- each_attribute(model, except: config(:ignore_attributes)) do |column|
31
- next unless validator_needed?(model, column)
32
- next if validator_present?(model, column)
33
-
34
- problem!(column: column.name, model: model.name)
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
- def validator_needed?(model, column)
40
- ![model.primary_key, "created_at", "updated_at", "created_on", "updated_on"].include?(column.name) &&
41
- (!column.null || not_null_check_constraint_exists?(model.table_name, column)) &&
42
- !default_value_instead_of_validation?(column)
43
- end
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
- def default_value_instead_of_validation?(column)
46
- !column.default.nil? && config(:ignore_columns_with_default)
47
- end
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
- def validator_present?(model, column)
50
- if column.type == :boolean
51
- inclusion_validator_present?(model, column) ||
52
- exclusion_validator_present?(model, column)
53
- else
54
- presence_validator_present?(model, column)
55
- end
56
- end
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
- def inclusion_validator_present?(model, column)
59
- model.validators.any? do |validator|
60
- validator_items = inclusion_validator_items(validator)
61
- return true if validator_items.is_a?(Proc)
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
- validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
64
- validator.attributes.include?(column.name.to_sym) &&
65
- !validator_items.include?(nil)
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
- def exclusion_validator_present?(model, column)
70
- model.validators.any? do |validator|
71
- validator_items = inclusion_validator_items(validator)
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
- def presence_validator_present?(model, column)
81
- allowed_attributes = [column.name.to_sym]
82
-
83
- belongs_to = model.reflect_on_all_associations(:belongs_to).find do |reflection|
84
- reflection.foreign_key == column.name
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
- # ActiveRecord < 5.0 does not support expression indexes,
69
- # so this will always be a false positive.
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])
@@ -20,6 +20,8 @@ module ActiveRecordDoctor
20
20
  end
21
21
 
22
22
  def detect
23
+ return if ActiveRecordDoctor::Utils.sqlite?
24
+
23
25
  each_table(except: config(:ignore_tables)) do |table|
24
26
  column = primary_key(table)
25
27
  next if column.nil?
@@ -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 existense should not be checked",
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 named_like_foreign_key?(column) || foreign_key?(table, column)
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.each do |name, _|
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 expression_indexes_unsupported?(connection = ActiveRecord::Base.connection)
15
- (ActiveRecord::VERSION::STRING < "5.0") ||
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordDoctor
4
- VERSION = "1.15.0"
4
+ VERSION = "2.0.0"
5
5
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "active_record_doctor/railtie" if defined?(Rails) && defined?(Rails::Railtie)
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#{migration_version}
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: 1.15.0
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: 2024-08-28 00:00:00.000000000 Z
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: 4.2.0
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: 4.2.0
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.3
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.3
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.6
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.6
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: 4.2.0
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: 4.2.0
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: 12.3.3
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: 12.3.3
95
+ version: 13.2.1
97
96
  - !ruby/object:Gem::Dependency
98
- name: transient_record
97
+ name: rubocop
99
98
  requirement: !ruby/object:Gem::Requirement
100
99
  requirements:
101
100
  - - "~>"
102
101
  - !ruby/object:Gem::Version
103
- version: 2.0.0
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: 2.0.0
109
+ version: 1.68.0
111
110
  - !ruby/object:Gem::Dependency
112
- name: rubocop
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: 1.57.1
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: 1.57.1
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.1.0
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.5.11
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: []