FormObject
This shard gives you an opportunity to separate form data from your model. Also you can move ny data-specific validation to form object level and be free from coercing data from the request instance - it will take care of it.
ATM FormObject is designed to be used in air with Jennifer ORM but can be also used as ORM-agnostic tool but with some limitations.
Installation
Add this to your application's shard.yml
:
dependencies:
form_object:
github: imdrasil/form_object
Usage
Require FromObject somewhere after Jennifer:
require "jennifer"
# ...
require "form_object"
require "form_object/coercer/pg" # if you are going to use PG::Numeric
Also it is important to notice that form_object
modifies HTTP::Request
core class to store body in private variable @cached_body : IO::Memory?
of maximum size 1 GB. This is done because to allow request body multiple reading.
Defining Form
Forms are defined in the separate classes. Often (but not necessary) these classes are pretty similar to related models:
class PostForm < FormObject::Base(Post)
attr :title, String
end
Use .attr
macro to define a field.
Also you can specify any validation supported by Jennifer model.
class PostForm < FormObject::Base(Post)
attr :title, String
validates_length :title, in: 1...255
end
f = PostForm.new(Post.new)
f.title = "a" * 255
f.valid? # false
f.errors # Jennifer::Model::Errors
Resource model translation messages are used for the form.
Nesting
To define nested object use .object
macro:
class AddressForm < FormObject::Base(Address)
attr :street, Address
end
class ContactForm < FormObject::Base(Contact)
object :address, Address
end
For collection use .collection
macro.
Populators
In #verify
, nested hash is passed. Form object by default will try to match nested hashes to the nested forms. But sometimes the incoming hash and the existing object graph are not matching 1-to-1. That's where populators will help you.
You have to declare a populator when the form has to deserialize nested input. ATM populator may be only a method name.
Populator is called only if an incoming part for particular object is present.
# request with { addresses: [{ :street => "Some street" }]} payload
form.verify(request) # will call populator once
# request with { addresses: [] of String} payload
form.verify(request) # will not call populator
Populator for collection is executed for every collection part in the incoming hash.
class ContactForm < FormObject::Base(Contact)
collection :addresses, Address, populator: :address_populator
def address_populator(collection, index, **opts)
if item = collection[index]?
item
else
item = AddressForm.new(Address.new({contact_id: resource.id}))
collection << item
item
end
end
This populator checks if a nested form is already existing by using collection[index]?
. While the index
argument represents where we are in the incoming array traversal, collection
is identical to self.addresses
.
It is very important that each populator invocation returns the form not the model.
Delete
Populators can not only create, but also destroy. Let's say the following input is passed in.
# request with the { addresses: [{:street => "Street", :id => 2, :_delete => "1" }] } payload
form.verify(request)
You can implement your own deletion:
class ContactForm < FormObject::Base(Contact)
collection :addresses, Address, populator: :address_populator
property ids_to_destroy : Array(Int32)
def address_populator(context, **opts)
item = addresses.find { |address| address.id == context["id"] }
if context["_delete"]
addresses.delete(item)
ids_to_destroy << item.id
skip
end
if item
item
else
item = AddressForm.new(Address.new)
collection << item
item
end
end
def persist
super.tap do |result|
next unless result
ids = ids_to_destroy
Address.where { _id.in(ids) }.destroy
end
end
end
Skip
Populators can skip processing of a part by invoking #skip
. This method raises FormObject::SkipException
which makes form object to ignore particular part.
Reusability
To reuse common attributes or functionality you can use modules inclusion and inheritance:
module PostTitle
include FormObject::Module
attr :title, String
end
module PostText
include FormObject::Module
attr :text, String
end
module BasePostAttributes
include PostTitle
include PostText
end
class PostForm < FormObject::Base(Post)
include BasePostAttributes
attr :release_date, Time
validates_length :title, in: 1...255
end
class AdvancedPostForm < PostForm
attr :likes, Int32
end
Create Form
class PostsController < ApplicationController
def edit
@form = PostForm.new(Post.find!(params["id"]))
render("edit.slang")
end
end
Form will automatically read attributes from the model.
Validation
To save model you should validate input data:
class PostsController < ApplicationController
def create
@form = PostForm.new(Post.new)
if @form.verify(request) && @form.save
flash["success"] = "Created Post successfully."
redirect_to "/posts"
else
flash["danger"] = "Could not create Post!"
render("new.slang")
end
end
end
The #verify
method parses data from the given request object and updates form attributes - the underlying model at this step remains unchanged. Next if runs defined validations and returns whether they succeed.
Data Synching
After validation you can call #save
(as in example above) and let FormObject take care of model persistence. Also you can use #sync
to only write attributes from form to the resource and do everything else by your own.
Custom Persistence Mechanism
You can define your own way of model persistence at the form level implementing own #persist
method:
class PostForm < FormObject::Base(Post)
attr :title, String
def persist
resource.save
# some other logic goes here
end
end
Contributing
- Fork it (<https://github.com/imdrasil/form_object/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
Contributors
FormObject is heavily inspired by reform ruby gem.
- imdrasil Roman Kalnytskyi - creator, maintainer