Hathor Operation

Structuring complex procedures within an instance. Push up readability and maintainability!

Inspired by Ruby Trailblazer Operations.

About

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.

Hathor Contracts

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.

Installation

Add this to your application's shard.yml:

  hathor-operation:
    github: ikaru5/hathor-operation
    version: ~> 0.2.1

Usage

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

Goals

result, params and other args

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!

Inheritance

You can inherit methods like you normally do it with Crystal. Macros will not be inherited.

Class API

# 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

Instance API

success?

Get the current state of operation. Can be used within internal methods.

operation.success? # => Bool
# or within a step
success? # => Bool

success?(:step, :step_type)

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

failure?

Get the current state of operation. Can be used within internal methods.

operation.failure? # => Bool
# or within a step
failure? # => Bool

failure?(:step, :step_type)

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

log

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

update_operation_state

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)

run

Will call the steps and control the flow, by checking operation state and updating it using update_operation_state.

operation.run # returns self

Macros

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

step

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

failure

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

success

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

policy

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

policy!

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

Nested Steps

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.

Passing Variables

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.

Default behavior

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.

Input and Output options

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

Sync Option

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

Operation Logger

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.

Access the logs

entries

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

success?(:step, :step_type)

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

failure?(:step, :step_type)

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

to_s

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

Custom Messages

Simply add custom messages by using add method.

# add(message : String)
@log.add "my custom message"

Development

Contributing

  1. Fork it (https://github.com/your-github-user/schemas/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors and Contact

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.

Thanks

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

Copyright (c) 2021 Kirill Kulikov [email protected]

hathor-operation is released under the MIT License.