interactify 0.1.0.pre.alpha.1 → 0.2.0.pre.alpha.1

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,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactify
4
+ class InteractorWiring
5
+ class ErrorContext
6
+ def previously_defined_keys
7
+ @previously_defined_keys ||= Set.new
8
+ end
9
+
10
+ def append_previously_defined_keys(keys)
11
+ keys.each do |key|
12
+ previously_defined_keys << key
13
+ end
14
+ end
15
+
16
+ def missing_keys
17
+ @missing_keys ||= {}
18
+ end
19
+
20
+ def add_promised_keys(promised_keys)
21
+ promised_keys.each do |key|
22
+ previously_defined_keys << key
23
+ end
24
+ end
25
+
26
+ def infer_missing_keys(callable)
27
+ new_keys = callable.expected_keys
28
+ not_in_previous_keys = new_keys.reject { |key| previously_defined_keys.include?(key) }
29
+
30
+ add_missing_keys(callable, not_in_previous_keys)
31
+ end
32
+
33
+ def add_missing_keys(callable, not_in_previous_keys)
34
+ return if not_in_previous_keys.empty?
35
+
36
+ missing_keys[callable] ||= Set.new
37
+ missing_keys[callable] += not_in_previous_keys
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Interactify
4
+ class InteractorWiring
5
+ class Files
6
+ attr_reader :root
7
+
8
+ def initialize(root:)
9
+ @root = root
10
+ end
11
+
12
+ def organizer_files
13
+ possible_files.select { |_, contents| organizer_file?(contents) }.map(&:first).sort
14
+ end
15
+
16
+ def interactor_files
17
+ possible_files.select { |_, contents| interactor_file?(contents) }.map(&:first).sort
18
+ end
19
+
20
+ private
21
+
22
+ def organizer_file?(code)
23
+ organizer?(code) && (interactified?(code) || vanilla_organizer?(code))
24
+ end
25
+
26
+ def interactor_file?(code)
27
+ !organizer?(code) && (interactified?(code) || vanilla_interactor?(code))
28
+ end
29
+
30
+ def vanilla_organizer?(code)
31
+ code[/include Interactor::Organizer/]
32
+ end
33
+
34
+ def vanilla_interactor?(code)
35
+ code[/include Interactor$/]
36
+ end
37
+
38
+ def interactified?(code)
39
+ code["include Interactify"]
40
+ end
41
+
42
+ def organizer?(code)
43
+ code[/^\s+organize/]
44
+ end
45
+
46
+ def possible_files
47
+ @possible_files ||= Dir.glob("#{root}/**/*.rb").map { |f| [f, File.read(f)] }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -1,305 +1,90 @@
1
- require 'active_support/all'
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/all"
4
+
5
+ require "interactify/interactor_wiring/callable_representation"
6
+ require "interactify/interactor_wiring/constants"
7
+ require "interactify/interactor_wiring/files"
2
8
 
3
9
  module Interactify
4
10
  class InteractorWiring
5
- attr_reader :root, :namespace, :ignore
11
+ attr_reader :root, :ignore
6
12
 
7
- def initialize(root: Rails.root, namespace: 'Object', ignore: [])
8
- @root = root.to_s.gsub(%r{/$}, '')
9
- @namespace = namespace
13
+ def initialize(root: Rails.root, ignore: [])
14
+ @root = root.to_s.gsub(%r{/$}, "")
10
15
  @ignore = ignore
11
16
  end
12
17
 
13
- concerning :Validation do
14
- def validate_app
15
- errors = organizers.each_with_object({}) do |organizer, all_errors|
16
- next if ignore_klass?(ignore, organizer.klass)
17
-
18
- errors = organizer.validate_callable
19
- all_errors[organizer] = errors
20
- end
18
+ def validate_app
19
+ errors = organizers.each_with_object({}) do |organizer, all_errors|
20
+ next if ignore_klass?(ignore, organizer.klass)
21
21
 
22
- format_errors(errors)
22
+ errors = organizer.validate_callable
23
+ all_errors[organizer] = errors
23
24
  end
24
25
 
25
- def format_errors(all_errors)
26
- formatted_errors = []
27
-
28
- all_errors.each do |organizer, error_context|
29
- next if ignore_klass?(ignore, organizer.klass)
30
-
31
- error_context.missing_keys.each do |interactor, missing|
32
- next if ignore_klass?(ignore, interactor.klass)
33
-
34
- formatted_errors << <<~ERROR
35
- Missing keys: #{missing.to_a.map(&:to_sym).map(&:inspect).join(', ')}
36
- in: #{interactor.klass}
37
- for: #{organizer.klass}
38
- ERROR
39
- end
40
- end
41
-
42
- formatted_errors.join("\n\n")
43
- end
44
-
45
- def ignore_klass?(ignore, klass)
46
- case ignore
47
- when Array
48
- ignore.any? { ignore_klass?(_1, klass) }
49
- when Regexp
50
- klass.to_s =~ ignore
51
- when String
52
- klass.to_s[ignore]
53
- when Proc
54
- ignore.call(klass)
55
- when Class
56
- klass <= ignore
57
- end
58
- end
26
+ format_errors(errors)
59
27
  end
60
28
 
61
- class CallableRepresentation
62
- attr_reader :filename, :klass, :wiring
29
+ def format_errors(all_errors)
30
+ formatted_errors = []
63
31
 
64
- delegate :interactor_lookup, to: :wiring
65
-
66
- def initialize(filename:, klass:, wiring:, organizer: nil)
67
- @filename = filename
68
- @klass = klass
69
- @wiring = wiring
70
- end
71
-
72
- def validate_callable(error_context: ErrorContext.new)
73
- if organizer?
74
- assign_previously_defined(error_context: error_context)
75
- validate_children(error_context: error_context)
76
- end
77
-
78
- validate_self(error_context: error_context)
79
- end
80
-
81
- def promised_keys
82
- Array(klass.contract.promises.instance_eval { @terms }.json&.rules&.keys)
83
- end
84
-
85
- def expected_keys
86
- Array(klass.contract.expectations.instance_eval { @terms }.json&.rules&.keys)
32
+ each_error(all_errors) do |missing, interactor, organizer|
33
+ format_error(missing, interactor, organizer, formatted_errors)
87
34
  end
88
35
 
89
- def all_keys
90
- expected_keys.concat(promised_keys)
91
- end
92
-
93
- def inspect
94
- "#<#{self.class.name}#{object_id} @filename=#{filename}, @klass=#{klass.name}>"
95
- end
96
-
97
- def organizer?
98
- klass.respond_to?(:organized)
99
- end
100
-
101
- def assign_previously_defined(error_context:)
102
- return unless contract?
103
-
104
- error_context.append_previously_defined_keys(all_keys)
105
- end
106
-
107
- def validate_children(error_context:)
108
- klass.organized.each do |interactor|
109
- nested_callable = interactor_lookup[interactor]
110
- next if nested_callable.nil?
111
-
112
- error_context = nested_callable.validate_callable(error_context: error_context)
113
- end
114
-
115
- error_context
116
- end
117
-
118
- private
119
-
120
- def contract?
121
- klass.ancestors.include? Interactor::Contracts
122
- end
123
-
124
- def validate_self(error_context:)
125
- return error_context unless contract?
126
-
127
- error_context.infer_missing_keys(self)
128
- error_context.add_promised_keys(promised_keys)
129
- error_context
130
- end
36
+ formatted_errors.join("\n\n")
131
37
  end
132
38
 
133
- class ErrorContext
134
- def previously_defined_keys
135
- @previously_defined_keys ||= Set.new
136
- end
137
-
138
- def append_previously_defined_keys(keys)
139
- keys.each do |key|
140
- previously_defined_keys << key
141
- end
142
- end
39
+ def each_error(all_errors)
40
+ all_errors.each do |organizer, error_context|
41
+ next if ignore_klass?(ignore, organizer.klass)
143
42
 
144
- def missing_keys
145
- @missing_keys ||= {}
146
- end
43
+ error_context.missing_keys.each do |interactor, missing|
44
+ next if ignore_klass?(ignore, interactor.klass)
147
45
 
148
- def add_promised_keys(promised_keys)
149
- promised_keys.each do |key|
150
- previously_defined_keys << key
46
+ yield missing, interactor, organizer
151
47
  end
152
48
  end
153
-
154
- def infer_missing_keys(callable)
155
- new_keys = callable.expected_keys
156
- not_in_previous_keys = new_keys.reject { |key| previously_defined_keys.include?(key) }
157
-
158
- add_missing_keys(callable, not_in_previous_keys)
159
- end
160
-
161
- def add_missing_keys(callable, not_in_previous_keys)
162
- return if not_in_previous_keys.empty?
163
-
164
- missing_keys[callable] ||= Set.new
165
- missing_keys[callable] += not_in_previous_keys
166
- end
167
49
  end
168
50
 
169
- concerning :Constants do
170
- def organizers
171
- @organizers ||= organizer_files.flat_map do |f|
172
- next if f[/interactor_organizer_contracts/] || f[/interactor_contracts/]
173
-
174
- callables_in_file(f)
175
- end.compact.select(&:organizer?)
176
- end
177
-
178
- def interactors
179
- @interactors ||= interactor_files.flat_map do |f|
180
- callables_in_file(f)
181
- end.compact.reject(&:organizer?)
182
- end
183
-
184
- def callables_in_file(f)
185
- @callables_in_file ||= {}
186
-
187
- @callables_in_file[f] ||= _callables_in_file(f)
188
- end
189
-
190
- def _callables_in_file(f)
191
- constant = constant_for(f)
192
- return if constant == Interactify
193
-
194
- internal_klasses = internal_constants_for(constant)
195
-
196
- ([constant] + internal_klasses).map { |k| new_callable(f, k, self) }
197
- end
198
-
199
- def internal_constants_for(constant)
200
- constant
201
- .constants
202
- .map { |sym| constant_from_symbol(constant, sym) }
203
- .select { |pk| interactor_klass?(pk) }
204
- end
205
-
206
- def constant_from_symbol(constant, symbol)
207
- constant.module_eval do
208
- symbol.to_s.constantize
209
- rescue StandardError
210
- "#{constant.name}::#{symbol}".constantize rescue nil
211
- end
212
- end
213
-
214
- def interactor_klass?(object)
215
- return unless object.is_a? Class
216
-
217
- object.ancestors.include?(Interactor) || object.ancestors.include?(Interactor::Organizer)
218
- end
219
-
220
- def new_callable(filename, klass, wiring)
221
- CallableRepresentation.new(filename: filename, klass: klass, wiring: wiring)
222
- end
223
-
224
- def interactor_lookup
225
- @interactor_lookup ||= (interactors + organizers).index_by(&:klass)
226
- end
227
-
228
- private
229
-
230
- def constant_for(filename)
231
- require filename
232
-
233
- underscored_klass_name = underscored_klass_name_without_outer_namespace(filename)
234
- underscored_klass_name = trim_rails_folder underscored_klass_name
235
-
236
- klass_name = underscored_klass_name.classify
237
-
238
- should_pluralize = filename[underscored_klass_name.pluralize]
239
- klass_name = klass_name.pluralize if should_pluralize
240
-
241
- outer_namespace_constant.const_get(klass_name)
242
- end
243
-
244
- # Example:
245
- # trim_rails_folder("interactors/namespace/sub_namespace/class_name.rb")
246
- # => "namespace/sub_namespace/class_name.rb"
247
- def trim_rails_folder(filename)
248
- rails_folders = Dir.glob(Interactify.root / '*').map { |f| File.basename(f) }
249
-
250
- rails_folders.each do |folder|
251
- regexable_folder = Regexp.quote("#{folder}/")
252
- regex = /^#{regexable_folder}/
253
-
254
- return filename.gsub(regex, '') if filename.match?(regex)
255
- end
256
-
257
- filename
258
- end
259
-
260
- # Example:
261
- # "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
262
- # "/namespace/sub_namespace/class_name.rb"
263
- # ['', 'namespace', 'sub_namespace', 'class_name.rb']
264
- # ['namespace', 'sub_namespace', 'class_name.rb']
265
- # remove outernamespace (SpecSupport)
266
- def underscored_klass_name_without_outer_namespace(filename)
267
- filename.to_s # "/home/code/something/app/interactors/namespace/sub_namespace/class_name.rb"
268
- .gsub(root.to_s, '') # "/namespace/sub_namespace/class_name.rb"
269
- .gsub('/concerns', '') # concerns directory is ignored by Zeitwerk
270
- .split('/') # "['', 'namespace', 'sub_namespace', 'class_name.rb']
271
- .compact_blank # "['namespace', 'sub_namespace', 'class_name.rb']
272
- .reject.with_index { |segment, i| i.zero? && segment == namespace }
273
- .join('/') # 'namespace/sub_namespace/class_name.rb'
274
- .gsub(/\.rb\z/, '') # 'namespace/sub_namespace/class_name'
275
- end
276
-
277
- def outer_namespace_constant
278
- @outer_namespace_constant ||= Object.const_get(namespace)
279
- end
51
+ def format_error(missing, interactor, organizer, formatted_errors)
52
+ formatted_errors << <<~ERROR
53
+ Missing keys: #{missing.to_a.map(&:to_sym).map(&:inspect).join(', ')}
54
+ expected in: #{interactor.klass}
55
+ called by: #{organizer.klass}
56
+ ERROR
280
57
  end
281
58
 
282
- concerning :Files do
283
- def organizer_files
284
- possible_files.select { |_, contents| organizer_file?(contents) }.map(&:first).sort
59
+ def ignore_klass?(ignore, klass)
60
+ case ignore
61
+ when Array
62
+ ignore.any? { ignore_klass?(_1, klass) }
63
+ when Regexp
64
+ klass.to_s =~ ignore
65
+ when String
66
+ klass.to_s[ignore]
67
+ when Proc
68
+ ignore.call(klass)
69
+ when Class
70
+ klass <= ignore
285
71
  end
72
+ end
286
73
 
287
- def interactor_files
288
- possible_files.select { |_, contents| interactor_file?(contents) }.map(&:first).sort
289
- end
74
+ delegate :organizers, :interactors, :interactor_lookup, to: :constants
290
75
 
291
- def organizer_file?(file_contents)
292
- (file_contents['include Interactify'] || file_contents[/include Interactor::Organizer/]) &&
293
- file_contents[/^\s+organize/]
294
- end
76
+ def constants
77
+ @constants ||= Constants.new(
78
+ root:,
79
+ organizer_files:,
80
+ interactor_files:
81
+ )
82
+ end
295
83
 
296
- def interactor_file?(file_contents)
297
- file_contents['include Interactify'] || file_contents[/include Interactor$/]
298
- end
84
+ delegate :organizer_files, :interactor_files, to: :files
299
85
 
300
- def possible_files
301
- @possible_files ||= Dir.glob("#{root}/**/*.rb").map { |f| [f, File.read(f)] }
302
- end
86
+ def files
87
+ @files ||= Files.new(root:)
303
88
  end
304
89
  end
305
90
  end
@@ -1,5 +1,9 @@
1
- require 'sidekiq'
2
- require 'sidekiq/job'
1
+ # frozen_string_literal: true
2
+
3
+ require "sidekiq"
4
+ require "sidekiq/job"
5
+
6
+ require "interactify/async_job_klass"
3
7
 
4
8
  module Interactify
5
9
  class JobMaker
@@ -13,93 +17,40 @@ module Interactify
13
17
  end
14
18
 
15
19
  concerning :JobClass do
16
- def job_class
17
- @job_class ||= define_job_class
20
+ def job_klass
21
+ @job_klass ||= define_job_klass
18
22
  end
19
23
 
20
24
  private
21
25
 
22
- def define_job_class
26
+ def define_job_klass
23
27
  this = self
24
28
 
25
29
  invalid_keys = this.opts.symbolize_keys.keys - %i[queue retry dead backtrace pool tags]
26
30
 
27
31
  raise ArgumentError, "Invalid keys: #{invalid_keys}" if invalid_keys.any?
28
32
 
29
- job_class = Class.new do
33
+ build_job_klass(opts).tap do |klass|
34
+ klass.const_set(:JOBABLE_OPTS, opts)
35
+ klass.const_set(:JOBABLE_METHOD_NAME, method_name)
36
+ end
37
+ end
38
+
39
+ def build_job_klass(opts)
40
+ Class.new do
30
41
  include Sidekiq::Job
31
42
 
32
- sidekiq_options(this.opts)
43
+ sidekiq_options(opts)
33
44
 
34
45
  def perform(...)
35
46
  self.class.module_parent.send(self.class::JOBABLE_METHOD_NAME, ...)
36
47
  end
37
48
  end
38
-
39
- job_class.const_set(:JOBABLE_OPTS, opts)
40
- job_class.const_set(:JOBABLE_METHOD_NAME, method_name)
41
- job_class
42
49
  end
43
50
  end
44
51
 
45
- concerning :AsyncJobClass do
46
- def async_job_class
47
- klass = Class.new do
48
- include Interactor
49
- include Interactor::Contracts
50
- end
51
-
52
- attach_call(klass)
53
- attach_call!(klass)
54
-
55
- klass
56
- end
57
-
58
- def args(context)
59
- args = context.to_h.stringify_keys
60
-
61
- return args unless container_klass.respond_to?(:contract)
62
-
63
- restrict_to_optional_or_keys_from_contract(args)
64
- end
65
-
66
- private
67
-
68
- def attach_call(async_job_class)
69
- # e.g. SomeInteractor::AsyncWithSuffix.call(foo: 'bar')
70
- async_job_class.send(:define_singleton_method, :call) do |context|
71
- call!(context)
72
- end
73
- end
74
-
75
- def attach_call!(async_job_class)
76
- this = self
77
-
78
- # e.g. SomeInteractor::AsyncWithSuffix.call!(foo: 'bar')
79
- async_job_class.send(:define_singleton_method, :call!) do |context|
80
- # e.g. SomeInteractor::JobWithSuffix
81
- job_klass = this.container_klass.const_get("Job#{this.klass_suffix}")
82
-
83
- # e.g. SomeInteractor::JobWithSuffix.perform_async({foo: 'bar'})
84
- job_klass.perform_async(this.args(context))
85
- end
86
- end
87
-
88
- def restrict_to_optional_or_keys_from_contract(args)
89
- keys = container_klass
90
- .contract
91
- .expectations
92
- .instance_eval { @terms }
93
- .schema
94
- .key_map
95
- .to_dot_notation
96
- .map(&:to_s)
97
-
98
- optional = Array(container_klass.optional_attrs).map(&:to_s)
99
- keys += optional
100
-
101
- args.slice(*keys)
102
- end
52
+ def async_job_klass
53
+ AsyncJobKlass.new(container_klass:, klass_suffix:).async_job_klass
103
54
  end
104
55
  end
105
56
  end
@@ -1,4 +1,6 @@
1
- require 'interactify/job_maker'
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/job_maker"
2
4
 
3
5
  module Interactify
4
6
  module Jobable
@@ -18,7 +20,7 @@ module Interactify
18
20
 
19
21
  to_call = defined?(super_klass::Async) ? :interactor_job : :job_calling
20
22
 
21
- klass.send(to_call, opts: opts, method_name: jobable_method_name)
23
+ klass.send(to_call, opts:, method_name: jobable_method_name)
22
24
  super(klass)
23
25
  end
24
26
  end
@@ -51,18 +53,18 @@ module Interactify
51
53
  # obviously you will need to be aware that later interactors
52
54
  # in an interactor chain cannot depend on the result of the async
53
55
  # interactor
54
- def interactor_job(method_name: :call!, opts: {}, klass_suffix: '')
56
+ def interactor_job(method_name: :call!, opts: {}, klass_suffix: "")
55
57
  job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
56
58
  # with WhateverInteractor::Job you can perform the interactor as a job
57
59
  # from sidekiq
58
60
  # e.g. WhateverInteractor::Job.perform_async(...)
59
- const_set("Job#{klass_suffix}", job_maker.job_class)
61
+ const_set("Job#{klass_suffix}", job_maker.job_klass)
60
62
 
61
63
  # with WhateverInteractor::Async you can call WhateverInteractor::Job
62
64
  # in an organizer oro on its oen using normal interactor call call! semantics
63
65
  # e.g. WhateverInteractor::Async.call(...)
64
66
  # WhateverInteractor::Async.call!(...)
65
- const_set("Async#{klass_suffix}", job_maker.async_job_class)
67
+ const_set("Async#{klass_suffix}", job_maker.async_job_klass)
66
68
  end
67
69
 
68
70
  # if this was defined in ExampleClass this creates the following class
@@ -80,10 +82,10 @@ module Interactify
80
82
  # # the following class is created that you can use to enqueue a job
81
83
  # in the sidekiq yaml file
82
84
  # ExampleClass::Job.some_method
83
- def job_calling(method_name:, opts: {}, klass_suffix: '')
85
+ def job_calling(method_name:, opts: {}, klass_suffix: "")
84
86
  job_maker = JobMaker.new(container_klass: self, opts:, method_name:, klass_suffix:)
85
87
 
86
- const_set("Job#{klass_suffix}", job_maker.job_class)
88
+ const_set("Job#{klass_suffix}", job_maker.job_klass)
87
89
  end
88
90
  end
89
91
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "interactify/contract_failure"
4
+
5
+ module Interactify
6
+ class MismatchingPromiseError < ContractFailure
7
+ def initialize(interactor, promising, promised_keys)
8
+ super <<~MESSAGE.chomp
9
+ #{interactor} does not promise:
10
+ #{promising.inspect}
11
+
12
+ Actual promises are:
13
+ #{promised_keys.inspect}
14
+ MESSAGE
15
+ end
16
+ end
17
+ end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Interactify
2
4
  module OrganizerCallMonkeyPatch
3
5
  extend ActiveSupport::Concern
@@ -30,8 +32,11 @@ module Interactify
30
32
  def call
31
33
  self.class.organized.each do |interactor|
32
34
  instance = interactor.new(context)
33
- instance.instance_variable_set(:@_interactor_called_by_non_bang_method,
34
- @_interactor_called_by_non_bang_method)
35
+
36
+ instance.instance_variable_set(
37
+ :@_interactor_called_by_non_bang_method,
38
+ @_interactor_called_by_non_bang_method
39
+ )
35
40
 
36
41
  instance.tap(&:run!)
37
42
  end