FormObj
Form Object allows to define a complicated data structure (using nesting, arrays) and use it with Rails-compatible form builders. A Form Object can be serialized and deserialized to a model and/or a hash.
Compatibility and Dependency Requirements
Ruby: 2.2.8+ ActiveSupport: 3.2+ ActiveModel: 3.2+
The gem is tested against all ruby versions and all versions of its dependencies except ActiveSupport and ActiveModel version 4.0.x because they requires Minitest 4 which is not compatible with Minitest 5.
Installation
Add this line to your application's Gemfile:
gem 'form_obj'
And then execute:
$ bundle
Or install it yourself as:
$ gem install form_obj
Usage
WARNING!!! The gem is still under development. Expect braking changes in FormObj::ModelMapper module.
Class FormObj::Struct allows to describe complicated data structure, to update it with update_attributes method and to get its hash representation with to_hash method.
Class FormObj::Form inherits from FormObj::Struct and adds form builder compatibility and includes ActiveModel validations.
Module FormObj::ModelMapper being included into FormObj::Form descendants allows to map a form object to a model
in order to be able to exchange attributes value between them:
- load attributes value from the model,
- sync attributes value to the model,
- represent a form object as a model hash (similar to the
to_hashmethod but using the model attributes name) and - copy errors from the model(s) to the from object.
Table of Contents
FormObj::StructFormObj::FormFormObj::ModelMapperload_from_model- Initialize Form Object from Modelload_from_models- Initialize Form Object from Few Models- Do Not Map Certain Attribute
- Map Nested Form Objects
- Map Nested Form Objects to A Hash Model
- Custom Implementation of Loading of Array of Models
- Sync Form Object to Models
- Serialize Form Object to Model Hash
- Copy Model Validation Errors into Form Object
- Rails Example
- Reference Guide
1. FormObj::Struct
Inherit your class from FormObj::Struct and define its attributes.
class Team < FormObj::Struct
attribute :name
attribute :year
end
Read and write attribute values using dot-notation.
team = Team.new # => #<Team name: nil, year: nil>
team.name = 'Ferrari' # => "Ferrari"
team.year = 1950 # => 1950
team.name # => "Ferrari"
team.year # => 1950
Initialize attributes in constructor.
team = Team.new(
name: 'Ferrari',
year: 1950
) # => #<Team name: "Ferrari", year: 1950>
team.name # => "Ferrari"
team.year # => 1950
Update attributes using update_attributes method.
team.update_attributes(
name: 'McLaren',
year: 1966
) # => #<Team name: "McLaren", year: 1966>
team.name # => "McLaren"
team.year # => 1966
In both cases (initialization or update_attributes) hash is transformed to HashWithIndifferentAccess before applying its values
so it doesn't matter whether keys are symbols or strings.
team.update_attributes(
'name' => 'Ferrari',
'year' => 1950
) # => #<Team name: "Ferrari", year: 1950>
Attribute value stays unchanged if hash doesn't have corresponding key.
team = Team.new(name: 'Ferrari') # => #<Team name: "Ferrari", year: nil>
team.update_attributes(year: 1950) # => #<Team name: "Ferrari", year: 1950>
Exception UnknownAttributeError is raised if there is key that doesn't correspond to any attribute.
Team.new(name: 'Ferrari', a: 1) # => FormObj::UnknownAttributeError: a
Team.new.update_attributes(a: 1) # => FormObj::UnknownAttributeError: a
Use parameter raise_if_not_found: false in order to avoid exception and silently skip unknown key in the hash.
team = Team.new({
name: 'Ferrari',
a: 1
}, raise_if_not_found: false) # => #<Team name: "Ferrari", year: nil>
team.update_attributes({
name: 'McLaren',
a: 1
}, raise_if_not_found: false) # => #<Team name: "McLaren", year: nil>
Define default attribute value using default parameter.
Use Proc to calculate default value dynamically.
Proc is calculated only once at the moment of first access to attribute.
Proc receives two arguments:
struct_class- class (!!! not an instance) where attribute is definedattribute- internal representation of attribute
class Team < FormObj::Struct
attribute :name, default: 'Ferrari'
attribute :year, default: ->(struct_class, attribute) { struct_class.default_year(attribute) }
def self.default_year(attribute)
"#{attribute.name} = 1950"
end
end
team = Team.new # => #<Team name: "Ferrari", year: "year = 1950">
team.name # => "Ferrari"
team.year # => "year = 1950"
1.1. Nesting FormObj::Struct
Use blocks to define nested structs.
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :car do
attribute :code
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
Or explicitly define nested struct classes.
class Engine < FormObj::Struct
attribute :power
attribute :volume
end
class Car < FormObj::Struct
attribute :code
attribute :driver
attribute :engine, class: Engine
end
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :car, class: Car
end
Read and write attribute values using dot-notation.
team = Team.new # => #<Team name: nil, year: nil, car: #< code: nil, driver: nil, engine: #< power: nil, volume: nil>>>
team.name = 'Ferrari' # => "Ferrari"
team.year = 1950 # => 1950
team.car.code = '340 F1' # => "340 F1"
team.car.driver = 'Ascari' # => "Ascari"
team.car.engine.power = 335 # => 335
team.car.engine.volume = 4.1 # => 4.1
team.name # => "Ferrari"
team.year # => 1950
team.car.code # => "340 F1"
team.car.driver # => "Ascari"
team.car.engine.power # => 335
team.car.engine.volume # => 4.1
Initialize nested struct using nested hash.
team = Team.new(
name: 'Ferrari',
year: 1950,
car: {
code: '340 F1',
driver: 'Ascari',
engine: {
power: 335,
volume: 4.1,
}
}
) # => #<Team name: "Ferrari", year: 1950, car: #< code: "340 F1", driver: "Ascari", engine: #< power: 335, volume: 4.1>>>
team.name # => "Ferrari"
team.year # => 1950
team.car.code # => "340 F1"
team.car.driver # => "Ascari"
team.car.engine.power # => 335
team.car.engine.volume # => 4.1
Update nested struct using nested hash.
team.update_attributes(
name: 'McLaren',
year: 1966,
car: {
code: 'M2B',
driver: 'Bruce McLaren',
engine: {
power: 300,
volume: 3.0
}
}
) # => #<Team name: "McLaren", year: 1966, car: #< code: "M2B", driver: "Bruce McLaren", engine: #< power: 300, volume: 3.0>>>
team.name # => "McLaren"
team.year # => 1966
team.car.code # => "M2B"
team.car.driver # => "Bruce McLaren"
team.car.engine.power # => 300
team.car.engine.volume # => 3.0
Use hash to define default value of nested struct defined with block.
class Team < FormObj::Struct
attribute :car, default: { code: '340 F1', driver: 'Ascari' } do
attribute :code
attribute :driver
end
end
team = Team.new # => #<Team car: #< code: "340 F1", driver: "Ascari">>
team.car.code # => "340 F1"
team.car.driver # => "Ascari"
Use hash or struct instance to define default value of nested struct defined with class.
class Car < FormObj::Struct
attribute :code
attribute :driver
end
class Team < FormObj::Struct
attribute :car, class: Car, default: Car.new(code: '340 F1', driver: 'Ascari')
end
team = Team.new # => #<Team car: #<Car code: "340 F1", driver: "Ascari">>
team.car.code # => "340 F1"
team.car.driver # => "Ascari"
The struct instance class should correspond to nested attribute class!
class Team < FormObj::Struct
attribute :car, class: Car, default: 36
end
Team.new # => FormObj::WrongDefaultValueClass: FormObj::WrongDefaultValueClass
1.2. Array of FormObj::Struct
Use parameter array: true in order to define an array of nested structs.
Define primary_key so that update_attribute method be able to distinguish
whether to update existing array element or create a new one.
By default attribute id is considered to be a primary key.
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :cars, array: true do
attribute :code, primary_key: true # <- primary key is specified on attribute level
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
or
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :cars, array: true, primary_key: :code do # <- primary key is specified on struct level
attribute :code
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
or
class Engine < FormObj::Struct
attribute :power
attribute :volume
end
class Car < FormObj::Struct
attribute :code, primary_key: true # <- primary key is specified on attribute level
attribute :driver
attribute :engine, class: Engine
end
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :cars, array: true, class: Car
end
or
class Engine < FormObj::Struct
attribute :power
attribute :volume
end
class Car < FormObj::Struct
attribute :code
attribute :driver
attribute :engine, class: Engine
end
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :cars, array: true, class: Car, primary_key: :code # <- primary key is specified on struct level
end
Read and write attribute values using dot-notation.
Add new elements in the array using method create.
team = Team.new # => #<Team name: nil, year: nil, cars: []>
team.name = 'Ferrari' # => "Ferrari"
team.year = 1950 # => 1950
team.cars.size # => 0
car1 = team.cars.create # => #< code: nil, driver: nil, engine: #< power: nil, volume: nil>>
team.cars.size # => 1
car1.code = '340 F1' # => "340 F1"
car1.driver = 'Ascari' # => "Ascari"
car1.engine.power = 335 # => 335
car1.engine.volume = 4.1 # => 4.1
car2 = team.cars.create # => #< code: nil, driver: nil, engine: #< power: nil, volume: nil>>
team.cars.size # => 2
car2.code = '275 F1' # => "275 F1"
car2.driver = 'Villoresi' # => "Villoresi"
car2.engine.power = 330 # => 330
car2.engine.volume = 3.3 # => 3.3
team.name # => "Ferrari"
team.year # => 1950
team.cars[0].code # => "340 F1"
team.cars[0].driver # => "Ascari"
team.cars[0].engine.power # => 335
team.cars[0].engine.volume # => 4.1
team.cars[1].code # => "275 F1"
team.cars[1].driver # => "Villoresi"
team.cars[1].engine.power # => 330
team.cars[1].engine.volume # => 3.3
Initialize attributes using hash with array of hashes.
team = Team.new(
name: 'Ferrari',
year: 1950,
cars: [
{
code: '340 F1',
driver: 'Ascari',
engine: {
power: 335,
volume: 4.1,
}
}, {
code: '275 F1',
driver: 'Villoresi',
engine: {
power: 330,
volume: 3.3,
}
}
],
) # => #<Team name: "Ferrari", year: 1950, cars: [#< code: "340 F1", driver: "Ascari", engine: #< power: 335, volume: 4.1>>, #< code: "275 F1", driver: "Villoresi", engine: #< power: 330, volume: 3.3>>]>
team.name # => "Ferrari"
team.year # => 1950
team.cars[0].code # => "340 F1"
team.cars[0].driver # => "Ascari"
team.cars[0].engine.power # => 335
team.cars[0].engine.volume # => 4.1
team.cars[1].code # => "275 F1"
team.cars[1].driver # => "Villoresi"
team.cars[1].engine.power # => 330
team.cars[1].engine.volume # => 3.3
Update attributes using hash with array of hashes.
team.update_attributes(
name: 'McLaren',
year: 1966,
cars: [
{
code: '275 F1',
driver: 'Bruce McLaren',
engine: {
volume: 3.0
}
}, {
code: 'M7A',
driver: 'Denis Hulme',
engine: {
power: 415,
}
}
],
) # => #<Team name: "McLaren", year: 1966, cars: [#< code: "M2B", driver: "Bruce McLaren", engine: #< power: nil, volume: 3.0>>, #< code: "M7A", driver: "Denis Hulme", engine: #< power: 415, volume: nil>>]>
team.name # => "McLaren"
team.year # => 1966
team.cars[0].code # => "275 F1"
team.cars[0].driver # => "Bruce McLaren"
team.cars[0].engine.power # => 330 - this value was not updated in :update_attributes method
team.cars[0].engine.volume # => 3.0
team.cars[1].code # => "M7A"
team.cars[1].driver # => "Denis Hulme"
team.cars[1].engine.power # => 415
team.cars[1].engine.volume # => nil - this value is nil because this car was created in :updated_attributes method
Use primary_key method on class to get primary key attribute name.
Use primary_key and primary_key= method on instance to get and set primary key attribute value.
Team.primary_key # => :id - By default primary key is :id even if there is no such attribute
Car.primary_key # => :code
team.cars.first.primary_key # => "275 F1"
team.cars.last.primary_key # => "M7A"
update_attributes compares present elements in the array with new elements in hash by using primary key.
By default update_attributes:
- calls attribute setter under hood to update attribute value of present elements,
- calls
FormObj::Structconstructor to create all new elements (that exists in the hash but absent in the present array), - calls
delete_ifto delete all removed elements (that exists in the present array but absent in the hash).
Default behaviour could be easily redefined by overwriting corresponding methods.
class MyStruct < FormObj::Struct
class Array < FormObj::Struct::Array
private
def create_item(hash, raise_if_not_found:)
puts "Create new element from #{hash}"
super
end
def delete_items(ids)
each do |item|
if ids.include? item.primary_key
item._destroy = true
puts "Mark item #{item.primary_key} for deletion"
end
end
end
end
def self.array_class
MyStruct::Array
end
def self.nested_class
MyStruct
end
private
def update_attribute(attribute, new_value)
puts "Update attribute :#{attribute.name} value from #{send(attribute.name)} to #{new_value}"
super
end
end
class Team < MyStruct
attribute :name
attribute :year
attribute :cars, array: true, primary_key: :code do
attribute :code
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
attr_accessor :_destroy
end
end
team = Team.new(name: 'Ferrari', cars: [{ code: '340 F1' }, { code: '275 F1' }])
# => Update attribute :name value from to Ferrari
# => Create new element from {"code"=>"340 F1"}
# => Update attribute :code value from to 340 F1
# => Create new element from {"code"=>"275 F1"}
# => Update attribute :code value from to 275 F1
# => => #<Team name: "Ferrari", year: nil, cars: [#< code: "340 F1", driver: nil, engine: #< power: nil, volume: nil>>, #< code: "275 F1", driver: nil, engine: #< power: nil, volume: nil>>]>
team.update_attributes(cars: [{ code: '275 F1' }])
# => Update attribute :code value from 275 F1 to 275 F1
# => Mark item 340 F1 for deletion
# => => #<Team name: "Ferrari", year: nil, cars: [#< code: "340 F1", driver: nil, engine: #< power: nil, volume: nil>>, #< code: "275 F1", driver: nil, engine: #< power: nil, volume: nil>>]>
Use array of hashes to define default array of nested structs defined with block.
class Team < FormObj::Struct
attribute :cars, array: true, default: [{ code: '340 F1', driver: 'Ascari' }, { code: '275 F1', driver: 'Villoresi' }] do
attribute :code
attribute :driver
end
end
team = Team.new # => #<Team cars: [#< code: "340 F1", driver: "Ascari">, #< code: "275 F1", driver: "Villoresi">]>
team.cars.size # => 2
team.cars[0].code # => "340 F1"
team.cars[0].driver # => "Ascari"
team.cars[1].code # => "275 F1"
team.cars[1].driver # => "Villoresi"
Use array of hashes or struct instances to define default array of nested structs defined with class.
class Car < FormObj::Struct
attribute :code
attribute :driver
end
class Team < FormObj::Struct
attribute :cars, class: Car, array: true, default: [Car.new(code: '340 F1', driver: 'Ascari'), { code: '275 F1', driver: 'Villoresi' }]
end
team = Team.new # => #<Team cars: [#<Car code: "340 F1", driver: "Ascari">, #<Car code: "275 F1", driver: "Villoresi">]>
team.cars.size # => 2
team.cars[0].code # => "340 F1"
team.cars[0].driver # => "Ascari"
team.cars[1].code # => "275 F1"
team.cars[1].driver # => "Villoresi"
The struct instance class should correspond to nested attribute class!
class Team < FormObj::Struct
attribute :cars, class: Car, array: true, default: [36]
end
Team.new # => FormObj::WrongDefaultValueClass: FormObj::WrongDefaultValueClass
1.3. Serialize FormObj::Struct to Hash
Call to_hash() method in order to get a hash representation of FormObj::Struct
class Team < FormObj::Struct
attribute :name
attribute :year
attribute :cars, array: true do
attribute :code, primary_key: true
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
team = Team.new(
name: 'Ferrari',
year: 1950,
cars: [
{
code: '340 F1',
driver: 'Ascari',
engine: {
power: 335,
volume: 4.1,
}
}, {
code: '275 F1',
driver: 'Villoresi',
engine: {
power: 330,
volume: 3.3,
}
}
],
) # => #<Team name: "Ferrari", year: 1950, cars: [#< code: "340 F1", driver: "Ascari", engine: #< power: 335, volume: 4.1>>, #< code: "275 F1", driver: "Villoresi", engine: #< power: 330, volume: 3.3>>]>
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :cars => [{
# => :code => "340 F1",
# => :driver => "Ascari",
# => :engine => {
# => :power => 335,
# => :volume => 4.1
# => }
# => }, {
# => :code => "275 F1",
# => :driver => "Villoresi",
# => :engine => {
# => :power => 330,
# => :volume => 3.3
# => }
# => }]
# => }
2. FormObj::Form
FormObj::Form is inherited from FormObj::Struct and adds support for Rails compatible form builders and ActiveModel validations.
2.1. FormObj::Form Validation
class Team < FormObj::Form
attribute :name
attribute :year
validates :name, length: { minimum: 10 }
end
team = Team.new(name: 'Ferrari') # => #<Team name: "Ferrari", year: nil>
team.valid? # => false
team.errors. # => {:name=>["is too short (minimum is 10 characters)"]}
2.2. FormObj::Form Persistence
In order to make FormObj::Form compatible with form builder it has to respond to :persisted? message.
It maintains persistence status. Initial form is not persisted.
It can be marked as persisted by assigning persisted = true which marks as persisted only form itself or
by calling mark_as_persisted method which marks as persisted the form itself and all nested forms and arrays.
class Team < FormObj::Form
attribute :name
attribute :year
attribute :cars, array: true do
attribute :code, primary_key: true
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
team = Team.new(cars: [{code: 1}])
team.persisted? # => false
team.cars[0].persisted? # => false
team.cars[0].engine.persisted? # => false
team.persisted = true
team.persisted? # => false - because nested forms are not persisted
team.cars[0].persisted? # => false
team.cars[0].engine.persisted? # => false
team.cars[0].engine.persisted = true
team.persisted? # => false - because nested forms are not persisted
team.cars[0].persisted? # => false
team.cars[0].engine.persisted? # => true
team.mark_as_persisted
team.persisted? # => true
team.cars[0].persisted? # => true
team.cars[0].engine.persisted? # => true
Change of attribute value (directly or by update_attributes call) will change persistence status to false.
team.name = 'Ferrari'
team.persisted? # => false
team.mark_as_persisted
team.persisted? # => true
team.update_attributes(name: 'McLaren')
team.persisted? # => false
2.3. Delete from Array of FormObj::Form via update_attributes method
FormObj::Struct update_attributes method by default deletes all array elements that are not present in the new hash.
class Team < FormObj::Struct
attribute :cars, array: true, primary_key: :code do
attribute :code
attribute :driver
end
end
team = Team.new(cars: [{code: 1, driver: 'Ascari'}, {code: 2, driver: 'Villoresi'}])
team.update_attributes(cars: [{code: 1}])
team.cars # => [#< code: 1, driver: "Ascari">]
In oppose to this FormObj::Form update_attributes method ignores elements that are absent in the hash but
marks for destruction those elements that has _destroy: true key in the hash.
class Team < FormObj::Form
attribute :cars, array: true, primary_key: :code do
attribute :code
attribute :driver
end
end
team = Team.new(cars: [{code: 1, driver: 'Ascari'}, {code: 2, driver: 'Villoresi'}])
team.update_attributes(cars: [{code: 2, driver: 'James Hunt'}])
team.cars[0].code # => 1
team.cars[0].driver # => 'Ascari'
team.cars[0].marked_for_destruction? # => false
team.cars[1].code # => 2
team.cars[1].driver # => 'James Hunt'
team.cars[1].marked_for_destruction? # => false
team.update_attributes(cars: [{code: 1, _destroy: true}])
team.cars[0].code # => 2
team.cars[0].driver # => 'James Hunt'
team.cars[0].marked_for_destruction? # => false
team.cars[1].code # => 1
team.cars[1].driver # => 'Ascari'
team.cars[1].marked_for_destruction? # => true
Use mark_for_destruction in order to forcefully mark an array element for destruction.
team.cars[0].marked_for_destruction? # => false
team.cars[0].mark_for_destruction
team.cars[0].marked_for_destruction? # => true
2.4. Using FormObj::Form in Form Builder
class Team < FormObj::Form
attribute :name
attribute :year
attribute :cars, array: true, primary_key: :code do
attribute :code
attribute :driver
attribute :engine do
attribute :power
attribute :volume
end
end
end
@team = Team.new
<%= form_for(@team) do |f| %>
<%= f.label :name %>
<%= f.text_field :name %>
<%= f.label :year %>
<%= f.text_field :year %>
<% f.cars.each do |car| %>
<%= f.fields_for(:cars, car, index: '') do |fc| %>
<%= fc.label :code %>
<%= fc.text_field :code %>
<%= fc.label :driver %>
<%= fc.text_field :driver %>
<%= fc.field_for(:engine) do |fce| %>
<%= fce.label :power %>
<%= fce.text_field :power %>
<%= fce.label :volume %>
<%= fce.text_field :volume %>
<% end %>
<% end %>
<% end %>
<% end %>
3. FormObj::ModelMapper
Include FormObj::ModelMapper module and map form object attributes to one or more models by using :model and :model_attribute parameters.
Use dot notation to map model attribute to a nested model. Use colon to specify a "hash's attribute".
3.1. load_from_model - Initialize Form Object from Model
Use load_from_model(model) method to initialize form object from the model.
class Team < FormObj::Form
include FormObj::ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :engine_power, model_attribute: 'car.:engine.power'
end
car_model = { engine: Struct.new(:power).new(335) }
team_model = Struct.new(:team_name, :year, :car).new('Ferrari', 1950, car_model)
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari"
# => :year => 1950
# => :engine_power => 335
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
team.name |
team_model.team_name |
team.year |
team_model.year |
team.engine_power |
team_model.car[:engine].power |
3.2. load_from_models - Initialize Form Object from Few Models
Use load_from_models(models) method to initialize form object from few models.
models parameter is a hash where keys are the name of models and values are models themselves.
By default each form object attribute is mapped to :default model.
Use parameter :model to map it to another model.
class Team < FormObj::Form
include FormObj::ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :engine_power, model: :car, model_attribute: ':engine.power'
end
car_model = { engine: Struct.new(:power).new(335) }
team_model = Struct.new(:team_name, :year).new('Ferrari', 1950) # <- doesn't have car attribute !!!
team = Team.new.load_from_models(default: team_model, car: car_model)
team.to_hash # => {
# => :name => "Ferrari"
# => :year => 1950
# => :engine_power => 335
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
team.name |
team_model.team_name |
team.year |
team_model.year |
team.engine_power |
car_model[:engine].power |
3.3. Do Not Map Certain Attribute
Use model_attribute: false in order to avoid mapping of this attribute.
class Team < FormObj::Form
include ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :engine_power, model_attribute: false
end
team_model = Struct.new(:team_name, :year, :engine_power).new('Ferrari', 1950, 335)
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari"
# => :year => 1950
# => :engine_power => nil
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
form.name |
team_model.team_name |
form.year |
team_model.year |
form.engine_power |
- |
3.4. Map Nested Form Objects
Nested forms are mapped by default to corresponding nested models.
class Team < FormObj::Form
include ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :car do
attribute :code
attribute :driver
end
end
car_model = Struct.new(:code, :driver).new('340 F1', 'Ascari')
team_model = Struct.new(:team_name, :year, :car).new('Ferrari', 1950, car_model)
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :car => {
# => :code => "340 F1",
# => :driver => "Ascari"
# => }
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
team.name |
team_model.team_name |
team.year |
team_model.year |
team.car.code |
team_model.car.code |
team.car.driver |
team_model.car.driver |
Use model_nesting: false parameter to map nested form object to map parent level model.
class NestedForm < FormObj::Form
include ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :car, model_nesting: false do # nesting only in form object but not in a model
attribute :code
attribute :driver
end
end
team_model = Struct.new(:team_name, :year, :code, :driver).new('Ferrari', 1950, '340 F1', 'Ascari')
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :car => {
# => :code => "340 F1",
# => :driver => "Ascari"
# => }
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
team.name |
team_model.team_name |
team.year |
team_model.year |
team.car.code |
team_model.code |
team.car.driver |
team_model.driver |
3.5. Map Nested Form Object to A Hash Model
Use model_hash: true in order to map a nested form object to a hash as a model.
class Team < FormObj::Form
include ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :car, model_hash: true do # nesting only in form object but not in a model
attribute :code
attribute :driver
end
end
car_model = { code: '340 F1', driver: 'Ascari' }
team_model = Struct.new(:team_name, :year, :car).new('Ferrari', 1950, car_model)
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :car => {
# => :code => "340 F1",
# => :driver => "Ascari"
# => }
# => }
So attributes are mapped as follows:
| Form Object attribute | Model attribute |
|---|---|
team.name |
team_model.team_name |
team.year |
team_model.year |
team.car.code |
team_model.car[:code] |
team.car.driver |
team_model.car[:driver] |
3.6. Custom Implementation of Loading of Array of Models
By default load_from_model(s) methods loads all models from arrays.
class Team < FormObj::Form
include ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :cars, array: true do
attribute :code
attribute :driver
end
attribute :colours, array: true do
attribute :name
attribute :rgb
end
end
CarModel = Struct.new(:code, :driver)
ColourModel = Struct.new(:name, :rgb)
cars_model = [CarModel.new('340 F1', 'Ascari'), CarModel.new('275 F1', 'Villoresi')]
colours_model = [ColourModel.new(:red, 0xFF0000), ColourModel.new(:white, 0xFFFFFF)]
team_model = Struct.new(:team_name, :year, :cars, :colours).new('Ferrari', 1950, cars_model, colours_model)
team = Team.new.load_from_model(team_model)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :cars => [{
# => :code => "340 F1",
# => :driver => "Ascari"
# => }, {
# => :code => "275 F1",
# => :driver => "Villoresi"
# => }],
# => :colours => [{
# => :name => :red,
# => :rgb => 0xFF0000
# => }, {
# => :name => :white,
# => :rgb => 0xFFFFFF
# => }]
# => }
FormObj::ModelMapper::Array class implements method (where *args are additional params passed to load_from_model(s) methods)
def iterate_through_models_to_load_them(models, *args, &block)
models.each { |model| block.call(model) }
end
This method should iterate through all models that has to be loaded and call a block for each of them.
In the example above it will receive cars_model as the value of models parameter.
Overwrite this method in order to implement your own logic.
class ArrayLoadLimit < FormObj::ModelMapper::Array
private
def iterate_through_models_to_load_them(models, params = {}, &block)
models = models.slice(params[:offset] || 0, params[:limit] || 999999999) if model_attribute.names.last == :cars
super(models, &block)
end
end
class LoadLimitForm < FormObj::Form
include FormObj::ModelMapper
def self.array_class
ArrayLoadLimit
end
end
class Team < LoadLimitForm
attribute :name, model_attribute: :team_name
attribute :year
attribute :cars, array: true do
attribute :code
attribute :driver
end
attribute :colours, array: true do
attribute :name
attribute :rgb
end
end
CarModel = Struct.new(:code, :driver)
ColourModel = Struct.new(:name, :rgb)
cars_model = [CarModel.new('340 F1', 'Ascari'), CarModel.new('275 F1', 'Villoresi')]
colours_model = [ColourModel.new(:red, 0xFF0000), ColourModel.new(:white, 0xFFFFFF)]
team_model = Struct.new(:team_name, :year, :cars, :colours).new('Ferrari', 1950, cars_model, colours_model)
team = Team.new.load_from_model(team_model, offset: 0, limit: 1)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :cars => [{
# => :code => "340 F1",
# => :driver => "Ascari"
# => }],
# => :colours => [{
# => :name => :red,
# => :rgb => 0xFF0000
# => }, {
# => :name => :white,
# => :rgb => 0xFFFFFF
# => }]
# => }
team = Team.new.load_from_model(team_model, offset: 1, limit: 1)
team.to_hash # => {
# => :name => "Ferrari",
# => :year => 1950,
# => :cars => [{
# => :code => "275 F1",
# => :driver => "Villoresi"
# => }],
# => :colours => [{
# => :name => :red,
# => :rgb => 0xFF0000
# => }, {
# => :name => :white,
# => :rgb => 0xFFFFFF
# => }]
# => }
Note that our new implementation of iterate_through_models_to_load_them limits only cars but not colours.
It identifies requested model attribute using model_attribute.names which returns
an array of model attribute accessors (in our example [:cars])
In case of ActiveRecord model iterate_through_models_to_load_them will receive an instance of ActiveRecord::Relation as models parameter.
This allows to load in the memory only necessary association models.
class ArrayLoadLimit < FormObj::ModelMapper::Array
private
def iterate_through_models_to_load_them(models, params = {}, &block)
models = models.offset(params[:offset] || 0).limit(params[:limit] || 999999999) if model_attribute.names.last == :cars
super(models, &block)
end
end
3.7. Sync Form Object to Models
Use sync_to_models(models) to sync form object attributes to mapped models.
Method returns self so one can chain calls.
class MultiForm < FormObj::Form
include FormObj::ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :engine_power, model: :car, model_attribute: ':engine.power'
end
default_model = Struct.new(:team_name, :year).new('Ferrari', 1950)
car_model = { engine: Struct.new(:power).new(335) }
multi_form = MultiForm.new
multi_form.update_attributes(name: 'McLaren', year: 1966, engine_power: 415)
multi_form.sync_to_models(default: default_model, car: car_model)
default_model.name # => "McLaren"
default_model.year # => 1966
car_model[:engine].power # => 415
Use sync_to_models(default: model) or sync_to_model(model) to sync to single model.
Neither sync_to_models nor sync_to_model calls save method on the model(s).
Also they don't call valid? method on the model(s).
Instead they just assign form object attributes value to mapped model attributes
using <attribute_name>= accessors on the model(s).
It is completely up to developer to do any additional validations on the model(s) and save it(them).
3.7.1. Array of Form Objects and Models
Saving array of form objects to corresponding array of models requires the class of the model to be known by the form object
because it could create new instances of the model array elements.
Use :model_class parameter to specify it.
Form object will try to guess the name of the class from the name of the attribute if this parameter is absent.
class ArrayForm < FormObj::Form
include FormObj::ModelMapper
attribute :name
attribute :year
attribute :cars, array: true, model_class: Car do
attribute :code, primary_key: true # <- primary key is specified on attribute level
attribute :driver
end
end
If corresponding :model_attribute parameter uses dot notations to reference
nested models the value of :model_class parameter should be an array of corresponding model classes.
class ArrayForm < FormObj::Form
include FormObj::ModelMapper
attribute :name
attribute :year
attribute :cars, array: true, model_attribute: 'equipment.cars', model_class: [Equipment, Car] do
attribute :code, primary_key: true # <- primary key is specified on attribute level
attribute :driver
end
end
3.8. Serialize Form Object to Model Hash
Use to_model_hash(model = :default) to get hash representation of the model that mapped to the form object.
class MultiForm < FormObj::Form
include FormObj::ModelMapper
attribute :name, model_attribute: :team_name
attribute :year
attribute :engine_power, model: :car, model_attribute: ':engine.power'
end
multi_form = MultiForm.new
multi_form.update_attributes(name: 'McLaren', year: 1966, engine_power: 415)
multi_form.to_model_hash # => { :team_name => "McLaren", :year => 1966 }
multi_form.to_model_hash(:default) # => { :team_name => "McLaren", :year => 1966 }
multi_form.to_model_hash(:car) # => { :engine => { :power => 415 } }
Use to_models_hash() to get hash representation of all models that mapped to the form object.
multi_form.to_models_hash # => {
# => default: { :team_name => "McLaren", :year => 1966 }
# => car: { :engine => { :power => 415 } }
# => }
If array of form objects mapped to the parent model (model_attribute: false) it is serialized to :self key.
class ArrayForm < FormObj::Form
include FormObj::ModelMapper
attribute :name
attribute :year
attribute :cars, array: true, model_attribute: false do
attribute :code, primary_key: true
attribute :driver
end
end
array_form = ArrayForm.new
array_form.update_attributes(
name: 'McLaren',
year: 1966,
cars: [{
code: 'M2B',
driver: 'Bruce McLaren'
}, {
code: 'M7A',
driver: 'Denis Hulme'
}]
)
array_form.to_model_hash # => {
# => :team_name => "McLaren",
# => :year => 1966,
# => :self => {
# => :code => "M2B",
# => :driver => "Bruce McLaren"
# => }, {
# => :code => "M7A",
# => :driver => "Denis Hulme"
# => }
# => }
3.9. Copy Model Validation Errors into Form Object
Even though validation could and should happen in the form object it is possible to have (additional) validation(s) in the model(s). In this case it is handy to copy model validation errors to form object in order to be able to present them to the user in a standard way.
Use copy_errors_from_models(models) or copy_errors_from_model(model) in order to do it.
Methods return self so one can chain calls.
multi_form.copy_errors_from_models(default: default_model, car: car_model)
In case of single model:
single_form.copy_errors_from_model(model)
4. Rails Example
# db/migrate/yyyymmddhhmiss_create_team.rb
class CreateTeam < ActiveRecord::Migration
def change
create_table :teams do |t|
t.string :team_name
t.integer :year
end
end
end
# app/models/team.rb
class Team < ApplicationRecord
has_many :cars, autosave: true
validates :year, numericality: { greater_than_or_equal_to: 1950 }
end
# db/migrate/yyyymmddhhmiss_create_car.rb
class CreateCar < ActiveRecord::Migration
def change
create_table :cars do |t|
t.references :team
t.string :code
t.text :engine
end
end
end
# app/models/car.rb
class Car < ApplicationRecord
belongs_to :team
serialize :engine, Hash
end
# app/form_objects/team_form.rb
class TeamForm < FormObj::Form
include FormObj::ModelMapper
attribute :id
attribute :name, model_attribute: :team_name
attribute :year
attribute :cars, array: true do
attribute :id
attribute :code
attribute :engine_power, model_attribute: 'engine.:power'
validates :code, presence: true
end
validates :name, :year, presence: true
end
# app/controllers/teams_controller.rb
class TeamsController < ApplicationController
def show
@team = TeamForm.new.load_from_model(Team.find(params[:id]))
end
def new
@team = TeamForm.new
end
def edit
@team = TeamForm.new.load_from_model(Team.find(params[:id]))
end
def create
@team = TeamForm.new.update_attributes(params[:team])
if @team.valid?
@team.sync_to_model(model = Team.new)
if model.save
return redirect_to team_path(model), notice: 'Team has been created'
else
@team.copy_errors_from_model(model)
end
end
render :new
end
def update
@team = TeamForm.new.load_from_model(model = Team.find(params[:id]))
@team.update_attributes(params[:team])
if @team.valid?
@team.sync_to_model(model)
if model.save
return redirect_to team_path(model), notice: 'Team has been updated'
else
@team.copy_errors_from_model(model)
end
end
render :edit
end
end
# app/views/teams/show.erb.erb
<p>Name: <%= @team.name %></p>
<p>Year: <%= @team.year %></p>
<p>Cars:</p>
<ul>
<% @team.cars.each do |car| %>
<li><%= car.code %> (<%= car.engine[:power] %> hp)</li>
<% end %>
</ul>
# app/views/teams/new.erb.erb
<%= nested_form_for @team do |f| %>
<%= f.text_field :name %>
<%= f.text_field :year %>
<%= f.link_to_add 'Add a Car', :cars %>
<% end %>
# app/views/teams/edit.erb.erb
<%= nested_form_for @team do |f| %>
<%= f.text_field :name %>
<%= f.text_field :year %>
<%= f.fields_for :cars do |cf| %>
<%= cf.text_field :code %>
<%= cf.link_to_remove 'Remove the Car' %>
<% end %>
<%= f.link_to_add 'Add a Car', :cars %>
<% end %>
5. Reference Guide: attribute parameters
| Parameter | Condition | Default value | Defined in | Description |
|---|---|---|---|---|
| array | block* or :class** |
false |
FormObj::Struct |
This attribute is an array of form objects. The structure of array element form object is described either in the block or in the separate class referenced by :class parameter |
| class | - | - | FormObj::Struct |
This attribute is either nested form object or array of form objects. The value of this parameter is the class of this form object or the name of the class. |
| default | - | - | FormObj::Struct |
Defines default value for the attribute. Nested structures default value can be defined either with Hash or with object. |
| model_hash | block* or :class** |
false |
FormObj::ModelMapper |
This attribute is either nested form object or array of form objects. This form object is mapped to a model of the class Hash so all its attributes should be accessed by [:<attribute_name>] instead of .<attribute_name> |
| model | - | :default |
FormObj::ModelMapper |
The name of the model to which this attribute is mapped |
| model_attribute | - | <attribute_name> |
FormObj::ModelMapper |
The name of the model attribute to which this form object attribute is mapped. Dot notation is used in order to map to nested model, ex. "car.engine.power". Colon is used in front of the name if the model is hash, ex. "car.:engine.power" - means call to #car returns Hash so the model attribute should be accessed like car[:engine].power. false value means that attribute is not mapped. |
| model_class | block* or :class** or dot notation for :model_attribute*** |
<attribute_name>.classify |
FormObj::ModelMapper |
The class (or the name of the class) of the mapped model. |
| model_nesting | block* or :class** |
true |
FornObj::ModelMapper |
If attribute describes nested form object and has model_nesting: false the attributes of nested form will be called on the parent (upper level) model. If attribute describes array of form objects and has model_nesting: false the methods to access array elements (:[] etc.) will be called on the parent (upper level) model. |
| primary_key | no block* and no :class** |
false |
FormObj::Struct |
This attribute is the primary key of the form object. The mapped model attribute is considered to be a primary key for the corresponding model. |
| primary_key | block* or :class** |
- | FormObj::Struct |
This attribute is either nested form object or array of form objects. The value of this parameter is the name of the primary key attribute of this form object. |
* block - means that there is block definition for the attribute
** :class - means that this attribute has :class parameter specified
*** dot notation for :model_attribute - means that this attribute is mapped to nested model attribute (using dot notation)
Development
After checking out the repo, run bin/setup to install dependencies. Then, run rake spec to run the tests. You can also run bin/console for an interactive prompt that will allow you to experiment.
To install this gem onto your local machine, run bundle exec rake install. To release a new version, update the version number in version.rb, and then run bundle exec rake release, which will create a git tag for the version, push git commits and tags, and push the .gem file to rubygems.org.
Contributing
Bug reports and pull requests are welcome on GitHub at https://github.com/akoltun/form_obj.
License
The gem is available as open source under the terms of the MIT License.