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,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # before hook for Cucumber
4
+ Before do
5
+ DutyFree.enabled = false
6
+ DutyFree.request.enabled = true
7
+ DutyFree.request.whodunnit = nil
8
+ DutyFree.request.controller_info = {} if defined?(::Rails)
9
+ end
10
+
11
+ module DutyFree
12
+ module Cucumber
13
+ # Helper method for enabling DutyFree in Cucumber features.
14
+ module Extensions
15
+ def with_df_importing
16
+ was_enabled = ::DutyFree.enabled?
17
+ ::DutyFree.enabled = true
18
+ begin
19
+ yield
20
+ ensure
21
+ ::DutyFree.enabled = was_enabled
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ World DutyFree::Cucumber::Extensions
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # require "duty_free/frameworks/rails/controller"
4
+ require 'duty_free/frameworks/rails/engine'
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DutyFree
4
+ module Rails
5
+ # Extensions to rails controllers. Provides convenient ways to pass certain
6
+ # information to the model layer, with `controller_info` and `whodunnit`.
7
+ # Also includes a convenient on/off switch,
8
+ # `duty_free_enabled_for_controller`.
9
+ module Controller
10
+ def self.included(controller)
11
+ controller.before_action(
12
+ :set_duty_free_enabled_for_controller,
13
+ :set_duty_free_controller_info
14
+ )
15
+ end
16
+
17
+ protected
18
+
19
+ # Returns the user who is responsible for any changes that occur.
20
+ # By default this calls `current_user` and returns the result.
21
+ #
22
+ # Override this method in your controller to call a different
23
+ # method, e.g. `current_person`, or anything you like.
24
+ #
25
+ # @api public
26
+ def user_for_duty_free
27
+ return unless defined?(current_user)
28
+
29
+ ActiveSupport::VERSION::MAJOR >= 4 ? current_user.try!(:id) : current_user.try(:id)
30
+ rescue NoMethodError
31
+ current_user
32
+ end
33
+ end
34
+ end
35
+ end
36
+
37
+ if defined?(::ActionController)
38
+ ::ActiveSupport.on_load(:action_controller) do
39
+ include ::DutyFree::Rails::Controller
40
+ end
41
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DutyFree
4
+ module Rails
5
+ # See http://guides.rubyonrails.org/engines.html
6
+ class Engine < ::Rails::Engine
7
+ # paths['app/models'] << 'lib/duty_free/frameworks/active_record/models'
8
+ config.duty_free = ActiveSupport::OrderedOptions.new
9
+ initializer 'duty_free.initialisation' do |app|
10
+ DutyFree.enabled = app.config.duty_free.fetch(:enabled, true)
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/core'
4
+ require 'rspec/matchers'
5
+ # require "duty_free/frameworks/rspec/helpers"
6
+
7
+ RSpec.configure do |config|
8
+ # config.include ::DutyFree::RSpec::Helpers::InstanceMethods
9
+ # config.extend ::DutyFree::RSpec::Helpers::ClassMethods
10
+
11
+ # config.before(:each) do
12
+ # ::DutyFree.enabled = false
13
+ # end
14
+
15
+ # config.before(:each, df_importing: true) do
16
+ # ::DutyFree.enabled = true
17
+ # end
18
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DutyFree
4
+ module Serializers
5
+ # An alternate serializer for, e.g. `versions.object`.
6
+ module JSON
7
+ extend self # makes all instance methods become module methods as well
8
+
9
+ def load(string)
10
+ ActiveSupport::JSON.decode string
11
+ end
12
+
13
+ def dump(object)
14
+ ActiveSupport::JSON.encode object
15
+ end
16
+
17
+ # Returns a SQL LIKE condition to be used to match the given field and
18
+ # value in the serialized object.
19
+ def where_object_condition(arel_field, field, value)
20
+ # Convert to JSON to handle strings and nulls correctly.
21
+ json_value = value.to_json
22
+
23
+ # If the value is a number, we need to ensure that we find the next
24
+ # character too, which is either `,` or `}`, to ensure that searching
25
+ # for the value 12 doesn't yield false positives when the value is
26
+ # 123.
27
+ if value.is_a? Numeric
28
+ arel_field.matches("%\"#{field}\":#{json_value},%")
29
+ .or(arel_field.matches("%\"#{field}\":#{json_value}}%"))
30
+ else
31
+ arel_field.matches("%\"#{field}\":#{json_value}%")
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module DutyFree
6
+ module Serializers
7
+ # The default serializer for, e.g. `versions.object`.
8
+ module YAML
9
+ extend self # makes all instance methods become module methods as well
10
+
11
+ def load(string)
12
+ ::YAML.safe_load string
13
+ end
14
+
15
+ def dump(object)
16
+ ::YAML.dump object
17
+ end
18
+
19
+ # Returns a SQL LIKE condition to be used to match the given field and
20
+ # value in the serialized object.
21
+ def where_object_condition(arel_field, field, value)
22
+ arel_field.matches("%\n#{field}: #{value}\n%")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,315 @@
1
+ # frozen_string_literal: true
2
+
3
+ # :nodoc:
4
+ module DutyFree
5
+ module SuggestTemplate
6
+ module ClassMethods
7
+ # Helpful suggestions to get started creating a template
8
+ # Pass in -1 for hops if you want to traverse all possible links
9
+ def suggest_template(hops = 0, do_has_many = false, show_output = true, this_klass = self)
10
+ ::DutyFree.instance_variable_set(:@errored_assocs, [])
11
+ ::DutyFree.instance_variable_set(:@errored_columns, [])
12
+ uniques, _required = ::DutyFree::SuggestTemplate._suggest_unique_column(this_klass, nil, '')
13
+ template, required = ::DutyFree::SuggestTemplate._suggest_template(hops, do_has_many, this_klass)
14
+ template = {
15
+ uniques: uniques,
16
+ required: required.map(&:to_sym),
17
+ all: template,
18
+ as: {}
19
+ }
20
+ # puts "Errors: #{::DutyFree.instance_variable_get(:@errored_assocs).inspect}"
21
+
22
+ if show_output
23
+ path = this_klass.name.split('::').map(&:underscore).join('/')
24
+ puts "\n# Place the following into app/models/#{path}.rb:"
25
+ arguments = method(__method__).parameters[0..2].map { |_, name| binding.local_variable_get(name).to_s }
26
+ puts "# Generated by: #{this_klass.name}.suggest_template(#{arguments.join(', ')})"
27
+ ::DutyFree::SuggestTemplate._template_pretty_print(template)
28
+ puts '# ------------------------------------------'
29
+ puts
30
+ end
31
+ template
32
+ end
33
+ end
34
+
35
+ def self._suggest_template(hops, do_has_many, this_klass, poison_links = [], path = '')
36
+ errored_assocs = ::DutyFree.instance_variable_get(:@errored_assocs)
37
+ this_primary_key = Array(this_klass.primary_key)
38
+ # Find all associations, and track all belongs_tos
39
+ this_belongs_tos = []
40
+ assocs = {}
41
+ this_klass.reflect_on_all_associations.each do |assoc|
42
+ # PolymorphicReflection AggregateReflection RuntimeReflection
43
+ is_belongs_to = assoc.is_a?(ActiveRecord::Reflection::BelongsToReflection)
44
+ # Figure out if it's belongs_to, has_many, or has_one
45
+ belongs_to_or_has_many =
46
+ if is_belongs_to
47
+ 'belongs_to'
48
+ elsif (is_habtm = assoc.is_a?(ActiveRecord::Reflection::HasAndBelongsToManyReflection))
49
+ 'has_and_belongs_to_many'
50
+ else
51
+ (assoc.is_a?(ActiveRecord::Reflection::HasManyReflection) ? 'has_many' : 'has_one')
52
+ end
53
+ # Always process belongs_to, and also process has_one and has_many if do_has_many is chosen.
54
+ # Skip any HMT or HABTM. (Maybe break out HABTM into a combo HM and BT in the future.)
55
+ if is_habtm
56
+ unless ActiveRecord::Base.connection.table_exists?(assoc.join_table)
57
+ puts "* In the #{this_klass.name} model there's a problem with: \"has_and_belongs_to_many :#{assoc.name}\" because join table \"#{assoc.join_table}\" does not exist. You can create it with a create_join_table migration."
58
+ end
59
+ # %%% Search for other associative candidates to use instead of this HABTM contraption
60
+ if assoc.options.include?(:through)
61
+ puts "* In the #{this_klass.name} model there's a problem with: \"has_and_belongs_to_many :#{assoc.name}\" because it includes \"through: #{assoc.options[:through].inspect}\" which is pointless and should be removed."
62
+ end
63
+ end
64
+ if (is_through = assoc.is_a?(ActiveRecord::Reflection::ThroughReflection)) && assoc.options.include?(:as)
65
+ puts "* In the #{this_klass.name} model there's a problem with: \"has_many :#{assoc.name} through: #{assoc.options[:through].inspect}\" because it also includes \"as: #{assoc.options[:as].inspect}\", so please choose either for this line to be a \"has_many :#{assoc.name} through:\" or to be a polymorphic \"has_many :#{assoc.name} as:\". It can't be both."
66
+ end
67
+ next if is_through || is_habtm || (!is_belongs_to && !do_has_many) || errored_assocs.include?(assoc)
68
+
69
+ if is_belongs_to && assoc.polymorphic? # Polymorphic belongs_to?
70
+ # Load all models
71
+ # %%% Note that this works in Rails 5.x, but may not work in Rails 6.0 and later, which uses the Zeitwerk loader by default:
72
+ Rails.configuration.eager_load_namespaces.select { |ns| ns < Rails::Application }.each(&:eager_load!)
73
+ # Find all current possible polymorphic relations
74
+ ActiveRecord::Base.descendants.each do |model|
75
+ # Skip auto-generated HABTM_DestinationModel models
76
+ next if model.respond_to?(:table_name_resolver) &&
77
+ model.name.start_with?('HABTM_') &&
78
+ model.table_name_resolver.is_a?(
79
+ ActiveRecord::Associations::Builder::HasAndBelongsToMany::JoinTableResolver::KnownClass
80
+ )
81
+
82
+ # Find applicable polymorphic has_many associations from each real model
83
+ model.reflect_on_all_associations.each do |poly_assoc|
84
+ next unless poly_assoc.is_a?(ActiveRecord::Reflection::HasManyReflection) &&
85
+ poly_assoc.inverse_of == assoc
86
+
87
+ this_belongs_tos += (fkeys = [poly_assoc.type, poly_assoc.foreign_key])
88
+ assocs["#{assoc.name}_#{poly_assoc.active_record.name.underscore}".to_sym] = [[fkeys, assoc.active_record], poly_assoc.active_record]
89
+ end
90
+ end
91
+ else
92
+ # Is it a polymorphic has_many, which is defined using as: :somethingable ?
93
+ is_polymorphic_hm = assoc.inverse_of&.polymorphic?
94
+ begin
95
+ # Standard has_one, or has_many, and belongs_to uses assoc.klass.
96
+ # Also polymorphic belongs_to uses assoc.klass.
97
+ assoc_klass = is_polymorphic_hm ? assoc.inverse_of.active_record : assoc.klass
98
+ rescue NameError # For models which cannot be found by name
99
+ end
100
+ new_assoc =
101
+ if assoc_klass.nil?
102
+ puts "* In the #{this_klass.name} model there's a problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\" because there is no \"#{assoc.class_name}\" model."
103
+ nil # Cause this one to be excluded
104
+ elsif is_belongs_to
105
+ this_belongs_tos << (fk = assoc.foreign_key.to_s)
106
+ [[[fk], assoc.active_record], assoc_klass]
107
+ else # has_many or has_one
108
+ inverse_foreign_keys = is_polymorphic_hm ? [assoc.type, assoc.foreign_key] : [assoc.inverse_of&.foreign_key&.to_s]
109
+ puts "* Missing inverse foreign key for #{assoc.inspect}" if inverse_foreign_keys.first.nil?
110
+ missing_key_columns = inverse_foreign_keys - assoc_klass.columns.map(&:name)
111
+ if missing_key_columns.empty?
112
+ # puts "Has columns #{inverse_foreign_keys.inspect}"
113
+ [[inverse_foreign_keys, assoc_klass], assoc_klass]
114
+ else
115
+ if inverse_foreign_keys.length > 1
116
+ puts "* The #{assoc_klass.name} model is missing #{missing_key_columns.join(' and ')} columns to allow it to support polymorphic inheritance."
117
+ else
118
+ print "* In the #{this_klass.name} model there's a problem with: \"#{belongs_to_or_has_many} :#{assoc.name}\"."
119
+
120
+ if (inverses = _find_belongs_tos(assoc_klass, this_klass, errored_assocs)).empty?
121
+ if inverse_foreign_keys.first.nil?
122
+ puts " Consider adding \"foreign_key: :#{this_klass.name.underscore}_id\" regarding some column in #{assoc_klass.name} to this #{belongs_to_or_has_many} entry."
123
+ else
124
+ puts " (Cannot find foreign key \"#{inverse_foreign_keys.first.inspect}\" in #{assoc_klass.name}.)"
125
+ end
126
+ else
127
+ puts " Consider adding \"#{inverses.map { |x| "inverse_of: :#{x.name}" }.join(' or ')}\" to this entry."
128
+ end
129
+ end
130
+ nil
131
+ end
132
+ end
133
+ if new_assoc.nil?
134
+ errored_assocs << assoc
135
+ else
136
+ assocs[assoc.name] = new_assoc
137
+ end
138
+ end
139
+ end
140
+
141
+ # Include all columns except for the primary key, any foreign keys, and excluded_columns
142
+ # %%% add EXCLUDED_ALL_COLUMNS || ...
143
+ excluded_columns = %w[created_at updated_at deleted_at]
144
+ template = (this_klass.columns.map(&:name) - this_primary_key - this_belongs_tos - excluded_columns)
145
+ template.map!(&:to_sym)
146
+ requireds = _find_requireds(this_klass).map { |r| "#{path}#{r}".to_sym }
147
+ # Now add the foreign keys and any has_manys in the form of references to associated models
148
+ assocs.each do |k, assoc|
149
+ # assoc.first describes this foreign key and class, and is used for a "reverse poison"
150
+ # detection so we don't fold back on ourselves
151
+ next if poison_links.include?(assoc.first)
152
+
153
+ is_has_many = (assoc.first.last == assoc.last)
154
+ # puts "#{k} #{hops}"
155
+ unique, new_requireds =
156
+ if hops.zero?
157
+ # For has_one or has_many, exclude with priority the foreign key column(s) we rode in here on
158
+ priority_excluded_columns = assoc.first.first if is_has_many
159
+ # puts "Excluded: #{priority_excluded_columns.inspect}"
160
+ _suggest_unique_column(assoc.last, priority_excluded_columns, "#{path}#{k}_")
161
+ else
162
+ new_poison_links =
163
+ if is_has_many
164
+ # has_many is simple, just exclude how we got here from the foreign table
165
+ [assoc.first]
166
+ else
167
+ # belongs_to is more involved since there may be multiple foreign keys which point
168
+ # from the foreign table to this primary one, so exclude all these links.
169
+ _find_belongs_tos(assoc.first.last, assoc.last, errored_assocs).map do |f_assoc|
170
+ [f_assoc.foreign_key.to_s, f_assoc.active_record]
171
+ end
172
+ end
173
+ # puts "New Poison: #{new_poison_links.inspect}"
174
+ _suggest_template(hops - 1, do_has_many, assoc.last, poison_links + new_poison_links, "#{path}#{k}_")
175
+ end
176
+ template << { k => unique }
177
+ requireds += new_requireds
178
+ end
179
+ [template, requireds]
180
+ end
181
+
182
+ # Find belongs_tos for this model to one more more other klasses
183
+ def self._find_belongs_tos(klass, to_klass, errored_assocs)
184
+ klass.reflect_on_all_associations.each_with_object([]) do |bt_assoc, s|
185
+ next unless bt_assoc.is_a?(ActiveRecord::Reflection::BelongsToReflection) && !errored_assocs.include?(bt_assoc)
186
+
187
+ begin
188
+ s << bt_assoc if !bt_assoc.polymorphic? && bt_assoc.klass == to_klass
189
+ rescue NameError
190
+ errored_assocs << bt_assoc
191
+ puts "* In the #{bt_assoc.active_record.name} model \"belongs_to :#{bt_assoc.name}\" could not find a model named #{bt_assoc.class_name}."
192
+ end
193
+ end
194
+ end
195
+
196
+ def self._suggest_unique_column(klass, priority_excluded_columns, path)
197
+ # %%% Try to find out if this klass already has an import template, and if so then
198
+ # bring in its first unique column set as a suggestion
199
+ # ...
200
+ # Not available, so grasping at straws, just search for any available column
201
+ # %%% add EXCLUDED_UNIQUE_COLUMNS || ...
202
+ klass_columns = klass.columns
203
+
204
+ # Requireds takes its cues from all attributes having a presence validator
205
+ requireds = _find_requireds(klass)
206
+ if priority_excluded_columns
207
+ klass_columns = klass_columns.reject { |col| priority_excluded_columns.include?(col.name) }
208
+ end
209
+ excluded_columns = %w[created_at updated_at deleted_at]
210
+ unique = [(
211
+ # Find the first text field of a required if one exists
212
+ klass_columns.find { |col| requireds.include?(col.name) && col.type == :string }&.name ||
213
+ # Find the first text field, now of a non-required, if one exists
214
+ klass_columns.find { |col| col.type == :string }&.name ||
215
+ # If no string then look for the first non-PK that is also not a foreign key or created_at or updated_at
216
+ klass_columns.find do |col|
217
+ requireds.include?(col.name) && col.name != klass.primary_key && !excluded_columns.include?(col.name)
218
+ end&.name ||
219
+ # And now the same but not a required, the first non-PK that is also not a foreign key or created_at or updated_at
220
+ klass_columns.find do |col|
221
+ col.name != klass.primary_key && !excluded_columns.include?(col.name)
222
+ end&.name ||
223
+ # Finally just accept the PK if nothing else
224
+ klass.primary_key
225
+ ).to_sym]
226
+
227
+ [unique, requireds.map { |r| "#{path}#{r}".to_sym }]
228
+ end
229
+
230
+ def self._find_requireds(klass)
231
+ errored_columns = ::DutyFree.instance_variable_get(:@errored_columns)
232
+ klass.validators.select do |v|
233
+ v.is_a?(ActiveRecord::Validations::PresenceValidator)
234
+ end.each_with_object([]) do |v, s|
235
+ v.attributes.each do |a|
236
+ attrib = a.to_s
237
+ klass_col = [klass, attrib]
238
+ next if errored_columns.include?(klass_col)
239
+
240
+ if klass.columns.map(&:name).include?(attrib)
241
+ s << attrib
242
+ else
243
+ puts "* In the #{klass.name} model \"validates_presence_of :#{attrib}\" should be removed as it does not refer to any existing column."
244
+ errored_columns << klass_col
245
+ end
246
+ end
247
+ end
248
+ end
249
+
250
+ # Show a "pretty" version of IMPORT_COLUMNS, to be placed in a model
251
+ def self._template_pretty_print(template, indent = 0, child_count = 0, is_hash_in_hash = false)
252
+ unless indent.negative?
253
+ if indent.zero?
254
+ print 'IMPORT_COLUMNS = '
255
+ else
256
+ puts unless is_hash_in_hash
257
+ end
258
+ print "#{' ' * indent unless is_hash_in_hash}{"
259
+ if indent.zero?
260
+ indent = 2
261
+ print "\n#{' ' * indent}"
262
+ else
263
+ print ' ' unless is_hash_in_hash
264
+ end
265
+ end
266
+ is_first = true
267
+ template.each do |k, v|
268
+ # Skip past this when doing a child count
269
+ child_count = _template_pretty_print(v, -10_000) if indent >= 0
270
+ if is_first
271
+ is_first = false
272
+ elsif indent == 2 || (indent >= 0 && child_count > 5)
273
+ print ",\n#{' ' * indent}" # Comma, newline, and indentation
274
+ end
275
+ if indent.negative?
276
+ child_count += 1
277
+ else
278
+ # Fairly good to troubleshoot child_count things with: "#{k}#{child_count}: "
279
+ print "#{k}: "
280
+ end
281
+ if v.is_a?(Array)
282
+ print '[' unless indent.negative?
283
+ v.each_with_index do |item, idx|
284
+ # This is where most of the commas get printed, so you can do "#{child_count}," to diagnose things
285
+ print ',' if idx.positive? && indent >= 0
286
+ if item.is_a?(Hash)
287
+ # puts '^' unless child_count < 5 || indent.negative?
288
+ child_count = _template_pretty_print(item, indent + 2, child_count)
289
+ elsif item.is_a?(Symbol)
290
+ if indent.negative?
291
+ child_count += 1
292
+ else
293
+ print ' ' if idx.positive?
294
+ print item.inspect
295
+ end
296
+ end
297
+ end
298
+ print ']' unless indent.negative?
299
+ elsif v.is_a?(Hash) # A hash in a hash
300
+ child_count = _template_pretty_print(v, indent + 2, child_count, true)
301
+ elsif v.nil?
302
+ puts 'nil' unless indent.negative?
303
+ end
304
+ end
305
+ if indent == 2
306
+ puts
307
+ indent = 0
308
+ puts '}'
309
+ elsif indent >= 0
310
+ print "#{' ' unless child_count.zero?}}"
311
+ end
312
+ child_count
313
+ end
314
+ end
315
+ end