typed_operation 0.4.1 → 1.0.0.pre1

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