typed_operation 0.4.2 → 1.0.0.pre2

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: a5af7163e167fa37ba366469393a59d8ea25d4eede88472a7c739bee91e987d7
4
- data.tar.gz: fb1de81a7eb3ce710c36355474c7f3b01e970c4696a82ede8fd680273cae1cb2
3
+ metadata.gz: 1337560f2bffbfd45574c1df5f007c951dba43293324c309c3409aab18e57f0c
4
+ data.tar.gz: 63deedbca3cde1fadb88668af46ec21652e17f9600d23c0e13c8b22429c05e0c
5
5
  SHA512:
6
- metadata.gz: fd8a364079683dbe13c02c6090a707499db4f847bf86c395baa80ffed3e7bc997b11ae545c2a7f20d9b2c46e66f9e4b3b5496776524fe70b276eef46eff33f88
7
- data.tar.gz: 7a7cf690621318b56b44cfe8cf4d4231694d65befd2ee62dd1cfb53d283be653b41011de4ad77f9f46636eb0b4146093f37162f4059aa74afc8190f9a789b68e
6
+ metadata.gz: 0a8fdc89c772282de6c654365e18d7397532b84aebd738285499998c04c32906dc1e63961f5b47d139dfff532a458461dbe07a29bdfcda9f29d8132d645599df
7
+ data.tar.gz: cee03eb2c1e2d9100e657d98a1b70f6889b0e276251dd839f343586ff89f17287853224a487357343fc421ff6723686a2bf672c7ad53a058246668bb1d80a926
data/README.md CHANGED
@@ -1,33 +1,513 @@
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
+ Operations can also be partially applied and curried:
96
+
97
+ ```ruby
98
+ class TestOperation < ::TypedOperation::Base
99
+ param :foo, String, positional: true
100
+ param :bar, String
101
+ param :baz, String, &:to_s
102
+
103
+ def call = "It worked! (#{foo}, #{bar}, #{baz})"
104
+ end
105
+
106
+ # Invoking the operation directly
107
+ TestOperation.("1", bar: "2", baz: 3)
108
+ # => "It worked! (1, 2, 3)"
109
+
110
+ # Partial application of parameters
111
+ partially_applied = TestOperation.with("1").with(bar: "2")
112
+ # => #<TypedOperation::PartiallyApplied:0x0000000110270248 @keyword_args={:bar=>"2"}, @operation_class=TestOperation, @positional_args=["1"]>
113
+
114
+ # You can partially apply more than one parameter at a time, and chain calls to `.with`.
115
+ # With all the required parameters set, the operation is 'prepared' and can be instantiated and called
116
+ prepared = TestOperation.with("1", bar: "2").with(baz: 3)
117
+ # => #<TypedOperation::Prepared:0x0000000110a9df38 @keyword_args={:bar=>"2", :baz=>3}, @operation_class=TestOperation, @positional_args=["1"]>
118
+
119
+ # A 'prepared' operation can instantiated & called
120
+ prepared.call
121
+ # => "It worked! (1, 2, 3)"
122
+
123
+ # You can provide additional parameters when calling call on a partially applied operation
124
+ partially_applied.call(baz: 3)
125
+ # => "It worked! (1, 2, 3)"
126
+
127
+ # Partial application can be done using `.with or `.[]`
128
+ TestOperation.with("1")[bar: "2", baz: 3].call
129
+ # => "It worked! (1, 2, 3)"
130
+
131
+ # Currying an operation, note that *all required* parameters must be provided an argument in order
132
+ TestOperation.curry.("1").("2").(3)
133
+ # => "It worked! (1, 2, 3)"
134
+
135
+ # You can also curry from an already partially applied operation, so you can set optional named parameters first.
136
+ # Note currying won't let you set optional positional parameters.
137
+ partially_applied = TestOperation.with("1")
138
+ partially_applied.curry.("2").(3)
139
+ # => "It worked! (1, 2, 3)"
140
+
141
+ # > TestOperation.with("1").with(bar: "2").call
142
+ # => Raises an error because it is PartiallyApplied and so can't be called (it is missing required args)
143
+ # "Cannot call PartiallyApplied operation TestOperation (key: test_operation), are you expecting it to be Prepared? (TypedOperation::MissingParameterError)"
144
+
145
+ TestOperation.with("1").with(bar: "2").with(baz: 3).operation
146
+ # same as > TestOperation.new("1", bar: "2", baz: 3)
147
+ # => <TestOperation:0x000000014a0048a8 ...>
148
+
149
+ # > TestOperation.with(foo: "1").with(bar: "2").operation
150
+ # => Raises an error because it is PartiallyApplied so operation can't be instantiated
151
+ # "Cannot instantiate Operation TestOperation (key: test_operation), as it is only partially applied. (TypedOperation::MissingParameterError)"
152
+ ```
153
+
154
+ ## Documentation
155
+
156
+ ### Create an operation (subclass `TypedOperation::Base` or `TypedOperation::ImmutableBase`)
157
+
158
+ Create an operation by subclassing `TypedOperation::Base` or `TypedOperation::ImmutableBase` and specifying the parameters the operation requires.
159
+
160
+ - `TypedOperation::Base` (uses `Literal::Struct`) is the parent class for an operation where the arguments are potentially mutable (ie not frozen).
161
+ No attribute writer methods are defined, so the arguments can not be changed after initialization, but the values passed in are not guaranteed to be frozen.
162
+ - `TypedOperation::ImmutableBase` (uses `Literal::Data`) is the parent class for an operation where the arguments are immutable (frozen on initialization),
163
+ thus giving a somewhat stronger immutability guarantee (ie that the operation does not mutate its arguments).
164
+
165
+ The subclass must implement the `#call` method which is where the operations main work is done.
166
+
167
+ The operation can also implement:
168
+
169
+ - `#prepare` - called when the operation is initialized, and after the parameters have been set
170
+
171
+
172
+ ### Specifying parameters (using `.param`)
173
+
174
+ Parameters are specified using the provided class methods (`.positional_param` and `.named_param`),
175
+ or using the underlying `.param` method.
176
+
177
+ Types are specified using the `literal` gem. In many cases this simply means providing the class of the
178
+ expected type, but there are also some other useful types provided by `literal` (eg `Union`).
179
+
180
+ These can be either accessed via the `Literal` module, eg `Literal::Types::BooleanType`:
181
+
182
+ ```ruby
183
+ class MyOperation < ::TypedOperation::Base
184
+ param :name, String
185
+ param :age, Integer, optional: true
186
+ param :choices, Literal::Types::ArrayType.new(String)
187
+ param :chose, Literal::Types::BooleanType
188
+ end
189
+
190
+ MyOperation.new(name: "bob", choices: ["st"], chose: true)
191
+ ```
192
+
193
+ or by including the `Literal::Types` module into your operation class, and using the aliases provided:
194
+
195
+ ```ruby
196
+ class MyOperation < ::TypedOperation::Base
197
+ include Literal::Types
198
+
199
+ param :name, String
200
+ param :age, _Nilable(Integer) # optional can also be specifed using `.optional`
201
+ param :choices, _Array(String)
202
+ param :chose, _Boolean
203
+ end
204
+ ```
205
+
206
+ Type constraints can be modified to make the parameter optional using `.optional`.
207
+
208
+ #### Your own aliases
209
+
210
+ Note that you may also like to alias the param methods to your own preferred names in a common base operation class.
211
+
212
+ Some possible aliases are:
213
+ - `positional`/`named`
214
+ - `arg`/`key`
215
+
216
+ For example:
12
217
 
13
218
  ```ruby
14
219
  class ApplicationOperation < ::TypedOperation::Base
220
+ class << self
221
+ alias_method :arg, :positional_param
222
+ alias_method :key, :named_param
223
+ end
224
+ end
225
+
226
+ class MyOperation < ApplicationOperation
227
+ arg :name, String
228
+ key :age, Integer
229
+ end
230
+
231
+ MyOperation.new("Steve", age: 20)
232
+ ```
233
+
234
+ #### Positional parameters (`positional: true` or `.positional_param`)
235
+
236
+ Defines a positional parameter (positional argument passed to the operation when creating it).
237
+
238
+ The following are equivalent:
239
+
240
+ - `param <param_name>, <type>, positional: true, <**options>`
241
+ - `positional_param <param_name>, <type>, <**options>`
242
+
243
+ The `<para_name>` is a symbolic name, used to create the accessor method, and when deconstructing to a hash.
244
+
245
+ The `<type>` constraint provides the expected type of the parameter (the type is a type signature compatible with `literal`).
246
+
247
+ The `<options>` are:
248
+ - `default:` - a default value for the parameter (can be a proc or a frozen value)
249
+ - `optional:` - a boolean indicating whether the parameter is optional (default: false). Note you may prefer to use the
250
+ `.optional` method instead of this option.
251
+
252
+ **Note** when positional arguments are provided to the operation, they are matched in order of definition or positional
253
+ params. Also note that you cannot define required positional parameters after optional ones.
254
+
255
+ Eg
256
+
257
+ ```ruby
258
+ class MyOperation < ::TypedOperation::Base
259
+ positional_param :name, String, positional: true
260
+ # Or alternatively => `param :name, String, positional: true`
261
+ positional_param :age, Integer, default: -> { 0 }
262
+
263
+ def call
264
+ puts "Hello #{name} (#{age})"
265
+ end
266
+ end
267
+
268
+ MyOperation.new("Steve").call
269
+ # => "Hello Steve (0)"
270
+
271
+ MyOperation.with("Steve").call(20)
272
+ # => "Hello Steve (20)"
273
+ ```
274
+
275
+ #### Named (keyword) parameters
276
+
277
+ Defines a named parameter (keyword argument passed to the operation when creating it).
278
+
279
+ The following are equivalent:
280
+ - `param <param_name>, <type>, <**options>`
281
+ - `named_param <param_name>, <type>, <**options>`
282
+
283
+ The `<para_name>` is a symbol, used as parameter name for the keyword arguments in the operation constructor, to
284
+ create the accessor method and when deconstructing to a hash.
285
+
286
+ The type constraint and options are the same as for positional parameters.
287
+
288
+ ```ruby
289
+ class MyOperation < ::TypedOperation::Base
290
+ named_param :name, String
291
+ # Or alternatively => `param :name, String`
292
+ named_param :age, Integer, default: -> { 0 }
293
+
294
+ def call
295
+ puts "Hello #{name} (#{age})"
296
+ end
297
+ end
298
+
299
+ MyOperation.new(name: "Steve").call
300
+ # => "Hello Steve (0)"
301
+
302
+ MyOperation.with(name: "Steve").call(age: 20)
303
+ # => "Hello Steve (20)"
304
+ ```
305
+
306
+ #### Using both positional and named parameters
307
+
308
+ You can use both positional and named parameters in the same operation.
309
+
310
+ ```ruby
311
+ class MyOperation < ::TypedOperation::Base
312
+ positional_param :name, String
313
+ named_param :age, Integer, default: -> { 0 }
314
+
315
+ def call
316
+ puts "Hello #{name} (#{age})"
317
+ end
318
+ end
319
+
320
+ MyOperation.new("Steve").call
321
+ # => "Hello Steve (0)"
322
+
323
+ MyOperation.new("Steve", age: 20).call
324
+ # => "Hello Steve (20)"
325
+
326
+ MyOperation.with("Steve").call(age: 20)
327
+ # => "Hello Steve (20)"
328
+ ```
329
+
330
+ #### Optional parameters (using `optional:` or `.optional`)
331
+
332
+ Optional parameters are ones that do not need to be specified for the operation to be instantiated.
333
+
334
+ An optional parameter can be specified by:
335
+ - using the `optional:` option
336
+ - using the `.optional` method around the type constraint
337
+
338
+ ```ruby
339
+ class MyOperation < ::TypedOperation::Base
340
+ param :name, String
341
+ param :age, Integer, optional: true
342
+ param :nickname, optional(String)
343
+ # ...
344
+ end
345
+
346
+ MyOperation.new(name: "Steve")
347
+ MyOperation.new(name: "Steve", age: 20)
348
+ MyOperation.new(name: "Steve", nickname: "Steve-o")
349
+ ```
350
+
351
+ This `.optional` class method effectively makes the type signature a union of the provided type and `NilClass`.
352
+
353
+ #### Coercing parameters
354
+
355
+ You can specify a block after a parameter definition to coerce the argument value.
356
+
357
+ ```ruby
358
+ param :name, String, &:to_s
359
+ param :choice, Literal::Types::BooleanType do |v|
360
+ v == "y"
361
+ end
362
+ ```
363
+
364
+ #### Default values (with `default:`)
365
+
366
+ You can specify a default value for a parameter using the `default:` option.
367
+
368
+ 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.
369
+
370
+ ```ruby
371
+ param :name, String, default: "Steve".freeze
372
+ param :age, Integer, default: -> { rand(100) }
373
+ ```
374
+
375
+ If using the directive `# frozen_string_literal: true` then you string values are frozen by default.
376
+
377
+ ### Partially applying (fixing parameters) on an operation (using `.with`)
378
+
379
+ `.with(...)` creates a partially applied operation with the provided parameters.
380
+
381
+ It is aliased to `.[]` for an alternative syntax.
382
+
383
+ Note that `.with` can take both positional and keyword arguments, and can be chained.
384
+
385
+ **An important caveat about partial application is that type checking is not done until the operation is instantiated**
386
+
387
+ ```ruby
388
+ MyOperation.new(123)
389
+ # => Raises an error as the type of the first parameter is incorrect:
390
+ # Expected `123` to be of type: `String`. (Literal::TypeError)
391
+
392
+ op = MyOperation.with(123)
393
+ # => #<TypedOperation::Prepared:0x000000010b1d3358 ...
394
+ # Does **not raise** an error, as the type of the first parameter is not checked until the operation is instantiated
395
+
396
+ op.call # or op.operation
397
+ # => Now raises an error as the type of the first parameter is incorrect and operation is instantiated
398
+ ```
399
+
400
+ ### Calling an operation (using `.call`)
401
+
402
+ An operation can be invoked by:
403
+
404
+ - instantiating it with at least required params and then calling the `#call` method on the instance
405
+ - once a partially applied operation has been prepared (all required parameters have been set), the call
406
+ method on TypedOperation::Prepared can be used to instantiate and call the operation.
407
+ - once an operation is curried, the `#call` method on last TypedOperation::Curried in the chain will invoke the operation
408
+ - calling `#call` on a partially applied operation and passing in any remaining required parameters
409
+
410
+ See the many examples in this document.
411
+
412
+ ### Pattern matching on an operation
413
+
414
+ `TypedOperation::Base` and `TypedOperation::PartiallyApplied` implement `deconstruct` and `deconstruct_keys` methods,
415
+ so they can be pattern matched against.
416
+
417
+ ```ruby
418
+ case MyOperation.new("Steve", age: 20)
419
+ in MyOperation[name, age]
420
+ puts "Hello #{name} (#{age})"
421
+ end
422
+
423
+ case MyOperation.new("Steve", age: 20)
424
+ in MyOperation[name:, age: 20]
425
+ puts "Hello #{name} (#{age})"
426
+ end
427
+ ```
428
+
429
+ ### Introspection of parameters & other methods
430
+
431
+ #### `.to_proc`
432
+
433
+ Get a proc that calls `.call(...)`
434
+
435
+
436
+ #### `#to_proc`
437
+
438
+ Get a proc that calls the `#call` method on an operation instance
439
+
440
+ #### `.prepared?`
441
+
442
+ Check if an operation is prepared
443
+
444
+ #### `.operation`
445
+
446
+ Return an operation instance from a Prepared operation. Will raise if called on a PartiallyApplied operation
447
+
448
+ #### `.positional_parameters`
449
+
450
+ List of the names of the positional parameters, in order
451
+
452
+ #### `.keyword_parameters`
453
+
454
+ List of the names of the keyword parameters
455
+
456
+ #### `.required_positional_parameters`
457
+
458
+ List of the names of the required positional parameters, in order
459
+
460
+ #### `.required_keyword_parameters`
461
+
462
+ List of the names of the required keyword parameters
463
+
464
+ #### `.optional_positional_parameters`
465
+
466
+ List of the names of the optional positional parameters, in order
467
+
468
+ #### `.optional_keyword_parameters`
469
+
470
+ List of the names of the optional keyword parameters
471
+
472
+
473
+ ### Using with Rails
474
+
475
+ You can use the provided generator to create an `ApplicationOperation` class in your Rails project.
476
+
477
+ You can then extend this to add extra functionality to all your operations.
478
+
479
+ This is an example of a `ApplicationOperation` in a Rails app that uses `Dry::Monads`:
480
+
481
+ ```ruby
482
+ # frozen_string_literal: true
483
+
484
+ class ApplicationOperation < ::TypedOperation::Base
485
+ # We choose to use dry-monads for our operations, so include the required modules
15
486
  include Dry::Monads[:result, :do]
16
487
 
17
- param :initiator, ::RegisteredUser, allow_nil: true
488
+ class << self
489
+ # Setup our own preferred names for the DSL methods
490
+ alias_method :positional, :positional_param
491
+ alias_method :named, :named_param
492
+ end
493
+
494
+ # Parameters common to all Operations in this application
495
+ named :initiator, optional(::User)
18
496
 
19
497
  private
20
498
 
499
+ # We setup some helper methods for our operations to use
500
+
21
501
  def succeeded(value)
22
502
  Success(value)
23
503
  end
24
504
 
25
505
  def failed_with_value(value, message: "Operation failed", error_code: nil)
26
- failed(error_code || self.class.operation_key, message, value)
506
+ failed(error_code || operation_key, message, value)
27
507
  end
28
508
 
29
509
  def failed_with_message(message, error_code: nil)
30
- failed(error_code || self.class.operation_key, message)
510
+ failed(error_code || operation_key, message)
31
511
  end
32
512
 
33
513
  def failed(error_code, message = "Operation failed", value = nil)
@@ -37,48 +517,76 @@ class ApplicationOperation < ::TypedOperation::Base
37
517
  def failed_with_code_and_value(error_code, value, message: "Operation failed")
38
518
  failed(error_code, message, value)
39
519
  end
40
- end
41
520
 
521
+ def operation_key
522
+ self.class.name
523
+ end
524
+ end
42
525
  ```
43
526
 
44
- A simple operation:
527
+ ### Using with `literal` monads
528
+
529
+ You can use the `literal` gem to provide a `Result` type for your operations.
45
530
 
46
531
  ```ruby
47
- class TestOperation < ::ApplicationOperation
48
- param :foo, String
49
- param :bar, String
50
- param :baz, String, convert: true
532
+ class MyOperation < ::TypedOperation::Base
533
+ param :account_name, String
534
+ param :owner, String
51
535
 
52
- def prepare
53
- # to setup (optional)
54
- puts "lets go!"
536
+ def call
537
+ create_account.bind do |account|
538
+ associate_owner(account).map { account }
539
+ end
540
+ end
541
+
542
+ private
543
+
544
+ def create_account
545
+ # ...
546
+ # Literal::Failure.new(:cant_create_account)
547
+ Literal::Success.new(account_name)
55
548
  end
56
549
 
57
- def call
58
- succeeded("It worked!")
59
- # failed_with_message("It failed!")
550
+ def associate_owner(account)
551
+ # ...
552
+ Literal::Failure.new(:cant_associate_owner)
553
+ # Literal::Success.new("ok")
60
554
  end
61
555
  end
556
+
557
+ MyOperation.new(account_name: "foo", owner: "bar").call
558
+ # => Literal::Failure(:cant_associate_owner)
62
559
  ```
63
560
 
64
- ```ruby
65
- TestOperation.(foo: "1", bar: "2", baz: 3)
66
- # => Success("It worked!")
561
+ ### Using with `Dry::Monads`
67
562
 
68
- TestOperation.with(foo: "1").with(bar: "2")
69
- # => #<TypedOperation::PartiallyApplied:0x000000014a655310 @applied_args={:foo=>"1", :bar=>"2"}, @operation=TestOperation>
563
+ As per the example in [`Dry::Monads` documentation](https://dry-rb.org/gems/dry-monads/1.0/do-notation/)
70
564
 
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>
565
+ ```ruby
566
+ class MyOperation < ::TypedOperation::Base
567
+ include Dry::Monads[:result]
568
+ include Dry::Monads::Do.for(:call)
73
569
 
74
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).call
75
- # => Success("It worked!")
570
+ param :account_name, String
571
+ param :owner, ::Owner
572
+
573
+ def call
574
+ account = yield create_account(account_name)
575
+ yield associate_owner(account, owner)
76
576
 
77
- TestOperation.with(foo: "1").with(bar: "2").with(baz: 3).operation
78
- # => <TestOperation:0x000000014a0048a8 @__attributes=#<TestOperation::TypedSchema foo="1" bar="2" baz="3">>
577
+ Success(account)
578
+ end
579
+
580
+ private
581
+
582
+ def create_account(account_name)
583
+ # returns Success(account) or Failure(:cant_create)
584
+ end
585
+ end
79
586
  ```
80
587
 
81
588
  ## Installation
589
+
82
590
  Add this line to your application's Gemfile:
83
591
 
84
592
  ```ruby
@@ -125,7 +633,13 @@ The default path is `app/operations`.
125
633
  The generator will also create a test file.
126
634
 
127
635
  ## Contributing
128
- Contribution directions go here.
636
+
637
+ 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
638
 
130
639
  ## License
640
+
131
641
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
642
+
643
+ ## Code of Conduct
644
+
645
+ 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).
data/Rakefile CHANGED
@@ -1,3 +1,17 @@
1
- require "bundler/setup"
1
+ # frozen_string_literal: true
2
2
 
3
3
  require "bundler/gem_tasks"
4
+ require "bundler/setup"
5
+ require "rake/testtask"
6
+
7
+ ENV["NO_RAILS"] = "true"
8
+
9
+ Rake::TestTask.new(:test) do |t|
10
+ t.libs << "test"
11
+ t.libs << "lib"
12
+ t.test_files = FileList["test/typed_operation/**/*_test.rb"]
13
+ end
14
+
15
+ require "standard/rake"
16
+
17
+ task default: %i[test standard]
@@ -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...
@@ -1,58 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "vident/typed"
4
- require "vident/typed/attributes"
5
-
6
3
  module TypedOperation
7
- class Base
8
- include Vident::Typed::Attributes
9
-
10
- class << self
11
- def call(...)
12
- new(...).call
13
- end
14
-
15
- def curry(**args)
16
- PartiallyApplied.new(self, **args).curry
17
- end
18
- alias_method :[], :curry
19
- alias_method :with, :curry
20
-
21
- def to_proc
22
- method(:call).to_proc
23
- end
24
-
25
- def operation_key
26
- name.underscore.to_sym
27
- end
4
+ class Base < Literal::Struct
5
+ extend Operations::Introspection
6
+ extend Operations::Parameters
7
+ extend Operations::PartialApplication
28
8
 
29
- # property are required by default, you can fall back to attribute or set allow_nil: true if you want optional
30
- def param(name, signature = :any, **options, &converter)
31
- attribute(name, signature, **{allow_nil: false}.merge(options), &converter)
32
- end
33
- end
9
+ include Operations::Callable
10
+ include Operations::Lifecycle
11
+ include Operations::Deconstruct
34
12
 
35
- def initialize(**attributes)
36
- begin
37
- prepare_attributes(attributes)
38
- rescue ::Dry::Struct::Error => e
39
- raise ParameterError, e.message
13
+ class << self
14
+ def attribute(name, type, special = nil, reader: :public, writer: :public, positional: false, default: nil)
15
+ super(name, type, special, reader:, writer: false, positional:, default:)
40
16
  end
41
- prepare if respond_to?(:prepare)
42
17
  end
43
18
 
44
- def call
45
- raise InvalidOperationError, "You must implement #call"
46
- end
47
-
48
- def to_proc
49
- method(:call).to_proc
50
- end
51
-
52
- private
53
-
54
- def operation_key
55
- self.class.operation_key
19
+ def with(...)
20
+ # copy to new operation with new attrs
21
+ self.class.new(**attributes.merge(...))
56
22
  end
57
23
  end
58
24
  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,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ class ImmutableBase < Literal::Data
5
+ extend Operations::Introspection
6
+ extend Operations::Parameters
7
+ extend Operations::PartialApplication
8
+
9
+ include Operations::Callable
10
+ include Operations::Lifecycle
11
+ include Operations::Deconstruct
12
+ end
13
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ class AttributeBuilder
6
+ def initialize(typed_operation, parameter_name, type_signature, options)
7
+ @typed_operation = typed_operation
8
+ @name = parameter_name
9
+ @signature = type_signature
10
+ @optional = options[:optional]
11
+ @positional = options[:positional]
12
+ @reader = options[:reader] || :public
13
+ @default_key = options.key?(:default)
14
+ @default = options[:default]
15
+
16
+ prepare_type_signature_for_literal
17
+ end
18
+
19
+ def define(&converter)
20
+ @typed_operation.attribute(
21
+ @name,
22
+ @signature,
23
+ default: default_value_for_literal,
24
+ positional: @positional,
25
+ reader: @reader,
26
+ &converter
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ def prepare_type_signature_for_literal
33
+ @signature = Literal::Types::NilableType.new(@signature) if needs_to_be_nilable?
34
+ union_with_nil_to_support_nil_default
35
+ validate_positional_order_params! if @positional
36
+ end
37
+
38
+ # If already wrapped in a Nilable then don't wrap again
39
+ def needs_to_be_nilable?
40
+ @optional && !type_nilable?
41
+ end
42
+
43
+ def type_nilable?
44
+ @signature.is_a?(Literal::Types::NilableType)
45
+ end
46
+
47
+ def union_with_nil_to_support_nil_default
48
+ @signature = Literal::Union.new(@signature, NilClass) if has_default_value_nil?
49
+ end
50
+
51
+ def has_default_value_nil?
52
+ default_provided? && @default.nil?
53
+ end
54
+
55
+ def validate_positional_order_params!
56
+ # Optional ones can always be added after required ones, or before any others, but required ones must be first
57
+ unless type_nilable? || @typed_operation.optional_positional_parameters.empty?
58
+ raise ParameterError, "Cannot define required positional parameter '#{@name}' after optional positional parameters"
59
+ end
60
+ end
61
+
62
+ def default_provided?
63
+ @default_key
64
+ end
65
+
66
+ def default_value_for_literal
67
+ if has_default_value_nil? || type_nilable?
68
+ -> {}
69
+ else
70
+ @default
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ module Callable
6
+ def self.included(base)
7
+ base.extend(CallableMethods)
8
+ end
9
+
10
+ module CallableMethods
11
+ def call(...)
12
+ new(...).call
13
+ end
14
+
15
+ def to_proc
16
+ method(:call).to_proc
17
+ end
18
+ end
19
+
20
+ include CallableMethods
21
+
22
+ def call
23
+ raise InvalidOperationError, "You must implement #call"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ module Deconstruct
6
+ def deconstruct
7
+ attributes.values
8
+ end
9
+
10
+ def deconstruct_keys(keys)
11
+ h = attributes.to_h
12
+ keys ? h.slice(*keys) : h
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ # Introspection methods
6
+ module Introspection
7
+ def positional_parameters
8
+ literal_attributes.filter_map { |name, attribute| name if attribute.positional? }
9
+ end
10
+
11
+ def keyword_parameters
12
+ literal_attributes.filter_map { |name, attribute| name unless attribute.positional? }
13
+ end
14
+
15
+ def required_parameters
16
+ literal_attributes.filter do |name, attribute|
17
+ attribute.default.nil? # Any optional parameters will have a default value/proc in their Literal::Attribute
18
+ end
19
+ end
20
+
21
+ def required_positional_parameters
22
+ required_parameters.filter_map { |name, attribute| name if attribute.positional? }
23
+ end
24
+
25
+ def required_keyword_parameters
26
+ required_parameters.filter_map { |name, attribute| name unless attribute.positional? }
27
+ end
28
+
29
+ def optional_positional_parameters
30
+ positional_parameters - required_positional_parameters
31
+ end
32
+
33
+ def optional_keyword_parameters
34
+ keyword_parameters - required_keyword_parameters
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ module Lifecycle
6
+ def after_initialization
7
+ prepare if respond_to?(:prepare)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ # Method to define parameters for your operation.
6
+ module Parameters
7
+ # Parameter for keyword argument, or a positional argument if you use positional: true
8
+ # Required, but you can set a default or use optional: true if you want optional
9
+ def param(name, signature = :any, **options, &converter)
10
+ AttributeBuilder.new(self, name, signature, options).define(&converter)
11
+ end
12
+
13
+ # Alternative DSL
14
+
15
+ # Parameter for positional argument
16
+ def positional_param(name, signature = :any, **options, &converter)
17
+ param(name, signature, **options.merge(positional: true), &converter)
18
+ end
19
+
20
+ # Parameter for a keyword or named argument
21
+ def named_param(name, signature = :any, **options, &converter)
22
+ param(name, signature, **options.merge(positional: false), &converter)
23
+ end
24
+
25
+ # Wrap a type signature in a NilableType meaning it is optional to TypedOperation
26
+ def optional(type_signature)
27
+ Literal::Types::NilableType.new(type_signature)
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TypedOperation
4
+ module Operations
5
+ module PartialApplication
6
+ def with(...)
7
+ PartiallyApplied.new(self, ...).with
8
+ end
9
+ alias_method :[], :with
10
+
11
+ def curry
12
+ Curried.new(self)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -2,34 +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 do |name|
14
- meta = @operation.attribute_metadata(name)
15
- meta[:required] != false && !meta[:typed_attribute_options].key?(:default)
16
- end
17
- 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)
14
+
15
+ validate_positional_arg_count!(all_positional.size)
18
16
 
19
- if missing_keys.size > 0
20
- # Partially apply the arguments
21
- PartiallyApplied.new(@operation, **all_args)
17
+ if partially_applied?(all_positional, all_kw_args)
18
+ PartiallyApplied.new(operation_class, *all_positional, **all_kw_args)
22
19
  else
23
- Prepared.new(@operation, **all_args)
20
+ Prepared.new(operation_class, *all_positional, **all_kw_args)
24
21
  end
25
22
  end
26
- alias_method :[], :curry
27
- alias_method :with, :curry
23
+ alias_method :[], :with
24
+
25
+ def curry
26
+ Curried.new(operation_class, self)
27
+ end
28
28
 
29
29
  def call(...)
30
- prepared = curry(...)
30
+ prepared = with(...)
31
31
  return prepared.operation.call if prepared.is_a?(Prepared)
32
- raise TypedOperation::MissingParameterError, "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
33
86
  end
34
87
  end
35
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.2"
2
+ VERSION = "1.0.0.pre2"
3
3
  end
@@ -1,11 +1,28 @@
1
+ if Gem::Version.new(RUBY_VERSION) < Gem::Version.new("3.2.0")
2
+ require "polyfill-data"
3
+ end
4
+
5
+ require "literal"
6
+
1
7
  require "typed_operation/version"
2
- require "typed_operation/railtie"
8
+ require "typed_operation/railtie" if defined?(Rails::Railtie)
9
+ require "typed_operation/operations/introspection"
10
+ require "typed_operation/operations/parameters"
11
+ require "typed_operation/operations/partial_application"
12
+ require "typed_operation/operations/callable"
13
+ require "typed_operation/operations/lifecycle"
14
+ require "typed_operation/operations/deconstruct"
15
+ require "typed_operation/operations/attribute_builder"
16
+ require "typed_operation/curried"
17
+ require "typed_operation/immutable_base"
3
18
  require "typed_operation/base"
4
19
  require "typed_operation/partially_applied"
5
20
  require "typed_operation/prepared"
6
21
 
7
22
  module TypedOperation
8
23
  class InvalidOperationError < StandardError; end
24
+
9
25
  class MissingParameterError < ArgumentError; end
26
+
10
27
  class ParameterError < TypeError; end
11
28
  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.2
4
+ version: 1.0.0.pre2
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-07-27 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-22 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:
@@ -79,6 +31,15 @@ files:
79
31
  - lib/tasks/typed_operation_tasks.rake
80
32
  - lib/typed_operation.rb
81
33
  - lib/typed_operation/base.rb
34
+ - lib/typed_operation/curried.rb
35
+ - lib/typed_operation/immutable_base.rb
36
+ - lib/typed_operation/operations/attribute_builder.rb
37
+ - lib/typed_operation/operations/callable.rb
38
+ - lib/typed_operation/operations/deconstruct.rb
39
+ - lib/typed_operation/operations/introspection.rb
40
+ - lib/typed_operation/operations/lifecycle.rb
41
+ - lib/typed_operation/operations/parameters.rb
42
+ - lib/typed_operation/operations/partial_application.rb
82
43
  - lib/typed_operation/partially_applied.rb
83
44
  - lib/typed_operation/prepared.rb
84
45
  - lib/typed_operation/railtie.rb
@@ -97,14 +58,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
97
58
  requirements:
98
59
  - - ">="
99
60
  - !ruby/object:Gem::Version
100
- version: '0'
61
+ version: '3.1'
101
62
  required_rubygems_version: !ruby/object:Gem::Requirement
102
63
  requirements:
103
- - - ">="
64
+ - - ">"
104
65
  - !ruby/object:Gem::Version
105
- version: '0'
66
+ version: 1.3.1
106
67
  requirements: []
107
- rubygems_version: 3.4.10
68
+ rubygems_version: 3.4.18
108
69
  signing_key:
109
70
  specification_version: 4
110
71
  summary: TypedOperation is a command pattern implementation