duty_free 1.0.0

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.
@@ -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