strict_associations 0.1.0 → 0.1.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: 14e631cb0e94a7fd616afc1c62fd686625008b0f30916a6f45a3614038e4a460
4
- data.tar.gz: a229561f5c4b1a54abf6433e54cfa1e13ec0480cd2274906fef3f5c52aa85049
3
+ metadata.gz: 413a5326728f775abc9050baf64f35ab7f0f90d88ebb32627d360ade8156fb3e
4
+ data.tar.gz: e2eb32329274995e220142c30c9c7055d2f6a675849b3df7d7680cddd17f8537
5
5
  SHA512:
6
- metadata.gz: 57336b91485f245790e47e8e6afe1974024f308b747c628b7eb370640e30873e2f190c196974f182dede3591fbcec43e2ba3fec03cb1536b9a1ad600cf6cc32e
7
- data.tar.gz: 24dadd9079a717411b2a76a7df76c2bf056140ab13d662e6879e4de7b3b070100ffeef25669e1ba65526f51cfe5e28b741654efd01db2561bbe7a12944b9936c
6
+ metadata.gz: e9aa8d31880309f3338f40dff593f768b6fee510e47a160e2a0a28f76c1cf949182f2e340cc8ef9a2ca40b70b812380e74386a5623e0489425406a0f0295b85e
7
+ data.tar.gz: b558d3374c0b552323b73c47b5b34c2e37a873e8f0999d6fbd0687982e481854718322774b94f8444fad58ba724de899c16a784209ff07a936c3cac0e8595d29
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+
4
+
5
+ ## [0.1.1] - Unreleased
6
+
3
7
  ## [0.1.0] - 2026-03-12
4
8
 
5
9
  ### Added
@@ -13,8 +13,10 @@ module StrictAssociations
13
13
  models_to_check.each do |model|
14
14
  check_habtm(model, violations)
15
15
  check_belongs_to_inverses(model, violations)
16
+ check_has_many_inverses(model, violations)
16
17
  check_polymorphic_inverses(model, violations)
17
18
  check_dependent_options(model, violations)
19
+ check_orphaned_foreign_keys(model, violations)
18
20
  end
19
21
 
20
22
  violations
@@ -84,10 +86,10 @@ module StrictAssociations
84
86
  rule: :missing_inverse,
85
87
  message: <<~MSG.squish
86
88
  #{target} has no has_many/has_one pointing back to \
87
- #{model.table_name} with foreign key #{fk}. \
88
- Define the inverse. \
89
- Or mark this association with strict: false. \
90
- Or call skip_strict_association :#{inverse_name} \
89
+ #{model.table_name} with foreign key #{fk}.
90
+ Define the inverse.
91
+ Or mark this association with strict: false.
92
+ Or call skip_strict_association :#{inverse_name}
91
93
  on #{target}
92
94
  MSG
93
95
  )
@@ -95,6 +97,34 @@ module StrictAssociations
95
97
  end
96
98
  end
97
99
 
100
+ def check_has_many_inverses(model, violations)
101
+ %i[has_many has_one].each do |macro|
102
+ model.reflect_on_all_associations(macro).each do |ref|
103
+ next if skipped?(model, ref)
104
+ next if ref.is_a?(ActiveRecord::Reflection::ThroughReflection)
105
+ next if ref.options[:as] # skip for polymorphic inverse
106
+
107
+ target = resolve_target(ref)
108
+ next unless target
109
+
110
+ unless belongs_to_exists?(model, ref, target)
111
+ violations << Violation.new(
112
+ model:,
113
+ association_name: ref.name,
114
+ rule: :missing_belongs_to,
115
+ message: <<~MSG.squish
116
+ #{model} has #{macro} :#{ref.name} but #{target} has no belongs_to \
117
+ pointing back with foreign key #{ref.foreign_key}.
118
+ Define the belongs_to on #{target}.
119
+ Or mark with strict: false.
120
+ Or call skip_strict_association :#{ref.name} on #{model}.
121
+ MSG
122
+ )
123
+ end
124
+ end
125
+ end
126
+ end
127
+
98
128
  def check_polymorphic_inverses(model, violations)
99
129
  refs = model.reflect_on_all_associations(:belongs_to)
100
130
  refs.each do |ref|
@@ -109,20 +139,16 @@ module StrictAssociations
109
139
  association_name: ref.name,
110
140
  rule: :unregistered_polymorphic,
111
141
  message: <<~MSG.squish
112
- #{model}##{ref.name} is polymorphic but has no \
113
- valid_types declared. Add valid_types: to the \
114
- association. Or mark with strict: false.
142
+ #{model}##{ref.name} is polymorphic but has no valid_types declared.
143
+ Add valid_types: to the association. Or mark with strict: false.
115
144
  MSG
116
145
  )
117
146
  next
118
147
  end
119
148
 
120
- type_violations, types =
121
- resolved.partition { |r| r.is_a?(Violation) }
149
+ type_violations, types = resolved.partition { |r| r.is_a?(Violation) }
122
150
  violations.concat(type_violations)
123
- check_registered_polymorphic_types(
124
- model, ref, types, violations
125
- )
151
+ check_registered_polymorphic_types(model, ref, types, violations)
126
152
  end
127
153
  end
128
154
 
@@ -139,9 +165,8 @@ module StrictAssociations
139
165
  association_name: ref.name,
140
166
  rule: :invalid_valid_type,
141
167
  message: <<~MSG.squish
142
- #{model}##{ref.name} declares valid_types \
143
- containing "#{t}" but #{e.message}. \
144
- Check for typos in valid_types.
168
+ #{model}##{ref.name} declares valid_types containing "#{t}" but \
169
+ #{e.message}. Check for typos in valid_types.
145
170
  MSG
146
171
  )
147
172
  end
@@ -149,26 +174,20 @@ module StrictAssociations
149
174
  resolved
150
175
  end
151
176
 
152
- def check_registered_polymorphic_types(
153
- model, ref, types, violations
154
- )
177
+ def check_registered_polymorphic_types(model, ref, types, violations)
155
178
  fk = ref.foreign_key.to_s
156
179
  source_table = model.table_name
157
180
 
158
181
  types.each do |type_class|
159
- next if polymorphic_inverse_exists?(
160
- type_class, source_table, fk
161
- )
182
+ next if polymorphic_inverse_exists?(type_class, source_table, fk)
162
183
 
163
184
  violations << Violation.new(
164
185
  model:,
165
186
  association_name: ref.name,
166
187
  rule: :missing_polymorphic_inverse,
167
188
  message: <<~MSG.squish
168
- #{type_class} is registered for \
169
- #{model}##{ref.name} but has no has_many/has_one \
170
- pointing back to #{source_table} with foreign \
171
- key #{fk}.
189
+ #{type_class} is registered for #{model}##{ref.name} but has no \
190
+ has_many/has_one pointing back to #{source_table} with foreign key #{fk}.
172
191
  MSG
173
192
  )
174
193
  end
@@ -178,9 +197,7 @@ module StrictAssociations
178
197
  %i[has_many has_one].each do |macro|
179
198
  model.reflect_on_all_associations(macro).each do |ref|
180
199
  next if skipped?(model, ref)
181
- next if ref.is_a?(
182
- ActiveRecord::Reflection::ThroughReflection
183
- )
200
+ next if ref.is_a?(ActiveRecord::Reflection::ThroughReflection)
184
201
  next if target_is_view?(ref)
185
202
 
186
203
  unless ref.options.key?(:dependent)
@@ -189,12 +206,11 @@ module StrictAssociations
189
206
  association_name: ref.name,
190
207
  rule: :missing_dependent,
191
208
  message: <<~MSG.squish
192
- #{model}##{ref.name} is missing a :dependent \
193
- option. Add dependent: :destroy, :delete_all, \
194
- :nullify, or :restrict_with_exception. \
195
- Or mark with strict: false. \
196
- Or call skip_strict_association :#{ref.name} \
197
- on #{model}.
209
+ #{model}##{ref.name} is missing a :dependent option.
210
+ Add dependent: :destroy, :delete_all, :nullify, or \
211
+ :restrict_with_exception.
212
+ Or mark with strict: false.
213
+ Or call skip_strict_association :#{ref.name} on #{model}.
198
214
  MSG
199
215
  )
200
216
  end
@@ -202,25 +218,70 @@ module StrictAssociations
202
218
  end
203
219
  end
204
220
 
221
+ def check_orphaned_foreign_keys(model, violations)
222
+ indexed_fk_columns = indexed_foreign_key_columns(model)
223
+ defined_fk_columns = model
224
+ .reflect_on_all_associations(:belongs_to)
225
+ .map { |ref| ref.foreign_key.to_s }
226
+
227
+ (indexed_fk_columns - defined_fk_columns).each do |column|
228
+ assoc_name = column.delete_suffix("_id").to_sym
229
+ next if model.strict_association_skipped?(assoc_name)
230
+
231
+ violations << Violation.new(
232
+ model:,
233
+ association_name: assoc_name,
234
+ rule: :orphaned_foreign_key,
235
+ message: <<~MSG.squish
236
+ #{model.table_name} has an indexed column #{column} but #{model} has no \
237
+ belongs_to association for it.
238
+ Define a belongs_to.
239
+ Or remove the index.
240
+ Or call skip_strict_association :#{assoc_name} on #{model}.
241
+ MSG
242
+ )
243
+ end
244
+ end
245
+
246
+ def indexed_foreign_key_columns(model)
247
+ model.connection.indexes(model.table_name).filter_map do |index|
248
+ column = index.columns.first
249
+ next unless index.columns.one? && column.end_with?("_id")
250
+
251
+ column
252
+ end
253
+ end
254
+
205
255
  def resolve_target(reflection)
206
256
  reflection.klass
207
257
  rescue NameError
208
258
  nil
209
259
  end
210
260
 
261
+ def belongs_to_exists?(source_model, has_many_ref, target)
262
+ fk = has_many_ref.foreign_key.to_s
263
+
264
+ target.reflect_on_all_associations(:belongs_to).any? do |ref|
265
+ next if ref.options[:polymorphic]
266
+
267
+ begin
268
+ ref.klass == source_model && ref.foreign_key.to_s == fk
269
+ rescue NameError
270
+ false
271
+ end
272
+ end
273
+ end
274
+
211
275
  def inverse_exists?(source_model, belongs_to_ref, target)
212
276
  fk = belongs_to_ref.foreign_key.to_s
213
277
  source_table = source_model.table_name
214
278
 
215
279
  target.reflect_on_all_associations.any? do |ref|
216
280
  next unless %i[has_many has_one].include?(ref.macro)
217
- next if ref.is_a?(
218
- ActiveRecord::Reflection::ThroughReflection
219
- )
281
+ next if ref.is_a?(ActiveRecord::Reflection::ThroughReflection)
220
282
 
221
283
  begin
222
- ref.klass.table_name == source_table &&
223
- ref.foreign_key.to_s == fk
284
+ ref.klass.table_name == source_table && ref.foreign_key.to_s == fk
224
285
  rescue NameError
225
286
  false
226
287
  end
@@ -230,13 +291,10 @@ module StrictAssociations
230
291
  def polymorphic_inverse_exists?(type, table, fk)
231
292
  type.reflect_on_all_associations.any? do |ref|
232
293
  next unless %i[has_many has_one].include?(ref.macro)
233
- next if ref.is_a?(
234
- ActiveRecord::Reflection::ThroughReflection
235
- )
294
+ next if ref.is_a?(ActiveRecord::Reflection::ThroughReflection)
236
295
 
237
296
  begin
238
- ref.klass.table_name == table &&
239
- ref.foreign_key.to_s == fk
297
+ ref.klass.table_name == table && ref.foreign_key.to_s == fk
240
298
  rescue NameError
241
299
  false
242
300
  end
@@ -249,8 +307,7 @@ module StrictAssociations
249
307
  end
250
308
 
251
309
  def skipped?(model, ref)
252
- ref.options[:strict] == false ||
253
- model.strict_association_skipped?(ref.name)
310
+ ref.options[:strict] == false || model.strict_association_skipped?(ref.name)
254
311
  end
255
312
  end
256
313
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StrictAssociations
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: strict_associations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jeff Lange