Structuring complex procedures within an instance. Push up readability and maintainability!
Inspired by Ruby Trailblazer Operations.
- About
- Hathor Contracts
- Installation
- Usage
- Goals
- result, params and other args
- Inheritance
- Class API
- Instance API
- Macros
- Nested Steps
- Operation Logger
- Development
- Contributing
- Contributors and Contact
- Thanks
- Copyright
If you are coming from the Ruby and Rails world, you probably heard of or used Trailblazer. It adds an additional abstraction level to encapsulate your business code from the framework and adds nice syntactic sugar.
On MVC you often may run into the question: "Does this complex procedure go into the controller or the model?" Operations are the answer to this! Keep your business code out of framework dependency and write it in a beautiful and readable way.
If you are looking for Trailblazer-like Contracts or Representers, you may also have a look at Hathor Contracts. The shards are decoupled and have no dependencies to each other.
Add this to your application's shard.yml
:
hathor-operation:
github: ikaru5/hathor-operation
version: ~> 0.2.1
require "hathor-operation" # to avoid this every time, create a base class and inherit from it
class World::Create < Hathor::Operation
property model : World | Nil
property size_x : Int32
property size_y : Int32
def initialize(@size_x : Int32, @size_y : Int32); end
policy! enough_ressources? # strict policy -> if it fails operation will stop there
policy permitted? # simple policy -> considered as a simple step, but other name
step model! # step -> if fails, other steps won't run
step validate!
step persist!
success send_email! # success -> runs only if all previous steps successful, doesn't change state itself
failure log! # failure -> runs only if a step or simple policy failed, doesn't change state itself
# define all methods
def enough_ressources?; true; end
def permitted?; true; end
def model!; true; end
def validate!; true; end
def persist!; true; end
def send_email!; true; end
def log
puts @log.to_s # use the Operation Logger to get steps
end
end
# ...
operation = World::Create.run(size_x: 10, size_y: 10)
# or
operation = World::Create.new(size_x: 10, size_y: 10).run
operation.success? # => true
- Performance: Since you are using Crystal you are probably looking for something faster than Ruby. So the main goal is not compromising performance in favor of syntactic sugar.
- Maintainability: Crystal is changing pretty fast, so a lot of things may seem redundant and the code may take a few more lines than needed.
- Clarity and Comprehensibility: Hathor does not aim to be Crystal's high-level architecture. Its a tiny lib for syntactic sugar in big and small projects.
If you know Trailblazer Operation you may expect a result class and options/ctx with params in your methods. But right now, there is no way to do this without compromising performance. Pass parameters like you would usually do for classes and make them instance variables.
For example:
Define them with property
macro:
property model : User | Nil
Define the initialize
method like shown in Usage to pass params.
Hathor Operations return their instance and not a result.
If you need something from the inside, than just access it through a getter. (property
macro will create one)
If you want to avoid private variables for nil checks you can use the property!
macro:
property! model : World
def model!
self.model = World.find(1)
end
def do_something!
# when using normal property macro this would not compile
model.size_x * model.size_y
end
I think this is a clean way for doing things like this in Crystal. If you have other ideas, share them with me! Or contribute!
You can inherit methods like you normally do it with Crystal. Macros will not be inherited.
# run
# run is shortcut to instantiate and run the operation, returns instance of operation
operation = World::Create.run(size_x: 10, size_y: 10)
# simply create a new empty operation instance without running it
# you may want to populate some properties before running it or something
operation = World::Create.new
operation.size_x = 10
operation.size_y = 10
operation.run
Get the current state of operation. Can be used within internal methods.
operation.success? # => Bool
# or within a step
success? # => Bool
Get the output state of a step. Can be used within internal methods.
Is a shortcut to @log.success?(step_name, step_type = nil)
.
Learn more
operation.success?(:data_valid?, :policy) # => Bool
# or within a step or an method
success?(:model!) # => Bool
Get the current state of operation. Can be used within internal methods.
operation.failure? # => Bool
# or within a step
failure? # => Bool
Get the output state of a step. Can be used within internal methods.
Is a shortcut to @log.failure?(step_name, step_type = nil)
.
Learn more
operation.failure?(:data_valid?, :policy) # => Bool
# or within a step or an method
failure?(:model!) # => Bool
Getter to OperationLogger instance. Learn more: Logger
operation.log.to_s # returns a formatted String with a list of all steps run and custom messages
# or within a step
@log.add "Custom Message" # will add a custom message to Logger
Used internally for flow control and logging. but can also be used to force a new state.
# update_operation_state(new_status : Bool, log_reason = "updated without submitting reason", force = false)
operation.update_operation_state(true, "I SAID IT DID NOT FAIL!", true)
Will call the steps and control the flow, by checking operation state and updating it using update_operation_state
.
operation.run # returns self
All macros are written to be straight forward and most importantly fast during resulting execution.
The current macros build the instance method run
during compilation, not execution! Thats great for performance.
Instead of passing a method name it's also possible to pass another nested operation. Learn more
Will call the method provided. Method must return something that is not Nil
and not false
to be passed!
If it fails, operation state will change to failing.
If a step fails, following steps won't be executed.
# macro step(method, **options)
step some_method_name
A failure step will only run if operation changed it's state to failing. The failure step itself, always passes.
Internally it will call step macro with step method, step_type: :failure
.
# macro failure(method, **options)
failure some_method_name
A success step will only run if operation is in success state. The success step itself, always passes.
Internally it will call step macro with step method, step_type: :success
.
# macro success(method, **options)
success some_method_name
A policy step is the same as a normal step, but will produce an according log message. It may get more features in future releases.
Internally it will call step macro with step method, step_type: :policy
.
# macro policy(method, **options)
policy some_method_name
A policy!
is a strict policy. This means, if it fails, the whole execution will be stopped and even failure steps won't be called.
Internally it will call step macro with step method, step_type: :strict_policy
.
# macro policy!(method, **options)
policy! some_method_name
Instead of passing a method to one of the above step macros you can also pass another Hathor::Operation
class. This will add a method to your operation that executes the nested operation and returns whether the operation was successful.
Simplified a generated method would look like this:
def nested_run!
operation = Your::Nested::Operation.new
operation.run
operation.success?
end
The operation that was run will be available as a property!
on the parent operation and is named using the class name of the nested operation (underscored). If you run operations multiple times an index will be added to the name. An example:
class Base < Hathor::Operation
step Nested
success Nested
step do_something!
def do_something!
# you can access this.nested and this.nested_2 in this method
end
end
NOTE: Hathor will not catch cyclic nested dependencies. You have to take care of that by yourself.
By default Hathor passes all properties between both operations that occur in the constructor of the nested operation. It's also possible to explicitly pass variables by using the options input
, output
and sync
. Note that shallow copies of the properties will always be passed.
If no explicit option is given, Hathor will pass all properties to the nested operation that are in it's constructor and are available in the base operation. See the following example:
class Base < Hathor::Operation
property x = "nested", y = ""
step Nested
end
class Nested < Hathor::Operation
property x : String
property y
def initialize(@x, @y = ""); end
step pass_to_y!
def pass_to_y!
@y = x
end
end
The x
property of the Base operation will be implicitly passed to the nested operation. The y
property will be written back to the Base operation after being modified by the nested operation. This means y
in Base will be "nested"
after execution.
It's possible to explicitly control property-flow by using the input and output options. Both options accept Tuples and NamedTuples. One may also use Arrays or Hashes in the same way.
When using Tuples or Arrays Hathor expects both operations to have the same matching property.
If the properties have different names you can pass a NamedTuple or a Hash to rename properties. The key will always be the property name of the nested operation, while the value has to be the property of the base operation.
class Base < Hathor::Operation
property x = 1, z = 0
step Nested, input: { y: x }, output: { z }
end
class Nested < Hathor::Operation
property y : Int32, z = 0
def initialize(@y); end
step add!
def add!
@z = @y + 5
end
end
Using the sync option Hathor will pass a variable to the nested operation and write it back to the base operation. It's similar to passing the same value to the input and output options.
class Base < Hathor::Operation
property x = 1
# step Nested, input: { y: x }, output: { y: x } will be the same as
step Nested, sync: { y: x }
end
class Nested < Hathor::Operation
property y : Int32
def initialize(@y); end
step add!
def add!
@y += 5
end
end
The Operation Logger is a class, witch is initialized with the operation and
can be accessed through the log
property.
The first entry is created on initialize and shows that the operation has started.
It is used for logging the internal flow, but can also be used to log some custom messages.
The logs can be accessed through entries
:
# entries(steps_only = false)
operation.log.entries
# => Array({
# status: Bool | Nil,
# reason: String | Nil,
# step: Symbol | Nil,
# step_type: Symbol | Nil,
# force: Bool | Nil,
# message: String | Nil
# })
# example:
# [
# {status: true, reason: "Start TestOperationBasicsTest::TestOperationWithPolicy", step: nil, step_type: nil, force: false, message: nil},
# {status: true, reason: "policy: return_param!", step: :return_param!, step_type: :policy, force: false, message: nil},
# {status: nil, reason: nil, step: nil, step_type: nil, force: nil, message: "Custom Message"},
# {status: false, reason: "strict_policy: return_other_param!", step: :return_other_param!, step_type: :strict_policy, force: false, message: nil}
# ]
operation.log.entries(true) # => will filter all custom messages
Gets the output state of a step by iterating through log entries.
NOTE: success
steps for example do not change the state, so checking them with this is most likely useless.
NOTE: Testing an undefined step, will return that it failed.
NOTE: It will return the state of the first found occurrence. Pay attention if you call same step twice!
There is a shortcut at the operation itself. Learn more
# def success?(step_name : Symbol, step_type : Symbol | Nil = nil)
operation.log.success?(:data_valid?, :policy) # => Bool
# or within a step or an method
@log.success?(:model!) # => Bool
Gets the output state of a step by iterating through log entries. Calls !success(:step, :step_type)
internally.
There is a shortcut at the operation itself. Learn more
You can also get a formatted output with to_s
. Great for logging and debug.
# to_s(one_line = false, steps_only = false)
# one_line = true => output won't have linebreaks
# steps_only = true => output won't have custom messages
operation.log.to_s # =>
# >> Start TestOperationBasicsTest::TestOperationWithPolicy -> 'true'
# >> policy: return_param! -> 'true'
# log message: Custom Message
# >> strict_policy: return_other_param! -> 'false'
# >> Operation End
# or within the operation
@log.to_s
Simply add custom messages by using add
method.
# add(message : String)
@log.add "my custom message"
- better logging and output to console and logs
- maybe some more macros
- a beautiful way to pass parameters - maybe there is none
- ... even more possibilities
- Fork it (https://github.com/your-github-user/schemas/fork)
- 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 a new Pull Request
If you have ideas on how to develop hathor more or what features it is missing, I would love to hear about it. You can always contact me on gitter @ikaru5 or E-Mail.
- @richardboehme Richard Böhme - maintainer
- @ikaru5 Kirill Kulikov - creator, maintainer
I want to say a big Thank You to George Dietrich gitter @Blacksmoke16! He helped me to start with Crystal and macros. Answers questions professionally in no time! Jon Skeet of Crystal world for me. :)
Copyright (c) 2021 Kirill Kulikov k.kulikov94@gmail.com
hathor-operation
is released under the MIT License.