TypedOperation
An implementation of a Command pattern, which is callable, and can be partially applied.
Inputs to the operation are specified as typed attributes (uses literal
).
Type of result of the operation is up to you, eg you could use literal
monads or Dry::Monads
.
Note the version described here (~ 1.0.0) is not yet released on Rubygems, it is waiting for a release of literal
)
Features
- Operations can be partially applied or curried
- Operations are callable
- Operations can be pattern matched on
- Parameters:
- specified with type constraints (uses
literal
gem) - can be positional or named
- can be optional, or have default values
- can be coerced by providing a block
Example
class ShelveBookOperation < ::TypedOperation::Base
positional_param :title, String
named_param :description, String
named_param :author_id, Integer, &:to_i
named_param :isbn, String
named_param :shelf_code, optional(Integer)
named_param :category, String, default: "unknown".freeze
def prepare
raise ArgumentError, "ISBN is invalid" unless valid_isbn?
end
def before_execute_operation
super
end
def perform
"Put away '#{title}' by author ID #{author_id}#{shelf_code ? " on shelf #{shelf_code}" : "" }"
end
def after_execute_operation(result)
super
end
private
def valid_isbn?
true
end
end
shelve = ShelveBookOperation.new("The Hobbit", description: "A book about a hobbit", author_id: "1", isbn: "978-0261103283")
shelve.call
shelve = ShelveBookOperation.with("The Silmarillion", description: "A book about the history of Middle-earth", shelf_code: 1)
shelve.call(author_id: "1", isbn: "978-0261102736")
curried = shelve.curry
curried.(1).("978-0261102736")
shelve.call(author_id: "1", isbn: false)
Partially applying parameters
Operations can also be partially applied and curried:
class TestOperation < ::TypedOperation::Base
param :foo, String, positional: true
param :bar, String
param :baz, String, &:to_s
def perform = "It worked! (#{foo}, #{bar}, #{baz})"
end
TestOperation.("1", bar: "2", baz: 3)
partially_applied = TestOperation.with("1").with(bar: "2")
prepared = TestOperation.with("1", bar: "2").with(baz: 3)
prepared.call
partially_applied.call(baz: 3)
TestOperation.with("1")[bar: "2", baz: 3].call
TestOperation.curry.("1").("2").(3)
partially_applied = TestOperation.with("1")
partially_applied.curry.("2").(3)
TestOperation.with("1").with(bar: "2").with(baz: 3).operation
Documentation
Create an operation (subclass TypedOperation::Base
or TypedOperation::ImmutableBase
)
Create an operation by subclassing TypedOperation::Base
or TypedOperation::ImmutableBase
and specifying the parameters the operation requires.
TypedOperation::Base
(uses Literal::Struct
) is the parent class for an operation where the arguments are potentially mutable (ie not frozen).
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.TypedOperation::ImmutableBase
(uses Literal::Data
) is the parent class for an operation where the arguments are immutable (frozen on initialization),
thus giving a somewhat stronger immutability guarantee (ie that the operation does not mutate its arguments).
The subclass must implement the #perform
method which is where the operations main work is done.
The operation can also implement:
#prepare
- called when the operation is initialized, and after the parameters have been set#before_execute_operation
- optionally hook in before execution ... and call super to allow subclasses to hook in too#after_execute_operation
- optionally hook in after execution ... and call super to allow subclasses to hook in too
def before_execute_operation
super
end
def perform
end
def after_execute_operation(result)
super
end
Specifying parameters (using .param
)
Parameters are specified using the provided class methods (.positional_param
and .named_param
),
or using the underlying .param
method.
Types are specified using the literal
gem. In many cases this simply means providing the class of the
expected type, but there are also some other useful types provided by literal
(eg Union
).
These can be either accessed via the Literal
module, eg Literal::Types::BooleanType
:
class MyOperation < ::TypedOperation::Base
param :name, String
param :age, Integer, optional: true
param :choices, Literal::Types::ArrayType.new(String)
param :chose, Literal::Types::BooleanType
end
MyOperation.new(name: "bob", choices: ["st"], chose: true)
or by including the Literal::Types
module into your operation class, and using the aliases provided:
class MyOperation < ::TypedOperation::Base
include Literal::Types
param :name, String
param :age, _Nilable(Integer)
param :choices, _Array(String)
param :chose, _Boolean
end
Type constraints can be modified to make the parameter optional using .optional
.
Your own aliases
Note that you may also like to alias the param methods to your own preferred names in a common base operation class.
Some possible aliases are:
For example:
class ApplicationOperation < ::TypedOperation::Base
class << self
alias_method :arg, :positional_param
alias_method :key, :named_param
end
end
class MyOperation < ApplicationOperation
arg :name, String
key :age, Integer
end
MyOperation.new("Steve", age: 20)
Positional parameters (positional: true
or .positional_param
)
Defines a positional parameter (positional argument passed to the operation when creating it).
The following are equivalent:
param <param_name>, <type>, positional: true, <**options>
positional_param <param_name>, <type>, <**options>
The <para_name>
is a symbolic name, used to create the accessor method, and when deconstructing to a hash.
The <type>
constraint provides the expected type of the parameter (the type is a type signature compatible with literal
).
The <options>
are:
default:
- a default value for the parameter (can be a proc or a frozen value)optional:
- a boolean indicating whether the parameter is optional (default: false). Note you may prefer to use the
.optional
method instead of this option.
Note when positional arguments are provided to the operation, they are matched in order of definition or positional
params. Also note that you cannot define required positional parameters after optional ones.
Eg
class MyOperation < ::TypedOperation::Base
positional_param :name, String, positional: true
positional_param :age, Integer, default: -> { 0 }
def perform
puts "Hello #{name} (#{age})"
end
end
MyOperation.new("Steve").call
MyOperation.with("Steve").call(20)
Named (keyword) parameters
Defines a named parameter (keyword argument passed to the operation when creating it).
The following are equivalent:
param <param_name>, <type>, <**options>
named_param <param_name>, <type>, <**options>
The <para_name>
is a symbol, used as parameter name for the keyword arguments in the operation constructor, to
create the accessor method and when deconstructing to a hash.
The type constraint and options are the same as for positional parameters.
class MyOperation < ::TypedOperation::Base
named_param :name, String
named_param :age, Integer, default: -> { 0 }
def perform
puts "Hello #{name} (#{age})"
end
end
MyOperation.new(name: "Steve").call
MyOperation.with(name: "Steve").call(age: 20)
Using both positional and named parameters
You can use both positional and named parameters in the same operation.
class MyOperation < ::TypedOperation::Base
positional_param :name, String
named_param :age, Integer, default: -> { 0 }
def perform
puts "Hello #{name} (#{age})"
end
end
MyOperation.new("Steve").call
MyOperation.new("Steve", age: 20).call
MyOperation.with("Steve").call(age: 20)
Optional parameters (using optional:
or .optional
)
Optional parameters are ones that do not need to be specified for the operation to be instantiated.
An optional parameter can be specified by:
- using the
optional:
option - using the
.optional
method around the type constraint
class MyOperation < ::TypedOperation::Base
param :name, String
param :age, Integer, optional: true
param :nickname, optional(String)
end
MyOperation.new(name: "Steve")
MyOperation.new(name: "Steve", age: 20)
MyOperation.new(name: "Steve", nickname: "Steve-o")
This .optional
class method effectively makes the type signature a union of the provided type and NilClass
.
Coercing parameters
You can specify a block after a parameter definition to coerce the argument value.
param :name, String, &:to_s
param :choice, Literal::Types::BooleanType do |v|
v == "y"
end
Default values (with default:
)
You can specify a default value for a parameter using the default:
option.
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.
param :name, String, default: "Steve".freeze
param :age, Integer, default: -> { rand(100) }
If using the directive # frozen_string_literal: true
then you string values are frozen by default.
Partially applying (fixing parameters) on an operation (using .with
)
.with(...)
creates a partially applied operation with the provided parameters.
It is aliased to .[]
for an alternative syntax.
Note that .with
can take both positional and keyword arguments, and can be chained.
An important caveat about partial application is that type checking is not done until the operation is instantiated
MyOperation.new(123)
op = MyOperation.with(123)
op.call
Calling an operation (using .call
)
An operation can be invoked by:
- instantiating it with at least required params and then calling the
#call
method on the instance - once a partially applied operation has been prepared (all required parameters have been set), the call
method on
TypedOperation::Prepared
can be used to instantiate and call the operation. - once an operation is curried, the
#call
method on last TypedOperation::Curried in the chain will invoke the operation - calling
#call
on a partially applied operation and passing in any remaining required parameters - calling
#execute_operation
on an operation instance (this is the method that is called by #call
)
See the many examples in this document.
Pattern matching on an operation
TypedOperation::Base
and TypedOperation::PartiallyApplied
implement deconstruct
and deconstruct_keys
methods,
so they can be pattern matched against.
case MyOperation.new("Steve", age: 20)
in MyOperation[name, age]
puts "Hello #{name} (#{age})"
end
case MyOperation.new("Steve", age: 20)
in MyOperation[name:, age: 20]
puts "Hello #{name} (#{age})"
end
Introspection of parameters & other methods
.to_proc
Get a proc that calls .call(...)
#to_proc
Get a proc that calls the #call
method on an operation instance
.prepared?
Check if an operation is prepared
.operation
Return an operation instance from a Prepared operation. Will raise if called on a PartiallyApplied operation
.positional_parameters
List of the names of the positional parameters, in order
.keyword_parameters
List of the names of the keyword parameters
.required_positional_parameters
List of the names of the required positional parameters, in order
.required_keyword_parameters
List of the names of the required keyword parameters
.optional_positional_parameters
List of the names of the optional positional parameters, in order
.optional_keyword_parameters
List of the names of the optional keyword parameters
Using with Rails
You can use the provided generator to create an ApplicationOperation
class in your Rails project.
You can then extend this to add extra functionality to all your operations.
This is an example of a ApplicationOperation
in a Rails app that uses Dry::Monads
:
class ApplicationOperation < ::TypedOperation::Base
include Dry::Monads[:result, :do]
class << self
alias_method :positional, :positional_param
alias_method :named, :named_param
end
named :initiator, optional(::User)
private
def succeeded(value)
Success(value)
end
def failed_with_value(value, message: "Operation failed", error_code: nil)
failed(error_code || operation_key, message, value)
end
def failed_with_message(message, error_code: nil)
failed(error_code || operation_key, message)
end
def failed(error_code, message = "Operation failed", value = nil)
Failure[error_code, message, value]
end
def failed_with_code_and_value(error_code, value, message: "Operation failed")
failed(error_code, message, value)
end
def operation_key
self.class.name
end
end
Using with Action Policy (action_policy
gem)
Base you ApplicationOperation
on the following:
class ApplicationOperation < ::TypedOperation::Base
include ActionPolicy::Behaviour
param :initiator, ::User
authorize :initiator
end
Using with literal
monads
You can use the literal
gem to provide a Result
type for your operations.
class MyOperation < ::TypedOperation::Base
param :account_name, String
param :owner, String
def perform
create_account.bind do |account|
associate_owner(account).map { account }
end
end
private
def create_account
Literal::Success.new(account_name)
end
def associate_owner(account)
Literal::Failure.new(:cant_associate_owner)
end
end
MyOperation.new(account_name: "foo", owner: "bar").call
Using with Dry::Monads
As per the example in Dry::Monads
documentation
class MyOperation < ::TypedOperation::Base
include Dry::Monads[:result]
include Dry::Monads::Do.for(:call)
param :account_name, String
param :owner, ::Owner
def perform
account = yield create_account(account_name)
yield associate_owner(account, owner)
Success(account)
end
private
def create_account(account_name)
end
end
Installation
Add this line to your application's Gemfile:
gem "typed_operation"
And then execute:
$ bundle
Or install it yourself as:
$ gem install typed_operation
Add an ApplicationOperation
to your project
bin/rails g typed_operation:install
Use the --dry_monads
switch to include Dry::Monads[:result]
into your ApplicationOperation
(don't forget to also
add gem "dry-monads"
to your Gemfile)
bin/rails g typed_operation:install --dry_monads
Generate a new Operation
bin/rails g typed_operation TestOperation
You can optionally specify the directory to generate the operation in:
bin/rails g typed_operation TestOperation --path=app/operations
The default path is app/operations
.
The generator will also create a test file.
Contributing
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.
License
The gem is available as open source under the terms of the MIT License.
Code of Conduct
Everyone interacting in the TypedOperation project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the code of conduct.