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 itsprocessmethod callsvalidate. - Presenting operations (
form,present, andcollection) don't populate the form - unless
prepopulate!is executed (which onlyformdoes) - or it is executed explicitly.
- Prepopulators cannot access
params - but
validateordeserializecan 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@formis not set and this also means that the contract is not instantiated.collection- a@collectioninstance variable is assigned with the same value as@model;@formis set.form-@formis 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@modelandsetup_model!can augment it. These are called sequentially during instantiation of the operation.model!is usually provided by including theModelmodule.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
paramsin a processing operation by callingvalidatefrom itsprocessmethod. - An alternative to
validateis to useprepopulate!but this doesn't have access toparams - An alternative to
prepopulatethat can useparamsis to callvalidate(ordeserialize) fromsetup_model!. - The
setup_model!hook can be used by a presenting operation to populate its contract fromparamsbyvalidateordeserialize.
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.