duty_free 1.0.8 → 1.0.9

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: 714b772aa09eb15409cd7c58d4ccb64f967caf41f2a13e7813e90cc94d7eb48f
4
- data.tar.gz: 750dfe36b729979a0356f14f8588568822ae22911c142d294358231504daa78e
3
+ metadata.gz: dc07e472cca2426a694a59754a83d12b93ad6572530d9b5f584b8aecfbbbacf4
4
+ data.tar.gz: 7b160b28ab7e70409ee095d6ba1892fa06d24310ebba4529ae0a5f8c57949673
5
5
  SHA512:
6
- metadata.gz: 9d77a228b4e4990dad39882a4e67d1009f3c16114bc0e9b24280e1f8e9437c7fc55a8884eb37e8f6c15898958833c7f79bb7984d721f4dbd1fce7311ca6421d0
7
- data.tar.gz: 1d35f3197ab500a6210eecc561190c02a04d87e3d4b2634a9a62695488d4e2ed0825f7240836aa9d32fe8ce01e06be38ddccda1955ca3d3669fd7ad18e8335f2
6
+ metadata.gz: 25e0702b19e9e5e810f0632da354060ae15da6207c7af33c930eb5825c2f28046e09a481a49ce95bbce46ddb02cc6dd991d2d2728582335f1260b7153bf28998
7
+ data.tar.gz: 0e265c33a7b1611aa8cb5c28edef6632d3f9e1c2adc9c15f644b6757b2229fb22248a0a37589449e97adbea8876e76aa601331b44556f277ea17255ad6af47d0
@@ -9,6 +9,7 @@ module DutyFree
9
9
  # rubocop:disable Style/CommentedKeyword
10
10
  module Extensions
11
11
  MAX_ID = Arel.sql('MAX(id)')
12
+ IS_AMOEBA = Gem.loaded_specs['amoeba']
12
13
 
13
14
  def self.included(base)
14
15
  base.send :extend, ClassMethods
@@ -111,8 +112,8 @@ module DutyFree
111
112
  rows
112
113
  end
113
114
 
114
- def df_import(data, import_template = nil)
115
- ::DutyFree::Extensions.import(self, data, import_template)
115
+ def df_import(data, import_template = nil, insert_only = false)
116
+ ::DutyFree::Extensions.import(self, data, import_template, insert_only)
116
117
  end
117
118
 
118
119
  private
@@ -159,7 +160,7 @@ module DutyFree
159
160
  # For use with importing, based on the provided column list calculate all valid combinations
160
161
  # of unique columns. If there is no valid combination, throws an error.
161
162
  # Returns an object found by this means, as well as the criteria that was used to find it.
162
- def _find_existing(uniques, cols, starred, import_template, keepers, train_we_came_in_here_on,
163
+ def _find_existing(uniques, cols, starred, import_template, keepers, train_we_came_in_here_on, insert_only,
163
164
  row = nil, klass_or_collection = nil, template_all = nil, trim_prefix = '',
164
165
  assoc = nil, base_obj = nil)
165
166
  unless trim_prefix.blank?
@@ -262,7 +263,8 @@ module DutyFree
262
263
  # If we're processing a row then this list of foreign key column name entries, named such as
263
264
  # "order_id" or "product_id" instead of column-specific stuff like "Order Date" and "Product Name",
264
265
  # is kept until the last and then gets merged on top of the other criteria before being returned.
265
- bt_criteria[(fk_name = sn_bt.foreign_key)] = fk_id
266
+ fk_name = sn_bt.foreign_key
267
+ bt_criteria[fk_name] = fk_id unless bt_criteria.include?(fk_name)
266
268
 
267
269
  # Check to see if belongs_tos are generally required on this specific table
268
270
  bt_req_by_default = sn_bt.klass.respond_to?(:belongs_to_required_by_default) &&
@@ -282,7 +284,7 @@ module DutyFree
282
284
  (sn_bt.options[:optional] || !bt_req_by_default)
283
285
 
284
286
  # Add to the criteria
285
- criteria[fk_name] = fk_id
287
+ criteria[fk_name] = fk_id if insert_only && !criteria.include?(fk_name)
286
288
  end
287
289
  end
288
290
 
@@ -332,14 +334,15 @@ module DutyFree
332
334
  # Find by all corresponding columns
333
335
  if (row_value = row[v])
334
336
  new_criteria_all_nil = false
335
- criteria[k_sym] = row_value # The data, or how to look up the data
337
+ criteria[k_sym] = row_value # The data
336
338
  end
337
339
  end
338
340
  end
341
+ # puts uniq_lookups.inspect
339
342
 
340
- return uniq_lookups.merge(criteria) if only_valid_uniques
343
+ return [uniq_lookups.merge(criteria), bt_criteria] if only_valid_uniques
341
344
  # If there's nothing to match upon then we're out
342
- return [nil, {}] if new_criteria_all_nil
345
+ return [nil, {}, {}] if new_criteria_all_nil
343
346
 
344
347
  # With this criteria, find any matching has_many row we can so we can update it.
345
348
  # First try directly looking it up through ActiveRecord.
@@ -410,12 +413,12 @@ module DutyFree
410
413
  # Standard criteria as well as foreign key column name detail with exact foreign keys
411
414
  # that match up to a primary key so that if needed a new related object can be built,
412
415
  # complete with all its association detail.
413
- [found_object, criteria.merge(bt_criteria)]
416
+ [found_object, criteria, bt_criteria]
414
417
  end # _find_existing
415
418
  end # module ClassMethods
416
419
 
417
420
  # With an array of incoming data, the first row having column names, perform the import
418
- def self.import(obj_klass, data, import_template = nil)
421
+ def self.import(obj_klass, data, import_template = nil, insert_only)
419
422
  instance_variable_set(:@defined_uniques, nil)
420
423
  instance_variable_set(:@valid_uniques, nil)
421
424
 
@@ -559,7 +562,7 @@ module DutyFree
559
562
  raise ::DutyFree::LessThanHalfAreMatchingColumnsError, I18n.t('import.altered_import_template_coumns') if keepers.length < (cols.length / 2) - 1
560
563
 
561
564
  # Returns just the first valid unique lookup set if there are multiple
562
- valid_unique = obj_klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, false)
565
+ valid_unique, bt_criteria = obj_klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, false, insert_only)
563
566
  # Make a lookup from unique values to specific IDs
564
567
  existing = obj_klass.pluck(*([:id] + valid_unique.keys)).each_with_object(existing) do |v, s|
565
568
  s[v[1..-1].map(&:to_s)] = v.first
@@ -578,16 +581,17 @@ module DutyFree
578
581
  to_be_saved = []
579
582
  # Check to see if they want to preprocess anything
580
583
  existing_unique = @before_process.call(valid_unique, existing_unique) if @before_process ||= import_template[:before_process]
581
- if (criteria = existing[existing_unique])
582
- obj = obj_klass.find(criteria)
583
- else
584
- is_insert = true
585
- # unless build_tables.empty? # include?()
586
- # binding.pry
587
- # x = 5
588
- # end
589
- to_be_saved << [obj = obj_klass.new]
590
- end
584
+ obj = if (criteria = existing[existing_unique])
585
+ obj_klass.find(criteria)
586
+ else
587
+ is_insert = true
588
+ # unless build_tables.empty? # include?()
589
+ # binding.pry
590
+ # x = 5
591
+ # end
592
+ obj_klass.new
593
+ end
594
+ to_be_saved << [obj] unless criteria # || this one has any belongs_to that will be modified here
591
595
  sub_obj = nil
592
596
  polymorphics = []
593
597
  sub_objects = {}
@@ -623,18 +627,22 @@ module DutyFree
623
627
  start = 0
624
628
  trim_prefix = v.titleize[start..-(v.name.length + 2)]
625
629
  trim_prefix << ' ' unless trim_prefix.blank?
626
- if (sub_next = sub_obj.send(path_part)).nil? && assoc.belongs_to?
630
+ binding.pry unless assoc
631
+ if assoc.belongs_to?
627
632
  klass = Object.const_get(assoc&.class_name)
628
633
  # Try to find a unique item if one is referenced
629
634
  sub_next = nil
630
635
  begin
631
- sub_next, criteria = klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, nil, row, klass, all, trim_prefix, assoc)
636
+ sub_next, criteria, bt_criteria = klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, nil,
637
+ false, # insert_only
638
+ row, klass, all, trim_prefix, assoc)
632
639
  rescue ::DutyFree::NoUniqueColumnError
633
640
  end
634
- # puts "#{v.path} #{criteria.inspect}"
635
641
  bt_name = "#{path_part}="
636
- unless sub_next || (klass == sub_obj.class && criteria.empty?)
637
- sub_next = klass.new(criteria || {})
642
+ # Not yet wired up to the right one, or going to the parent of a self-referencing model?
643
+ # puts "#{v.path} #{criteria.inspect}"
644
+ unless sub_next || (klass == sub_obj.class && (all_criteria = criteria.merge(bt_criteria)).empty?)
645
+ sub_next = klass.new(all_criteria || {})
638
646
  to_be_saved << [sub_next, sub_obj, bt_name, sub_next]
639
647
  end
640
648
  # This wires it up in memory, but doesn't yet put the proper foreign key ID in
@@ -654,15 +662,17 @@ module DutyFree
654
662
  # Rails 4.0 and later can do: sub_next.is_a?(ActiveRecord::Associations::CollectionProxy)
655
663
  elsif [:has_many, :has_one, :has_and_belongs_to_many].include?(assoc.macro) # && !assoc.options[:through]
656
664
  ::DutyFree::Extensions._save_pending(to_be_saved)
665
+ sub_next = sub_obj.send(path_part)
657
666
  # Try to find a unique item if one is referenced
658
667
  # %%% There is possibility that when bringing in related classes using a nil
659
668
  # in IMPORT_TEMPLATE[:all] that this will break. Need to test deeply nested things.
660
669
 
661
670
  # assoc.inverse_of is the belongs_to side of the has_many train we came in here on.
662
- sub_hm, criteria = assoc.klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, assoc.inverse_of,
663
- row, sub_next, all, trim_prefix, assoc,
664
- # Just in case we're running Rails < 4.0 and this is a haas_*
665
- sub_obj)
671
+ sub_hm, criteria, bt_criteria = assoc.klass.send(:_find_existing, uniques, cols, starred, import_template, keepers, assoc.inverse_of,
672
+ false, # insert_only
673
+ row, sub_next, all, trim_prefix, assoc,
674
+ # Just in case we're running Rails < 4.0 and this is a has_*
675
+ sub_obj)
666
676
  # If still not found then create a new related object using this has_many collection
667
677
  # (criteria.empty? ? nil : sub_next.new(criteria))
668
678
  if sub_hm
@@ -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,161 +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?
44
- # Figure out if it's belongs_to, has_many, or has_one
45
- belongs_to_or_has_many =
46
- if is_belongs_to
47
- 'belongs_to'
48
- elsif (is_habtm = assoc.macro == :has_and_belongs_to_many)
49
- 'has_and_belongs_to_many'
50
- elsif assoc.macro == :has_many
51
- 'has_many'
52
- else
53
- '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
54
52
  end
55
- # Always process belongs_to, and also process has_one and has_many if do_has_many is chosen.
56
- # Skip any HMT or HABTM. (Maybe break out HABTM into a combo HM and BT in the future.)
57
- if is_habtm
58
- 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)
59
- # %%% Search for other associative candidates to use instead of this HABTM contraption
60
- 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)
61
- end
62
- if (is_through = assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)) && assoc.options.include?(:as)
63
- 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
64
66
  end
65
- next if is_through || is_habtm || (!is_belongs_to && !do_has_many) || errored_assocs.include?(assoc)
67
+ end
66
68
 
67
- if is_belongs_to && assoc.options[:polymorphic] # Polymorphic belongs_to?
68
- # Load all models
69
- # %%% 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:
70
- Rails.configuration.eager_load_namespaces.select { |ns| ns < Rails::Application }.each(&:eager_load!)
71
- # Find all current possible polymorphic relations
72
- ActiveRecord::Base.descendants.each do |model|
73
- # Skip auto-generated HABTM_DestinationModel models
74
- next if model.respond_to?(:table_name_resolver) &&
75
- model.name.start_with?('HABTM_') &&
76
- model.table_name_resolver.is_a?(
77
- ActiveRecord::Associations::Builder::HasAndBelongsToMany::JoinTableResolver::KnownClass
78
- )
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)
79
108
 
80
- # Find applicable polymorphic has_many associations from each real model
81
- model.reflect_on_all_associations.each do |poly_assoc|
82
- next unless poly_assoc.macro == :has_many && 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
+ )
83
126
 
84
- this_belongs_tos += (fkeys = [poly_assoc.type, poly_assoc.foreign_key])
85
- assocs["#{assoc.name}_#{poly_assoc.active_record.name.underscore}".to_sym] = [[fkeys, assoc.active_record], poly_assoc.active_record]
86
- end
87
- end
88
- else
89
- # Is it a polymorphic has_many, which is defined using as: :somethingable ?
90
- is_polymorphic_hm = assoc.inverse_of&.options&.fetch(:polymorphic) { nil }
91
- begin
92
- # Standard has_one, or has_many, and belongs_to uses assoc.klass.
93
- # Also polymorphic belongs_to uses assoc.klass.
94
- assoc_klass = is_polymorphic_hm ? assoc.inverse_of.active_record : assoc.klass
95
- rescue NameError # For models which cannot be found by name
96
- end
97
- new_assoc =
98
- if assoc_klass.nil?
99
- 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."
100
- nil # Cause this one to be excluded
101
- elsif is_belongs_to
102
- this_belongs_tos << (fk = assoc.foreign_key.to_s)
103
- [[[fk], assoc.active_record], assoc_klass]
104
- else # has_many or has_one
105
- inverse_foreign_keys = is_polymorphic_hm ? [assoc.type, assoc.foreign_key] : [assoc.inverse_of&.foreign_key&.to_s]
106
- missing_key_columns = inverse_foreign_keys - assoc_klass.columns.map(&:name)
107
- if missing_key_columns.empty?
108
- puts "* Missing inverse foreign key for #{this_klass.name} #{belongs_to_or_has_many} :#{assoc.name}" if inverse_foreign_keys.first.nil?
109
- # puts "Has columns #{inverse_foreign_keys.inspect}"
110
- [[inverse_foreign_keys, assoc_klass], assoc_klass]
111
- else
112
- if inverse_foreign_keys.length > 1
113
- puts "* The #{assoc_klass.name} model is missing #{missing_key_columns.join(' and ')} columns to allow it to support polymorphic inheritance."
114
- else
115
- 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
116
130
 
117
- if (inverses = _find_belongs_tos(assoc_klass, this_klass, errored_assocs)).empty?
118
- if inverse_foreign_keys.first.nil?
119
- 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."
120
- else
121
- 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
122
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]
123
180
  else
124
- 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."
125
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
126
274
  end
127
- nil
275
+ if new_assoc.nil?
276
+ errored_assocs << assoc
277
+ else
278
+ assocs[assoc.name] = new_assoc
128
279
  end
129
280
  end
130
- if new_assoc.nil?
131
- errored_assocs << assoc
132
- else
133
- 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
134
327
  end
135
328
  end
329
+ break if hops.zero? || next_layer.empty?
330
+
331
+ hops -= 1
332
+ this_layer = next_layer
136
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
137
339
 
138
- # Include all columns except for the primary key, any foreign keys, and excluded_columns
139
- # %%% add EXCLUDED_ALL_COLUMNS || ...
140
- excluded_columns = %w[created_at updated_at deleted_at]
141
- template = (this_klass.columns.map(&:name) - this_primary_key - this_belongs_tos - excluded_columns)
142
- template.map!(&:to_sym)
143
- requireds = _find_requireds(this_klass).map { |r| "#{path}#{r}".to_sym }
144
- # Now add the foreign keys and any has_manys in the form of references to associated models
145
- assocs.each do |k, assoc|
146
- # assoc.first describes this foreign key and class, and is used for a "reverse poison"
147
- # detection so we don't fold back on ourselves
148
- 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
149
353
 
150
- is_has_many = (assoc.first.last == assoc.last)
151
- # puts "#{k} #{hops}"
152
- unique, new_requireds =
153
- if hops.zero?
154
- # For has_one or has_many, exclude with priority the foreign key column(s) we rode in here on
155
- priority_excluded_columns = assoc.first.first if is_has_many
156
- # puts "Excluded: #{priority_excluded_columns.inspect}"
157
- _suggest_unique_column(assoc.last, priority_excluded_columns, "#{path}#{k}_")
158
- else
159
- new_poison_links =
160
- if is_has_many
161
- # has_many is simple, just exclude how we got here from the foreign table
162
- [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'"
163
359
  else
164
- # belongs_to is more involved since there may be multiple foreign keys which point
165
- # from the foreign table to this primary one, so exclude all these links.
166
- _find_belongs_tos(assoc.first.last, assoc.last, errored_assocs).map do |f_assoc|
167
- [[f_assoc.foreign_key.to_s], f_assoc.active_record]
168
- 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'"
169
362
  end
170
- # puts "New Poison: #{new_poison_links.inspect}"
171
- _suggest_template(hops - 1, do_has_many, assoc.last, poison_links + new_poison_links, "#{path}#{k}_")
172
- end
173
- template << { k => unique }
174
- 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)
175
369
  end
176
- [template, requireds]
370
+ all_tables
177
371
  end
178
372
 
179
373
  # Find belongs_tos for this model to one more more other klasses
180
- def self._find_belongs_tos(klass, to_klass, errored_assocs)
181
- klass.reflect_on_all_associations.each_with_object([]) do |bt_assoc, s|
182
- # .is_a?(ActiveRecord::Reflection::BelongsToReflection)
183
- 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
184
393
 
185
- begin
186
- s << bt_assoc if !bt_assoc.options[:polymorphic] && bt_assoc.klass == to_klass
187
- rescue NameError
188
- errored_assocs << bt_assoc
189
- 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
190
396
  end
191
397
  end
192
398
  end
@@ -197,36 +403,20 @@ module DutyFree
197
403
  # ...
198
404
  # Not available, so grasping at straws, just search for any available column
199
405
  # %%% add EXCLUDED_UNIQUE_COLUMNS || ...
200
- klass_columns = klass.columns
201
-
202
- # Requireds takes its cues from all attributes having a presence validator
203
- requireds = _find_requireds(klass)
204
- klass_columns = klass_columns.reject { |col| priority_excluded_columns.include?(col.name) } if priority_excluded_columns
205
- excluded_columns = %w[created_at updated_at deleted_at]
206
- unique = [(
207
- # Find the first text field of a required if one exists
208
- klass_columns.find { |col| requireds.include?(col.name) && col.type == :string }&.name ||
209
- # Find the first text field, now of a non-required, if one exists
210
- klass_columns.find { |col| col.type == :string }&.name ||
211
- # If no string then look for the first non-PK that is also not a foreign key or created_at or updated_at
212
- klass_columns.find do |col|
213
- requireds.include?(col.name) && col.name != klass.primary_key && !excluded_columns.include?(col.name)
214
- end&.name ||
215
- # 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
216
- klass_columns.find do |col|
217
- col.name != klass.primary_key && !excluded_columns.include?(col.name)
218
- end&.name ||
219
- # Finally just accept the PK if nothing else
220
- klass.primary_key
221
- ).to_sym]
222
-
223
- [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 }]
224
408
  end
225
409
 
226
- def self._find_requireds(klass)
410
+ def self._find_requireds(klass, is_loose = false, priority_excluded_columns = nil)
227
411
  errored_columns = ::DutyFree.instance_variable_get(:@errored_columns)
228
- klass.validators.select do |v|
229
- 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?
230
420
  end.each_with_object([]) do |v, s|
231
421
  v.attributes.each do |a|
232
422
  attrib = a.to_s
@@ -236,17 +426,39 @@ module DutyFree
236
426
  if klass.columns.map(&:name).include?(attrib)
237
427
  s << attrib
238
428
  else
239
- hm_and_bt_names = klass.reflect_on_all_associations.each_with_object([]) do |assoc, names|
240
- names << assoc.name.to_s if [:belongs_to, :has_many, :has_one].include?(assoc.macro)
241
- names
242
- end
243
- unless hm_and_bt_names.include?(attrib)
244
- 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."
245
431
  errored_columns << klass_col
246
432
  end
247
433
  end
248
434
  end
249
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]
250
462
  end
251
463
 
252
464
  # Show a "pretty" version of IMPORT_TEMPLATE, to be placed in a model
@@ -5,7 +5,7 @@ module DutyFree
5
5
  module VERSION
6
6
  MAJOR = 1
7
7
  MINOR = 0
8
- TINY = 8
8
+ TINY = 9
9
9
 
10
10
  # PRE is nil unless it's a pre-release (beta, RC, etc.)
11
11
  PRE = nil
data/lib/duty_free.rb CHANGED
@@ -129,9 +129,21 @@ end
129
129
  # Major compatibility fixes for ActiveRecord < 4.2
130
130
  # ================================================
131
131
  ActiveSupport.on_load(:active_record) do
132
- # Rails < 4.0 cannot do #find_by, #find_or_create_by, or do #pluck on multiple columns, so here are the patches:
133
- if ActiveRecord.version < ::Gem::Version.new('4.0')
134
- module ActiveRecord
132
+ # rubocop:disable Lint/ConstantDefinitionInBlock
133
+ module ActiveRecord
134
+ class Base
135
+ unless respond_to?(:execute_sql)
136
+ class << self
137
+ def execute_sql(sql, *param_array)
138
+ param_array = param_array.first if param_array.length == 1 && param_array.first.is_a?(Array)
139
+ connection.execute(send(:sanitize_sql_array, [sql] + param_array))
140
+ end
141
+ end
142
+ end
143
+ end
144
+
145
+ # Rails < 4.0 cannot do #find_by, #find_or_create_by, or do #pluck on multiple columns, so here are the patches:
146
+ if version < ::Gem::Version.new('4.0')
135
147
  # Normally find_by is in FinderMethods, which older AR doesn't have
136
148
  module Calculations
137
149
  def find_by(*args)
@@ -280,6 +292,7 @@ ActiveSupport.on_load(:active_record) do
280
292
  end
281
293
  end
282
294
  end
295
+ # rubocop:enable Lint/ConstantDefinitionInBlock
283
296
 
284
297
  # Rails < 4.2 is not innately compatible with Ruby 2.4 and later, and comes up with:
285
298
  # "TypeError: Cannot visit Integer" unless we patch like this:
@@ -322,6 +335,7 @@ ActiveSupport.on_load(:active_record) do
322
335
  # First part of arel_table_type stuff:
323
336
  # ------------------------------------
324
337
  # (more found below)
338
+ # was: ActiveRecord.version >= ::Gem::Version.new('3.2') &&
325
339
  if ActiveRecord.version < ::Gem::Version.new('5.0')
326
340
  # Used by Util#_arel_table_type
327
341
  module ActiveRecord
@@ -336,6 +350,17 @@ ActiveSupport.on_load(:active_record) do
336
350
  end
337
351
 
338
352
  include ::DutyFree::Extensions
353
+
354
+ unless ::DutyFree::Extensions::IS_AMOEBA
355
+ # Add amoeba-compatible support
356
+ module ActiveRecord
357
+ class Base
358
+ def self.amoeba(*args)
359
+ puts "Amoeba called from #{name} with #{args.inspect}"
360
+ end
361
+ end
362
+ end
363
+ end
339
364
  end
340
365
 
341
366
  # Do this earlier because stuff here gets mixed into JoinDependency::JoinAssociation and AssociationScope
@@ -0,0 +1,349 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record'
5
+ require 'fancy_gets'
6
+
7
+ module DutyFree
8
+ # Auto-generates an IMPORT_TEMPLATE entry for a model
9
+ class ModelGenerator < ::Rails::Generators::Base
10
+ include FancyGets
11
+ # include ::Rails::Generators::Migration
12
+
13
+ # # source_root File.expand_path('templates', __dir__)
14
+ # class_option(
15
+ # :with_changes,
16
+ # type: :boolean,
17
+ # default: false,
18
+ # desc: 'Add IMPORT_TEMPLATE to model'
19
+ # )
20
+
21
+ desc 'Adds an appropriate IMPORT_TEMPLATE entry into a model of your choosing so that' \
22
+ ' DutyFree can perform exports, and with the Pro version, the same template also' \
23
+ ' does imports.'
24
+
25
+ def df_model_template
26
+ # %%% If Apartment is active, ask which schema they want
27
+
28
+ # Load all models
29
+ Rails.configuration.eager_load_namespaces.select { |ns| ns < Rails::Application }.each(&:eager_load!)
30
+
31
+ # Generate a list of viable models that can be chosen
32
+ longest_length = 0
33
+ model_info = Hash.new { |h, k| h[k] = {} }
34
+ tableless = Hash.new { |h, k| h[k] = [] }
35
+ models = ActiveRecord::Base.descendants.reject do |m|
36
+ trouble = if m.abstract_class?
37
+ true
38
+ elsif !m.table_exists?
39
+ tableless[m.table_name] << m.name
40
+ ' (No Table)'
41
+ else
42
+ this_f_keys = (model_info[m][:f_keys] = m.reflect_on_all_associations.select { |a| a.macro == :belongs_to }) || []
43
+ column_names = (model_info[m][:column_names] = m.columns.map(&:name) - [m.primary_key, 'created_at', 'updated_at', 'deleted_at'] - this_f_keys.map(&:foreign_key))
44
+ if column_names.empty? && this_f_keys && !this_f_keys.empty?
45
+ fk_message = ", although #{this_f_keys.length} foreign keys"
46
+ " (No columns#{fk_message})"
47
+ end
48
+ end
49
+ # puts "#{m.name}#{trouble}" if trouble&.is_a?(String)
50
+ trouble
51
+ end
52
+ models.sort! do |a, b| # Sort first to separate namespaced stuff from the rest, then alphabetically
53
+ is_a_namespaced = a.name.include?('::')
54
+ is_b_namespaced = b.name.include?('::')
55
+ if is_a_namespaced && !is_b_namespaced
56
+ 1
57
+ elsif !is_a_namespaced && is_b_namespaced
58
+ -1
59
+ else
60
+ a.name <=> b.name
61
+ end
62
+ end
63
+ models.each do |m| # Find longest name in the list for future use to show lists on the right side of the screen
64
+ # Strangely this can't be inlined since it assigns to "len"
65
+ if longest_length < (len = m.name.length)
66
+ longest_length = len
67
+ end
68
+ end
69
+
70
+ model_name = ARGV[0]&.camelize
71
+ unless (starting = models.find { |m| m.name == model_name })
72
+ puts "#{"Couldn't find #{model_name}. " if model_name}Pick a model to start from:"
73
+ starting = gets_list(
74
+ list: models,
75
+ on_select: proc do |item|
76
+ selected = item[:selected] || item[:focused]
77
+ this_model_info = model_info[selected]
78
+ selected.name + " (#{(this_model_info[:column_names] + this_model_info[:f_keys].map(&:name).map(&:upcase)).join(', ')})"
79
+ end
80
+ )
81
+ end
82
+ puts "\nThinking..."
83
+
84
+ # %%% Find out how many hops at most we can go from this model
85
+ max_hm_nav = starting.suggest_template(-1, true, false)
86
+ max_bt_nav = starting.suggest_template(-1, false, false)
87
+ hops_with_hm, num_hm_hops_tables = calc_num_hops([[starting, max_hm_nav[:all]]], models)
88
+ hops, num_hops_tables = calc_num_hops([[starting, max_bt_nav[:all]]], models)
89
+ # print "\b" * 11
90
+ unless hops_with_hm.length == hops.length
91
+ starting_name = starting.name
92
+ unless (is_hm = ARGV[1]&.downcase)
93
+ puts "Navigate from #{starting_name} using:\n#{'=' * (21 + starting_name.length)}"
94
+ is_hm = gets_list(
95
+ ["Only belongs_to (max of #{hops.length} hops and #{num_hops_tables} tables)",
96
+ "has_many as well as belongs_to (max of #{hops_with_hm.length} hops and #{num_hm_hops_tables} tables)"]
97
+ )
98
+ end
99
+ is_hm = is_hm.start_with?('has_many') || is_hm[0] == 'y'
100
+ hops = hops_with_hm if is_hm
101
+ end
102
+
103
+ unless (num_hops = ARGV[2]&.to_i)
104
+ puts "\nNow, how many hops total would you like to navigate?"
105
+ index = 0
106
+ cumulative = 0
107
+ hops_list = ['0'] + hops.map { |h| "#{index += 1} (#{cumulative += h.length} linkages)" }
108
+ num_hops = gets_list(
109
+ list: hops_list,
110
+ on_select: proc do |value|
111
+ associations = Hash.new { |h, k| h[k] = 0 }
112
+ index = (value[:selected] || value[:focused]).split(' ').first.to_i - 1
113
+ layer = hops[index] if index >= 0
114
+ layer ||= []
115
+ layer.each { |i| associations[i.last] += 1 }
116
+ associations.each { |k, v| associations.delete(k) if v == 1 }
117
+ layer.map do |l|
118
+ associations.keys.include?(l.last) ? "#{l.first.name.demodulize} #{l.last}" : l.last
119
+ end.join(', ')
120
+ # y = model_info[data[:focused].name]
121
+ # data[:focused].name + " (#{(y[:column_names] + y[:f_keys].map(&:name).map(&:upcase)).join(', ')})"
122
+ # layer.inspect
123
+ end
124
+ ).split(' ').first.to_i
125
+ end
126
+
127
+ print "Navigating from #{starting_name}" if model_name
128
+ puts "\nOkay, #{num_hops} hops#{', including has_many,' if is_hm} it is!"
129
+ # Grab the console output from this:
130
+ original_stdout = $stdout
131
+ $stdout = StringIO.new
132
+ starting.suggest_template(num_hops, is_hm)
133
+ output = $stdout
134
+ $stdout = original_stdout
135
+ filename = nil
136
+ output.rewind
137
+ lines = output.each_line.each_with_object([]) do |line, s|
138
+ if line == "\n"
139
+ # Do nothing
140
+ elsif filename
141
+ s << line
142
+ elsif line.start_with?('# Place the following into ')
143
+ filename = line[27..-1]&.split(':')&.first
144
+ end
145
+ s
146
+ end
147
+
148
+ model_file = File.open(filename, 'r+')
149
+ insert_at = nil
150
+ starting_name = starting.name.demodulize
151
+ loop do
152
+ break if model_file.eof?
153
+
154
+ line_parts = model_file.readline.strip.gsub(/ +/, ' ').split(' ')
155
+ if line_parts.first == 'class' && line_parts[1] && (line_parts[1] == starting_name || line_parts[1].end_with?("::#{starting_name}"))
156
+ insert_at = model_file.pos
157
+ break
158
+ end
159
+ end
160
+ line = nil
161
+ import_template_blocks = []
162
+ import_template_block = nil
163
+ indentation = nil
164
+ # See if there's already any IMPORT_TEMPLATE entries in the model file.
165
+ # If there already is just one, we will comment it out if needs be before adding a fresh one.
166
+ loop do
167
+ break if model_file.eof?
168
+
169
+ line_parts = (line = model_file.readline).strip.split(/[\s=]+/)
170
+ indentation ||= line[0...(/\S/ =~ line)]
171
+ case line_parts[-2..-1]
172
+ when ['IMPORT_TEMPLATE', '{']
173
+ import_template_blocks << import_template_block if import_template_block
174
+ import_template_block = [model_file.pos - line.length, nil, line.strip[0] == '#', []]
175
+ when ['#', '------------------------------------------']
176
+ import_template_block[1] = model_file.pos if import_template_block # && import_template_block[1].nil?
177
+ end
178
+ next unless import_template_block
179
+
180
+ # Collect all the lines of any existing block
181
+ import_template_block[3] << line
182
+ # Cap this one if it's done
183
+ if import_template_block[1]
184
+ import_template_blocks << import_template_block
185
+ import_template_block = nil
186
+ end
187
+ end
188
+ import_template_blocks << import_template_block if import_template_block
189
+ comments = nil
190
+ is_add_cr = nil
191
+ if import_template_blocks.length > 1
192
+ # %%% maybe in the future: remove any older commented ones
193
+ puts 'Found multiple existing import template blocks. Will not attempt to automatically add yet another.'
194
+ insert_at = nil
195
+ elsif import_template_blocks.length == 1
196
+ # Get set up to add the new block after the existing one
197
+ insert_at = (import_template_block = import_template_blocks.first)[1]
198
+ if insert_at.nil?
199
+ puts "Found what looked like the start of an existing IMPORT_TEMPLATE block, but couldn't determine where it ends. Will not attempt to automatically add anything."
200
+ elsif import_template_block[2] # Already commented
201
+ is_add_cr = true
202
+ else # Needs to be commented
203
+ # Find what kind and how much indentation is present from the first commented line
204
+ indentation = import_template_block[3].first[0...(/\S/ =~ import_template_block[3].first)]
205
+ comments = import_template_block[3].map { |l| "#{l[0...indentation.length]}# #{l[indentation.length..-1]}" }
206
+ end
207
+ # else # Must be no IMPORT_TEMPLATE block yet
208
+ # insert_at = model_file.pos
209
+ end
210
+ if insert_at.nil?
211
+ puts "Please edit #{filename} manually and add this code:\n\n#{lines.join}"
212
+ else
213
+ is_good = ARGV[3]&.downcase&.start_with?('y')
214
+ args = [starting_name,
215
+ is_hm ? 'has_many' : 'no',
216
+ num_hops]
217
+ args << 'yes' if is_good
218
+ args = args.each_with_object(+'') do |v, s|
219
+ s << " #{v}"
220
+ s
221
+ end
222
+ lines.unshift("# Added #{DateTime.now.strftime('%b %d, %Y %I:%M%P')} by running `bin/rails g duty_free:model#{args}`\n")
223
+ # add a new one afterwards
224
+ print is_good ? 'Will' : 'OK to'
225
+ print "#{" comment #{comments.length} existing lines and" if comments} add #{lines.length} new lines to #{filename}"
226
+ puts is_good ? '.' : '?'
227
+ if is_good || gets_list(%w[Yes No]) == 'Yes'
228
+ # Store rest of file
229
+ model_file.pos = insert_at
230
+ rest_of_file = model_file.read
231
+ if comments
232
+ model_file.pos = import_template_block[0]
233
+ model_file.write("#{comments.join}\n")
234
+ puts "Commented #{comments.length} existing lines"
235
+ else
236
+ model_file.pos = insert_at
237
+ end
238
+ model_file.write("\n") if is_add_cr
239
+ model_file.write(lines.map { |l| "#{indentation}#{l}" }.join)
240
+ model_file.write(rest_of_file)
241
+ end
242
+ end
243
+ model_file.close
244
+ end
245
+
246
+ private
247
+
248
+ # def calc_num_hops(all, num = 0)
249
+ # max_num = num
250
+ # all.each do |item|
251
+ # if item.is_a?(Hash)
252
+ # item.each do |k, v|
253
+ # # puts "#{k} - #{num}"
254
+ # this_num = calc_num_hops(item[k], num + 1)
255
+ # max_num = this_num if this_num > max_num
256
+ # end
257
+ # end
258
+ # end
259
+ # max_num
260
+ # end
261
+
262
+ # Breadth first approach
263
+ def calc_num_hops(this_layer, models = nil)
264
+ seen_it = {}
265
+ layers = []
266
+ loop do
267
+ this_keys = []
268
+ next_layer = []
269
+ this_layer.each do |grouping|
270
+ klass = grouping.first
271
+ # binding.pry #unless klass.is_a?(Class)
272
+ grouping.last.each do |item|
273
+ next unless item.is_a?(Hash) && !seen_it.include?([klass, (k, v = item.first).first])
274
+
275
+ seen_it[[klass, k]] = nil
276
+ this_keys << [klass, k]
277
+ this_klass = klass.reflect_on_association(k)&.klass
278
+ if this_klass.nil? # Perhaps it's polymorphic
279
+ polymorphics = klass.reflect_on_all_associations.each_with_object([]) do |r, s|
280
+ prefix = "#{r.name}_"
281
+ if r.polymorphic? && k.to_s.start_with?(prefix)
282
+ suffix = k.to_s[prefix.length..-1]
283
+ possible_klass = models.find { |m| m.name.underscore == suffix }
284
+ s << [suffix, possible_klass] if possible_klass
285
+ end
286
+ s
287
+ end
288
+ # binding.pry if polymorphics.length != 1
289
+ this_klass = polymorphics.first&.last
290
+ end
291
+ next_layer << [this_klass, v.select { |ip| ip.is_a?(Hash) }] if this_klass
292
+ end
293
+ end
294
+ layers << this_keys unless this_keys.empty?
295
+ break if next_layer.empty?
296
+
297
+ this_layer = next_layer
298
+ end
299
+ # puts "#{k} - #{num}"
300
+ [layers, seen_it.keys.map(&:first).uniq.length]
301
+ end
302
+
303
+ # # MySQL 5.6 utf8mb4 limit is 191 chars for keys used in indexes.
304
+ # def item_type_options
305
+ # opt = { null: false }
306
+ # opt[:limit] = 191 if mysql?
307
+ # ", #{opt}"
308
+ # end
309
+
310
+ # def migration_version
311
+ # return unless (major = ActiveRecord::VERSION::MAJOR) >= 5
312
+
313
+ # "[#{major}.#{ActiveRecord::VERSION::MINOR}]"
314
+ # end
315
+
316
+ # # Class names of MySQL adapters.
317
+ # # - `MysqlAdapter` - Used by gems: `mysql`, `activerecord-jdbcmysql-adapter`.
318
+ # # - `Mysql2Adapter` - Used by `mysql2` gem.
319
+ # def mysql?
320
+ # [
321
+ # 'ActiveRecord::ConnectionAdapters::MysqlAdapter',
322
+ # 'ActiveRecord::ConnectionAdapters::Mysql2Adapter'
323
+ # ].freeze.include?(ActiveRecord::Base.connection.class.name)
324
+ # end
325
+
326
+ # # Even modern versions of MySQL still use `latin1` as the default character
327
+ # # encoding. Many users are not aware of this, and run into trouble when they
328
+ # # try to use DutyFree in apps that otherwise tend to use UTF-8. Postgres, by
329
+ # # comparison, uses UTF-8 except in the unusual case where the OS is configured
330
+ # # with a custom locale.
331
+ # #
332
+ # # - https://dev.mysql.com/doc/refman/5.7/en/charset-applications.html
333
+ # # - http://www.postgresql.org/docs/9.4/static/multibyte.html
334
+ # #
335
+ # # Furthermore, MySQL's original implementation of UTF-8 was flawed, and had
336
+ # # to be fixed later by introducing a new charset, `utf8mb4`.
337
+ # #
338
+ # # - https://mathiasbynens.be/notes/mysql-utf8mb4
339
+ # # - https://dev.mysql.com/doc/refman/5.5/en/charset-unicode-utf8mb4.html
340
+ # #
341
+ # def versions_table_options
342
+ # if mysql?
343
+ # ', { options: "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci" }'
344
+ # else
345
+ # ''
346
+ # end
347
+ # end
348
+ end
349
+ end
@@ -1,5 +1,5 @@
1
- # This migration creates the `versions` table, the only schema PT requires.
2
- # All other migrations PT provides are optional.
1
+ # This migration creates the `versions` table, the only schema DF requires.
2
+ # All other migrations DF provides are optional.
3
3
  class CreateVersions < ActiveRecord::Migration<%= migration_version %>
4
4
 
5
5
  # The largest text column available in all supported RDBMS is
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: duty_free
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.8
4
+ version: 1.0.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lorin Thwaits
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-01-06 00:00:00.000000000 Z
11
+ date: 2023-02-26 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -17,9 +17,6 @@ dependencies:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
19
  version: '3.0'
20
- - - "<"
21
- - !ruby/object:Gem::Version
22
- version: '6.2'
23
20
  type: :runtime
24
21
  prerelease: false
25
22
  version_requirements: !ruby/object:Gem::Requirement
@@ -27,9 +24,6 @@ dependencies:
27
24
  - - ">="
28
25
  - !ruby/object:Gem::Version
29
26
  version: '3.0'
30
- - - "<"
31
- - !ruby/object:Gem::Version
32
- version: '6.2'
33
27
  - !ruby/object:Gem::Dependency
34
28
  name: appraisal
35
29
  requirement: !ruby/object:Gem::Requirement
@@ -214,6 +208,7 @@ files:
214
208
  - lib/duty_free/version_number.rb
215
209
  - lib/generators/duty_free/USAGE
216
210
  - lib/generators/duty_free/install_generator.rb
211
+ - lib/generators/duty_free/model_generator.rb
217
212
  - lib/generators/duty_free/templates/add_object_changes_to_versions.rb.erb
218
213
  - lib/generators/duty_free/templates/create_versions.rb.erb
219
214
  homepage: https://github.com/lorint/duty_free
@@ -235,7 +230,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
235
230
  - !ruby/object:Gem::Version
236
231
  version: 1.3.6
237
232
  requirements: []
238
- rubygems_version: 3.2.3
233
+ rubygems_version: 3.1.6
239
234
  signing_key:
240
235
  specification_version: 4
241
236
  summary: Import and Export Data