typed_operation 0.4.1 → 1.0.0.pre1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1d1be5d239791c002be2003d22a4d1a3bad636d10f5b3b923810627e5427f6d1
4
- data.tar.gz: e539e29bb32268211d6831866ee5b455f11b96b1949b0ec788a8cd0ae90cb094
3
+ metadata.gz: ff925e2cc5ce03a696a209e0ca61130bff94ddb6ce28f2670ebaaae18f1968ba
4
+ data.tar.gz: b14a0952694653f918135a57180c8c7746aeffcb14385d17aeef7a1283726e99
5
5
  SHA512:
6
- metadata.gz: 0a0cccfd3499f7cd87e73ed646163f8617125e3c07dc9259972bdbc83d50634b32b129675e9d8bcf85aaef816b61f933d096aa2c6867bcde79c88b45d43cd491
7
- data.tar.gz: 6cf1a3f0d2d7a70e57d361cdff698ddbb29505bf8177b54db9bfba83b54f64cf23983f984d990b75d2278ffbfca0c2548d2b57d8736d1acb6ac3410f58e136c6
6
+ metadata.gz: 0cb23aed491058bdefba04d75661ec4cb5110a2491ba9d4b3afc929d41e1863a09b9ac09b41bdbd6306856af6d5552f2c86c3057b53df3076d0674a9c1feafda
7
+ data.tar.gz: 143f6ba5a00d5cabadec3f7f326dc7a724fdd2666ca0dffa326cacf9f9af2f4dce797f77a727468cd919eb42a4b6de530105d6866e49797997c10ff5a36db9fe
data/README.md CHANGED
@@ -1,33 +1,476 @@
1
1
  # TypedOperation
2
2
 
3
- An implementation of a Command pattern, which is callable, and can be partially applied (curried).
3
+ An implementation of a Command pattern, which is callable, and can be partially applied.
4
4
 
5
- Inputs to the operation are specified as typed attributes using the `param` method.
5
+ Inputs to the operation are specified as typed attributes (uses [`literal`](https://github.com/joeldrapper/literal)).
6
6
 
7
- Result format of the operation is up to you, but plays nicely with `Dry::Monads`.
7
+ Type of result of the operation is up to you, eg you could use [`literal` monads](https://github.com/joeldrapper/literal) or [`Dry::Monads`](https://dry-rb.org/gems/dry-monads/1.3/).
8
8
 
9
- ### Examples:
9
+ **Note the version described here (~ 1.0.0) is not yet released on Rubygems, it is waiting for a release of `literal`)**
10
10
 
11
- A base operation class:
11
+ ## Features
12
+
13
+ - Operations can be **partially applied** or **curried**
14
+ - Operations are **callable**
15
+ - Operations can be **pattern matched** on
16
+ - Parameters:
17
+ - specified with **type constraints** (uses `literal` gem)
18
+ - can be **positional** or **named**
19
+ - can be **optional**, or have **default** values
20
+ - can be **coerced** by providing a block
21
+
22
+ ### Example
23
+
24
+ ```ruby
25
+ class ShelveBookOperation < ::TypedOperation::Base
26
+ # Parameters can be specified with `positional_param`/`named_param` or directly with the
27
+ # underlying `param` method.
28
+
29
+ # Note that you may also like to simply alias the param methods to your own preferred names:
30
+ # `positional`/`named` or `arg`/`key` for example.
31
+
32
+ # A positional parameter (positional argument passed to the operation when creating it).
33
+ positional_param :title, String
34
+ # Or if you prefer:
35
+ # `param :title, String, positional: true`
36
+
37
+ # A named parameter (keyword argument passed to the operation when creating it).
38
+ named_param :description, String
39
+ # Or if you prefer:
40
+ # `param :description, String`
41
+
42
+ named_param :author_id, Integer, &:to_i
43
+ named_param :isbn, String
44
+
45
+ # Optional parameters are specified by wrapping the type constraint in the `optional` method, or using the `optional:` option
46
+ named_param :shelf_code, optional(Integer)
47
+ # Or if you prefer:
48
+ # `named_param :shelf_code, Integer, optional: true`
49
+
50
+ named_param :category, String, default: "unknown".freeze
51
+
52
+ # to setup (optional)
53
+ def prepare
54
+ raise ArgumentError, "ISBN is invalid" unless valid_isbn?
55
+ end
56
+
57
+ # The 'work' of the operation
58
+ def call
59
+ "Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }"
60
+ end
61
+
62
+ private
63
+
64
+ def valid_isbn?
65
+ # ...
66
+ true
67
+ end
68
+ end
69
+
70
+ shelve = ShelveBookOperation.new("The Hobbit", description: "A book about a hobbit", author_id: "1", isbn: "978-0261103283")
71
+ # => #<ShelveBookOperation:0x0000000108b3e490 @attributes={:title=>"The Hobbit", :description=>"A book about a hobbit", :author_id=>1, :isbn=>"978-0261103283", :shelf_code=>nil, :category=>"unknown"}, ...
72
+
73
+ shelve.call
74
+ # => "Put away 'The Hobbit' by author ID 1"
75
+
76
+ shelve = ShelveBookOperation.with("The Silmarillion", description: "A book about the history of Middle-earth", shelf_code: 1)
77
+ # => #<TypedOperation::PartiallyApplied:0x0000000103e6f560 ...
78
+
79
+ shelve.call(author_id: "1", isbn: "978-0261102736")
80
+ # => "Put away 'The Silmarillion' by author ID 1 on shelf 1"
81
+
82
+ curried = shelve.curry
83
+ # => #<TypedOperation::Curried:0x0000000108d98a10 ...
84
+
85
+ curried.(1).("978-0261102736")
86
+ # => "Put away 'The Silmarillion' by author ID 1 on shelf 1"
87
+
88
+ shelve.call(author_id: "1", isbn: false)
89
+ # => Raises an error because isbn is invalid
90
+ # :in `initialize': Expected `false` to be of type: `String`. (Literal::TypeError)
91
+ ```
92
+
93
+ ### Partially applying parameters
94
+
95
+ ```ruby
96
+ class TestOperation < ::TypedOperation::Base
97
+ param :foo, String, positional: true
98
+ param :bar, String
99
+ param :baz, String, &:to_s
100
+
101
+ def call = "It worked! (#{foo}, #{bar}, #{baz})"
102
+ end
103
+
104
+ # Invoking the operation directly
105
+ TestOperation.("1", bar: "2", baz: 3)
106
+ # => "It worked! (1, 2, 3)"
107
+
108
+ # Partial application of parameters
109
+ partially_applied = TestOperation.with("1").with(bar: "2")
110
+ # => #<TypedOperation::PartiallyApplied:0x0000000110270248 @keyword_args={:bar=>"2"}, @operation_class=TestOperation, @positional_args=["1"]>
111
+
112
+ # You can partially apply more than one parameter at a time, and chain calls to `.with`.
113
+ # With all the required parameters set, the operation is 'prepared' and can be instantiated and called
114
+ prepared = TestOperation.with("1", bar: "2").with(baz: 3)
115
+ # => #<TypedOperation::Prepared:0x0000000110a9df38 @keyword_args={:bar=>"2", :baz=>3}, @operation_class=TestOperation, @positional_args=["1"]>
116
+
117
+ # A 'prepared' operation can instantiated & called
118
+ prepared.call
119
+ # => "It worked! (1, 2, 3)"
120
+
121
+ # You can provide additional parameters when calling call on a partially applied operation
122
+ partially_applied.call(baz: 3)
123
+ # => "It worked! (1, 2, 3)"
124
+
125
+ # Partial application can be done using `.with or `.[]`
126
+ TestOperation.with("1")[bar: "2", baz: 3].call
127
+ # => "It worked! (1, 2, 3)"
128
+
129
+ # Currying an operation, note that *all required* parameters must be provided an argument in order
130
+ TestOperation.curry.("1").("2").(3)
131
+ # => "It worked! (1, 2, 3)"
132
+
133
+ # You can also curry from an already partially applied operation, so you can set optional named parameters first.
134
+ # Note currying won't let you set optional positional parameters.
135
+ partially_applied = TestOperation.with("1")
136
+ partially_applied.curry.("2").(3)
137
+ # => "It worked! (1, 2, 3)"
138
+
139
+ # > TestOperation.with("1").with(bar: "2").call
140
+ # => Raises an error because it is PartiallyApplied and so can't be called (it is missing required args)
141
+ # "Cannot call PartiallyApplied operation TestOperation (key: test_operation), are you expecting it to be Prepared? (TypedOperation::MissingParameterError)"
142
+
143
+ TestOperation.with("1").with(bar: "2").with(baz: 3).operation
144
+ # same as > TestOperation.new("1", bar: "2", baz: 3)
145
+ # => <TestOperation:0x000000014a0048a8 ...>
146
+
147
+ # > TestOperation.with(foo: "1").with(bar: "2").operation
148
+ # => Raises an error because it is PartiallyApplied so operation can't be instantiated
149
+ # "Cannot instantiate Operation TestOperation (key: test_operation), as it is only partially applied. (TypedOperation::MissingParameterError)"
150
+ ```
151
+
152
+ ## Documentation
153
+
154
+ ### Create an operation (subclass `TypedOperation::Base`)
155
+
156
+ Create an operation by subclassing `TypedOperation::Base` and specifying the parameters the operation requires.
157
+
158
+ The subclass must implement the `#call` method which is where the operations main work is done.
159
+
160
+ The operation can also implement:
161
+
162
+ - `#prepare` - called when the operation is initialized, and after the parameters have been set
163
+
164
+ ### Specifying parameters (using `.param`)
165
+
166
+ Parameters are specified using the provided class methods (`.positional_param` and `.named_param`),
167
+ or using the underlying `.param` method.
168
+
169
+ Type constraints can be modified to make the parameter optional using `.optional`.
170
+
171
+ #### Your own aliases
172
+
173
+ Note that you may also like to alias the param methods to your own preferred names in a common base operation class.
174
+
175
+ Some possible aliases are:
176
+ - `positional`/`named`
177
+ - `arg`/`key`
178
+
179
+ For example:
180
+
181
+ ```ruby
182
+ class ApplicationOperation < ::TypedOperation::Base
183
+ class << self
184
+ alias_method :arg, :positional_param
185
+ alias_method :key, :named_param
186
+ end
187
+ end
188
+
189
+ class MyOperation < ApplicationOperation
190
+ arg :name, String
191
+ key :age, Integer
192
+ end
193
+
194
+ MyOperation.new("Steve", age: 20)
195
+ ```
196
+
197
+ #### Positional parameters (`positional: true` or `.positional_param`)
198
+
199
+ Defines a positional parameter (positional argument passed to the operation when creating it).
200
+
201
+ The following are equivalent:
202
+
203
+ - `param <param_name>, <type>, positional: true, <**options>`
204
+ - `positional_param <param_name>, <type>, <**options>`
205
+
206
+ The `<para_name>` is a symbolic name, used to create the accessor method, and when deconstructing to a hash.
207
+
208
+ The `<type>` constraint provides the expected type of the parameter (the type is a type signature compatible with `literal`).
209
+
210
+ The `<options>` are:
211
+ - `default:` - a default value for the parameter (can be a proc or a frozen value)
212
+ - `optional:` - a boolean indicating whether the parameter is optional (default: false). Note you may prefer to use the
213
+ `.optional` method instead of this option.
214
+
215
+ **Note** when positional arguments are provided to the operation, they are matched in order of definition or positional
216
+ params. Also note that you cannot define required positional parameters after optional ones.
217
+
218
+ Eg
219
+
220
+ ```ruby
221
+ class MyOperation < ::TypedOperation::Base
222
+ positional_param :name, String, positional: true
223
+ # Or alternatively => `param :name, String, positional: true`
224
+ positional_param :age, Integer, default: -> { 0 }
225
+
226
+ def call
227
+ puts "Hello #{name} (#{age})"
228
+ end
229
+ end
230
+
231
+ MyOperation.new("Steve").call
232
+ # => "Hello Steve (0)"
233
+
234
+ MyOperation.with("Steve").call(20)
235
+ # => "Hello Steve (20)"
236
+ ```
237
+
238
+ #### Named (keyword) parameters
239
+
240
+ Defines a named parameter (keyword argument passed to the operation when creating it).
241
+
242
+ The following are equivalent:
243
+ - `param <param_name>, <type>, <**options>`
244
+ - `named_param <param_name>, <type>, <**options>`
245
+
246
+ The `<para_name>` is a symbol, used as parameter name for the keyword arguments in the operation constructor, to
247
+ create the accessor method and when deconstructing to a hash.
248
+
249
+ The type constraint and options are the same as for positional parameters.
250
+
251
+ ```ruby
252
+ class MyOperation < ::TypedOperation::Base
253
+ named_param :name, String
254
+ # Or alternatively => `param :name, String`
255
+ named_param :age, Integer, default: -> { 0 }
256
+
257
+ def call
258
+ puts "Hello #{name} (#{age})"
259
+ end
260
+ end
261
+
262
+ MyOperation.new(name: "Steve").call
263
+ # => "Hello Steve (0)"
264
+
265
+ MyOperation.with(name: "Steve").call(age: 20)
266
+ # => "Hello Steve (20)"
267
+ ```
268
+
269
+ #### Using both positional and named parameters
270
+
271
+ You can use both positional and named parameters in the same operation.
272
+
273
+ ```ruby
274
+ class MyOperation < ::TypedOperation::Base
275
+ positional_param :name, String
276
+ named_param :age, Integer, default: -> { 0 }
277
+
278
+ def call
279
+ puts "Hello #{name} (#{age})"
280
+ end
281
+ end
282
+
283
+ MyOperation.new("Steve").call
284
+ # => "Hello Steve (0)"
285
+
286
+ MyOperation.new("Steve", age: 20).call
287
+ # => "Hello Steve (20)"
288
+
289
+ MyOperation.with("Steve").call(age: 20)
290
+ # => "Hello Steve (20)"
291
+ ```
292
+
293
+ #### Optional parameters (using `optional:` or `.optional`)
294
+
295
+ Optional parameters are ones that do not need to be specified for the operation to be instantiated.
296
+
297
+ An optional parameter can be specified by:
298
+ - using the `optional:` option
299
+ - using the `.optional` method around the type constraint
300
+
301
+ ```ruby
302
+ class MyOperation < ::TypedOperation::Base
303
+ param :name, String
304
+ param :age, Integer, optional: true
305
+ param :nickname, optional(String)
306
+ # ...
307
+ end
308
+
309
+ MyOperation.new(name: "Steve")
310
+ MyOperation.new(name: "Steve", age: 20)
311
+ MyOperation.new(name: "Steve", nickname: "Steve-o")
312
+ ```
313
+
314
+ This `.optional` class method effectively makes the type signature a union of the provided type and `NilClass`.
315
+
316
+ #### Coercing parameters
317
+
318
+ You can specify a block after a parameter definition to coerce the argument value.
319
+
320
+ ```ruby
321
+ param :name, String, &:to_s
322
+ param :choice, Union(FalseClass, TrueClass) do |v|
323
+ v == "y"
324
+ end
325
+ ```
326
+
327
+ #### Default values (with `default:`)
328
+
329
+ You can specify a default value for a parameter using the `default:` option.
330
+
331
+ The default value can be a proc or a frozen value. If the value is specified as `nil` then the default value is literally nil and the parameter is optional.
332
+
333
+ ```ruby
334
+ param :name, String, default: "Steve".freeze
335
+ param :age, Integer, default: -> { rand(100) }
336
+ ```
337
+
338
+ If using the directive `# frozen_string_literal: true` then you string values are frozen by default.
339
+
340
+ ### Partially applying (fixing parameters) on an operation (using `.with`)
341
+
342
+ `.with(...)` creates a partially applied operation with the provided parameters.
343
+
344
+ It is aliased to `.[]` for an alternative syntax.
345
+
346
+ Note that `.with` can take both positional and keyword arguments, and can be chained.
347
+
348
+ **An important caveat about partial application is that type checking is not done until the operation is instantiated**
349
+
350
+ ```ruby
351
+ MyOperation.new(123)
352
+ # => Raises an error as the type of the first parameter is incorrect:
353
+ # Expected `123` to be of type: `String`. (Literal::TypeError)
354
+
355
+ op = MyOperation.with(123)
356
+ # => #<TypedOperation::Prepared:0x000000010b1d3358 ...
357
+ # Does **not raise** an error, as the type of the first parameter is not checked until the operation is instantiated
358
+
359
+ op.call # or op.operation
360
+ # => Now raises an error as the type of the first parameter is incorrect and operation is instantiated
361
+ ```
362
+
363
+ ### Calling an operation (using `.call`)
364
+
365
+ An operation can be invoked by:
366
+
367
+ - instantiating it with at least required params and then calling the `#call` method on the instance
368
+ - once a partially applied operation has been prepared (all required parameters have been set), the call
369
+ method on TypedOperation::Prepared can be used to instantiate and call the operation.
370
+ - once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation
371
+ - calling `#call` on a partially applied operation and passing in any remaining required parameters
372
+
373
+ See the many examples in this document.
374
+
375
+ ### Pattern matching on an operation
376
+
377
+ `TypedOperation::Base` and `TypedOperation::PartiallyApplied` implement `deconstruct` and `deconstruct_keys` methods,
378
+ so they can be pattern matched against.
379
+
380
+ ```ruby
381
+ case MyOperation.new("Steve", age: 20)
382
+ in MyOperation[name, age]
383
+ puts "Hello #{name} (#{age})"
384
+ end
385
+
386
+ case MyOperation.new("Steve", age: 20)
387
+ in MyOperation[name:, age: 20]
388
+ puts "Hello #{name} (#{age})"
389
+ end
390
+ ```
391
+
392
+ ### Introspection of parameters & other methods
393
+
394
+ #### `.to_proc`
395
+
396
+ Get a proc that calls `.call(...)`
397
+
398
+
399
+ #### `#to_proc`
400
+
401
+ Get a proc that calls the `#call` method on an operation instance
402
+
403
+ #### `.prepared?`
404
+
405
+ Check if an operation is prepared
406
+
407
+ #### `.operation`
408
+
409
+ Return an operation instance from a Prepared operation. Will raise if called on a PartiallyApplied operation
410
+
411
+ #### `.positional_parameters`
412
+
413
+ List of the names of the positional parameters, in order
414
+
415
+ #### `.keyword_parameters`
416
+
417
+ List of the names of the keyword parameters
418
+
419
+ #### `.required_positional_parameters`
420
+
421
+ List of the names of the required positional parameters, in order
422
+
423
+ #### `.required_keyword_parameters`
424
+
425
+ List of the names of the required keyword parameters
426
+
427
+ #### `.optional_positional_parameters`
428
+
429
+ List of the names of the optional positional parameters, in order
430
+
431
+ #### `.optional_keyword_parameters`
432
+
433
+ List of the names of the optional keyword parameters
434
+
435
+
436
+ ### Using with Rails
437
+
438
+ You can use the provided generator to create an `ApplicationOperation` class in your Rails project.
439
+
440
+ You can then extend this to add extra functionality to all your operations.
441
+
442
+ This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Monads`:
12
443
 
13
444
  ```ruby
445
+ # frozen_string_literal: true
446
+
14
447
  class ApplicationOperation < ::TypedOperation::Base
448
+ # We choose to use dry-monads for our operations, so include the required modules
15
449
  include Dry::Monads[:result, :do]
16
450
 
17
- param :initiator, ::RegisteredUser, allow_nil: true
451
+ class << self
452
+ # Setup our own preferred names for the DSL methods
453
+ alias_method :positional, :positional_param
454
+ alias_method :named, :named_param
455
+ end
456
+
457
+ # Parameters common to all Operations in this application
458
+ named :initiator, optional(::User)
18
459
 
19
460
  private
20
461
 
462
+ # We setup some helper methods for our operations to use
463
+
21
464
  def succeeded(value)
22
465
  Success(value)
23
466
  end
24
467
 
25
468
  def failed_with_value(value, message: "Operation failed", error_code: nil)
26
- failed(error_code || self.class.operation_key, message, value)
469
+ failed(error_code || operation_key, message, value)
27
470
  end
28
471
 
29
472
  def failed_with_message(message, error_code: nil)
30
- failed(error_code || self.class.operation_key, message)
473
+ failed(error_code || operation_key, message)
31
474
  end
32
475
 
33
476
  def failed(error_code, message = "Operation failed", value = nil)
@@ -37,48 +480,77 @@ class ApplicationOperation < ::TypedOperation::Base
37
480
  def failed_with_code_and_value(error_code, value, message: "Operation failed")
38
481
  failed(error_code, message, value)
39
482
  end
40
- end
41
483
 
484
+ def operation_key
485
+ self.class.name
486
+ end
487
+ end
42
488
  ```
43
489
 
44
- A simple operation:
490
+ ### Using with `literal` monads
491
+
492
+ You can use the `literal` gem to provide a `Result` type for your operations.
45
493
 
46
494
  ```ruby
47
- class TestOperation < ::ApplicationOperation
48
- param :foo, String
49
- param :bar, String
50
- param :baz, String, convert: true
495
+ class MyOperation < ::TypedOperation::Base
496
+ param :account_name, String
497
+ param :owner, String
51
498
 
52
- def prepare
53
- # to setup (optional)
54
- puts "lets go!"
499
+ def call
500
+ create_account.bind do |account|
501
+ associate_owner(account).bind do
502
+ account
503
+ end
504
+ end
505
+ end
506
+
507
+ private
508
+
509
+ def create_account
510
+ # returns Literal::Success(account) or Literal::Failure(:cant_create)
511
+ Literal::Success.new(account_name)
55
512
  end
56
513
 
57
- def call
58
- succeeded("It worked!")
59
- # failed_with_message("It failed!")
514
+ def associate_owner(account)
515
+ # ...
516
+ Literal::Failure.new(:cant_associate_owner)
60
517
  end
61
518
  end
519
+
520
+ MyOperation.new(account_name: "foo", owner: "bar").call
521
+ # => Literal::Failure(:cant_associate_owner)
522
+
62
523
  ```
63
524
 
64
- ```ruby
65
- TestOperation.(foo: "1", bar: "2", baz: 3)
66
- # => Success("It worked!")
525
+ ### Using with `Dry::Monads`
67
526
 
68
- TestOperation.with(foo: "1").with(bar: "2")
69
- # => #<TypedOperation::PartiallyApplied:0x000000014a655310 @applied_args={:foo=>"1", :bar=>"2"}, @operation=TestOperation>
527
+ As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
70
528
 
71
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3)
72
- # => <TypedOperation::Prepared:0x000000012dac6498 @applied_args={:foo=>"1", :bar=>"2", :baz=>3}, @operation=TestOperation>
529
+ ```ruby
530
+ class MyOperation < ::TypedOperation::Base
531
+ include Dry::Monads[:result]
532
+ include Dry::Monads::Do.for(:call)
73
533
 
74
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).call
75
- # => Success("It worked!")
534
+ param :account_name, String
535
+ param :owner, ::Owner
536
+
537
+ def call
538
+ account = yield create_account(account_name)
539
+ yield associate_owner(account, owner)
540
+
541
+ Success(account)
542
+ end
76
543
 
77
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).operation
78
- # => <TestOperation:0x000000014a0048a8 @__attributes=#<TestOperation::TypedSchema foo="1" bar="2" baz="3">>
544
+ private
545
+
546
+ def create_account(account_name)
547
+ # returns Success(account) or Failure(:cant_create)
548
+ end
549
+ end
79
550
  ```
80
551
 
81
552
  ## Installation
553
+
82
554
  Add this line to your application's Gemfile:
83
555
 
84
556
  ```ruby
@@ -125,7 +597,13 @@ The default path is `app/operations`.
125
597
  The generator will also create a test file.
126
598
 
127
599
  ## Contributing
128
- Contribution directions go here.
600
+
601
+ Bug reports and pull requests are welcome on GitHub at https://github.com/stevegeek/typed_operation. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/stevegeek/typed_operation/blob/master/CODE_OF_CONDUCT.md).
129
602
 
130
603
  ## License
604
+
131
605
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
606
+
607
+ ## Code of Conduct
608
+
609
+ Everyone interacting in the TypedOperation project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/stevegeek/typed_operation/blob/master/CODE_OF_CONDUCT.md).
@@ -4,8 +4,11 @@
4
4
  module <%= namespace_name %>
5
5
  class <%= name %> < ::ApplicationOperation
6
6
  # Replace with implementation...
7
- param :required_param, String
8
- param :an_optional_param, Integer, convert: true, allow_nil: true
7
+ positional_param :required_positional_param, String
8
+ param :required_named_param, String
9
+ param :an_optional_param, Integer, optional: true do |value|
10
+ value.to_i
11
+ end
9
12
 
10
13
  def prepare
11
14
  # Prepare...
@@ -20,8 +23,11 @@ end
20
23
  <% else %>
21
24
  class <%= name %> < ::ApplicationOperation
22
25
  # Replace with implementation...
23
- param :required_param, String
24
- param :an_optional_param, Integer, convert: true, allow_nil: true
26
+ positional_param :required_positional_param, String
27
+ param :required_named_param, String
28
+ param :an_optional_param, Integer, optional: true do |value|
29
+ value.to_i
30
+ end
25
31
 
26
32
  def prepare
27
33
  # Prepare...
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ class AttributeBuilder
5
+ def initialize(typed_operation, parameter_name, type_signature, options)
6
+ @typed_operation = typed_operation
7
+ @name = parameter_name
8
+ @signature = type_signature
9
+ @optional = options[:optional]
10
+ @positional = options[:positional]
11
+ @reader = options[:reader] || :public
12
+ @default_key = options.key?(:default)
13
+ @default = options[:default]
14
+
15
+ prepare_type_signature_for_literal
16
+ end
17
+
18
+ def define(&converter)
19
+ @typed_operation.attribute(
20
+ @name,
21
+ @signature,
22
+ default: default_value_for_literal,
23
+ positional: @positional,
24
+ reader: @reader,
25
+ &converter
26
+ )
27
+ end
28
+
29
+ private
30
+
31
+ def prepare_type_signature_for_literal
32
+ @signature = NilableType.new(@signature) if needs_to_be_nilable?
33
+ union_with_nil_to_support_nil_default
34
+ validate_positional_order_params! if @positional
35
+ end
36
+
37
+ # If already wrapped in a Nilable then don't wrap again
38
+ def needs_to_be_nilable?
39
+ @optional && !type_nilable?
40
+ end
41
+
42
+ def type_nilable?
43
+ @signature.is_a?(NilableType)
44
+ end
45
+
46
+ def union_with_nil_to_support_nil_default
47
+ @signature = Literal::Union.new(@signature, NilClass) if has_default_value_nil?
48
+ end
49
+
50
+ def has_default_value_nil?
51
+ default_provided? && @default.nil?
52
+ end
53
+
54
+ def validate_positional_order_params!
55
+ # Optional ones can always be added after required ones, or before any others, but required ones must be first
56
+ unless type_nilable? || @typed_operation.optional_positional_parameters.empty?
57
+ raise ParameterError, "Cannot define required positional parameter '#{@name}' after optional positional parameters"
58
+ end
59
+ end
60
+
61
+ def default_provided?
62
+ @default_key
63
+ end
64
+
65
+ def default_value_for_literal
66
+ if has_default_value_nil? || type_nilable?
67
+ -> {}
68
+ else
69
+ @default
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,58 +1,106 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "vident/typed"
4
- require "vident/typed/attributes"
3
+ require "literal"
5
4
 
6
5
  module TypedOperation
7
- class Base
8
- include Vident::Typed::Attributes
9
-
6
+ class Base < Literal::Data
10
7
  class << self
11
8
  def call(...)
12
9
  new(...).call
13
10
  end
14
11
 
15
- def curry(**args)
16
- PartiallyApplied.new(self, **args).curry
12
+ def with(...)
13
+ PartiallyApplied.new(self, ...).with
14
+ end
15
+ alias_method :[], :with
16
+
17
+ def curry
18
+ Curried.new(self)
17
19
  end
18
- alias_method :[], :curry
19
- alias_method :with, :curry
20
20
 
21
21
  def to_proc
22
22
  method(:call).to_proc
23
23
  end
24
24
 
25
- def operation_key
26
- name.underscore.to_sym
27
- end
25
+ # Method to define parameters for your operation.
28
26
 
29
- # property are required by default, you can fall back to attribute or set allow_nil: true if you want optional
27
+ # Parameter for keyword argument, or a positional argument if you use positional: true
28
+ # Required, but you can set a default or use optional: true if you want optional
30
29
  def param(name, signature = :any, **options, &converter)
31
- attribute(name, signature, **{allow_nil: false}.merge(options), &converter)
30
+ AttributeBuilder.new(self, name, signature, options).define(&converter)
31
+ end
32
+
33
+ # Alternative DSL
34
+
35
+ # Parameter for positional argument
36
+ def positional_param(name, signature = :any, **options, &converter)
37
+ param(name, signature, **options.merge(positional: true), &converter)
38
+ end
39
+
40
+ # Parameter for a keyword or named argument
41
+ def named_param(name, signature = :any, **options, &converter)
42
+ param(name, signature, **options.merge(positional: false), &converter)
43
+ end
44
+
45
+ # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
46
+ def optional(type_signature)
47
+ NilableType.new(type_signature)
48
+ end
49
+
50
+ # Introspection methods
51
+
52
+ def positional_parameters
53
+ literal_attributes.filter_map { |name, attribute| name if attribute.positional? }
32
54
  end
33
- end
34
55
 
35
- def initialize(**attributes)
36
- begin
37
- prepare_attributes(attributes)
38
- rescue ::Dry::Struct::Error => e
39
- raise ParameterError, e.message
56
+ def keyword_parameters
57
+ literal_attributes.filter_map { |name, attribute| name unless attribute.positional? }
40
58
  end
59
+
60
+ def required_positional_parameters
61
+ required_parameters.filter_map { |name, attribute| name if attribute.positional? }
62
+ end
63
+
64
+ def required_keyword_parameters
65
+ required_parameters.filter_map { |name, attribute| name unless attribute.positional? }
66
+ end
67
+
68
+ def optional_positional_parameters
69
+ positional_parameters - required_positional_parameters
70
+ end
71
+
72
+ def optional_keyword_parameters
73
+ keyword_parameters - required_keyword_parameters
74
+ end
75
+
76
+ private
77
+
78
+ def required_parameters
79
+ literal_attributes.filter do |name, attribute|
80
+ attribute.default.nil? # Any optional parameters will have a default value/proc in their Literal::Attribute
81
+ end
82
+ end
83
+ end
84
+
85
+ def after_initialization
41
86
  prepare if respond_to?(:prepare)
42
87
  end
43
88
 
44
89
  def call
45
- raise NotImplementedError, "You must implement #call"
90
+ raise InvalidOperationError, "You must implement #call"
46
91
  end
47
92
 
48
93
  def to_proc
49
94
  method(:call).to_proc
50
95
  end
51
96
 
52
- private
97
+ def deconstruct
98
+ attributes.values
99
+ end
53
100
 
54
- def operation_key
55
- self.class.operation_key
101
+ def deconstruct_keys(keys)
102
+ h = attributes.to_h
103
+ keys ? h.slice(*keys) : h
56
104
  end
57
105
  end
58
106
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ class Curried
5
+ def initialize(operation_class, partial_operation = nil)
6
+ @operation_class = operation_class
7
+ @partial_operation = partial_operation || operation_class.with
8
+ end
9
+
10
+ def call(arg)
11
+ raise ArgumentError, "A prepared operation should not be curried" if @partial_operation.prepared?
12
+
13
+ next_partially_applied = if next_parameter_positional?
14
+ @partial_operation.with(arg)
15
+ else
16
+ @partial_operation.with(next_keyword_parameter => arg)
17
+ end
18
+ if next_partially_applied.prepared?
19
+ next_partially_applied.call
20
+ else
21
+ Curried.new(@operation_class, next_partially_applied)
22
+ end
23
+ end
24
+
25
+ def to_proc
26
+ method(:call).to_proc
27
+ end
28
+
29
+ private
30
+
31
+ def next_keyword_parameter
32
+ remaining = @operation_class.required_keyword_parameters - @partial_operation.keyword_args.keys
33
+ remaining.first
34
+ end
35
+
36
+ def next_parameter_positional?
37
+ @partial_operation.positional_args.size < @operation_class.required_positional_parameters.size
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "literal"
4
+
5
+ module TypedOperation
6
+ class NilableType < Literal::Union
7
+ def initialize(*types)
8
+ @types = types + [NilClass]
9
+ end
10
+ end
11
+ end
@@ -2,31 +2,87 @@
2
2
 
3
3
  module TypedOperation
4
4
  class PartiallyApplied
5
- def initialize(operation, **applied_args)
6
- @operation = operation
7
- @applied_args = applied_args
5
+ def initialize(operation_class, *positional_args, **keyword_args)
6
+ @operation_class = operation_class
7
+ @positional_args = positional_args
8
+ @keyword_args = keyword_args
8
9
  end
9
10
 
10
- def curry(**params)
11
- all_args = @applied_args.merge(params)
12
- # check if required attrs are in @applied_args
13
- required_keys = @operation.attribute_names.select { |name| @operation.attribute_metadata(name)[:required] != false }
14
- missing_keys = required_keys - all_args.keys
11
+ def with(*positional, **keyword)
12
+ all_positional = positional_args + positional
13
+ all_kw_args = keyword_args.merge(keyword)
15
14
 
16
- if missing_keys.size > 0
17
- # Partially apply the arguments
18
- PartiallyApplied.new(@operation, **all_args)
15
+ validate_positional_arg_count!(all_positional.size)
16
+
17
+ if partially_applied?(all_positional, all_kw_args)
18
+ PartiallyApplied.new(operation_class, *all_positional, **all_kw_args)
19
19
  else
20
- Prepared.new(@operation, **all_args)
20
+ Prepared.new(operation_class, *all_positional, **all_kw_args)
21
21
  end
22
22
  end
23
- alias_method :[], :curry
24
- alias_method :with, :curry
23
+ alias_method :[], :with
24
+
25
+ def curry
26
+ Curried.new(operation_class, self)
27
+ end
25
28
 
26
29
  def call(...)
27
- prepared = curry(...)
30
+ prepared = with(...)
28
31
  return prepared.operation.call if prepared.is_a?(Prepared)
29
- raise "Cannot call PartiallyApplied operation #{@operation.name} (key: #{@operation.operation_key}), are you expecting it to be Prepared?"
32
+ raise MissingParameterError, "Cannot call PartiallyApplied operation #{operation_class.name} (key: #{operation_class.name}), are you expecting it to be Prepared?"
33
+ end
34
+
35
+ def operation
36
+ raise MissingParameterError, "Cannot instantiate Operation #{operation_class.name} (key: #{operation_class.name}), as it is only partially applied."
37
+ end
38
+
39
+ def prepared?
40
+ false
41
+ end
42
+
43
+ def to_proc
44
+ method(:call).to_proc
45
+ end
46
+
47
+ def deconstruct
48
+ positional_args + keyword_args.values
49
+ end
50
+
51
+ def deconstruct_keys(keys)
52
+ h = keyword_args.dup
53
+ positional_args.each_with_index { |v, i| h[positional_parameters[i]] = v }
54
+ keys ? h.slice(*keys) : h
55
+ end
56
+
57
+ attr_reader :positional_args, :keyword_args
58
+
59
+ private
60
+
61
+ attr_reader :operation_class
62
+
63
+ def required_positional_parameters
64
+ @required_positional_parameters ||= operation_class.required_positional_parameters
65
+ end
66
+
67
+ def required_keyword_parameters
68
+ @required_keyword_parameters ||= operation_class.required_keyword_parameters
69
+ end
70
+
71
+ def positional_parameters
72
+ @positional_parameters ||= operation_class.positional_parameters
73
+ end
74
+
75
+ def validate_positional_arg_count!(count)
76
+ if count > positional_parameters.size
77
+ raise ArgumentError, "Too many positional arguments provided for #{operation_class.name} (key: #{operation_class.name})"
78
+ end
79
+ end
80
+
81
+ def partially_applied?(all_positional, all_kw_args)
82
+ missing_positional = required_positional_parameters.size - all_positional.size
83
+ missing_keys = required_keyword_parameters - all_kw_args.keys
84
+
85
+ missing_positional > 0 || missing_keys.size > 0
30
86
  end
31
87
  end
32
88
  end
@@ -3,7 +3,11 @@
3
3
  module TypedOperation
4
4
  class Prepared < PartiallyApplied
5
5
  def operation
6
- @operation.new(**@applied_args)
6
+ operation_class.new(*@positional_args, **@keyword_args)
7
+ end
8
+
9
+ def prepared?
10
+ true
7
11
  end
8
12
  end
9
13
  end
@@ -1,3 +1,3 @@
1
1
  module TypedOperation
2
- VERSION = "0.4.1"
2
+ VERSION = "1.0.0.pre1"
3
3
  end
@@ -1,9 +1,20 @@
1
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2.0")
2
+ require "polyfill-data"
3
+ end
4
+
1
5
  require "typed_operation/version"
2
- require "typed_operation/railtie"
6
+ require "typed_operation/railtie" if defined?(Rails::Railtie)
7
+ require "typed_operation/nilable_type"
8
+ require "typed_operation/attribute_builder"
9
+ require "typed_operation/curried"
3
10
  require "typed_operation/base"
4
11
  require "typed_operation/partially_applied"
5
12
  require "typed_operation/prepared"
6
13
 
7
14
  module TypedOperation
8
- class ParameterError < StandardError; end
15
+ class InvalidOperationError < StandardError; end
16
+
17
+ class MissingParameterError < ArgumentError; end
18
+
19
+ class ParameterError < TypeError; end
9
20
  end
metadata CHANGED
@@ -1,63 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: typed_operation
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.1
4
+ version: 1.0.0.pre1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Stephen Ierodiaconou
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-06-22 00:00:00.000000000 Z
12
- dependencies:
13
- - !ruby/object:Gem::Dependency
14
- name: rails
15
- requirement: !ruby/object:Gem::Requirement
16
- requirements:
17
- - - ">="
18
- - !ruby/object:Gem::Version
19
- version: '6.0'
20
- - - "<"
21
- - !ruby/object:Gem::Version
22
- version: '8.0'
23
- type: :runtime
24
- prerelease: false
25
- version_requirements: !ruby/object:Gem::Requirement
26
- requirements:
27
- - - ">="
28
- - !ruby/object:Gem::Version
29
- version: '6.0'
30
- - - "<"
31
- - !ruby/object:Gem::Version
32
- version: '8.0'
33
- - !ruby/object:Gem::Dependency
34
- name: vident-typed
35
- requirement: !ruby/object:Gem::Requirement
36
- requirements:
37
- - - "~>"
38
- - !ruby/object:Gem::Version
39
- version: 0.1.0
40
- type: :runtime
41
- prerelease: false
42
- version_requirements: !ruby/object:Gem::Requirement
43
- requirements:
44
- - - "~>"
45
- - !ruby/object:Gem::Version
46
- version: 0.1.0
47
- - !ruby/object:Gem::Dependency
48
- name: dry-initializer
49
- requirement: !ruby/object:Gem::Requirement
50
- requirements:
51
- - - "~>"
52
- - !ruby/object:Gem::Version
53
- version: '3.0'
54
- type: :runtime
55
- prerelease: false
56
- version_requirements: !ruby/object:Gem::Requirement
57
- requirements:
58
- - - "~>"
59
- - !ruby/object:Gem::Version
60
- version: '3.0'
11
+ date: 2023-08-20 00:00:00.000000000 Z
12
+ dependencies: []
61
13
  description: TypedOperation is a command pattern implementation where inputs can be
62
14
  defined with runtime type checks. Operations can be partially applied.
63
15
  email:
@@ -78,7 +30,10 @@ files:
78
30
  - lib/generators/typed_operation_generator.rb
79
31
  - lib/tasks/typed_operation_tasks.rake
80
32
  - lib/typed_operation.rb
33
+ - lib/typed_operation/attribute_builder.rb
81
34
  - lib/typed_operation/base.rb
35
+ - lib/typed_operation/curried.rb
36
+ - lib/typed_operation/nilable_type.rb
82
37
  - lib/typed_operation/partially_applied.rb
83
38
  - lib/typed_operation/prepared.rb
84
39
  - lib/typed_operation/railtie.rb
@@ -97,14 +52,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
97
52
  requirements:
98
53
  - - ">="
99
54
  - !ruby/object:Gem::Version
100
- version: '0'
55
+ version: '3.1'
101
56
  required_rubygems_version: !ruby/object:Gem::Requirement
102
57
  requirements:
103
- - - ">="
58
+ - - ">"
104
59
  - !ruby/object:Gem::Version
105
- version: '0'
60
+ version: 1.3.1
106
61
  requirements: []
107
- rubygems_version: 3.4.10
62
+ rubygems_version: 3.4.18
108
63
  signing_key:
109
64
  specification_version: 4
110
65
  summary: TypedOperation is a command pattern implementation