duty_free 1.0.8 → 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.
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