typed_operation 0.4.2 → 1.0.0.pre2

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: 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