Composable Operations is a tool set for creating operations and assembling multiple of these operations in operation pipelines. An operation is, at its core, an implementation of the strategy pattern and in this sense an encapsulation of an algorithm. An operation pipeline is an assembly of multiple operations and useful for implementing complex algorithms. Pipelines themselves can be part of other pipelines.
Add this line to your application's Gemfile:
gem 'composable_operations'
And then execute:
$ bundle
Or install it yourself as:
$ gem install composable_operations
Operations can be defined by subclassing ComposableOperations::Operation
and
operation pipelines by subclassing ComposableOperations::ComposedOperation
.
To define an operation, two steps are necessary:
- create a new subclass of
ComposableOperations::Operations
, and - implement the
#execute
method.
The listing below shows an operation that extracts a timestamp in the format
yyyy-mm-dd
from a string.
class DateExtractor < ComposableOperations::Operation
processes :text
def execute
text.scan(/(\d{4})-(\d{2})-(\d{2})/)
end
end
The macro method .processes
followed by a single argument denotes that the
operation expects a single object as input and results in the definition of a
getter method named as specified by this argument. The macro method can also be
called with multiple arguments resulting in the creation of multiple getter
methods. The latter is useful if the operation requires more than one object as
input to operate. Calling the macro method is entirely optional. An operation's
input can always be accessed by calling the getter method #input
. This method
returns either a single object or an array of objects.
There are two ways to execute this operation:
- create a new instance of this operation and call
#perform
, or - directly call
.perform
on the operation class.
The major difference between these two approaches is that in case of a failure
the latter raises an exception while the former returns nil
and sets the
operation's state to failed
. For more information on canceling the execution
of an operation, see below. Please note that directly calling the #execute
method is prohibited. To enforce this constraint, the method is automatically
marked as protected upon definition.
The listing below demonstrates how to execute the operation defined above.
text = "This gem was first published on 2013-06-10."
extractor = DateExtractor.new(text)
extractor.perform # => [["2013", "06", "10"]]
DateExtractor.perform(text) # => [["2013", "06", "10"]]
Assume that we are provided an operation that converts these arrays of strings
into actual Time
objects. The following listing provides a potential
implementation of such an operation.
class DateArrayToTimeObjectConverter < ComposableOperations::Operation
processes :collection_of_date_arrays
def execute
collection_of_date_arrays.map do |date_array|
Time.new(*(date_array.map(&:to_i)))
end
end
end
Using these two operations, it is possible to create a composed operation that
extracts dates from a string and directly converts them into Time
objects. To
define a composed operation, two steps are necessary:
- create a subclass of
ComposableOperations::ComposedOperation
, and - use the macro method
use
to assemble the operation.
The listing below shows how to assemble the two operations, DateExtractor
and
DateArrayToTimeObjectConverter
, into a composed operation named DateParser
.
class DateParser < ComposableOperations::ComposedOperation
use DateExtractor
use DateArrayToTimeObjectConverter
end
Composed operations provide the same interface as normal operations. Hence,
they can be invoked the same way. For the sake of completeness, the listing
below shows how to use the DateParser
operation.
text = "This gem was first published on 2013-06-10."
parser = DateParser.new(text)
parser.perform # => 2013-06-07 00:00:00 +0200
DateParser.perform(text) # => 2013-06-07 00:00:00 +0200
An operation can be halted or aborted if a successful execution is not
possible. Aborting an operation will result in an exception if the operation
was invoked using the class method .perform
. If the operation was invoked
using the instance method #perform
, the operation's state will be updated
accordingly, but no exception will be raised. The listing below provides, among
other things, examples on how to access an operation's state.
class StrictDateParser < DateParser
def execute
result = super
fail "no timestamp found" if result.empty?
result
end
end
class LessStrictDateParser < DateParser
def execute
result = super
halt "no timestamp found" if result.empty?
result
end
end
parser = StrictDateParser.new("")
parser.message # => "no timestamp found"
parser.perform # => nil
parser.succeeded? # => false
parser.halted? # => false
parser.failed? # => true
StrictDateParser.perform("") # => ComposableOperations::OperationError: no timestamp found
parser = LessStricDateParser.new("")
parser.message # => "no timestamp found"
parser.perform # => nil
parser.succeeded? # => false
parser.halted? # => true
parser.failed? # => false
StrictDateParser.perform("") # => nil
Instead of cluttering the #execute
method with sentinel code or in general
with code that is not part of an operation's algorithmic core, we can move this
code into before
or after
callbacks. The listing below provides an alternative
implementation of the StrictDateParser
operation.
class StrictDateParser < DateParser
after do
fail "no timestamp found" if result.empty?
end
end
parser = StrictDateParser.new("")
parser.message # => "no timestamp found"
parser.perform # => nil
parser.failed? # => true
StrictDateParser.perform("") # => ComposableOperations::OperationError: no timestamp found
Operations and composed operations support SmartProperties to conveniently provide additional settings upon initialization of an operation. In the example below, an operation is defined that indents a given string. The indent is set to 2 by default but can easily be changed by supplying an options hash to the initializer.
class Indention < ComposableOperations::Operation
processes :text
property :indent, default: 2,
converts: lambda { |value| value.to_s.to_i },
accepts: lambda { |value| value >= 0 },
required: true
def execute
text.split("\n").map { |line| " " * indent + line }.join("\n")
end
end
Indention.perform("Hello World", indent: 4) # => " Hello World"
Operations that are part of an composed operation can be configured by calling
the .use
method with a hash of options as the second argument. See the
listing below for an example.
class SomeComposedOperation < ComposableOperations::ComposedOperation
# ...
use Indention, indent: 4
# ...
end
- Fork it
- Create your feature branch (
git checkout -b my-new-feature
) - Commit your changes (
git commit -am 'Add some feature'
) - Push to the branch (
git push origin my-new-feature
) - Create new Pull Request