duty_free 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4c9a4a9d552788b5fa4d273e4c000d8d12bbd7f8808eb7904c69bc57c7ccbd61
4
+ data.tar.gz: 3e7ccb0be3785814ef2c2bcb488b73d52a956c9ebcc6365ab9a0e3a4160a3924
5
+ SHA512:
6
+ metadata.gz: 10df4b7fbf2d78d2f4c9d24ea318461465e61064697b315e5f84ed92e2d342347a17d0c07e2440858f19453fe0a128524ba70b1adb7c7a564ed84d786ffca7b1
7
+ data.tar.gz: 1e377aa74a39e3fe9b3f4accda7ef83535bf3ca2b0fe36de1b3b50289df30a96192d6eca22265e4d6749cf2101b4316cc29211fb450aeb0c2112f84b64f031d8
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ require 'duty_free/config'
6
+ require 'duty_free/extensions'
7
+ require 'duty_free/version_number'
8
+ # require 'duty_free/serializers/json'
9
+ # require 'duty_free/serializers/yaml'
10
+
11
+ # An ActiveRecord extension that simplifies importing and exporting of data
12
+ # stored in one or more models. Source and destination can be CSV, XLS,
13
+ # XLSX, ODT, HTML tables, or simple Ruby arrays.
14
+ module DutyFree
15
+ class << self
16
+ # Switches DutyFree on or off, for all threads.
17
+ # @api public
18
+ def enabled=(value)
19
+ DutyFree.config.enabled = value
20
+ end
21
+
22
+ # Returns `true` if DutyFree is on, `false` otherwise. This is the
23
+ # on/off switch that affects all threads. Enabled by default.
24
+ # @api public
25
+ def enabled?
26
+ !!DutyFree.config.enabled
27
+ end
28
+
29
+ # Returns DutyFree's `::Gem::Version`, convenient for comparisons. This is
30
+ # recommended over `::DutyFree::VERSION::STRING`.
31
+ #
32
+ # @api public
33
+ def gem_version
34
+ ::Gem::Version.new(VERSION::STRING)
35
+ end
36
+
37
+ # Set the DutyFree serializer. This setting affects all threads.
38
+ # @api public
39
+ def serializer=(value)
40
+ DutyFree.config.serializer = value
41
+ end
42
+
43
+ # Get the DutyFree serializer used by all threads.
44
+ # @api public
45
+ def serializer
46
+ DutyFree.config.serializer
47
+ end
48
+
49
+ # Returns DutyFree's global configuration object, a singleton. These
50
+ # settings affect all threads.
51
+ # @api private
52
+ def config
53
+ @config ||= DutyFree::Config.instance
54
+ yield @config if block_given?
55
+ @config
56
+ end
57
+ alias configure config
58
+
59
+ def version
60
+ VERSION::STRING
61
+ end
62
+ end
63
+ end
64
+
65
+ ActiveSupport.on_load(:active_record) do
66
+ include ::DutyFree::Extensions
67
+ end
68
+
69
+ # # Require frameworks
70
+ # if defined?(::Rails)
71
+ # # Rails module is sometimes defined by gems like rails-html-sanitizer
72
+ # # so we check for presence of Rails.application.
73
+ # if defined?(::Rails.application)
74
+ # require "duty_free/frameworks/rails"
75
+ # else
76
+ # ::Kernel.warn(<<-EOS.freeze
77
+ # DutyFree has been loaded too early, before rails is loaded. This can
78
+ # happen when another gem defines the ::Rails namespace, then DF is loaded,
79
+ # all before rails is loaded. You may want to reorder your Gemfile, or defer
80
+ # the loading of DF by using `require: false` and a manual require elsewhere.
81
+ # EOS
82
+ # )
83
+ # end
84
+ # else
85
+ # require "duty_free/frameworks/active_record"
86
+ # end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'duty_free/util'
4
+
5
+ module DutyFree
6
+ # Holds detail about each column as we recursively explore the scope of what to import
7
+ class Column
8
+ attr_accessor :name, :pre_prefix, :prefix, :prefix_assocs, :import_columns_as
9
+ attr_writer :obj_class
10
+
11
+ def initialize(name, pre_prefix, prefix, prefix_assocs, obj_class, import_columns_as)
12
+ self.name = name
13
+ self.pre_prefix = pre_prefix
14
+ self.prefix = prefix
15
+ self.prefix_assocs = prefix_assocs
16
+ self.import_columns_as = import_columns_as
17
+ self.obj_class = obj_class
18
+ end
19
+
20
+ def to_s(mapping = nil)
21
+ # Crap way:
22
+ # sql_col = ::DutyFree::Util._prefix_join([prefix_assocs.last&.klass&.table_name, name])
23
+
24
+ # Slightly less crap:
25
+ # table_name = [prefix_assocs.first&.klass&.table_name]
26
+ # alias_name = prefix_assocs.last&.plural_name&.to_s
27
+ # table_name.unshift(alias_name) unless table_name.first == alias_name
28
+ # sql_col = ::DutyFree::Util._prefix_join([table_name.compact.join('_'), name])
29
+
30
+ # Foolproof way, using the AREL mapping:
31
+ sql_col = ::DutyFree::Util._prefix_join([mapping["#{pre_prefix.tr('.', '_')}_#{prefix}_"], name])
32
+ sym = to_sym.to_s
33
+ sql_col == sym ? sql_col : "#{sql_col} AS #{sym}"
34
+ end
35
+
36
+ def titleize
37
+ @titleized ||= sym_string.titleize
38
+ end
39
+
40
+ delegate :to_sym, to: :sym_string
41
+
42
+ def path
43
+ @path ||= ::DutyFree::Util._prefix_join([pre_prefix, prefix]).split('.').map(&:to_sym)
44
+ end
45
+
46
+ private
47
+
48
+ # The snake-cased column name to be used for building the full list of template_columns
49
+ def sym_string
50
+ @sym_string ||= ::DutyFree::Util._prefix_join(
51
+ [pre_prefix, prefix, ::DutyFree::Util._clean_name(name, import_columns_as)],
52
+ '_'
53
+ ).tr('.', '_')
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+ require 'duty_free/serializers/yaml'
5
+
6
+ module DutyFree
7
+ # Global configuration affecting all threads. Some thread-specific
8
+ # configuration can be found in `duty_free.rb`, others in `controller.rb`.
9
+ class Config
10
+ include Singleton
11
+ attr_accessor :serializer, :version_limit, :association_reify_error_behaviour,
12
+ :object_changes_adapter, :root_model
13
+
14
+ def initialize
15
+ # Variables which affect all threads, whose access is synchronized.
16
+ @mutex = Mutex.new
17
+ @enabled = true
18
+
19
+ # Variables which affect all threads, whose access is *not* synchronized.
20
+ @serializer = DutyFree::Serializers::YAML
21
+ end
22
+
23
+ # Indicates whether DutyFree is on or off. Default: true.
24
+ def enabled
25
+ @mutex.synchronize { !!@enabled }
26
+ end
27
+
28
+ def enabled=(enable)
29
+ @mutex.synchronize { @enabled = enable }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,525 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'duty_free/column'
4
+ require 'duty_free/suggest_template'
5
+ # require 'duty_free/attribute_serializers/object_attribute'
6
+ # require 'duty_free/attribute_serializers/object_changes_attribute'
7
+ # require 'duty_free/model_config'
8
+ # require 'duty_free/record_trail'
9
+
10
+ # :nodoc:
11
+ module DutyFree
12
+ module Extensions
13
+ def self.included(base)
14
+ base.send :extend, ClassMethods
15
+ base.send :extend, ::DutyFree::SuggestTemplate::ClassMethods
16
+ end
17
+
18
+ # :nodoc:
19
+ module ClassMethods
20
+ # def self.extended(model)
21
+ # end
22
+
23
+ # Export at least column header, and optionally include all existing data as well
24
+ def df_export(is_with_data = true, import_columns = nil)
25
+ # In case they are only supplying the columns hash
26
+ if is_with_data.is_a?(Hash) && !import_columns
27
+ import_columns = is_with_data
28
+ is_with_data = true
29
+ end
30
+ import_columns ||= if constants.include?(:IMPORT_COLUMNS)
31
+ self::IMPORT_COLUMNS
32
+ else
33
+ suggest_template(0, false, false)
34
+ end
35
+ rows = [friendly_columns(import_columns)]
36
+ if is_with_data
37
+ # Automatically create a JOINs strategy and select list to get back all related rows
38
+ template_cols, template_joins = recurse_def(import_columns[:all], import_columns)
39
+ relation = joins(template_joins)
40
+
41
+ # So we can properly create the SELECT list, create a mapping between our
42
+ # column alias prefixes and the aliases AREL creates.
43
+ # Warning: Delegating ast to arel is deprecated and will be removed in Rails 6.0
44
+ arel_alias_names = ::DutyFree::Util._recurse_arel(relation.ast.cores.first.source)
45
+ our_names = ::DutyFree::Util._recurse_arel(template_joins)
46
+ mapping = our_names.zip(arel_alias_names).to_h
47
+
48
+ relation.select(template_cols.map { |x| x.to_s(mapping) }).each do |result|
49
+ rows << template_columns(import_columns).map do |col|
50
+ value = result.send(col)
51
+ case value
52
+ when true
53
+ 'Yes'
54
+ when false
55
+ 'No'
56
+ else
57
+ value.to_s
58
+ end
59
+ end
60
+ end
61
+ end
62
+ rows
63
+ end
64
+
65
+ # With an array of incoming data, the first row having column names, perform the import
66
+ def df_import(data, import_columns = nil)
67
+ import_columns ||= if constants.include?(:IMPORT_COLUMNS)
68
+ self::IMPORT_COLUMNS
69
+ else
70
+ suggest_template(0, false, false)
71
+ end
72
+ inserts = []
73
+ updates = []
74
+ counts = Hash.new { |h, k| h[k] = [] }
75
+ errors = []
76
+
77
+ is_first = true
78
+ uniques = nil
79
+ cols = nil
80
+ starred = []
81
+ partials = []
82
+ all = import_columns[:all]
83
+ keepers = {}
84
+ valid_unique = nil
85
+ existing = {}
86
+ devise_class = ''
87
+
88
+ reference_models = if Object.const_defined?('Apartment')
89
+ Apartment.excluded_models
90
+ else
91
+ []
92
+ end
93
+
94
+ if Object.const_defined?('Devise')
95
+ Object.const_get('Devise') # If this fails, devise_class will remain a blank string.
96
+ devise_class = Devise.mappings.values.first.class_name
97
+ reference_models -= [devise_class]
98
+ else
99
+ devise_class = ''
100
+ end
101
+
102
+ # Did they give us a filename?
103
+ if data.is_a?(String)
104
+ data = if data.length <= 4096 && data.split('\n').length == 1
105
+ File.open(data)
106
+ else
107
+ # Hope that other multi-line strings might be CSV data
108
+ CSV.new(data)
109
+ end
110
+ end
111
+ # Or perhaps us a file?
112
+ if data.is_a?(File)
113
+ # Use the "roo" gem if it's available
114
+ data = if Object.const_defined?('Roo::Spreadsheet', { csv_options: { encoding: 'bom|utf-8' } })
115
+ Roo::Spreadsheet.open(data)
116
+ else
117
+ # Otherwise generic CSV parsing
118
+ require 'csv' unless Object.const_defined?('CSV')
119
+ CSV.open(data)
120
+ end
121
+ end
122
+
123
+ # Will show as just one transaction when using auditing solutions such as PaperTrail
124
+ ActiveRecord::Base.transaction do
125
+ # Check to see if they want to do anything before the whole import
126
+ if before_import ||= (import_columns[:before_import]) # || some generic before_import)
127
+ before_import.call(data)
128
+ end
129
+ data.each_with_index do |row, row_num|
130
+ row_errors = {}
131
+ if is_first # Anticipate that first row has column names
132
+ uniques = import_columns[:uniques]
133
+
134
+ # Look for UTF-8 BOM in very first cell
135
+ row[0] = row[0][3..-1] if row[0].start_with?([239, 187, 191].pack('U*'))
136
+ # How about a first character of FEFF or FFFE to support UTF-16 BOMs?
137
+ # FE FF big-endian (standard)
138
+ # FF FE little-endian
139
+ row[0] = row[0][1..-1] if [65_279, 65_534].include?(row[0][0].ord)
140
+ cols = row.map { |col| (col || '').strip }
141
+
142
+ # Unique column combinations can be called out explicitly in models using uniques: {...}, or to just
143
+ # define one column at a time simply mark with an asterisk.
144
+ # Track and clean up stars
145
+ starred = cols.select do |col|
146
+ if col[0] == '*'
147
+ col.slice!(0)
148
+ col.strip!
149
+ end
150
+ end
151
+ partials = cols.select do |col|
152
+ if col[0] == '~'
153
+ col.slice!(0)
154
+ col.strip!
155
+ end
156
+ end
157
+ defined_uniques(uniques, cols, starred)
158
+ cols.map! { |col| ::DutyFree::Util._clean_name(col, import_columns[:as]) } # %%%
159
+ # Make sure that at least half of them match what we know as being good column names
160
+ template_column_objects = recurse_def(import_columns[:all], import_columns).first
161
+ cols.each_with_index do |col, idx|
162
+ # prefixes = col_detail.pre_prefix + (col_detail.prefix.blank? ? [] : [col_detail.prefix])
163
+ keepers[idx] = template_column_objects.find { |col_obj| col_obj.titleize == col }
164
+ # puts "Could not find a match for column #{idx + 1}, #{col}" if keepers[idx].nil?
165
+ end
166
+ if keepers.length < (cols.length / 2) - 1
167
+ raise ::DutyFree::LessThanHalfAreMatchingColumnsError, I18n.t('import.altered_import_template_coumns')
168
+ end
169
+
170
+ # Returns just the first valid unique lookup set if there are multiple
171
+ valid_unique = valid_uniques(uniques, cols, starred, import_columns)
172
+ # Make a lookup from unique values to specific IDs
173
+ existing = pluck(*([:id] + valid_unique.keys)).each_with_object(existing) { |v, s| s[v[1..-1].map(&:to_s)] = v.first; }
174
+ is_first = false
175
+ else # Normal row of data
176
+ is_insert = false
177
+ is_do_save = true
178
+ existing_unique = valid_unique.inject([]) do |s, v|
179
+ s << row[v.last].to_s
180
+ end
181
+ # Check to see if they want to preprocess anything
182
+ if @before_process ||= import_columns[:before_process]
183
+ existing_unique = @before_process.call(valid_unique, existing_unique)
184
+ end
185
+ obj = if existing.include?(existing_unique)
186
+ find(existing[existing_unique])
187
+ else
188
+ is_insert = true
189
+ new
190
+ end
191
+ sub_obj = nil
192
+ is_has_one = false
193
+ has_ones = []
194
+ polymorphics = []
195
+ sub_objects = {}
196
+ this_path = nil
197
+ keepers.each do |key, v|
198
+ klass = nil
199
+ next if v.nil?
200
+
201
+ # Not the same as the last path?
202
+ if this_path && v.path != this_path.split(',').map(&:to_sym) && !is_has_one
203
+ if sub_obj&.valid?
204
+ # %%% Perhaps send them even invalid objects so they can be made valid here?
205
+ if around_import_save
206
+ around_import_save(sub_obj) do |yes_do_save|
207
+ sub_obj.save if yes_do_save && sub_obj&.valid?
208
+ end
209
+ end
210
+ end
211
+ end
212
+ sub_obj = obj
213
+ this_path = ''
214
+ v.path.each_with_index do |path_part, idx|
215
+ this_path << (this_path.blank? ? path_part.to_s : ",#{path_part}")
216
+ unless (sub_next = sub_objects[this_path])
217
+ # Check if we're hitting platform data / a lookup thing
218
+ assoc = v.prefix_assocs[idx]
219
+ # belongs_to some lookup (reference) data
220
+ if assoc && reference_models.include?(assoc.class_name)
221
+ lookup_match = assoc.klass.find_by(v.name => row[key])
222
+ # Do a partial match if this column allows for it
223
+ # and we only find one matching result.
224
+ if lookup_match.nil? && partials.include?(v.titleize)
225
+ lookup_match ||= assoc.klass.where("#{v.name} LIKE '#{row[key]}%'")
226
+ lookup_match = (lookup_match.length == 1 ? lookup_match.first : nil)
227
+ end
228
+ sub_obj.send("#{path_part}=", lookup_match) unless lookup_match.nil?
229
+ # Reference data from the platform level means we stop here
230
+ sub_obj = nil
231
+ break
232
+ end
233
+ # This works for belongs_to or has_one. has_many gets sorted below.
234
+ # Get existing related object, or create a new one
235
+ if (sub_next = sub_obj.send(path_part)).nil?
236
+ is_has_one = assoc.is_a?(ActiveRecord::Reflection::HasOneReflection)
237
+ klass = Object.const_get(assoc&.class_name)
238
+ sub_next = if is_has_one
239
+ has_ones << v.path
240
+ klass.new
241
+ else
242
+ # Try to find a unique item if one is referenced
243
+ trim_prefix = v.titleize[0..-(v.name.length + 2)]
244
+ begin
245
+ sub_unique = assoc.klass.valid_uniques(uniques, cols, starred, import_columns, all, trim_prefix)
246
+ rescue ::DutyFree::NoUniqueColumnError
247
+ sub_unique = nil
248
+ end
249
+ # Find by all corresponding columns
250
+ criteria = sub_unique&.inject({}) do |s, v|
251
+ s[v.first.to_sym] = row[v.last]
252
+ s
253
+ end
254
+ # Try looking up this belongs_to object through ActiveRecord
255
+ sub_bt = assoc.klass.find_by(criteria) if criteria
256
+ sub_bt || sub_obj.send("#{path_part}=", klass.new(criteria || {}))
257
+ end
258
+ end
259
+ # Look for possible missing polymorphic detail
260
+ if assoc.is_a?(ActiveRecord::Reflection::ThroughReflection) &&
261
+ (delegate = assoc.send(:delegate_reflection)&.active_record&.reflect_on_association(assoc.source_reflection_name)) &&
262
+ delegate.options[:polymorphic]
263
+ polymorphics << { parent: sub_next, child: sub_obj, type_col: delegate.foreign_type, id_col: delegate.foreign_key.to_s }
264
+ end
265
+ # From a has_many?
266
+ if sub_next.is_a?(ActiveRecord::Associations::CollectionProxy)
267
+ # Try to find a unique item if one is referenced
268
+ # %%% There is possibility that when bringing in related classes using a nil
269
+ # in IMPORT_COLUMNS[:all] that this will break. Need to test deeply nested things.
270
+ start = (v.pre_prefix.blank? ? 0 : v.pre_prefix.length)
271
+ trim_prefix = v.titleize[start..-(v.name.length + 2)]
272
+ puts sub_next.klass
273
+ sub_unique = sub_next.klass.valid_uniques(uniques, cols, starred, import_columns, all, trim_prefix)
274
+ # Find by all corresponding columns
275
+ criteria = sub_unique.each_with_object({}) { |v, s| s[v.first.to_sym] = row[v.last]; }
276
+ sub_hm = sub_next.find do |hm_obj|
277
+ is_good = true
278
+ criteria.each do |k, v|
279
+ if hm_obj.send(k).to_s != v.to_s
280
+ is_good = false
281
+ break
282
+ end
283
+ end
284
+ is_good
285
+ end
286
+ # Try looking it up through ActiveRecord
287
+ sub_hm = sub_next.find_by(criteria) if sub_hm.nil?
288
+ # If still not found then create a new related object using this has_many collection
289
+ sub_next = sub_hm || sub_next.new(criteria)
290
+ end
291
+ unless sub_next.nil?
292
+ # if sub_next.class.name == devise_class && # only for Devise users
293
+ # sub_next.email =~ Devise.email_regexp
294
+ # if existing.include?([sub_next.email])
295
+ # User already exists
296
+ # else
297
+ # sub_next.invite!
298
+ # end
299
+ # end
300
+ sub_objects[this_path] = sub_next if this_path.present?
301
+ end
302
+ end
303
+ sub_obj = sub_next unless sub_next.nil?
304
+ end
305
+ next if sub_obj.nil?
306
+
307
+ sym = "#{v.name}=".to_sym
308
+ sub_class = sub_obj.class
309
+ next unless sub_obj.respond_to?(sym)
310
+
311
+ col_type = sub_class.columns_hash[v.name.to_s]&.type
312
+ if col_type.nil? && (virtual_columns = import_columns[:virtual_columns]) &&
313
+ (virtual_columns = virtual_columns[this_path] || virtual_columns)
314
+ col_type = virtual_columns[v.name]
315
+ end
316
+ if col_type == :boolean
317
+ if row[key].nil?
318
+ # Do nothing when it's nil
319
+ elsif %w[yes y].include?(row[key]&.downcase) # Used to cover 'true', 't', 'on'
320
+ row[key] = true
321
+ elsif %w[no n].include?(row[key]&.downcase) # Used to cover 'false', 'f', 'off'
322
+ row[key] = false
323
+ else
324
+ row_errors[v.name] ||= []
325
+ row_errors[v.name] << "Boolean value \"#{row[key]}\" in column #{key + 1} not recognized"
326
+ end
327
+ end
328
+ sub_obj.send(sym, row[key])
329
+ # else
330
+ # puts " #{sub_class.name} doesn't respond to #{sym}"
331
+ end
332
+ # Try to save a final sub-object if one exists
333
+ sub_obj.save if sub_obj && this_path && !is_has_one && sub_obj.valid?
334
+
335
+ # Wire up has_one associations
336
+ has_ones.each do |hasone|
337
+ parent = sub_objects[hasone[0..-2].map(&:to_s).join(',')] || obj
338
+ hasone_object = sub_objects[hasone.map(&:to_s).join(',')]
339
+ parent.send("#{hasone[-1]}=", hasone_object) if parent.new_record? || hasone_object.valid?
340
+ end
341
+
342
+ # Reinstate any missing polymorphic _type and _id values
343
+ polymorphics.each do |poly|
344
+ if !poly[:parent].new_record? || poly[:parent].save
345
+ poly[:child].send("#{poly[:type_col]}=".to_sym, poly[:parent].class.name)
346
+ poly[:child].send("#{poly[:id_col]}=".to_sym, poly[:parent].id)
347
+ end
348
+ end
349
+
350
+ # Give a window of opportinity to tweak user objects controlled by Devise
351
+ is_do_save = before_devise_save(obj, existing) if before_devise_save && obj.class.name == devise_class
352
+
353
+ if obj.valid?
354
+ obj.save if is_do_save
355
+ # Snag back any changes to the unique columns. (For instance, Devise downcases email addresses.)
356
+ existing_unique = valid_unique.keys.inject([]) { |s, v| s << obj.send(v) }
357
+ # Update the duplicate counts and inserted / updated results
358
+ counts[existing_unique] << row_num
359
+ (is_insert ? inserts : updates) << { row_num => existing_unique } if is_do_save
360
+ # Track this new object so we can properly sense any duplicates later
361
+ existing[existing_unique] = obj.id
362
+ else
363
+ row_errors.merge! obj.errors.messages
364
+ end
365
+ errors << { row_num => row_errors } unless row_errors.empty?
366
+ end
367
+ end
368
+ duplicates = counts.inject([]) do |s, v|
369
+ s + v.last[1..-1].map { |line_num| { line_num => v.first } } if v.last.count > 1
370
+ end
371
+ # Check to see if they want to do anything before the whole import
372
+ ret = { inserted: inserts, updated: updates, duplicates: duplicates, errors: errors }
373
+ if @after_import ||= (import_columns[:after_import]) # || some generic after_import)
374
+ ret = ret2 if (ret2 = @after_import.call(ret)).is_a?(Hash)
375
+ end
376
+ end
377
+ ret
378
+ end
379
+
380
+ # Friendly column names that end up in the first row of the CSV
381
+ # Required columns get prefixed with a *
382
+ def friendly_columns(import_columns = self::IMPORT_COLUMNS)
383
+ requireds = (import_columns[:required] || [])
384
+ template_columns(import_columns).map do |col|
385
+ is_required = requireds.include?(col)
386
+ col = col.to_s.titleize
387
+ # Alias-ify the full column names
388
+ aliases = (import_columns[:as] || [])
389
+ aliases.each do |k, v|
390
+ if col.start_with?(v)
391
+ col = k + col[v.length..-1]
392
+ break
393
+ end
394
+ end
395
+ (is_required ? '* ' : '') + col
396
+ end
397
+ end
398
+
399
+ # The snake-cased column alias names used in the query to export data
400
+ def template_columns(import_columns = nil)
401
+ if @template_import_columns != import_columns
402
+ @template_import_columns = import_columns
403
+ @template_detail_columns = nil
404
+ end
405
+ @template_detail_columns ||= recurse_def(import_columns[:all], import_columns).first.map(&:to_sym)
406
+ end
407
+
408
+ # For use with importing, based on the provided column list calculate all valid combinations
409
+ # of unique columns. If there is no valid combination, throws an error.
410
+ def valid_uniques(uniques, cols, starred, import_columns, all = nil, trim_prefix = '')
411
+ col_name_offset = (trim_prefix.blank? ? 0 : trim_prefix.length + 1)
412
+ @valid_uniques ||= {} # Fancy memoisation
413
+ col_list = cols.join('|')
414
+ unless (vus = @valid_uniques[col_list])
415
+ # Find all unique combinations that are available based on incoming columns, and
416
+ # pair them up with column number mappings.
417
+ template_column_objects = recurse_def(all || import_columns[:all], import_columns).first
418
+ available = if trim_prefix.blank?
419
+ template_column_objects.select { |col| col.pre_prefix.blank? && col.prefix.blank? }
420
+ else
421
+ trim_prefix_snake = trim_prefix.downcase.tr(' ', '_')
422
+ template_column_objects.select do |col|
423
+ trim_prefix_snake == ::DutyFree::Util._prefix_join([col.pre_prefix, col.prefix], '_').tr('.', '_')
424
+ end
425
+ end.map { |avail| avail.name.to_s.titleize }
426
+ vus = defined_uniques(uniques, cols, starred).select do |k, _v|
427
+ is_good = true
428
+ k.each do |k_col|
429
+ unless k_col.start_with?(trim_prefix) && available.include?(k_col[col_name_offset..-1])
430
+ is_good = false
431
+ break
432
+ end
433
+ end
434
+ is_good
435
+ end
436
+ @valid_uniques[col_list] = vus
437
+ end
438
+
439
+ # Make sure they have at least one unique combination to take cues from
440
+ raise ::DutyFree::NoUniqueColumnError, I18n.t('import.no_unique_column_error') if vus.empty?
441
+
442
+ # Convert the first entry to a simplified hash, such as:
443
+ # {[:investigator_institutions_name, :investigator_institutions_email] => [8, 9], ...}
444
+ # to {:name => 8, :email => 9}
445
+ key, val = vus.first
446
+ ret = {}
447
+ key.each_with_index do |k, idx|
448
+ ret[k[col_name_offset..-1].downcase.tr(' ', '_').to_sym] = val[idx]
449
+ end
450
+ ret
451
+ end
452
+
453
+ private
454
+
455
+ def defined_uniques(uniques, cols = [], starred = [])
456
+ @defined_uniques ||= {}
457
+ unless (defined_uniques = @defined_uniques[cols])
458
+ utilised = {} # Track columns that have been referenced thusfar
459
+ defined_uniques = uniques.each_with_object({}) do |unique, s|
460
+ if unique.is_a?(Array)
461
+ key = []
462
+ value = []
463
+ unique.each do |unique_part|
464
+ val = cols.index(unique_part_name = unique_part.to_s.titleize)
465
+ next if val.nil?
466
+
467
+ key << unique_part_name
468
+ value << val
469
+ end
470
+ unless key.empty?
471
+ s[key] = value
472
+ utilised[key] = nil
473
+ end
474
+ else
475
+ val = cols.index(unique_part_name = unique.to_s.titleize)
476
+ unless val.nil?
477
+ s[[unique_part_name]] = [val]
478
+ utilised[[unique_part_name]] = nil
479
+ end
480
+ end
481
+ end
482
+ (starred - utilised.keys).each { |star| defined_uniques[[star]] = [cols.index(star)] }
483
+ @defined_uniques[cols] = defined_uniques
484
+ end
485
+ defined_uniques
486
+ end
487
+
488
+ # Recurse and return two arrays -- one with all columns in sequence, and one a hierarchy of
489
+ # nested hashes to be used with ActiveRecord's .joins() to facilitate export.
490
+ def recurse_def(array, import_columns, assocs = [], joins = [], pre_prefix = '', prefix = '')
491
+ # Confirm we can actually navigate through this association
492
+ prefix_assoc = (assocs.last&.klass || self).reflect_on_association(prefix) if prefix.present?
493
+ assocs = assocs.dup << prefix_assoc unless prefix_assoc.nil?
494
+ prefixes = ::DutyFree::Util._prefix_join([pre_prefix, prefix])
495
+ array = array.inject([]) do |s, col|
496
+ s += if col.is_a?(Hash)
497
+ col.inject([]) do |s2, v|
498
+ joins << { v.first.to_sym => (joins_array = []) }
499
+ s2 += recurse_def((v.last.is_a?(Array) ? v.last : [v.last]), import_columns, assocs, joins_array, prefixes, v.first.to_sym).first
500
+ end
501
+ elsif col.nil?
502
+ if assocs.empty?
503
+ []
504
+ else
505
+ # Bring in from another class
506
+ joins << { prefix => (joins_array = []) }
507
+ # %%% Also bring in uniques and requireds
508
+ recurse_def(assocs.last.klass::IMPORT_COLUMNS[:all], import_columns, assocs, joins_array, prefixes).first
509
+ end
510
+ else
511
+ [::DutyFree::Column.new(col, pre_prefix, prefix, assocs, self, import_columns[:as])]
512
+ end
513
+ s
514
+ end
515
+ [array, joins]
516
+ end
517
+ end # module ClassMethods
518
+ end # module Extensions
519
+
520
+ class NoUniqueColumnError < ActiveRecord::RecordNotUnique
521
+ end
522
+
523
+ class LessThanHalfAreMatchingColumnsError < ActiveRecord::RecordInvalid
524
+ end
525
+ end