duty_free 1.0.7 → 1.0.9

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.
@@ -7,7 +7,6 @@ module DutyFree
7
7
  # Helpful suggestions to get started creating a template
8
8
  # Pass in -1 for hops if you want to traverse all possible links
9
9
  def suggest_template(hops = 0, do_has_many = false, show_output = true, this_klass = self)
10
- ::DutyFree.instance_variable_set(:@errored_assocs, [])
11
10
  ::DutyFree.instance_variable_set(:@errored_columns, [])
12
11
  uniques, _required = ::DutyFree::SuggestTemplate._suggest_unique_column(this_klass, nil, '')
13
12
  template, required = ::DutyFree::SuggestTemplate._suggest_template(hops, do_has_many, this_klass)
@@ -17,7 +16,6 @@ module DutyFree
17
16
  all: template,
18
17
  as: {}
19
18
  }
20
- # puts "Errors: #{::DutyFree.instance_variable_get(:@errored_assocs).inspect}"
21
19
 
22
20
  if show_output
23
21
  path = this_klass.name.split('::').map(&:underscore).join('/')
@@ -32,163 +30,369 @@ module DutyFree
32
30
  end
33
31
  end
34
32
 
35
- def self._suggest_template(hops, do_has_many, this_klass, poison_links = [], path = '')
36
- errored_assocs = ::DutyFree.instance_variable_get(:@errored_assocs)
37
- this_primary_key = Array(this_klass.primary_key)
38
- # Find all associations, and track all belongs_tos
39
- this_belongs_tos = []
40
- assocs = {}
41
- this_klass.reflect_on_all_associations.each do |assoc|
42
- # PolymorphicReflection AggregateReflection RuntimeReflection
43
- is_belongs_to = assoc.belongs_to? # is_a?(ActiveRecord::Reflection::BelongsToReflection)
44
- # Figure out if it's belongs_to, has_many, or has_one
45
- # HasAndBelongsToManyReflection
46
- belongs_to_or_has_many =
47
- if is_belongs_to
48
- 'belongs_to'
49
- elsif (is_habtm = assoc.respond_to?(:macro) ? (assoc.macro == :has_and_belongs_to_many) : assoc.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection))
50
- 'has_and_belongs_to_many'
51
- elsif assoc.respond_to?(:macro) ? (assoc.macro == :has_many) : assoc.is_a?(ActiveRecord::Reflection::HasManyReflection)
52
- 'has_many'
53
- else
54
- 'has_one'
33
+ def self._suggest_template(hops, do_has_many, this_klass)
34
+ poison_links = []
35
+ requireds = []
36
+ errored_assocs = []
37
+ buggy_bts = []
38
+ is_papertrail = Object.const_defined?('::PaperTrail::Version')
39
+ # Get a list of all polymorphic models by searching out every has_many that has an :as option
40
+ all_polymorphics = Hash.new { |h, k| h[k] = [] }
41
+ _all_models.each do |model|
42
+ model.reflect_on_all_associations.select do |assoc|
43
+ # If the model name can not be found then we get something like this here:
44
+ # 1: from /home/lorin/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.4.6/lib/active_record/reflection.rb:418:in `compute_class'
45
+ # /home/lorin/.rvm/gems/ruby-2.6.5/gems/activerecord-5.2.4.6/lib/active_record/inheritance.rb:196:in `compute_type': uninitialized constant Site::SiteTeams (NameError)
46
+ ret = nil
47
+ begin
48
+ ret = assoc.polymorphic? ||
49
+ (assoc.options.include?(:as) && !(is_papertrail && assoc.klass&.<=(PaperTrail::Version)))
50
+ rescue NameError
51
+ false
55
52
  end
56
- # Always process belongs_to, and also process has_one and has_many if do_has_many is chosen.
57
- # Skip any HMT or HABTM. (Maybe break out HABTM into a combo HM and BT in the future.)
58
- if is_habtm
59
- puts "* In the #{this_klass.name} model there's a problem with: \"has_and_belongs_to_many :#{assoc.name}\" because join table \"#{assoc.join_table}\" does not exist. You can create it with a create_join_table migration." unless ActiveRecord::Base.connection.table_exists?(assoc.join_table)
60
- # %%% Search for other associative candidates to use instead of this HABTM contraption
61
- puts "* In the #{this_klass.name} model there's a problem with: \"has_and_belongs_to_many :#{assoc.name}\" because it includes \"through: #{assoc.options[:through].inspect}\" which is pointless and should be removed." if assoc.options.include?(:through)
62
- end
63
- if (is_through = assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)) && assoc.options.include?(:as)
64
- puts "* In the #{this_klass.name} model there's a problem with: \"has_many :#{assoc.name} through: #{assoc.options[:through].inspect}\" because it also includes \"as: #{assoc.options[:as].inspect}\", so please choose either for this line to be a \"has_many :#{assoc.name} through:\" or to be a polymorphic \"has_many :#{assoc.name} as:\". It can't be both."
53
+ ret
54
+ end.each do |assoc|
55
+ poly_hm = if assoc.belongs_to?
56
+ poly_key = [model, assoc.name.to_sym] if assoc.polymorphic?
57
+ nil
58
+ else
59
+ poly_key = [assoc.klass, assoc.options[:as]]
60
+ [model, assoc.macro, assoc.name.to_sym]
61
+ end
62
+ next if all_polymorphics.include?(poly_key) && all_polymorphics[poly_key].include?(poly_hm)
63
+
64
+ poly_hms = all_polymorphics[poly_key]
65
+ poly_hms << poly_hm if poly_hm
65
66
  end
66
- next if is_through || is_habtm || (!is_belongs_to && !do_has_many) || errored_assocs.include?(assoc)
67
+ end
67
68
 
68
- if is_belongs_to && assoc.options[:polymorphic] # Polymorphic belongs_to?
69
- # Load all models
70
- # %%% Note that this works in Rails 5.x, but may not work in Rails 6.0 and later, which uses the Zeitwerk loader by default:
71
- Rails.configuration.eager_load_namespaces.select { |ns| ns < Rails::Application }.each(&:eager_load!)
72
- # Find all current possible polymorphic relations
73
- ActiveRecord::Base.descendants.each do |model|
74
- # Skip auto-generated HABTM_DestinationModel models
75
- next if model.respond_to?(:table_name_resolver) &&
76
- model.name.start_with?('HABTM_') &&
77
- model.table_name_resolver.is_a?(
78
- ActiveRecord::Associations::Builder::HasAndBelongsToMany::JoinTableResolver::KnownClass
79
- )
69
+ bad_polymorphic_hms = []
70
+ # "path" starts with ''
71
+ # "template" starts with [], and we hold on to the root piece here so we can return it after
72
+ # going through all the layers, at which point it will have grown to include the entire hierarchy.
73
+ this_layer = [[do_has_many, this_klass, '', (whole_template = [])]]
74
+ loop do
75
+ next_layer = []
76
+ this_layer.each do |klass_item|
77
+ do_has_many, this_klass, path, template = klass_item
78
+ this_primary_key = Array(this_klass.primary_key)
79
+ # Find all associations, and track all belongs_tos
80
+ this_belongs_tos = []
81
+ assocs = {}
82
+ this_klass.reflect_on_all_associations.each do |assoc|
83
+ # PolymorphicReflection AggregateReflection RuntimeReflection
84
+ is_belongs_to = assoc.belongs_to?
85
+ # Figure out if it's belongs_to, has_many, or has_one
86
+ belongs_to_or_has_many =
87
+ if is_belongs_to
88
+ 'belongs_to'
89
+ elsif (is_habtm = assoc.macro == :has_and_belongs_to_many)
90
+ 'has_and_belongs_to_many'
91
+ elsif assoc.macro == :has_many
92
+ 'has_many'
93
+ else
94
+ 'has_one'
95
+ end
96
+ # Always process belongs_to, and also process has_one and has_many if do_has_many is chosen.
97
+ # Skip any HMT or HABTM. (Maybe break out HABTM into a combo HM and BT in the future.)
98
+ if is_habtm
99
+ puts "* #{this_klass.name} model - problem with: \"has_and_belongs_to_many :#{assoc.name}\" because join table \"#{assoc.join_table}\" does not exist. You can create it with a create_join_table migration." unless ActiveRecord::Base.connection.table_exists?(assoc.join_table)
100
+ # %%% Search for other associative candidates to use instead of this HABTM contraption
101
+ puts "* #{this_klass.name} model - problem with: \"has_and_belongs_to_many :#{assoc.name}\" because it includes \"through: #{assoc.options[:through].inspect}\" which is pointless and should be removed." if assoc.options.include?(:through)
102
+ end
103
+ if (is_through = assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)) && assoc.options.include?(:as)
104
+ puts "* #{this_klass.name} model - problem with: \"has_many :#{assoc.name} through: #{assoc.options[:through].inspect}\" because it also includes \"as: #{assoc.options[:as].inspect}\", " \
105
+ "so please choose either for this line to be a \"has_many :#{assoc.name} through:\" or to be a polymorphic \"has_many :#{assoc.name} as:\". It can't be both."
106
+ end
107
+ next if is_through || is_habtm || (!is_belongs_to && !do_has_many) || errored_assocs.include?(assoc)
80
108
 
81
- # Find applicable polymorphic has_many associations from each real model
82
- model.reflect_on_all_associations.each do |poly_assoc|
83
- next unless poly_assoc.is_a?(ActiveRecord::Reflection::HasManyReflection) &&
84
- poly_assoc.inverse_of == assoc
109
+ # Polymorphic belongs_to?
110
+ # (checking all_polymorphics in order to handle the rare case when the belongs_to side of a polymorphic association is missing "polymorphic: true")
111
+ if is_belongs_to && (
112
+ assoc.options[:polymorphic] ||
113
+ (
114
+ all_polymorphics.include?(poly_key = [assoc.active_record, assoc.name.to_sym]) &&
115
+ all_polymorphics[poly_key].map { |p| p&.first }.include?(this_klass)
116
+ )
117
+ )
118
+ # Find all current possible polymorphic relations
119
+ _all_models.each do |model|
120
+ # Skip auto-generated HABTM_DestinationModel models
121
+ next if model.respond_to?(:table_name_resolver) &&
122
+ model.name.start_with?('HABTM_') &&
123
+ model.table_name_resolver.is_a?(
124
+ ActiveRecord::Associations::Builder::HasAndBelongsToMany::JoinTableResolver::KnownClass
125
+ )
85
126
 
86
- this_belongs_tos += (fkeys = [poly_assoc.type, poly_assoc.foreign_key])
87
- assocs["#{assoc.name}_#{poly_assoc.active_record.name.underscore}".to_sym] = [[fkeys, assoc.active_record], poly_assoc.active_record]
88
- end
89
- end
90
- else
91
- # Is it a polymorphic has_many, which is defined using as: :somethingable ?
92
- is_polymorphic_hm = assoc.inverse_of&.options&.fetch(:polymorphic) { nil }
93
- begin
94
- # Standard has_one, or has_many, and belongs_to uses assoc.klass.
95
- # Also polymorphic belongs_to uses assoc.klass.
96
- assoc_klass = is_polymorphic_hm ? assoc.inverse_of.active_record : assoc.klass
97
- rescue NameError # For models which cannot be found by name
98
- end
99
- new_assoc =
100
- if assoc_klass.nil?
101
- puts "* In the #{this_klass.name} model there's a problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\" because there is no \"#{assoc.class_name}\" model."
102
- nil # Cause this one to be excluded
103
- elsif is_belongs_to
104
- this_belongs_tos << (fk = assoc.foreign_key.to_s)
105
- [[[fk], assoc.active_record], assoc_klass]
106
- else # has_many or has_one
107
- inverse_foreign_keys = is_polymorphic_hm ? [assoc.type, assoc.foreign_key] : [assoc.inverse_of&.foreign_key&.to_s]
108
- missing_key_columns = inverse_foreign_keys - assoc_klass.columns.map(&:name)
109
- if missing_key_columns.empty?
110
- puts "* Missing inverse foreign key for #{this_klass.name} #{belongs_to_or_has_many} :#{assoc.name}" if inverse_foreign_keys.first.nil?
111
- # puts "Has columns #{inverse_foreign_keys.inspect}"
112
- [[inverse_foreign_keys, assoc_klass], assoc_klass]
113
- else
114
- if inverse_foreign_keys.length > 1
115
- puts "* The #{assoc_klass.name} model is missing #{missing_key_columns.join(' and ')} columns to allow it to support polymorphic inheritance."
116
- else
117
- print "* In the #{this_klass.name} model there's a problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\"."
127
+ # Find applicable polymorphic has_many associations from each real model
128
+ model.reflect_on_all_associations.each do |poly_assoc|
129
+ next unless [:has_many, :has_one].include?(poly_assoc.macro) && poly_assoc.inverse_of == assoc
118
130
 
119
- if (inverses = _find_belongs_tos(assoc_klass, this_klass, errored_assocs)).empty?
120
- if inverse_foreign_keys.first.nil?
121
- puts " Consider adding \"foreign_key: :#{this_klass.name.underscore}_id\" regarding some column in #{assoc_klass.name} to this #{belongs_to_or_has_many} entry."
122
- else
123
- puts " (Cannot find foreign key \"#{inverse_foreign_keys.first.inspect}\" in #{assoc_klass.name}.)"
131
+ this_belongs_tos += (fkeys = [poly_assoc.type, poly_assoc.foreign_key])
132
+ assocs["#{assoc.name}_#{poly_assoc.active_record.name.underscore}".to_sym] = [[fkeys, assoc.active_record], poly_assoc.active_record]
133
+ end
134
+ end
135
+ else
136
+ # Is it a polymorphic has_many, which is defined using as: :somethingable ?
137
+ is_polymorphic_hm = assoc.inverse_of&.options&.fetch(:polymorphic) { nil }
138
+ begin
139
+ # Standard has_one, or has_many, and belongs_to uses assoc.klass.
140
+ # Also polymorphic belongs_to uses assoc.klass.
141
+ assoc_klass = is_polymorphic_hm ? assoc.inverse_of.active_record : assoc.klass
142
+ rescue NameError # For models which cannot be found by name
143
+ end
144
+ # Skip any PaperTrail audited things
145
+ # rubocop:disable Lint/SafeNavigationConsistency
146
+ next if (Object.const_defined?('PaperTrail::Version') && assoc_klass&.<=(PaperTrail::Version) && assoc.options.include?(:as)) ||
147
+ # And any goofy self-referencing aliases
148
+ (!is_belongs_to && assoc_klass <= assoc.active_record && assoc.foreign_key.to_s == assoc.active_record.primary_key)
149
+
150
+ # rubocop:enable Lint/SafeNavigationConsistency
151
+
152
+ # Avoid getting goofed up by the belongs_to side of a broken polymorphic association
153
+ assoc_klass = nil if assoc.belongs_to? && !(assoc_klass <= ActiveRecord::Base)
154
+
155
+ if !is_polymorphic_hm && assoc.options.include?(:as)
156
+ assoc_klass = assoc.inverse_of.active_record
157
+ is_polymorphic_hm = true
158
+ bad_polymorphic_hm = [assoc_klass, assoc.inverse_of]
159
+ unless bad_polymorphic_hms.include?(bad_polymorphic_hm)
160
+ bad_polymorphic_hms << bad_polymorphic_hm
161
+ puts "* #{assoc_klass.name} model - problem with the polymorphic association \"belongs_to :#{assoc.inverse_of.name}\". You can fix this in one of two ways:"
162
+ puts ' (1) add "polymorphic: true" on this belongs_to line, or'
163
+ poly_hms = all_polymorphics.inject([]) do |s, poly_hm|
164
+ if (key = poly_hm.first).first <= assoc_klass && key.last == assoc.inverse_of.name
165
+ s += poly_hm.last
124
166
  end
167
+ s
168
+ end
169
+ puts " (2) Undo #{assoc_klass.name} polymorphism by removing \"as: :#{assoc.inverse_of.name}\" in these #{poly_hms.length} places:"
170
+ poly_hms.each { |poly_hm| puts " In the #{poly_hm.first.name} class from the line: #{poly_hm[1]} :#{poly_hm.last}" }
171
+ end
172
+ end
173
+
174
+ new_assoc =
175
+ if assoc_klass.nil?
176
+ # In case this is a buggy polymorphic belongs_to, keep track of all of these and at the very end
177
+ # only show the pertinent ones.
178
+ if is_belongs_to
179
+ buggy_bts << [this_klass, assoc]
125
180
  else
126
- puts " Consider adding \"#{inverses.map { |x| "inverse_of: :#{x.name}" }.join(' or ')}\" to this entry."
181
+ puts "* #{this_klass.name} model - problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\" because there is no \"#{assoc.class_name}\" model."
127
182
  end
183
+ nil # Cause this one to be excluded
184
+ elsif is_belongs_to
185
+ this_belongs_tos << (foreign_key = assoc.foreign_key.to_s)
186
+ [[[foreign_key], assoc.active_record], assoc_klass]
187
+ elsif _all_tables.include?(assoc_klass.table_name) || # has_many or has_one
188
+ (assoc_klass.table_name.start_with?('public.') && _all_tables.include?(assoc_klass.table_name[7..-1]))
189
+ inverse_foreign_keys = is_polymorphic_hm ? [assoc.type, assoc.foreign_key] : [assoc.inverse_of&.foreign_key&.to_s]
190
+ missing_key_columns = inverse_foreign_keys - assoc_klass.columns.map(&:name)
191
+ if missing_key_columns.empty?
192
+ puts "* Missing inverse foreign key for #{this_klass.name} #{belongs_to_or_has_many} :#{assoc.name}" if inverse_foreign_keys.first.nil?
193
+ # puts "Has columns #{inverse_foreign_keys.inspect}"
194
+ [[inverse_foreign_keys, assoc_klass], assoc_klass]
195
+ else
196
+ if inverse_foreign_keys.length > 1
197
+ puts "* #{assoc_klass.name} model - missing #{missing_key_columns.join(' and ')} columns to allow it to support polymorphic inheritance."
198
+ else
199
+ puts "* #{this_klass.name} model - problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\"."
200
+
201
+ # Most general related parent class in case we're STI
202
+ root_class = test_class = this_klass
203
+ while (test_class = test_class.superclass) != ActiveRecord::Base
204
+ root_class = test_class unless test_class.abstract_class?
205
+ end
206
+ # If we haven't yet found a match, search for any appropriate unused foreign key that belongs_to the primary class
207
+ is_mentioned_consider = false
208
+ if (inverses = _find_assocs(:belongs_to, assoc_klass, root_class, errored_assocs, assoc.foreign_key)).empty?
209
+ if inverse_foreign_keys.first.nil?
210
+ # So we can rule them out, find the belongs_tos that already are inverses of any other relevant has_many
211
+ hm_assocs = _find_assocs(:has_many, this_klass, assoc_klass, errored_assocs)
212
+ hm_inverses = hm_assocs.each_with_object([]) do |hm, s|
213
+ s << hm.inverse_of if hm.inverse_of
214
+ s
215
+ end
216
+ # Remaining belongs_tos are also good candidates to become an inverse_of, so we'll suggest
217
+ # both to establish a :foreign_key and also duing the inverses.present? check an :inverse_of.
218
+ inverses = _find_assocs(:belongs_to, assoc_klass, root_class, errored_assocs).reject do |bt|
219
+ hm_inverses.include?(bt)
220
+ end
221
+ fks = inverses.map(&:foreign_key)
222
+ # All that and still no matches?
223
+ unless fks.present? || assoc_klass.columns.map(&:name).include?(suggested_fk = "#{root_class.name.underscore}_id")
224
+ # Find any polymorphic association on this model (that we're not already tied to) that could be used.
225
+ poly_hms = all_polymorphics.each_with_object([]) do |p, s|
226
+ if p.first.first == assoc_klass &&
227
+ p.last.none? { |poly_hm| this_klass <= poly_hm.first } # <= to deal with polymorphic inheritance
228
+ s << p.first
229
+ end
230
+ s
231
+ end
232
+
233
+ # Consider all the HMT with through: :contacts, find their source(s)
234
+ poly_hmts = this_klass.reflect_on_all_associations.each_with_object([]) do |a, s|
235
+ if [:has_many, :has_one].include?(a.macro) && a.options[:source] &&
236
+ a.options[:through] == assoc.name
237
+ s << a.options[:source]
238
+ end
239
+ s
240
+ end
241
+ poly_hms_hmts = poly_hms.select { |poly_hm| poly_hmts.include?(poly_hm.last) }
242
+ poly_hms = poly_hms_hmts unless poly_hms_hmts.blank?
243
+
244
+ poly_hms.map! { |poly_hm| "\"as: :#{poly_hm.last}\"" }
245
+ if poly_hms.blank?
246
+ puts " Consider removing this #{belongs_to_or_has_many} because the #{assoc_klass.name} model does not include a column named \"#{suggested_fk}\"."
247
+ else
248
+ puts " Consider adding #{poly_hms.join(' or ')} to establish a valid polymorphic association."
249
+ end
250
+ is_mentioned_consider = true
251
+ end
252
+ unless fks.empty? || fks.include?(assoc.foreign_key.to_s)
253
+ puts " Consider adding #{fks.map { |fk| "\"foreign_key: :#{fk}\"" }.join(' or ')} (or some other appropriate column from #{assoc_klass.name}) to this #{belongs_to_or_has_many} entry."
254
+ is_mentioned_consider = true
255
+ end
256
+ else
257
+ puts " (Cannot find foreign key \"#{inverse_foreign_keys.first.inspect}\" in #{assoc_klass.name}.)"
258
+ end
259
+ end
260
+ if inverses.empty?
261
+ opposite_macro = assoc.belongs_to? ? 'has_many or has_one' : 'belongs_to'
262
+ puts " (Could not identify any inverse #{opposite_macro} association in the #{assoc_klass.name} model.)"
263
+ else
264
+ print is_mentioned_consider ? ' Also consider ' : ' Consider '
265
+ puts "adding \"#{inverses.map { |x| "inverse_of: :#{x.name}" }.join(' or ')}\" to this entry."
266
+ end
267
+ end
268
+ nil
269
+ end
270
+ else
271
+ puts "* Missing table #{assoc_klass.table_name} for class #{assoc_klass.name}"
272
+ puts ' (Maybe try running: bin/rails db:migrate )'
273
+ nil # Related has_* is missing its table
128
274
  end
129
- nil
275
+ if new_assoc.nil?
276
+ errored_assocs << assoc
277
+ else
278
+ assocs[assoc.name] = new_assoc
130
279
  end
131
280
  end
132
- if new_assoc.nil?
133
- errored_assocs << assoc
134
- else
135
- assocs[assoc.name] = new_assoc
281
+ end
282
+
283
+ # Include all columns except for the primary key, any foreign keys, and excluded_columns
284
+ # %%% add EXCLUDED_ALL_COLUMNS || ...
285
+ excluded_columns = %w[created_at updated_at deleted_at]
286
+ (this_klass.columns.map(&:name) - this_primary_key - this_belongs_tos - excluded_columns).each do |column|
287
+ template << column.to_sym
288
+ end
289
+ # Okay, at this point it really searches for the uniques, and in the "strict" (not loose) kind of way
290
+ requireds += _find_requireds(this_klass, false, [this_klass.primary_key]).first.map { |r| "#{path}#{r}".to_sym }
291
+ # Now add the foreign keys and any has_manys in the form of references to associated models
292
+ assocs.each do |k, assoc|
293
+ # # assoc.first describes this foreign key and class, and is used for a "reverse poison"
294
+ # # detection so we don't fold back on ourselves
295
+ next if poison_links.include?(assoc.first)
296
+
297
+ is_has_many = (assoc.first.last == assoc.last)
298
+ if hops.zero?
299
+ # For has_one or has_many, exclude with priority the foreign key column(s) we rode in here on
300
+ priority_excluded_columns = assoc.first.first if is_has_many
301
+ # puts "Excluded: #{priority_excluded_columns.inspect}"
302
+ unique, new_requireds = _suggest_unique_column(assoc.last, priority_excluded_columns, "#{path}#{k}_")
303
+ template << { k => unique }
304
+ requireds += new_requireds
305
+ else
306
+ new_poison_links =
307
+ if is_has_many
308
+ # binding.pry if assoc.first.last.nil?
309
+ # has_many is simple, just exclude how we got here from the foreign table
310
+ [assoc.first]
311
+ else
312
+ # belongs_to is more involved since there may be multiple foreign keys which point
313
+ # from the foreign table to this primary one, so exclude all these links.
314
+ _find_assocs(:belongs_to, assoc.first.last, assoc.last, errored_assocs).map do |f_assoc|
315
+ [[f_assoc.foreign_key.to_s], f_assoc.active_record]
316
+ end
317
+ end
318
+ # puts "New Poison: #{new_poison_links.map{|a| "#{a.first.inspect} - #{a.last.name}"}.join(' / ')}"
319
+ # if (poison_links & new_poison_links).empty?
320
+ # Store the ones to do the next round
321
+ # puts "Test against #{assoc.first.inspect}"
322
+ template << { k => (next_template = []) }
323
+ next_layer << [do_has_many, assoc.last, "#{path}#{k}_", next_template]
324
+ poison_links += (new_poison_links - poison_links)
325
+ # end
326
+ end
136
327
  end
137
328
  end
329
+ break if hops.zero? || next_layer.empty?
330
+
331
+ hops -= 1
332
+ this_layer = next_layer
138
333
  end
334
+ (buggy_bts - bad_polymorphic_hms).each do |bad_bt|
335
+ puts "* #{bad_bt.first.name} model - problem with: \"belongs_to :#{bad_bt.last.name}\" because there is no \"#{bad_bt.last.class_name}\" model."
336
+ end
337
+ [whole_template, requireds]
338
+ end
139
339
 
140
- # Include all columns except for the primary key, any foreign keys, and excluded_columns
141
- # %%% add EXCLUDED_ALL_COLUMNS || ...
142
- excluded_columns = %w[created_at updated_at deleted_at]
143
- template = (this_klass.columns.map(&:name) - this_primary_key - this_belongs_tos - excluded_columns)
144
- template.map!(&:to_sym)
145
- requireds = _find_requireds(this_klass).map { |r| "#{path}#{r}".to_sym }
146
- # Now add the foreign keys and any has_manys in the form of references to associated models
147
- assocs.each do |k, assoc|
148
- # assoc.first describes this foreign key and class, and is used for a "reverse poison"
149
- # detection so we don't fold back on ourselves
150
- next if poison_links.include?(assoc.first)
340
+ # Load all models
341
+ # %%% Note that this works in Rails 5.x, but may not work in Rails 6.0 and later, which uses the Zeitwerk loader by default:
342
+ def self._all_models
343
+ unless ActiveRecord::Base.instance_variable_get(:@eager_loaded_all)
344
+ if ActiveRecord.version < ::Gem::Version.new('4.0')
345
+ Rails.configuration.eager_load_paths
346
+ else
347
+ Rails.configuration.eager_load_namespaces.select { |ns| ns < Rails::Application }.each(&:eager_load!)
348
+ end
349
+ ActiveRecord::Base.instance_variable_set(:@eager_loaded_all, true)
350
+ end
351
+ ActiveRecord::Base.descendants
352
+ end
151
353
 
152
- is_has_many = (assoc.first.last == assoc.last)
153
- # puts "#{k} #{hops}"
154
- unique, new_requireds =
155
- if hops.zero?
156
- # For has_one or has_many, exclude with priority the foreign key column(s) we rode in here on
157
- priority_excluded_columns = assoc.first.first if is_has_many
158
- # puts "Excluded: #{priority_excluded_columns.inspect}"
159
- _suggest_unique_column(assoc.last, priority_excluded_columns, "#{path}#{k}_")
160
- else
161
- new_poison_links =
162
- if is_has_many
163
- # has_many is simple, just exclude how we got here from the foreign table
164
- [assoc.first]
354
+ # Load all tables
355
+ def self._all_tables
356
+ unless (all_tables = ActiveRecord::Base.instance_variable_get(:@_all_tables))
357
+ sql = if ActiveRecord::Base.connection.class.name.end_with?('::SQLite3Adapter')
358
+ "SELECT DISTINCT name AS table_name FROM sqlite_master WHERE type = 'table'"
165
359
  else
166
- # belongs_to is more involved since there may be multiple foreign keys which point
167
- # from the foreign table to this primary one, so exclude all these links.
168
- _find_belongs_tos(assoc.first.last, assoc.last, errored_assocs).map do |f_assoc|
169
- [[f_assoc.foreign_key.to_s], f_assoc.active_record]
170
- end
360
+ # For everything else, which would be "::PostgreSQLAdapter", "::MysqlAdapter", or "::Mysql2Adapter":
361
+ "SELECT DISTINCT table_name FROM INFORMATION_SCHEMA.TABLES WHERE table_type = 'BASE TABLE'"
171
362
  end
172
- # puts "New Poison: #{new_poison_links.inspect}"
173
- _suggest_template(hops - 1, do_has_many, assoc.last, poison_links + new_poison_links, "#{path}#{k}_")
174
- end
175
- template << { k => unique }
176
- requireds += new_requireds
363
+ # The MySQL version of execute_sql returns arrays instead of a hash when there's just one column asked for.
364
+ all_tables = ActiveRecord::Base.execute_sql(sql).each_with_object({}) do |row, s|
365
+ s[row.is_a?(Array) ? row.first : row['table_name']] = nil
366
+ s
367
+ end
368
+ ActiveRecord::Base.instance_variable_set(:@_all_tables, all_tables)
177
369
  end
178
- [template, requireds]
370
+ all_tables
179
371
  end
180
372
 
181
373
  # Find belongs_tos for this model to one more more other klasses
182
- def self._find_belongs_tos(klass, to_klass, errored_assocs)
183
- klass.reflect_on_all_associations.each_with_object([]) do |bt_assoc, s|
184
- # .is_a?(ActiveRecord::Reflection::BelongsToReflection)
185
- next unless bt_assoc.belongs_to? && !errored_assocs.include?(bt_assoc)
374
+ def self._find_assocs(macro, klass, to_klass, errored_assocs, using_fk = nil)
375
+ case macro
376
+ when :belongs_to
377
+ klass.reflect_on_all_associations.each_with_object([]) do |bt_assoc, s|
378
+ next unless bt_assoc.belongs_to? && !errored_assocs.include?(bt_assoc)
379
+
380
+ begin
381
+ s << bt_assoc if !bt_assoc.options[:polymorphic] && bt_assoc.klass <= to_klass &&
382
+ (using_fk.nil? || bt_assoc.foreign_key == using_fk)
383
+ rescue NameError
384
+ errored_assocs << bt_assoc
385
+ puts "* #{bt_assoc.active_record.name} model - \"belongs_to :#{bt_assoc.name}\" could not find a model named #{bt_assoc.class_name}."
386
+ end
387
+ s
388
+ end
389
+ when :has_many # Also :has_one
390
+ klass.reflect_on_all_associations.each_with_object([]) do |hm_assoc, s|
391
+ next if ![:has_many, :has_one].include?(hm_assoc.macro) || errored_assocs.include?(hm_assoc) ||
392
+ (Object.const_defined?('PaperTrail::Version') && hm_assoc.klass <= PaperTrail::Version && hm_assoc.options.include?(:as)) # Skip any PaperTrail associations
186
393
 
187
- begin
188
- s << bt_assoc if !bt_assoc.options[:polymorphic] && bt_assoc.klass == to_klass
189
- rescue NameError
190
- errored_assocs << bt_assoc
191
- puts "* In the #{bt_assoc.active_record.name} model \"belongs_to :#{bt_assoc.name}\" could not find a model named #{bt_assoc.class_name}."
394
+ s << hm_assoc if hm_assoc.klass == to_klass && (using_fk.nil? || hm_assoc.foreign_key == using_fk)
395
+ s
192
396
  end
193
397
  end
194
398
  end
@@ -199,36 +403,20 @@ module DutyFree
199
403
  # ...
200
404
  # Not available, so grasping at straws, just search for any available column
201
405
  # %%% add EXCLUDED_UNIQUE_COLUMNS || ...
202
- klass_columns = klass.columns
203
-
204
- # Requireds takes its cues from all attributes having a presence validator
205
- requireds = _find_requireds(klass)
206
- klass_columns = klass_columns.reject { |col| priority_excluded_columns.include?(col.name) } if priority_excluded_columns
207
- excluded_columns = %w[created_at updated_at deleted_at]
208
- unique = [(
209
- # Find the first text field of a required if one exists
210
- klass_columns.find { |col| requireds.include?(col.name) && col.type == :string }&.name ||
211
- # Find the first text field, now of a non-required, if one exists
212
- klass_columns.find { |col| col.type == :string }&.name ||
213
- # If no string then look for the first non-PK that is also not a foreign key or created_at or updated_at
214
- klass_columns.find do |col|
215
- requireds.include?(col.name) && col.name != klass.primary_key && !excluded_columns.include?(col.name)
216
- end&.name ||
217
- # And now the same but not a required, the first non-PK that is also not a foreign key or created_at or updated_at
218
- klass_columns.find do |col|
219
- col.name != klass.primary_key && !excluded_columns.include?(col.name)
220
- end&.name ||
221
- # Finally just accept the PK if nothing else
222
- klass.primary_key
223
- ).to_sym]
224
-
225
- [unique, requireds.map { |r| "#{path}#{r}".to_sym }]
406
+ uniques, requireds = _find_requireds(klass, true, priority_excluded_columns)
407
+ [[uniques.first.to_sym], requireds.map { |r| "#{path}#{r}".to_sym }]
226
408
  end
227
409
 
228
- def self._find_requireds(klass)
410
+ def self._find_requireds(klass, is_loose = false, priority_excluded_columns = nil)
229
411
  errored_columns = ::DutyFree.instance_variable_get(:@errored_columns)
230
- klass.validators.select do |v|
231
- v.is_a?(ActiveRecord::Validations::PresenceValidator)
412
+ # %%% In case we need to exclude foreign keys in the future, this will do it:
413
+ # bts = klass.reflect_on_all_associations.each_with_object([]) do |bt_assoc, s|
414
+ # next unless bt_assoc.belongs_to?
415
+
416
+ # s << bt_assoc.name
417
+ # end
418
+ requireds = klass.validators.select do |v|
419
+ v.is_a?(ActiveRecord::Validations::PresenceValidator) # && (v.attributes & bts).empty?
232
420
  end.each_with_object([]) do |v, s|
233
421
  v.attributes.each do |a|
234
422
  attrib = a.to_s
@@ -238,17 +426,39 @@ module DutyFree
238
426
  if klass.columns.map(&:name).include?(attrib)
239
427
  s << attrib
240
428
  else
241
- ho_and_bt_names = klass.reflect_on_all_associations.each_with_object([]) do |assoc, names|
242
- names << assoc.name.to_s if assoc.belongs_to? || assoc.macro == :has_one
243
- names
244
- end
245
- unless ho_and_bt_names.include?(attrib)
246
- puts "* In the #{klass.name} model \"validates_presence_of :#{attrib}\" should be removed as it does not refer to any existing column."
429
+ unless klass.instance_methods.map(&:to_s).include?(attrib)
430
+ puts "* #{klass.name} model - \"validates_presence_of :#{attrib}\" should be removed as it does not refer to any existing column or relation."
247
431
  errored_columns << klass_col
248
432
  end
249
433
  end
250
434
  end
251
435
  end
436
+ klass_columns = klass.columns
437
+
438
+ # Take our cues from all attributes having a presence validator
439
+ klass_columns = klass_columns.reject { |col| priority_excluded_columns.include?(col.name) } if priority_excluded_columns
440
+ excluded_columns = %w[created_at updated_at deleted_at]
441
+
442
+ # First find any text fields that are required
443
+ uniques = klass_columns.select { |col| requireds.include?(col.name) && [:string, :text].include?(col.type) }
444
+ # If not that then find any text field, even those not required
445
+ uniques = klass_columns.select { |col| [:string, :text].include?(col.type) } if is_loose && uniques.empty?
446
+ # If still not then look for any required non-PK that is also not a foreign key or created_at or updated_at
447
+ if uniques.empty?
448
+ uniques = klass_columns.select do |col|
449
+ requireds.include?(col.name) && col.name != klass.primary_key && !excluded_columns.include?(col.name)
450
+ end
451
+ end
452
+ # If still nothing then the same but not a required, any non-PK that is also not a foreign key or created_at or updated_at
453
+ if is_loose && uniques.empty?
454
+ uniques = klass_columns.select do |col|
455
+ col.name != klass.primary_key && !excluded_columns.include?(col.name)
456
+ end
457
+ end
458
+ uniques.map!(&:name)
459
+ # Finally if nothing else then just accept the PK, if there is one
460
+ uniques = [klass.primary_key] if klass.primary_key && uniques.empty? && (!priority_excluded_columns || priority_excluded_columns.exclude?(klass.primary_key))
461
+ [uniques, requireds]
252
462
  end
253
463
 
254
464
  # Show a "pretty" version of IMPORT_TEMPLATE, to be placed in a model
@@ -287,10 +497,11 @@ module DutyFree
287
497
  v.each_with_index do |item, idx|
288
498
  # This is where most of the commas get printed, so you can do "#{child_count}," to diagnose things
289
499
  print ',' if idx.positive? && indent >= 0
290
- if item.is_a?(Hash)
500
+ case item
501
+ when Hash
291
502
  # puts '^' unless child_count < 5 || indent.negative?
292
503
  child_count = _template_pretty_print(item, indent + 2, child_count)
293
- elsif item.is_a?(Symbol)
504
+ when Symbol
294
505
  if indent.negative?
295
506
  child_count += 1
296
507
  else