duty_free 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,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