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 +4 -4
- data/lib/duty_free/extensions.rb +40 -30
- data/lib/duty_free/suggest_template.rb +378 -166
- data/lib/duty_free/version_number.rb +1 -1
- data/lib/duty_free.rb +28 -3
- data/lib/generators/duty_free/model_generator.rb +349 -0
- data/lib/generators/duty_free/templates/create_versions.rb.erb +2 -2
- metadata +4 -9
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dc07e472cca2426a694a59754a83d12b93ad6572530d9b5f584b8aecfbbbacf4
|
4
|
+
data.tar.gz: 7b160b28ab7e70409ee095d6ba1892fa06d24310ebba4529ae0a5f8c57949673
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 25e0702b19e9e5e810f0632da354060ae15da6207c7af33c930eb5825c2f28046e09a481a49ce95bbce46ddb02cc6dd991d2d2728582335f1260b7153bf28998
|
7
|
+
data.tar.gz: 0e265c33a7b1611aa8cb5c28edef6632d3f9e1c2adc9c15f644b6757b2229fb22248a0a37589449e97adbea8876e76aa601331b44556f277ea17255ad6af47d0
|
data/lib/duty_free/extensions.rb
CHANGED
@@ -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
|
-
|
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
|
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
|
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
|
-
|
583
|
-
|
584
|
-
|
585
|
-
|
586
|
-
|
587
|
-
|
588
|
-
|
589
|
-
|
590
|
-
|
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
|
-
|
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,
|
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
|
-
|
637
|
-
|
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
|
-
|
664
|
-
|
665
|
-
|
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
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
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
|
-
|
67
|
+
end
|
66
68
|
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
76
|
-
|
77
|
-
|
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
|
-
#
|
81
|
-
|
82
|
-
|
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
|
-
|
85
|
-
|
86
|
-
|
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
|
-
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
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 "
|
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
|
-
|
275
|
+
if new_assoc.nil?
|
276
|
+
errored_assocs << assoc
|
277
|
+
else
|
278
|
+
assocs[assoc.name] = new_assoc
|
128
279
|
end
|
129
280
|
end
|
130
|
-
|
131
|
-
|
132
|
-
|
133
|
-
|
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
|
-
|
139
|
-
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
|
146
|
-
|
147
|
-
|
148
|
-
|
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
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
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
|
-
#
|
165
|
-
|
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
|
-
|
171
|
-
|
172
|
-
|
173
|
-
|
174
|
-
|
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
|
-
|
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.
|
181
|
-
|
182
|
-
|
183
|
-
|
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
|
-
|
186
|
-
s
|
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
|
-
|
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
|
-
|
229
|
-
|
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
|
-
|
240
|
-
|
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
|
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
|
-
#
|
133
|
-
|
134
|
-
|
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
|
2
|
-
# All other migrations
|
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.
|
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:
|
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.
|
233
|
+
rubygems_version: 3.1.6
|
239
234
|
signing_key:
|
240
235
|
specification_version: 4
|
241
236
|
summary: Import and Export Data
|