active_record_doctor 1.13.0 → 1.15.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cc970595479a84692995f75b3e512f1b1a01dc6fc61eee9a738abbd402ac9e44
4
- data.tar.gz: 3bd5807bc126459379478c24d14a5595a2ee207415cb71c86c82ebdfae5f19f3
3
+ metadata.gz: de87f340b23468b20a336608acbb3adc75a1505845e9f03c03722199896a2582
4
+ data.tar.gz: b98205be522fc4a97d21d835fcf81d285209cdbb07b5ac8072d7c912cb544eb0
5
5
  SHA512:
6
- metadata.gz: 4b0ac66a6f9037ce810ff73660735b816bc58d614dd7612b7c7849292741519cbecd6bdb7105c7392e01fc1621ce550e1b8ba0726573beca33ee6e10ec6e35da
7
- data.tar.gz: c3d2d7b0448ccff98001c72fa6330b54f45364a43e3eff35ff7407d0e2aeaaf8c3070a3ce308992577fa41adf7cdde3a8ba2aa93db4c8955fd69e5ec7f29d03b
6
+ metadata.gz: 492104d7e80292d96b91760e453c27aeff140b83eff8f78092a90a34c83dc1e7f1b0defe5d47fa15408c08eeb48c7c0ee61280108dd2f31f3eeba4712f3093d2
7
+ data.tar.gz: 2be286fd10dbbbf6b54eebe96ea44618e48906d6875fdfce0e2f1ae3ac5420b0ec95e7c1c25c378f26ba3ffb3c2a40267c4fa3daf14643410ea78e90484b5b91
data/README.md CHANGED
@@ -15,12 +15,15 @@ can detect:
15
15
  * incorrect values of `dependent` on associations - [`active_record_doctor:incorrect_dependent_option`](#detecting-incorrect-dependent-option-on-associations)
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
+ * tables without primary keys - [`active_record_doctor:table_without_primary_key`](#detecting-tables-without-primary-keys)
18
19
 
19
20
  It can also:
20
21
 
21
22
  * index unindexed foreign keys - [`active_record_doctor:unindexed_foreign_keys`](#indexing-unindexed-foreign-keys)
22
23
 
23
- [![Build Status](https://github.com/gregnavis/active_record_doctor/actions/workflows/test.yml/badge.svg?branch=master)](https://github.com/gregnavis/active_record_doctor/actions/workflows/test.yml)
24
+ [![Build Status](https://github.com/gregnavis/active_record_doctor/actions/workflows/lint.yml/badge.svg?branch=master)](https://github.com/gregnavis/active_record_doctor/actions/workflows/lint.yml)
25
+ [![Build Status](https://github.com/gregnavis/active_record_doctor/actions/workflows/mysql.yml/badge.svg?branch=master)](https://github.com/gregnavis/active_record_doctor/actions/workflows/mysql.yml)
26
+ [![Build Status](https://github.com/gregnavis/active_record_doctor/actions/workflows/postgresql.yml/badge.svg?branch=master)](https://github.com/gregnavis/active_record_doctor/actions/workflows/postgresql.yml)
24
27
 
25
28
  ## Installation
26
29
 
@@ -28,14 +31,14 @@ In order to use the latest production release, please add the following to
28
31
  your `Gemfile`:
29
32
 
30
33
  ```ruby
31
- gem 'active_record_doctor', group: :development
34
+ gem 'active_record_doctor', group: [:development, :test]
32
35
  ```
33
36
 
34
37
  and run `bundle install`. If you'd like to use the most recent development
35
38
  version then use this instead:
36
39
 
37
40
  ```ruby
38
- gem 'active_record_doctor', github: 'gregnavis/active_record_doctor'
41
+ gem 'active_record_doctor', github: 'gregnavis/active_record_doctor', group: [:development, :test]
39
42
  ```
40
43
 
41
44
  That's it when it comes to Rails projects. If your project doesn't use Rails
@@ -55,7 +58,7 @@ ActiveRecordDoctor::Rake::Task.new do |task|
55
58
  task.deps = []
56
59
 
57
60
  # A path to your active_record_doctor configuration file.
58
- task.config_path = ::Rails.root.join(".active_record_doctor")
61
+ task.config_path = ::Rails.root.join(".active_record_doctor.rb")
59
62
 
60
63
  # A Proc called right before running detectors that should ensure your Active
61
64
  # Record models are preloaded and a database connection is ready.
@@ -101,6 +104,17 @@ bundle exec rake active_record_doctor:extraneous_indexes:help
101
104
  This will show the detector help text in the terminal, along with supported
102
105
  configuration options, their meaning, and whether they're global or local.
103
106
 
107
+ ### Debug Logging
108
+
109
+ It may be that `active_record_doctor` fails with an exception and it is hard to tell
110
+ what went wrong. For easier debugging, use `ACTIVE_RECORD_DOCTOR_DEBUG` environment variable.
111
+ If `active_record_doctor` fails for some reason for your application, feel free
112
+ to open an issue or a PR with the fix.
113
+
114
+ ```
115
+ ACTIVE_RECORD_DOCTOR_DEBUG=1 bundle exec rake active_record_doctor
116
+ ```
117
+
104
118
  ### Configuration
105
119
 
106
120
  `active_record_doctor` can be configured to better suit your project's needs.
@@ -111,7 +125,7 @@ If you want to use the default configuration then you don't have to do anything.
111
125
  Just run `active_record_doctor` in your project directory.
112
126
 
113
127
  If you want to customize the tool you should create a file named
114
- `.active_record_doctor` in your project root directory with content like:
128
+ `.active_record_doctor.rb` in your project root directory with content like:
115
129
 
116
130
  ```ruby
117
131
  ActiveRecordDoctor.configure do
@@ -358,9 +372,9 @@ Supported configuration options:
358
372
 
359
373
  ### Detecting Uniqueness Validations not Backed by an Index
360
374
 
361
- Model-level uniqueness validations and `has_one` associations should be backed
362
- by a database index in order to be robust. Otherwise you risk inserting
363
- duplicate values under a heavy load.
375
+ Model-level uniqueness validations, `has_one` and `has_and_belongs_to_many`
376
+ associations should be backed by a database index in order to be robust.
377
+ Otherwise you risk inserting duplicate values under a heavy load.
364
378
 
365
379
  In order to detect such validations run:
366
380
 
@@ -382,6 +396,8 @@ Supported configuration options:
382
396
  - `ignore_models` - models whose uniqueness validators should not be checked.
383
397
  - `ignore_columns` - specific validators, written as Model(column1, ...), that
384
398
  should not be checked.
399
+ - `ignore_join_tables` - join tables that should not be checked for existence
400
+ of unique indexes.
385
401
 
386
402
  ### Detecting Missing Non-`NULL` Constraints
387
403
 
@@ -442,6 +458,7 @@ Supported configuration options:
442
458
  - `ignore_models` - models whose underlying tables' columns should not be checked.
443
459
  - `ignore_attributes` - specific attributes, written as Model.attribute, that
444
460
  should not be checked.
461
+ - `ignore_columns_with_default` - set to `true` to ignore columns with default values.
445
462
 
446
463
  ### Detecting Incorrect Presence Validations on Boolean Columns
447
464
 
@@ -602,6 +619,29 @@ Supported configuration options:
602
619
  - `ignore_columns` - foreign keys, written as table.column, that should not be
603
620
  checked.
604
621
 
622
+ ### Detecting Tables Without Primary Keys
623
+
624
+ Tables should have primary keys. Otherwise, it becomes problematic to easily find a specific record,
625
+ logical replication in PostgreSQL will be troublesome, because all the rows need to be unique
626
+ in the table then etc.
627
+
628
+ Running the command below will list all tables without primary keys:
629
+
630
+ ```
631
+ bundle exec rake active_record_doctor:table_without_primary_key
632
+ ```
633
+
634
+ The output of the command looks like this:
635
+
636
+ ```
637
+ add a primary key to companies
638
+ ```
639
+
640
+ Supported configuration options:
641
+
642
+ - `enabled` - set to `false` to disable the detector altogether
643
+ - `ignore_tables` - tables whose primary key existense should not be checked
644
+
605
645
  ## Ruby and Rails Compatibility Policy
606
646
 
607
647
  The goal of the policy is to ensure proper functioning in reasonable
@@ -47,17 +47,23 @@ ActiveRecordDoctor.configure do
47
47
  detector :missing_presence_validation,
48
48
  enabled: true,
49
49
  ignore_models: [],
50
- ignore_attributes: []
50
+ ignore_attributes: [],
51
+ ignore_columns_with_default: false
51
52
 
52
53
  detector :missing_unique_indexes,
53
54
  enabled: true,
54
55
  ignore_models: [],
55
- ignore_columns: []
56
+ ignore_columns: [],
57
+ ignore_join_tables: []
56
58
 
57
59
  detector :short_primary_key_type,
58
60
  enabled: true,
59
61
  ignore_tables: []
60
62
 
63
+ detector :table_without_primary_key,
64
+ enabled: true,
65
+ ignore_tables: []
66
+
61
67
  detector :undefined_table_references,
62
68
  enabled: true,
63
69
  ignore_models: []
@@ -157,7 +157,7 @@ module ActiveRecordDoctor
157
157
  end
158
158
 
159
159
  def models
160
- ActiveRecord::Base.descendants
160
+ ActiveRecord::Base.descendants.sort_by(&:name)
161
161
  end
162
162
 
163
163
  def underscored_name
@@ -323,7 +323,7 @@ module ActiveRecordDoctor
323
323
  end
324
324
 
325
325
  def ignored?(name, patterns)
326
- patterns.any? { |pattern| pattern === name } # rubocop:disable Style/CaseEquality
326
+ patterns.any? { |pattern| pattern === name || name == pattern.to_s } # rubocop:disable Style/CaseEquality
327
327
  end
328
328
  end
329
329
  end
@@ -81,11 +81,12 @@ module ActiveRecordDoctor
81
81
 
82
82
  case [index1.unique, index2.unique]
83
83
  when [true, true]
84
- (index2_columns - index1_columns).empty?
84
+ contains_all?(index1_columns, index2_columns)
85
85
  when [true, false]
86
86
  false
87
87
  else
88
- prefix?(index1_columns, index2_columns)
88
+ prefix?(index1_columns, index2_columns) &&
89
+ contains_all?(index2_columns + includes(index2), includes(index1))
89
90
  end
90
91
  end
91
92
 
@@ -96,6 +97,14 @@ module ActiveRecordDoctor
96
97
  def prefix?(lhs, rhs)
97
98
  lhs.count <= rhs.count && rhs[0...lhs.count] == lhs
98
99
  end
100
+
101
+ def contains_all?(array1, array2)
102
+ (array2 - array1).empty?
103
+ end
104
+
105
+ def includes(index)
106
+ index.respond_to?(:include) ? Array(index.include) : []
107
+ end
99
108
  end
100
109
  end
101
110
  end
@@ -36,6 +36,7 @@ module ActiveRecordDoctor
36
36
  next if ignored?("#{table}.#{column.name}", config(:ignore_columns))
37
37
  next if !column.null
38
38
  next if !concrete_models.all? { |model| non_null_needed?(model, column) }
39
+ next if sti_column?(models, column.name)
39
40
  next if not_null_check_constraint_exists?(table, column)
40
41
 
41
42
  problem!(column: column.name, table: table)
@@ -66,10 +67,28 @@ module ActiveRecordDoctor
66
67
  model.validators.select do |validator|
67
68
  validator.is_a?(ActiveRecord::Validations::PresenceValidator) &&
68
69
  !validator.options[:allow_nil] &&
69
- !validator.options[:if] &&
70
- !validator.options[:unless]
70
+ (rails_belongs_to_presence_validator?(validator) || !conditional_validator?(validator))
71
71
  end
72
72
  end
73
+
74
+ def sti_column?(models, column_name)
75
+ models.any? { |model| model.inheritance_column == column_name }
76
+ end
77
+
78
+ def rails_belongs_to_presence_validator?(validator)
79
+ ActiveRecord.version >= Gem::Version.new("7.1") &&
80
+ !ActiveRecord.belongs_to_required_validates_foreign_key &&
81
+ validator.options[:message] == :required &&
82
+ proc_from_activerecord?(validator.options[:if])
83
+ end
84
+
85
+ def conditional_validator?(validator)
86
+ validator.options[:if] || validator.options[:unless]
87
+ end
88
+
89
+ def proc_from_activerecord?(object)
90
+ object.is_a?(Proc) && object.binding.receiver.name.start_with?("ActiveRecord::")
91
+ end
73
92
  end
74
93
  end
75
94
  end
@@ -13,6 +13,9 @@ module ActiveRecordDoctor
13
13
  },
14
14
  ignore_attributes: {
15
15
  description: "specific attributes, written as Model.attribute, that should not be checked"
16
+ },
17
+ ignore_columns_with_default: {
18
+ description: "ignore columns with default values, should be provided as boolean"
16
19
  }
17
20
  }
18
21
 
@@ -35,7 +38,12 @@ module ActiveRecordDoctor
35
38
 
36
39
  def validator_needed?(model, column)
37
40
  ![model.primary_key, "created_at", "updated_at", "created_on", "updated_on"].include?(column.name) &&
38
- (!column.null || not_null_check_constraint_exists?(model.table_name, column))
41
+ (!column.null || not_null_check_constraint_exists?(model.table_name, column)) &&
42
+ !default_value_instead_of_validation?(column)
43
+ end
44
+
45
+ def default_value_instead_of_validation?(column)
46
+ !column.default.nil? && config(:ignore_columns_with_default)
39
47
  end
40
48
 
41
49
  def validator_present?(model, column)
@@ -13,9 +13,17 @@ module ActiveRecordDoctor
13
13
  },
14
14
  ignore_columns: {
15
15
  description: "specific validators, written as Model(column1, column2, ...), that should not be checked"
16
+ },
17
+ ignore_join_tables: {
18
+ description: "join tables that should not be checked for existence of unique indexes"
16
19
  }
17
20
  }
18
21
 
22
+ def initialize(**)
23
+ super
24
+ @reported_join_tables = []
25
+ end
26
+
19
27
  private
20
28
 
21
29
  # rubocop:disable Layout/LineLength
@@ -28,6 +36,8 @@ module ActiveRecordDoctor
28
36
  "without an expression index can lead to duplicates (a regular unique index is not enough)"
29
37
  when :has_ones
30
38
  "add a unique index on #{table}(#{columns.join(', ')}) - using `has_one` in #{model.name} without an index can lead to duplicates"
39
+ when :has_and_belongs_to_many
40
+ "add a unique index on #{table}(#{columns.join(', ')}) - using `has_and_belongs_to_many` in #{model.name} without an index can lead to duplicates"
31
41
  end
32
42
  end
33
43
  # rubocop:enable Layout/LineLength
@@ -35,6 +45,7 @@ module ActiveRecordDoctor
35
45
  def detect
36
46
  validations_without_indexes
37
47
  has_ones_without_indexes
48
+ has_and_belongs_to_many_without_indexes
38
49
  end
39
50
 
40
51
  def validations_without_indexes
@@ -63,8 +74,11 @@ module ActiveRecordDoctor
63
74
 
64
75
  next if ignore_columns.include?("#{model.name}(#{columns.join(',')})")
65
76
 
66
- columns[-1] = "lower(#{columns[-1]})" unless case_sensitive
67
-
77
+ # citext is case-insensitive by default, so it doesn't have to be
78
+ # lowered.
79
+ if !case_sensitive && model.columns_hash[columns[-1]].type != :citext
80
+ columns[-1] = "lower(#{columns[-1]})"
81
+ end
68
82
  next if unique_index?(model.table_name, columns)
69
83
 
70
84
  if case_sensitive
@@ -108,6 +122,21 @@ module ActiveRecordDoctor
108
122
  (validator.options.keys & [:if, :unless, :conditions]).present?
109
123
  end
110
124
 
125
+ def has_and_belongs_to_many_without_indexes # rubocop:disable Naming/PredicateName
126
+ each_model do |model|
127
+ each_association(model, type: :has_and_belongs_to_many, has_scope: false) do |association|
128
+ join_table = association.join_table
129
+ next if @reported_join_tables.include?(join_table) || config(:ignore_join_tables).include?(join_table)
130
+
131
+ columns = [association.foreign_key, association.association_foreign_key]
132
+ next if unique_index?(join_table, columns)
133
+
134
+ @reported_join_tables << join_table
135
+ problem!(model: model, table: join_table, columns: columns, problem: :has_and_belongs_to_many)
136
+ end
137
+ end
138
+ end
139
+
111
140
  def resolve_attributes(model, attributes)
112
141
  attributes.flat_map do |attribute|
113
142
  reflection = model.reflect_on_association(attribute)
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record_doctor/detectors/base"
4
+
5
+ module ActiveRecordDoctor
6
+ module Detectors
7
+ class TableWithoutPrimaryKey < Base # :nodoc:
8
+ @description = "detect tables without primary keys"
9
+ @config = {
10
+ ignore_tables: {
11
+ description: "tables whose primary key existense should not be checked",
12
+ global: true
13
+ }
14
+ }
15
+
16
+ private
17
+
18
+ def message(table:)
19
+ "add a primary key to #{table}"
20
+ end
21
+
22
+ def detect
23
+ each_table(except: config(:ignore_tables)) do |table|
24
+ column = primary_key(table)
25
+ problem!(table: table) unless column
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -68,9 +68,41 @@ module ActiveRecordDoctor
68
68
  end
69
69
 
70
70
  def config
71
- @config ||= begin
72
- path = config_path && File.exist?(config_path) ? config_path : nil
73
- ActiveRecordDoctor.load_config_with_defaults(path)
71
+ @config ||=
72
+ ActiveRecordDoctor.load_config_with_defaults(effective_config_path)
73
+ end
74
+
75
+ def effective_config_path
76
+ if config_path.nil?
77
+ # No explicit config_path was set, so we're trying to use defaults.
78
+ legacy_default_path = Rails.root.join(".active_record_doctor")
79
+ new_default_path = Rails.root.join(".active_record_doctor.rb")
80
+
81
+ # First, if the legacy file exists we'll use it but show a warning.
82
+ if legacy_default_path.exist?
83
+ warn(<<~WARN.squish)
84
+ DEPRECATION WARNING: active_record_doctor is using the default
85
+ configuration file located in #{legacy_default_path.basename}. However,
86
+ that default will change to #{new_default_path.basename} in the future.
87
+
88
+ In order to avoid errors, please rename the file from
89
+ #{legacy_default_path.basename} to #{new_default_path.basename}.
90
+ WARN
91
+
92
+ return legacy_default_path
93
+ end
94
+
95
+ # Second, if the legacy file does NOT exist, but the new one does then
96
+ # we'll use that.
97
+ if new_default_path.exist?
98
+ return new_default_path
99
+ end
100
+
101
+ # Otherwise, there's no configuration file in use.
102
+ nil
103
+ else
104
+ # If an explicit configuration file was set then we use it as is.
105
+ config_path
74
106
  end
75
107
  end
76
108
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ActiveRecordDoctor
4
- VERSION = "1.13.0"
4
+ VERSION = "1.15.0"
5
5
  end
@@ -20,6 +20,7 @@ require "active_record_doctor/detectors/unindexed_foreign_keys"
20
20
  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
+ require "active_record_doctor/detectors/table_without_primary_key"
23
24
  require "active_record_doctor/errors"
24
25
  require "active_record_doctor/help"
25
26
  require "active_record_doctor/runner"
@@ -19,6 +19,5 @@ ActiveRecordDoctor::Rake::Task.new do |task|
19
19
  # This file is imported when active_record_doctor is being used as part of a
20
20
  # Rails app so it's the right place for all Rails-specific settings.
21
21
  task.deps = [:environment]
22
- task.config_path = Rails.root.join(".active_record_doctor")
23
22
  task.setup = -> { Rails.application.eager_load! }
24
23
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: active_record_doctor
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.13.0
4
+ version: 1.15.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Greg Navis
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-12-04 00:00:00.000000000 Z
11
+ date: 2024-08-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -58,14 +58,14 @@ dependencies:
58
58
  requirements:
59
59
  - - "~>"
60
60
  - !ruby/object:Gem::Version
61
- version: 1.1.4
61
+ version: 1.5.6
62
62
  type: :development
63
63
  prerelease: false
64
64
  version_requirements: !ruby/object:Gem::Requirement
65
65
  requirements:
66
66
  - - "~>"
67
67
  - !ruby/object:Gem::Version
68
- version: 1.1.4
68
+ version: 1.5.6
69
69
  - !ruby/object:Gem::Dependency
70
70
  name: railties
71
71
  requirement: !ruby/object:Gem::Requirement
@@ -98,16 +98,16 @@ dependencies:
98
98
  name: transient_record
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
- - - '='
101
+ - - "~>"
102
102
  - !ruby/object:Gem::Version
103
- version: 2.0.0.rc2
103
+ version: 2.0.0
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
- - - '='
108
+ - - "~>"
109
109
  - !ruby/object:Gem::Version
110
- version: 2.0.0.rc2
110
+ version: 2.0.0
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: rubocop
113
113
  requirement: !ruby/object:Gem::Requirement
@@ -147,6 +147,7 @@ files:
147
147
  - lib/active_record_doctor/detectors/missing_presence_validation.rb
148
148
  - lib/active_record_doctor/detectors/missing_unique_indexes.rb
149
149
  - lib/active_record_doctor/detectors/short_primary_key_type.rb
150
+ - lib/active_record_doctor/detectors/table_without_primary_key.rb
150
151
  - lib/active_record_doctor/detectors/undefined_table_references.rb
151
152
  - lib/active_record_doctor/detectors/unindexed_deleted_at.rb
152
153
  - lib/active_record_doctor/detectors/unindexed_foreign_keys.rb
@@ -184,7 +185,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
184
185
  - !ruby/object:Gem::Version
185
186
  version: '0'
186
187
  requirements: []
187
- rubygems_version: 3.3.26
188
+ rubygems_version: 3.5.11
188
189
  signing_key:
189
190
  specification_version: 4
190
191
  summary: Identify database issues before they hit production.