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 +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
|