These notes record my attempts to understand how and when to populate a Trailblazer contract. They may be incomplete, inaccurate or just plain wrong. They may also be right; I hope they are! Comments are welcome.
It all began with the requirement to seed a presenting contract from the inbound request. Having looked for answers in the Trailblazer book and on Gitter (there was a similar conversation on the 25th September), taking some time to understand some of the Trailblazer code provided the answers and spawned this writing.
- The contract is normally populated by a processing operation (
run
) when itsprocess
method callsvalidate
. - Presenting operations (
form
,present
, andcollection
) don't populate the form - unless
prepopulate!
is executed (which onlyform
does) - or it is executed explicitly.
- Prepopulators cannot access
params
- but
validate
ordeserialize
can be used by presenting operations to populate the form fromparams
. - The
setup_model!
hook is a good place to do this.
When looking at the workflow from a request's perspective, where everything begins with a Rails controller action, the Trailblazer way is for the controller to delegate to an Operation that prepares a form for the controller action's view to render, and the view uses a Cell to render the form.
The typical controller action either renders output or processes input, so it follows that the two basic usage patterns for an operation are preparation of a form for rendering and processing a submitted form. Trailblazer calls these patterns presenting and processing.
The objective here is to understand the operation's inputs and outputs when
used for presenting and processing. Input can come from two places: the
request's params
hash and the application's models. Its outputs are form
and model objects, the latter of which can optionally be saved to the
persistence layer (the database).
An operation is an ephemeral object that is instantiated to perform one of these tasks and is discarded afterwards, so the first step is to instantiate an operation object.
Trailblazer operation classes have class methods that begin an operation.
These methods take two arugments: a params
hash and an optional options
hash. The methods are present
, form
, collection
, run
and reject
.
The params
argument would usually receive the controller's params
hash.
The options
affect instantiation but are not passed into the instantiated
operation object. As such, they aren't discussed here.
The first three methods, present
, form
, and collection
are for
presenting, whereas run
and reject
are for processing. They all set
instance variables in the controller action's namespace that the controller
may use to render its response once the operation completes:
@operation
- the operation object@model
- the underlying model object from the persistence layer@collection
- the same as@model
@form
- the operation's form.
Note also the @form
is the same as @operation.contract
and that @model
is the same as @operation.model
.
Contract and form mean the same thing; contract is a broader term used within an operation because it defines its properties and their validity; the operation does not know if a controller will present its contract as a visual form.
The form
, present
and collection
controller methods initiate an
operation in the same way; they all run the same present
class method
which just calls another class method called build_operation
to instantiate
a new instance of the operation class, passing it the params
hash.
The final step in build_operation
calls setup_operation_instance_variables
which assigns the controller instance variables, @operation
, @model
and
@form
. The first two are simple assignments but the assignment of @form
is what causes the contract to be instantiated.
The form
, present
and collection
controller methods all return the
instantiated operation. They differ only in what happens after the operation
(and possibly its contract) have been instantiated:
present
- nothing else happens but@form
is not set and this also means that the contract is not instantiated.collection
- a@collection
instance variable is assigned with the same value as@model
;@form
is set.form
-@form
is set and any form prepopulators are run.
Note that the contract is instantiated when first accessed; If present
is
used to instantiate the operation then @operation.contract
will instantiate
the contract.
Note also that instantiating the contract does not populate it. That is performed during validation or prepopulation; these are described below.
The run
operation is different. In addition to the abovementioned arguments,
it also accepts an optional block. It runs a run
class method which calls
the same build_operation
class method to instantiate the operation in the
same way as described above, includng instantiation of the contract. It then
calls its run
instance method and yields to the block (if given) if the
run
was successful. It propagates the return value of run
.
The reject
operation is exactly the same as run
except that it yields to
the block if run
was unsuccessful.
The operation instance's run
method calls another instance method called
process
, passing params
to it. This method needs to be implemented by the
operation.
The typical pattern for process
is to call a validate
class method,
passing the params
hash as an argument. The first thing that this does
is ensure that the the contract has been instantiated. It then calls
validate
on the contract and yields to an optional block if the validate
was successful.
Any value returned by process
is ignored; the return value of
run
is an array [result, operation]
where result
is the validity of
the operation and operation
is the operation instance itself.
An operation's validity is returned by valid?
and can be invalidated by
invalid!
, as happens when a validated contract contains errors (its errors
are accessible as the contract.errors
- class Reform::Contract::Errors
which is a subclass of Enumerable
). The contract is only invalid if it
contains errors (contract.errors.empty? == false
).
The primary input to an operation is the controller's params
hash, and
Trailblazer provides the controller action with DSL methods that imply this:
[run|present|form|collection] Operation::Class [do block]
When an operation is instantiated, it receives a hash of parameters that
is accessible when presenting or processing. Instantiation also calls an,
empty by default, setup_params!
method that an operation may implement to
modify the given hash:
def setup_params!(params)
params.merge!(foo: 'bar')
end
This hook happens before the model or form are initialised.
Summary:
setup_params!
is, if required, implemented by the operation.setup_params!
is the first hook in the operation lifecycle.
The model is created during instantiation of the operation by its Setup!
method, which calls build_model!
that, in turn, calls assign_model!
and then setup_model
, the first of which calls a model!
method that it
expects to instantiate the model. These methods all receive the params
hash as an argument but do nothing in a default operation.
An operation may implement these methods directly but the usual approach
is to use the Model
module with code similar to the below:
include Model
model Comment, :create
The Model
module implements the model!
method as well as a model
command to tell the operation its model's name and how model!
should
instantiate it.
(The Model
module was originally called CRUD
and is referred to as
such in the Trailblazer book.)
The above example configures the operation to use a create
action to
instantiate a Comment
model. The supported actions are create
, which
instantiates a new (empty) model object, and update
(also aliased as find
)
that uses params[:id]
to instantiate an existing (populated) one.
The action can also be specified separately with an action
command and
inherited operations use this to specify another action (like action :update
in an update operation).
An operation's model is usually an ActiveRecord (in a Rails application) object but can be any kind of object (e.g. ROM, PORO), or no object at all.
The setup_model!
method is provided as a way to augment the instantiated
model. An operation may implement this to perform its own setup tasks and
a typical usage pattern does this to instantiate dependent objects (e.g.
where a model has a has_many
relationship with another).
model!
sets up@model
andsetup_model!
can augment it. These are called sequentially during instantiation of the operation.model!
is usually provided by including theModel
module.setup_model!
is, if required, implemented by the operation.setup_model!
is the second hook in the operation lifecycle.
An operation may define a form (aka contract), a disposable
twin of the model. If not defined explicitly then the contract is an
instance of Reform::Form
.
A contract declaration takes the form:
contract [class] block
The class
, if given, refers to an externally defined Reform class,
however the usual style is to use the block to define the contract. A contract
contains properties that may be nested. Properties are attributes but
not all attributes are properties - only those explicitly declared as such.
The form is created empty when the operation is instantiated. It can be
populated (which assigns values to its properties) in two ways,
prepopulation and validation, the latter of which is usually invoked
when processing the operation (run
).
Before validate
runs, the model is loaded from the database during the
instantiation of the contact using params[:id]
to load a specific record.
None of the other params
are used at this point.
Validate is usually passed the subset of params
that relates to the form:
def process(params)
validate(params[:comment]) do...
end
Populating the contract from the params
passed to validate
occurs in a
private deserialize
method within the contract's Reform::Form
ancestor.
Prepopulation occurs only if the operation is instantiated using form
or
if contract.prepopulate!
is called explicitly. Prepopulation invokes per-
property prepopualtors (a method or lambda) that can directly modify the
the contract's properties:
property :some_prop, prepopulator: ->(*) { self.some_prop = 'a value' }
A prepopulator method has one argument, a normally-empty options hash, and
is attached to the property it's meant to be used to alter. However, it has
access to the whole contract via its self
so can update any of its
properties:
property :some_prop, prepopulator: ->(*) { self.some_other_prop = 'a value' }
But the params
hash isn't available in a prepopulator. Two ways that a
contract can be prepopulated from params
involve using the operation hook
setup_model!
to either seed model
from params
or to run the contract's
deserialize
method. The setup_model!
hook is used because it is invoked
after instantiating the model but before instantiating the contract. Seeding
the model from params
goes like this:
model.attributes = params.slice(:property1,:property2,:propertry3).permit!
model.relation.build(params.slice(:property4, :property5).permit!)
Mass-updating model attributes requires the delicate dance around the strong
parameters of ActiveRecord. The deserialize
method does not suffer this
pain but it is a private method. One way to execute it is by running
validate
and then clearing any errors raised (since they relate to
processing rather than presenting the operation):
contract.errors.clear unless contract.validate(params)
A (slightly, just a little bit, wrong?) alternative is to use send
to call
the private method directly:
contract.send(:deserialize, params)
It's possible, also, to invoke prepopulators from setup_model!
:
contract.prepopulate!
If both deserialize
and prepopulate
are used then the order that they
are used in is important if both update the same properties.
Any differences between the contract's properties and the current state of the model are identified as changes, as can be seen by
contract.changed
- The contract is usually populated from
params
in a processing operation by callingvalidate
from itsprocess
method. - An alternative to
validate
is to useprepopulate!
but this doesn't have access toparams
- An alternative to
prepopulate
that can useparams
is to callvalidate
(ordeserialize
) fromsetup_model!
. - The
setup_model!
hook can be used by a presenting operation to populate its contract fromparams
byvalidate
ordeserialize
.
There is no contract-level prepopulator. This does not work:
contract, prepopulator: ->(*) { self.some_attr = 'some value' }
It would be helpful if the deserialize
private method could be made public.
A proper presenter hook in the operation that is only called on presenting operations would allow presenter-specific tasks to be performed without impacting the processing operation, e.g.
def present
desearialize(params)
end
Currently, ensuring this requires use of build
to instantiate a subclass
operation in speciic circumstances.