active_record_doctor 1.15.0 → 2.1.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: 7c93e45a93d9c11392978f37626379bd322e4827aac59ca6f51400e33a009813
4
+ data.tar.gz: e3e920647f6b68fdec23d7e23e7e4ff3653cb8823673e56b0916d1247cbb3864
5
5
  SHA512:
6
- metadata.gz: 492104d7e80292d96b91760e453c27aeff140b83eff8f78092a90a34c83dc1e7f1b0defe5d47fa15408c08eeb48c7c0ee61280108dd2f31f3eeba4712f3093d2
7
- data.tar.gz: 2be286fd10dbbbf6b54eebe96ea44618e48906d6875fdfce0e2f1ae3ac5420b0ec95e7c1c25c378f26ba3ffb3c2a40267c4fa3daf14643410ea78e90484b5b91
6
+ metadata.gz: 54c002741991dc91c4afd46de5472d64be0ba02457d23df39bd16297a12f4192023679d281d9bc353a5de5a0c357ea960666967d820316fe5fdaceee36f99d6d
7
+ data.tar.gz: bb17cb57cc708b9d7e751bb79e341aac2df4de4adc5f59d2cf254ef564c0a481b8e8872f1eac34a37246eda9145a78abbb41511150bc064932504645e36453fd
data/README.md CHANGED
@@ -16,6 +16,8 @@ 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)
20
+ * unused tables - [`active_record_doctor:unused_tables](#detecting-unused-tables)
19
21
 
20
22
  It can also:
21
23
 
@@ -160,28 +162,26 @@ obtained via the help mechanism described in the previous section.
160
162
 
161
163
  ### Regexp-Based Ignores
162
164
 
163
- Settings like `ignore_tables`, `ignore_indexes`, and so on accept list of
164
- identifiers to ignore. These can be either:
165
+ Settings like `ignore_tables`, `ignore_indexes`, `ignore_models` and so on
166
+ accept list of identifiers to ignore. These can be either:
165
167
 
166
168
  1. Strings - in which case an exact match is needed.
167
169
  2. Regexps - which are matched against object names, and matching ones are
168
170
  excluded from output.
169
171
 
170
- For example, to ignore all tables starting with `legacy_` you can write:
172
+ For example, to ignore all tables starting with `legacy_` and all models under
173
+ the `Legacy::` namespace you can write:
171
174
 
172
175
  ```ruby
173
176
  ActiveRecordDoctor.configure do
174
177
  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
178
  # Ignore all legacy tables.
183
179
  /^legacy_/
184
180
  ]
181
+ global :ignore_models, [
182
+ # Ignore all legacy models.
183
+ /^Legacy::/
184
+ ]
185
185
  end
186
186
  ```
187
187
 
@@ -270,7 +270,7 @@ Supported configuration options:
270
270
 
271
271
  - `enabled` - set to `false` to disable the detector altogether
272
272
  - `ignore_tables` - tables whose indexes should never be reported as extraneous.
273
- - `ignore_columns` - indexes that should never be reported as extraneous.
273
+ - `ignore_indexes` - indexes that should never be reported as extraneous.
274
274
 
275
275
  ### Detecting Unindexed `deleted_at` Columns
276
276
 
@@ -303,16 +303,15 @@ Supported configuration options:
303
303
 
304
304
  ### Detecting Missing Foreign Key Constraints
305
305
 
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.
306
+ If `User` defines a `belongs_to` association to `Profile` then the underlying
307
+ column (`users.profile_id` by convention) should be marked as a foreign key in
308
+ the database. If it's not then it's possible to end up in a situation where a
309
+ user is referencing a non-existent profile.
311
310
 
312
311
  `active_record_doctor` can automatically detect foreign keys that could benefit
313
312
  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:
313
+ add the constraint; for now, it's your job). You can obtain the list of missing
314
+ foreign key constraints with the following command:
316
315
 
317
316
  ```bash
318
317
  bundle exec rake active_record_doctor:missing_foreign_keys
@@ -332,9 +331,9 @@ end
332
331
  Supported configuration options:
333
332
 
334
333
  - `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.
334
+ - `ignore_models` - models whose associations should not be checked.
335
+ - `ignore_associations` - associations, written as Model.association, that
336
+ should not be checked.
338
337
 
339
338
  ### Detecting Models Referencing Undefined Tables
340
339
 
@@ -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,17 +641,60 @@ 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
644
645
 
645
- ## Ruby and Rails Compatibility Policy
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
668
+
669
+ ### Detecting Unused Tables
670
+
671
+ **WARNING**: this detector is disabled by default and has to be turned on explicitly.
672
+
673
+ When a table is not used by the application it should usually be dropped from the database.
674
+
675
+ Running the command below will list all tables that are not referenced by any model:
676
+
677
+ ```
678
+ bundle exec rake active_record_doctor:unused_tables
679
+ ```
680
+
681
+ The output of the command looks like this:
646
682
 
647
- The goal of the policy is to ensure proper functioning in reasonable
648
- combinations of Ruby and Rails versions. Specifically:
683
+ ```
684
+ The users table is not referenced by any Rails model and can be dropped
685
+ ```
686
+
687
+ Supported configuration options:
688
+
689
+ - `enabled` - set to `true` to enable the detector
690
+ - `ignore_tables` - tables that are not represented by models but cannot yet be dropped from the database (i.e. because the deployed version of the code may still be reading it)
691
+
692
+ ## Ruby and Rails Compatibility Policy
649
693
 
650
- 1. If a Rails version is officially supported by the Rails Core Team then it's
694
+ 1. `active_record_doctor` supports all Ruby on Rails versions that are supported
695
+ by the Rails core team.
696
+ 2. If a Ruby version is compatible with a supported Rails version then it's also
651
697
  supported by `active_record_doctor`.
652
- 2. If a Ruby version is compatible with a supported Rails version then it's
653
- also supported by `active_record_doctor`.
654
698
  3. Only the most recent teeny Ruby versions and patch Rails versions are supported.
655
699
 
656
700
  ## Author
@@ -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: []
@@ -79,4 +83,8 @@ ActiveRecordDoctor.configure do
79
83
  enabled: true,
80
84
  ignore_tables: [],
81
85
  ignore_columns: []
86
+
87
+ detector :unused_tables,
88
+ enabled: false,
89
+ ignore_tables: []
82
90
  end
@@ -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
@@ -18,9 +18,12 @@ module ActiveRecordDoctor
18
18
 
19
19
  private
20
20
 
21
- def message(model:, attribute:, table:, database_maximum:, model_maximum:)
21
+ def message(model:, column:, table:, database_maximum:, model_maximum:)
22
+ attribute = column.name
22
23
  # rubocop:disable Layout/LineLength
23
- if database_maximum && model_maximum
24
+ if database_maximum && column.respond_to?(:array?) && column.array?
25
+ "the schema limits each #{table}.#{attribute} array element to #{database_maximum} characters - use a custom validation for this attribute or add it to the ignore list"
26
+ elsif database_maximum && model_maximum
24
27
  "the schema limits #{table}.#{attribute} to #{database_maximum} characters but the length validator on #{model}.#{attribute} enforces a maximum of #{model_maximum} characters - set both limits to the same value or remove both"
25
28
  elsif database_maximum && model_maximum.nil?
26
29
  "the schema limits #{table}.#{attribute} to #{database_maximum} characters but there's no length validator on #{model}.#{attribute} - remove the database limit or add the validator"
@@ -33,21 +36,23 @@ module ActiveRecordDoctor
33
36
  def detect
34
37
  each_model(except: config(:ignore_models), existing_tables_only: true) do |model|
35
38
  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
39
+ model_maximum = maximum_allowed_by_length_validation(model, column.name.to_sym)
40
+ database_maximum = column.limit
41
+ next if model_maximum == database_maximum
42
+ next if database_maximum && covered_by_inclusion_validation?(model, column.name.to_sym, database_maximum)
38
43
 
39
44
  problem!(
40
45
  model: model.name,
41
- attribute: column.name,
46
+ column: column,
42
47
  table: model.table_name,
43
- database_maximum: column.limit,
48
+ database_maximum: database_maximum,
44
49
  model_maximum: model_maximum
45
50
  )
46
51
  end
47
52
  end
48
53
  end
49
54
 
50
- def maximum_allowed_by_validations(model, column)
55
+ def maximum_allowed_by_length_validation(model, column)
51
56
  length_validator = model.validators.find do |validator|
52
57
  validator.kind == :length &&
53
58
  validator.options.include?(:maximum) &&
@@ -55,6 +60,20 @@ module ActiveRecordDoctor
55
60
  end
56
61
  length_validator ? length_validator.options[:maximum] : nil
57
62
  end
63
+
64
+ def covered_by_inclusion_validation?(model, column, limit)
65
+ inclusion_validator = model.validators.find do |validator|
66
+ validator.kind == :inclusion &&
67
+ validator.attributes.include?(column) &&
68
+ [:if, :unless].none? { |option| validator.options.key?(option) } &&
69
+ [:allow_nil, :allow_blank].none? { |option| validator.options[option] == true }
70
+ end
71
+
72
+ return false if !inclusion_validator
73
+
74
+ values = inclusion_validator.options[:in] || inclusion_validator.options[:within]
75
+ values.is_a?(Array) && values.all? { |value| value.is_a?(String) && value.size <= limit }
76
+ end
58
77
  end
59
78
  end
60
79
  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,78 +21,169 @@ 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
+ (dynamic_validator_items?(validator_items) || 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
+ (dynamic_validator_items?(validator_items) || 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?
91
- end
175
+ # The allowed values can be a Proc or a method name (Symbol) that's resolved
176
+ # at validation time, so the set of values isn't known statically.
177
+ def dynamic_validator_items?(validator_items)
178
+ validator_items.is_a?(Proc) || validator_items.is_a?(Symbol)
92
179
  end
93
180
 
94
- def inclusion_validator_items(validator)
95
- validator.options[:in] || validator.options[:within] || []
181
+ # Determines whether the given column is used as a counter cache column by
182
+ # a has_many association on the model.
183
+ def counter_cache_column?(model, column)
184
+ model.reflect_on_all_associations(:has_many).any? do |reflection|
185
+ reflection.has_cached_counter? && reflection.counter_cache_column == column.name
186
+ end
96
187
  end
97
188
  end
98
189
  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
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class UnusedTables < Base # :nodoc:
8
+ @description = "detect tables not referenced by any model"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose corresponding models should not be checked for existence",
12
+ global: true
13
+ }
14
+ }
15
+
16
+ private
17
+
18
+ def message(table:)
19
+ "The #{table} table is not referenced by any Rails model and can be dropped"
20
+ end
21
+
22
+ def detect
23
+ each_table(except: config(:ignore_tables)) do |table|
24
+ next if models.any? { |model| model.table_name == table }
25
+
26
+ problem!(table: table)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -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.1.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,8 @@ 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"
25
+ require "active_record_doctor/detectors/unused_tables"
24
26
  require "active_record_doctor/errors"
25
27
  require "active_record_doctor/help"
26
28
  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.1.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,113 +15,154 @@ 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
26
+ - !ruby/object:Gem::Dependency
27
+ name: minitest
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '5.0'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '5.0'
27
40
  - !ruby/object:Gem::Dependency
28
41
  name: minitest-fork_executor
29
42
  requirement: !ruby/object:Gem::Requirement
30
43
  requirements:
31
44
  - - "~>"
32
45
  - !ruby/object:Gem::Version
33
- version: 1.0.2
46
+ version: '1.1'
34
47
  type: :development
35
48
  prerelease: false
36
49
  version_requirements: !ruby/object:Gem::Requirement
37
50
  requirements:
38
51
  - - "~>"
39
52
  - !ruby/object:Gem::Version
40
- version: 1.0.2
53
+ version: '1.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: minitest-mock
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '5.27'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '5.27'
41
68
  - !ruby/object:Gem::Dependency
42
69
  name: mysql2
43
70
  requirement: !ruby/object:Gem::Requirement
44
71
  requirements:
45
72
  - - "~>"
46
73
  - !ruby/object:Gem::Version
47
- version: 0.5.3
74
+ version: 0.5.6
48
75
  type: :development
49
76
  prerelease: false
50
77
  version_requirements: !ruby/object:Gem::Requirement
51
78
  requirements:
52
79
  - - "~>"
53
80
  - !ruby/object:Gem::Version
54
- version: 0.5.3
81
+ version: 0.5.6
55
82
  - !ruby/object:Gem::Dependency
56
83
  name: pg
57
84
  requirement: !ruby/object:Gem::Requirement
58
85
  requirements:
59
86
  - - "~>"
60
87
  - !ruby/object:Gem::Version
61
- version: 1.5.6
88
+ version: 1.5.9
62
89
  type: :development
63
90
  prerelease: false
64
91
  version_requirements: !ruby/object:Gem::Requirement
65
92
  requirements:
66
93
  - - "~>"
67
94
  - !ruby/object:Gem::Version
68
- version: 1.5.6
95
+ version: 1.5.9
69
96
  - !ruby/object:Gem::Dependency
70
97
  name: railties
71
98
  requirement: !ruby/object:Gem::Requirement
72
99
  requirements:
73
100
  - - ">="
74
101
  - !ruby/object:Gem::Version
75
- version: 4.2.0
102
+ version: 7.0.0
76
103
  type: :development
77
104
  prerelease: false
78
105
  version_requirements: !ruby/object:Gem::Requirement
79
106
  requirements:
80
107
  - - ">="
81
108
  - !ruby/object:Gem::Version
82
- version: 4.2.0
109
+ version: 7.0.0
83
110
  - !ruby/object:Gem::Dependency
84
111
  name: rake
85
112
  requirement: !ruby/object:Gem::Requirement
86
113
  requirements:
87
114
  - - "~>"
88
115
  - !ruby/object:Gem::Version
89
- version: 12.3.3
116
+ version: 13.2.1
90
117
  type: :development
91
118
  prerelease: false
92
119
  version_requirements: !ruby/object:Gem::Requirement
93
120
  requirements:
94
121
  - - "~>"
95
122
  - !ruby/object:Gem::Version
96
- version: 12.3.3
123
+ version: 13.2.1
97
124
  - !ruby/object:Gem::Dependency
98
- name: transient_record
125
+ name: rubocop
99
126
  requirement: !ruby/object:Gem::Requirement
100
127
  requirements:
101
128
  - - "~>"
102
129
  - !ruby/object:Gem::Version
103
- version: 2.0.0
130
+ version: 1.68.0
104
131
  type: :development
105
132
  prerelease: false
106
133
  version_requirements: !ruby/object:Gem::Requirement
107
134
  requirements:
108
135
  - - "~>"
109
136
  - !ruby/object:Gem::Version
110
- version: 2.0.0
137
+ version: 1.68.0
111
138
  - !ruby/object:Gem::Dependency
112
- name: rubocop
139
+ name: sqlite3
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '1.4'
145
+ type: :development
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '1.4'
152
+ - !ruby/object:Gem::Dependency
153
+ name: transient_record
113
154
  requirement: !ruby/object:Gem::Requirement
114
155
  requirements:
115
156
  - - "~>"
116
157
  - !ruby/object:Gem::Version
117
- version: 1.57.1
158
+ version: 3.0.0
118
159
  type: :development
119
160
  prerelease: false
120
161
  version_requirements: !ruby/object:Gem::Requirement
121
162
  requirements:
122
163
  - - "~>"
123
164
  - !ruby/object:Gem::Version
124
- version: 1.57.1
125
- description:
165
+ version: 3.0.0
126
166
  email:
127
167
  - contact@gregnavis.com
128
168
  executables: []
@@ -148,9 +188,11 @@ files:
148
188
  - lib/active_record_doctor/detectors/missing_unique_indexes.rb
149
189
  - lib/active_record_doctor/detectors/short_primary_key_type.rb
150
190
  - lib/active_record_doctor/detectors/table_without_primary_key.rb
191
+ - lib/active_record_doctor/detectors/table_without_timestamps.rb
151
192
  - lib/active_record_doctor/detectors/undefined_table_references.rb
152
193
  - lib/active_record_doctor/detectors/unindexed_deleted_at.rb
153
194
  - lib/active_record_doctor/detectors/unindexed_foreign_keys.rb
195
+ - lib/active_record_doctor/detectors/unused_tables.rb
154
196
  - lib/active_record_doctor/errors.rb
155
197
  - lib/active_record_doctor/help.rb
156
198
  - lib/active_record_doctor/logger.rb
@@ -170,7 +212,6 @@ licenses:
170
212
  - MIT
171
213
  metadata:
172
214
  rubygems_mfa_required: 'true'
173
- post_install_message:
174
215
  rdoc_options: []
175
216
  require_paths:
176
217
  - lib
@@ -178,15 +219,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
178
219
  requirements:
179
220
  - - ">="
180
221
  - !ruby/object:Gem::Version
181
- version: 2.1.0
222
+ version: 2.7.0
182
223
  required_rubygems_version: !ruby/object:Gem::Requirement
183
224
  requirements:
184
225
  - - ">="
185
226
  - !ruby/object:Gem::Version
186
227
  version: '0'
187
228
  requirements: []
188
- rubygems_version: 3.5.11
189
- signing_key:
229
+ rubygems_version: 4.0.20
190
230
  specification_version: 4
191
231
  summary: Identify database issues before they hit production.
192
232
  test_files: []