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.
- checksums.yaml +7 -0
- data/lib/duty_free.rb +86 -0
- data/lib/duty_free/column.rb +56 -0
- data/lib/duty_free/config.rb +32 -0
- data/lib/duty_free/extensions.rb +525 -0
- data/lib/duty_free/frameworks/cucumber.rb +28 -0
- data/lib/duty_free/frameworks/rails.rb +4 -0
- data/lib/duty_free/frameworks/rails/controller.rb +41 -0
- data/lib/duty_free/frameworks/rails/engine.rb +14 -0
- data/lib/duty_free/frameworks/rspec.rb +18 -0
- data/lib/duty_free/serializers/json.rb +36 -0
- data/lib/duty_free/serializers/yaml.rb +26 -0
- data/lib/duty_free/suggest_template.rb +315 -0
- data/lib/duty_free/util.rb +73 -0
- data/lib/duty_free/version_number.rb +19 -0
- data/lib/generators/duty_free/USAGE +2 -0
- data/lib/generators/duty_free/install_generator.rb +98 -0
- data/lib/generators/duty_free/templates/add_object_changes_to_versions.rb.erb +12 -0
- data/lib/generators/duty_free/templates/create_versions.rb.erb +36 -0
- metadata +257 -0
checksums.yaml
ADDED
@@ -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
|
data/lib/duty_free.rb
ADDED
@@ -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
|