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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de87f340b23468b20a336608acbb3adc75a1505845e9f03c03722199896a2582
4
- data.tar.gz: b98205be522fc4a97d21d835fcf81d285209cdbb07b5ac8072d7c912cb544eb0
3
+ metadata.gz: 94e700a1c78ee5e9657d1107578c2932a84acba7f592ab6a416e5c063520a311
4
+ data.tar.gz: 8496adb8a597250ec95276fc9fbad50397667f7de2336f80405203b3a3c2a459
5
5
  SHA512:
6
- metadata.gz: 492104d7e80292d96b91760e453c27aeff140b83eff8f78092a90a34c83dc1e7f1b0defe5d47fa15408c08eeb48c7c0ee61280108dd2f31f3eeba4712f3093d2
7
- data.tar.gz: 2be286fd10dbbbf6b54eebe96ea44618e48906d6875fdfce0e2f1ae3ac5420b0ec95e7c1c25c378f26ba3ffb3c2a40267c4fa3daf14643410ea78e90484b5b91
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 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
 
@@ -303,16 +302,15 @@ Supported configuration options:
303
302
 
304
303
  ### Detecting Missing Foreign Key Constraints
305
304
 
306
- If `users.profile_id` references a row in `profiles` then this can be expressed
307
- at the database level with a foreign key constraint. It _forces_
308
- `users.profile_id` to point to an existing row in `profiles`. The problem is
309
- that in many legacy Rails apps, the constraint isn't enforced at the database
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 foreign
315
- keys with the following command:
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
- - `ignore_tables` - tables whose columns should not be checked.
336
- - `ignore_columns` - columns, written as table.column, that should not be
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 existense should not be checked
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
- ignore_tables: [],
40
- ignore_columns: []
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
- # 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
@@ -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
- ignore_tables: {
11
- description: "tables whose columns should not be checked",
10
+ ignore_models: {
11
+ description: "models whose columns should not be checked",
12
12
  global: true
13
13
  },
14
- ignore_columns: {
15
- description: "columns, written as table.column, that should not be checked"
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
- each_table(except: config(:ignore_tables)) do |table|
27
- each_column(table, except: config(:ignore_columns)) do |column|
28
- # We need to skip polymorphic associations as they can reference
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
- def named_like_foreign_key?(column)
41
- column.name.end_with?("_id")
42
- end
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
- def foreign_key?(table, column)
45
- connection.foreign_keys(table).any? do |foreign_key|
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
- def polymorphic_foreign_key?(table, column)
51
- type_column_name = column.name.sub(/_id\Z/, "_type")
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
- "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,164 @@ 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:, 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
- 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
+ ignored?("#{model.name}.#{column.name}", config(:ignore_attributes))
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
+ 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
- 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
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
- 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)
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
- validator.is_a?(ActiveModel::Validations::InclusionValidator) &&
64
- validator.attributes.include?(column.name.to_sym) &&
65
- !validator_items.include?(nil)
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
- 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
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
- 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?
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
- # 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.1"
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.1
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: []
@@ -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.1.0
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.5.11
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: []