duty_free 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|