active_record_doctor 1.8.0 → 1.10.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.
Files changed (52) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +316 -54
  3. data/lib/active_record_doctor/config/default.rb +76 -0
  4. data/lib/active_record_doctor/config/loader.rb +137 -0
  5. data/lib/active_record_doctor/config.rb +14 -0
  6. data/lib/active_record_doctor/detectors/base.rb +142 -21
  7. data/lib/active_record_doctor/detectors/extraneous_indexes.rb +59 -48
  8. data/lib/active_record_doctor/detectors/incorrect_boolean_presence_validation.rb +31 -23
  9. data/lib/active_record_doctor/detectors/incorrect_dependent_option.rb +102 -35
  10. data/lib/active_record_doctor/detectors/incorrect_length_validation.rb +63 -0
  11. data/lib/active_record_doctor/detectors/mismatched_foreign_key_type.rb +45 -0
  12. data/lib/active_record_doctor/detectors/missing_foreign_keys.rb +32 -23
  13. data/lib/active_record_doctor/detectors/missing_non_null_constraint.rb +41 -28
  14. data/lib/active_record_doctor/detectors/missing_presence_validation.rb +29 -23
  15. data/lib/active_record_doctor/detectors/missing_unique_indexes.rb +92 -32
  16. data/lib/active_record_doctor/detectors/short_primary_key_type.rb +45 -0
  17. data/lib/active_record_doctor/detectors/undefined_table_references.rb +17 -20
  18. data/lib/active_record_doctor/detectors/unindexed_deleted_at.rb +43 -18
  19. data/lib/active_record_doctor/detectors/unindexed_foreign_keys.rb +31 -20
  20. data/lib/active_record_doctor/detectors.rb +12 -4
  21. data/lib/active_record_doctor/errors.rb +226 -0
  22. data/lib/active_record_doctor/help.rb +39 -0
  23. data/lib/active_record_doctor/rake/task.rb +78 -0
  24. data/lib/active_record_doctor/runner.rb +41 -0
  25. data/lib/active_record_doctor/version.rb +1 -1
  26. data/lib/active_record_doctor.rb +8 -3
  27. data/lib/generators/active_record_doctor/add_indexes/add_indexes_generator.rb +34 -21
  28. data/lib/tasks/active_record_doctor.rake +9 -18
  29. data/test/active_record_doctor/config/loader_test.rb +120 -0
  30. data/test/active_record_doctor/config_test.rb +116 -0
  31. data/test/active_record_doctor/detectors/disable_test.rb +30 -0
  32. data/test/active_record_doctor/detectors/extraneous_indexes_test.rb +165 -8
  33. data/test/active_record_doctor/detectors/incorrect_boolean_presence_validation_test.rb +48 -5
  34. data/test/active_record_doctor/detectors/incorrect_dependent_option_test.rb +288 -12
  35. data/test/active_record_doctor/detectors/incorrect_length_validation_test.rb +105 -0
  36. data/test/active_record_doctor/detectors/mismatched_foreign_key_type_test.rb +82 -0
  37. data/test/active_record_doctor/detectors/missing_foreign_keys_test.rb +50 -4
  38. data/test/active_record_doctor/detectors/missing_non_null_constraint_test.rb +172 -24
  39. data/test/active_record_doctor/detectors/missing_presence_validation_test.rb +111 -14
  40. data/test/active_record_doctor/detectors/missing_unique_indexes_test.rb +223 -10
  41. data/test/active_record_doctor/detectors/short_primary_key_type_test.rb +72 -0
  42. data/test/active_record_doctor/detectors/undefined_table_references_test.rb +34 -21
  43. data/test/active_record_doctor/detectors/unindexed_deleted_at_test.rb +118 -8
  44. data/test/active_record_doctor/detectors/unindexed_foreign_keys_test.rb +56 -4
  45. data/test/active_record_doctor/runner_test.rb +42 -0
  46. data/test/generators/active_record_doctor/add_indexes/add_indexes_generator_test.rb +131 -0
  47. data/test/model_factory.rb +73 -23
  48. data/test/setup.rb +65 -71
  49. metadata +43 -7
  50. data/lib/active_record_doctor/printers/io_printer.rb +0 -133
  51. data/lib/active_record_doctor/task.rb +0 -28
  52. data/test/active_record_doctor/printers/io_printer_test.rb +0 -33
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::Detectors::ShortPrimaryKeyTypeTest < Minitest::Test
4
+ def setup
5
+ @connection = ActiveRecord::Base.connection
6
+ @connection.enable_extension("uuid-ossp") if postgresql?
7
+ super
8
+ end
9
+
10
+ def teardown
11
+ @connection.disable_extension("uuid-ossp") if postgresql?
12
+ super
13
+ end
14
+
15
+ def test_short_integer_primary_key_is_reported
16
+ create_table(:companies, id: :int)
17
+
18
+ # In Rails 4.2 and MySQL primary key is not created due to a bug
19
+ if mysql? && ActiveRecord::VERSION::STRING < "5.0"
20
+ @connection.execute("ALTER TABLE companies ADD PRIMARY KEY(id)")
21
+ end
22
+
23
+ assert_problems(<<~OUTPUT)
24
+ change the type of companies.id to bigint
25
+ OUTPUT
26
+ end
27
+
28
+ def test_long_integer_primary_key_is_not_reported
29
+ create_table(:companies, id: :bigint)
30
+ refute_problems
31
+ end
32
+
33
+ def test_uuid_primary_key_is_not_reported
34
+ skip unless postgresql?
35
+
36
+ create_table(:companies, id: :uuid)
37
+ refute_problems
38
+ end
39
+
40
+ def test_no_primary_key_is_not_reported
41
+ create_table(:companies, id: false) do |t|
42
+ t.string :name, null: false
43
+ end
44
+
45
+ refute_problems
46
+ end
47
+
48
+ def test_config_ignore_tables
49
+ create_table(:companies, id: :integer)
50
+
51
+ config_file(<<-CONFIG)
52
+ ActiveRecordDoctor.configure do |config|
53
+ config.detector :short_primary_key_type,
54
+ ignore_tables: ["companies"]
55
+ end
56
+ CONFIG
57
+
58
+ refute_problems
59
+ end
60
+
61
+ def test_global_ignore_tables
62
+ create_table(:companies, id: :integer)
63
+
64
+ config_file(<<-CONFIG)
65
+ ActiveRecordDoctor.configure do |config|
66
+ config.global :ignore_tables, ["companies"]
67
+ end
68
+ CONFIG
69
+
70
+ refute_problems
71
+ end
72
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ActiveRecordDoctor::Detectors::UndefinedTableReferencesTest < Minitest::Test
4
- def test_table_exists
4
+ def test_model_backed_by_table
5
5
  create_table(:users) do
6
6
  end.create_model do
7
7
  end
@@ -9,31 +9,19 @@ class ActiveRecordDoctor::Detectors::UndefinedTableReferencesTest < Minitest::Te
9
9
  refute_problems
10
10
  end
11
11
 
12
- def test_table_does_not_exist_when_views_supported
13
- create_model(:users)
14
-
15
- if mysql? && ActiveRecord::VERSION::STRING < "5.0"
16
- assert_problems(<<OUTPUT)
17
- WARNING: Models backed by database views are supported only in Rails 5+ OR
18
- Rails 4.2 + PostgreSQL. It seems this is NOT your setup. Therefore, such models
19
- will be erroneously reported below as not having their underlying tables/views.
20
- Consider upgrading Rails or disabling this task temporarily.
21
- The following models reference undefined tables:
22
- ModelFactory::Models::User (the table users is undefined)
23
- OUTPUT
24
- else
25
- assert_problems(<<OUTPUT)
26
- The following models reference undefined tables:
27
- ModelFactory::Models::User (the table users is undefined)
28
- OUTPUT
29
- end
12
+ def test_model_backed_by_non_existent_table
13
+ create_model(:User)
14
+
15
+ assert_problems(<<~OUTPUT)
16
+ ModelFactory::Models::User references a non-existent table or view named users
17
+ OUTPUT
30
18
  end
31
19
 
32
- def test_view_instead_of_table
20
+ def test_model_backed_by_view
33
21
  # We replace the underlying table with a view. The view doesn't have to be
34
22
  # backed by an actual table - it can simply return a predefined tuple.
35
23
  ActiveRecord::Base.connection.execute("CREATE VIEW users AS SELECT 1")
36
- create_model(:users)
24
+ create_model(:User)
37
25
 
38
26
  begin
39
27
  refute_problems
@@ -41,4 +29,29 @@ OUTPUT
41
29
  ActiveRecord::Base.connection.execute("DROP VIEW users")
42
30
  end
43
31
  end
32
+
33
+ def test_config_ignore_tables
34
+ create_model(:User)
35
+
36
+ config_file(<<-CONFIG)
37
+ ActiveRecordDoctor.configure do |config|
38
+ config.detector :undefined_table_references,
39
+ ignore_models: ["ModelFactory::Models::User"]
40
+ end
41
+ CONFIG
42
+
43
+ refute_problems
44
+ end
45
+
46
+ def test_global_ignore_tables
47
+ create_model(:User)
48
+
49
+ config_file(<<-CONFIG)
50
+ ActiveRecordDoctor.configure do |config|
51
+ config.global :ignore_models, ["ModelFactory::Models::User"]
52
+ end
53
+ CONFIG
54
+
55
+ refute_problems
56
+ end
44
57
  end
@@ -11,6 +11,9 @@ class ActiveRecordDoctor::Detectors::UnindexedDeletedAtTest < Minitest::Test
11
11
  t.index [:first_name, :last_name],
12
12
  name: "index_profiles_on_first_name_and_last_name",
13
13
  where: "deleted_at IS NULL"
14
+ t.index [:last_name],
15
+ name: "index_deleted_profiles_on_last_name",
16
+ where: "deleted_at IS NOT NULL"
14
17
  end
15
18
 
16
19
  refute_problems
@@ -27,10 +30,9 @@ class ActiveRecordDoctor::Detectors::UnindexedDeletedAtTest < Minitest::Test
27
30
  name: "index_profiles_on_first_name_and_last_name"
28
31
  end
29
32
 
30
- assert_problems(<<OUTPUT)
31
- The following indexes should include `deleted_at IS NULL`:
32
- index_profiles_on_first_name_and_last_name
33
- OUTPUT
33
+ assert_problems(<<~OUTPUT)
34
+ consider adding `WHERE deleted_at IS NULL` or `WHERE deleted_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
35
+ OUTPUT
34
36
  end
35
37
 
36
38
  def test_indexed_discarded_at_is_not_reported
@@ -43,6 +45,9 @@ OUTPUT
43
45
  t.index [:first_name, :last_name],
44
46
  name: "index_profiles_on_first_name_and_last_name",
45
47
  where: "discarded_at IS NULL"
48
+ t.index [:last_name],
49
+ name: "index_discarded_profiles_on_last_name",
50
+ where: "discarded_at IS NOT NULL"
46
51
  end
47
52
 
48
53
  refute_problems
@@ -59,9 +64,114 @@ OUTPUT
59
64
  name: "index_profiles_on_first_name_and_last_name"
60
65
  end
61
66
 
62
- assert_problems(<<OUTPUT)
63
- The following indexes should include `deleted_at IS NULL`:
64
- index_profiles_on_first_name_and_last_name
65
- OUTPUT
67
+ assert_problems(<<~OUTPUT)
68
+ consider adding `WHERE discarded_at IS NULL` or `WHERE discarded_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
69
+ OUTPUT
70
+ end
71
+
72
+ def test_config_ignore_tables
73
+ skip("MySQL doesn't support partial indexes") if mysql?
74
+
75
+ create_table(:users) do |t|
76
+ t.string :first_name
77
+ t.string :last_name
78
+ t.datetime :discarded_at
79
+ t.index [:first_name, :last_name],
80
+ name: "index_profiles_on_first_name_and_last_name"
81
+ end
82
+
83
+ config_file(<<-CONFIG)
84
+ ActiveRecordDoctor.configure do |config|
85
+ config.detector :unindexed_deleted_at,
86
+ ignore_tables: ["users"]
87
+ end
88
+ CONFIG
89
+
90
+ refute_problems
91
+ end
92
+
93
+ def test_global_ignore_tables
94
+ skip("MySQL doesn't support partial indexes") if mysql?
95
+
96
+ create_table(:users) do |t|
97
+ t.string :first_name
98
+ t.string :last_name
99
+ t.datetime :discarded_at
100
+ t.index [:first_name, :last_name],
101
+ name: "index_profiles_on_first_name_and_last_name"
102
+ end
103
+
104
+ config_file(<<-CONFIG)
105
+ ActiveRecordDoctor.configure do |config|
106
+ config.global :ignore_tables, ["users"]
107
+ end
108
+ CONFIG
109
+
110
+ refute_problems
111
+ end
112
+
113
+ def test_config_ignore_columns
114
+ skip("MySQL doesn't support partial indexes") if mysql?
115
+
116
+ create_table(:users) do |t|
117
+ t.string :first_name
118
+ t.string :last_name
119
+ t.datetime :discarded_at
120
+ t.index [:first_name, :last_name],
121
+ name: "index_profiles_on_first_name_and_last_name"
122
+ end
123
+
124
+ config_file(<<-CONFIG)
125
+ ActiveRecordDoctor.configure do |config|
126
+ config.detector :unindexed_deleted_at,
127
+ ignore_columns: ["users.discarded_at"]
128
+ end
129
+ CONFIG
130
+
131
+ refute_problems
132
+ end
133
+
134
+ def test_config_ignore_indexes
135
+ skip("MySQL doesn't support partial indexes") if mysql?
136
+
137
+ create_table(:users) do |t|
138
+ t.string :first_name
139
+ t.string :last_name
140
+ t.datetime :discarded_at
141
+ t.index [:first_name, :last_name],
142
+ name: "index_profiles_on_first_name_and_last_name"
143
+ end
144
+
145
+ config_file(<<-CONFIG)
146
+ ActiveRecordDoctor.configure do |config|
147
+ config.detector :unindexed_deleted_at,
148
+ ignore_indexes: ["index_profiles_on_first_name_and_last_name"]
149
+ end
150
+ CONFIG
151
+
152
+ refute_problems
153
+ end
154
+
155
+ def test_config_column_names
156
+ skip("MySQL doesn't support partial indexes") if mysql?
157
+
158
+ create_table(:users) do |t|
159
+ t.string :first_name
160
+ t.string :last_name
161
+ t.datetime :obliverated_at
162
+ t.index [:first_name, :last_name],
163
+ name: "index_profiles_on_first_name_and_last_name"
164
+ end
165
+
166
+ config_file(<<-CONFIG)
167
+ ActiveRecordDoctor.configure do |config|
168
+ config.detector :unindexed_deleted_at,
169
+ column_names: ["obliverated_at"]
170
+ end
171
+ CONFIG
172
+
173
+ assert_problems(<<~OUTPUT)
174
+ consider adding `WHERE obliverated_at IS NULL` or `WHERE obliverated_at IS NOT NULL` to index_profiles_on_first_name_and_last_name - a partial index can speed lookups of soft-deletable models
175
+ OUTPUT
66
176
  end
67
177
  end
@@ -9,10 +9,9 @@ class ActiveRecordDoctor::Detectors::UnindexedForeignKeysTest < Minitest::Test
9
9
  t.references :company, foreign_key: true, index: false
10
10
  end
11
11
 
12
- assert_problems(<<OUTPUT)
13
- The following foreign keys should be indexed for performance reasons:
14
- users company_id
15
- OUTPUT
12
+ assert_problems(<<~OUTPUT)
13
+ add an index on users.company_id - foreign keys are often used in database lookups and should be indexed for performance reasons
14
+ OUTPUT
16
15
  end
17
16
 
18
17
  def test_indexed_foreign_key_is_not_reported
@@ -23,4 +22,57 @@ OUTPUT
23
22
 
24
23
  refute_problems
25
24
  end
25
+
26
+ def test_config_ignore_tables
27
+ skip("MySQL always indexes foreign keys") if mysql?
28
+
29
+ create_table(:companies)
30
+ create_table(:users) do |t|
31
+ t.references :company, foreign_key: true, index: false
32
+ end
33
+
34
+ config_file(<<-CONFIG)
35
+ ActiveRecordDoctor.configure do |config|
36
+ config.detector :unindexed_foreign_keys,
37
+ ignore_tables: ["users"]
38
+ end
39
+ CONFIG
40
+
41
+ refute_problems
42
+ end
43
+
44
+ def test_global_ignore_tables
45
+ skip("MySQL always indexes foreign keys") if mysql?
46
+
47
+ create_table(:companies)
48
+ create_table(:users) do |t|
49
+ t.references :company, foreign_key: true, index: false
50
+ end
51
+
52
+ config_file(<<-CONFIG)
53
+ ActiveRecordDoctor.configure do |config|
54
+ config.global :ignore_tables, ["users"]
55
+ end
56
+ CONFIG
57
+
58
+ refute_problems
59
+ end
60
+
61
+ def test_config_ignore_columns
62
+ skip("MySQL always indexes foreign keys") if mysql?
63
+
64
+ create_table(:companies)
65
+ create_table(:users) do |t|
66
+ t.references :company, foreign_key: true, index: false
67
+ end
68
+
69
+ config_file(<<-CONFIG)
70
+ ActiveRecordDoctor.configure do |config|
71
+ config.detector :unindexed_foreign_keys,
72
+ ignore_columns: ["users.company_id"]
73
+ end
74
+ CONFIG
75
+
76
+ refute_problems
77
+ end
26
78
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ class ActiveRecordDoctor::RunnerTest < Minitest::Test
4
+ def test_run_one_raises_on_unknown_detectors
5
+ io = StringIO.new
6
+ runner = ActiveRecordDoctor::Runner.new(load_config, io)
7
+
8
+ assert_raises(KeyError) do
9
+ runner.run_one(:performance_issues)
10
+ end
11
+ end
12
+
13
+ def test_run_all_returns_true_when_no_errors
14
+ io = StringIO.new
15
+ runner = ActiveRecordDoctor::Runner.new(load_config, io)
16
+
17
+ assert(runner.run_all)
18
+ assert(io.string.blank?)
19
+ end
20
+
21
+ def test_run_all_returns_false_when_errors
22
+ # Create a model without its underlying table to trigger an error.
23
+ create_model(:User)
24
+
25
+ io = StringIO.new
26
+ runner = ActiveRecordDoctor::Runner.new(load_config, io)
27
+
28
+ refute(runner.run_all)
29
+ refute(io.string.blank?)
30
+ end
31
+
32
+ def test_help_prints_help
33
+ ActiveRecordDoctor.detectors.each do |name, _|
34
+ io = StringIO.new
35
+ runner = ActiveRecordDoctor::Runner.new(load_config, io)
36
+
37
+ runner.help(name)
38
+
39
+ refute(io.string.blank?, "expected help for #{name}")
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ require "generators/active_record_doctor/add_indexes/add_indexes_generator"
6
+
7
+ class ActiveRecordDoctor::AddIndexesGeneratorTest < Minitest::Test
8
+ TIMESTAMP = Time.new(2021, 2, 1, 13, 15, 30)
9
+
10
+ def test_create_migrations
11
+ create_table(:users) do |t|
12
+ t.integer :organization_id, null: false
13
+ t.integer :account_id, null: false
14
+ end
15
+ create_table(:organizations) do |t|
16
+ t.integer :owner_id
17
+ end
18
+
19
+ Dir.mktmpdir do |dir|
20
+ Dir.chdir(dir)
21
+
22
+ path = File.join(dir, "indexes.txt")
23
+ File.write(path, <<~INDEXES)
24
+ add an index on users.organization_id - foreign keys are often used in database lookups and should be indexed for performance reasons
25
+ add an index on users.account_id - foreign keys are often used in database lookups and should be indexed for performance reasons
26
+ add an index on organizations.owner_id - foreign keys are often used in database lookups and should be indexed for performance reasons
27
+ INDEXES
28
+
29
+ capture_io do
30
+ Time.stub(:now, TIMESTAMP) do
31
+ ActiveRecordDoctor::AddIndexesGenerator.start([path])
32
+
33
+ load(File.join("db", "migrate", "20210201131530_index_foreign_keys_in_users.rb"))
34
+ IndexForeignKeysInUsers.migrate(:up)
35
+
36
+ load(File.join("db", "migrate", "20210201131531_index_foreign_keys_in_organizations.rb"))
37
+ IndexForeignKeysInOrganizations.migrate(:up)
38
+
39
+ ::Object.send(:remove_const, :IndexForeignKeysInUsers)
40
+ ::Object.send(:remove_const, :IndexForeignKeysInOrganizations)
41
+ end
42
+ end
43
+
44
+ assert_indexes([
45
+ ["users", ["organization_id"]],
46
+ ["users", ["account_id"]],
47
+ ["organizations", ["owner_id"]]
48
+ ])
49
+
50
+ assert_equal(4, Dir.entries("./db/migrate").size)
51
+ end
52
+ end
53
+
54
+ def test_create_migrations_raises_when_malformed_inpout
55
+ Tempfile.create do |file|
56
+ file.write(<<~INDEXES)
57
+ add an index on users. - foreign keys are often used in database lookups and should be indexed for performance reasons
58
+ INDEXES
59
+ file.flush
60
+
61
+ assert_raises(RuntimeError) do
62
+ capture_io do
63
+ ActiveRecordDoctor::AddIndexesGenerator.start([file.path])
64
+ end
65
+ end
66
+ end
67
+ end
68
+
69
+ def test_create_migrations_skips_blank_lines
70
+ Dir.mktmpdir do |dir|
71
+ Dir.chdir(dir)
72
+
73
+ path = File.join(dir, "indexes.txt")
74
+ File.write(path, "\n")
75
+
76
+ capture_io do
77
+ Time.stub(:now, TIMESTAMP) do
78
+ # Not raising an exception is considered success.
79
+ ActiveRecordDoctor::AddIndexesGenerator.start([path])
80
+ end
81
+ end
82
+ end
83
+ end
84
+
85
+ def test_create_migrations_truncates_long_index_names
86
+ # Both the table and column names must be quite long. Otherwise, the
87
+ # we might reach table or column name length limits and fail to generate an
88
+ # index name that's long enough.
89
+ create_table(:organizations_migrated_from_legacy_app) do |t|
90
+ t.integer :legacy_owner_id_compatible_with_v1_to_v8
91
+ end
92
+
93
+ Dir.mktmpdir do |dir|
94
+ Dir.chdir(dir)
95
+
96
+ path = File.join(dir, "indexes.txt")
97
+ File.write(path, <<~INDEXES)
98
+ add an index on organizations_migrated_from_legacy_app.legacy_owner_id_compatible_with_v1_to_v8 - foreign keys are often used in database lookups and should be indexed for performance reasons
99
+ INDEXES
100
+
101
+ capture_io do
102
+ Time.stub(:now, TIMESTAMP) do
103
+ # If no exceptions are raised then we consider this to be a success.
104
+ ActiveRecordDoctor::AddIndexesGenerator.start([path])
105
+
106
+ load(File.join(
107
+ "db",
108
+ "migrate",
109
+ "20210201131530_index_foreign_keys_in_organizations_migrated_from_legacy_app.rb"
110
+ ))
111
+ ::IndexForeignKeysInOrganizationsMigratedFromLegacyApp.migrate(:up)
112
+
113
+ ::Object.send(:remove_const, :IndexForeignKeysInOrganizationsMigratedFromLegacyApp)
114
+ end
115
+ end
116
+ end
117
+ end
118
+
119
+ private
120
+
121
+ def assert_indexes(expected_indexes)
122
+ actual_indexes =
123
+ ActiveRecord::Base.connection.tables.map do |table|
124
+ ActiveRecord::Base.connection.indexes(table).map do |index|
125
+ [index.table, index.columns]
126
+ end
127
+ end.flatten(1)
128
+
129
+ assert_equal(expected_indexes.sort, actual_indexes.sort)
130
+ end
131
+ end
@@ -1,7 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ModelFactory
4
- def self.cleanup
4
+ def create_table(*args, &block)
5
+ ModelFactory.create_table(*args, &block)
6
+ end
7
+
8
+ def create_model(*args, &block)
9
+ ModelFactory.create_model(*args, &block)
10
+ end
11
+
12
+ def cleanup_models
13
+ ModelFactory.cleanup_models
14
+ end
15
+
16
+ def self.cleanup_models
5
17
  delete_models
6
18
  drop_all_tables
7
19
  GC.start
@@ -10,27 +22,53 @@ module ModelFactory
10
22
  def self.drop_all_tables
11
23
  connection = ActiveRecord::Base.connection
12
24
  loop do
13
- before = connection.tables.size
25
+ before = connection.data_sources.size
14
26
  break if before.zero?
15
27
 
16
- attempt_drop_all_tables(connection)
17
- after = connection.tables.size
28
+ try_drop_all_tables_and_views(connection)
29
+ remaining_data_sources = connection.data_sources
30
+ after = remaining_data_sources.size
18
31
 
32
+ # rubocop:disable Style/Next
19
33
  if before == after
20
- raise("cannot delete temporary tables - most likely due to failing constraints")
34
+ raise(<<~ERROR)
35
+ Cannot delete temporary tables - most likely due to failing constraints. Remaining tables and views:
36
+
37
+ #{remaining_data_sources.join("\n")}
38
+ ERROR
39
+ end
40
+ # rubocop:enable Style/Next
41
+ end
42
+ end
43
+
44
+ def self.try_drop_all_tables_and_views(connection)
45
+ connection.data_sources.each do |table_name|
46
+ try_drop_table(connection, table_name) || try_drop_view(connection, table_name)
47
+ end
48
+ end
49
+
50
+ def self.try_drop_table(connection, table_name)
51
+ ActiveRecord::Migration.suppress_messages do
52
+ begin
53
+ connection.drop_table(table_name, force: :cascade)
54
+ true
55
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::StatementInvalid
56
+ # The table cannot be dropped due to foreign key constraints so
57
+ # we'll try to drop it on another attempt.
58
+ false
21
59
  end
22
60
  end
23
61
  end
24
62
 
25
- def self.attempt_drop_all_tables(connection)
26
- connection.tables.each do |table_name|
27
- ActiveRecord::Migration.suppress_messages do
28
- begin
29
- connection.drop_table(table_name, force: :cascade)
30
- rescue ActiveRecord::InvalidForeignKey, ActiveRecord::StatementInvalid
31
- # The table cannot be dropped due to foreign key constraints so
32
- # we'll try to drop it on another attempt.
33
- end
63
+ def self.try_drop_view(connection, view_name)
64
+ ActiveRecord::Migration.suppress_messages do
65
+ begin
66
+ connection.execute("DROP VIEW #{view_name}")
67
+ true
68
+ rescue ActiveRecord::InvalidForeignKey, ActiveRecord::StatementInvalid
69
+ # The table cannot be dropped due to foreign key constraints so
70
+ # we'll try to drop it on another attempt.
71
+ false
34
72
  end
35
73
  end
36
74
  end
@@ -39,23 +77,35 @@ module ModelFactory
39
77
  Models.empty
40
78
  end
41
79
 
42
- def self.create_table(table_name, &block)
80
+ def self.create_table(table_name, options = {}, &block)
43
81
  table_name = table_name.to_sym
44
82
  ActiveRecord::Migration.suppress_messages do
45
- ActiveRecord::Migration.create_table(table_name, &block)
83
+ ActiveRecord::Migration.create_table(table_name, **options, &block)
46
84
  end
47
-
48
85
  # Return a proxy object allowing the caller to chain #create_model
49
86
  # right after creating a table so that it can be followed by the model
50
87
  # definition.
51
88
  ModelDefinitionProxy.new(table_name)
52
89
  end
53
90
 
54
- def self.create_model(table_name, &block)
55
- table_name = table_name.to_sym
56
- klass = Class.new(ActiveRecord::Base, &block)
57
- klass_name = table_name.to_s.classify
58
- Models.const_set(klass_name, klass)
91
+ def self.create_model(model_name, base_class = ActiveRecord::Base, &block)
92
+ model_name = model_name.to_sym
93
+
94
+ # Normally, when a class is defined via `class MyClass < MySuperclass` the
95
+ # .name class method returns the name of the class when called from within
96
+ # the class body. However, anonymous classes defined via Class.new DO NOT
97
+ # HAVE NAMES. They're assigned names when they're assigned to a constant.
98
+ # If we evaluated the class body, passed via block here, in the class
99
+ # definition below then some macros would break
100
+ # (e.g. has_and_belongs_to_many) due to nil name.
101
+ #
102
+ # We solve the problem by defining an empty model class first, assigning to
103
+ # a constant to ensure a name is assigned, and then reopening the class to
104
+ # give it a non-trivial body.
105
+ klass = Class.new(base_class)
106
+ Models.const_set(model_name, klass)
107
+
108
+ klass.class_eval(&block) if block_given?
59
109
  end
60
110
 
61
111
  class ModelDefinitionProxy
@@ -64,7 +114,7 @@ module ModelFactory
64
114
  end
65
115
 
66
116
  def create_model(&block)
67
- ModelFactory.create_model(@table_name, &block)
117
+ ModelFactory.create_model(@table_name.to_s.classify, &block)
68
118
  end
69
119
  end
70
120