Skip to content

toptal/granite-form

Repository files navigation

Build Status Code Climate

Granite::Form

Granite::Form is an ActiveModel-based front-end for your data. It is useful in the following cases:

  • When you need a form objects pattern.
class ProfileForm
  include Granite::Form::Model

  attribute 'first_name', String
  attribute 'last_name', String
  attribute 'birth_date', Date

  def full_name
    [first_name, last_name].reject(&:blank).join(' ')
  end

  def full_name= value
    self.first_name, self.last_name = value.split(' ', 2).map(&:strip)
  end
end

class ProfileController < ApplicationController
  def edit
    @form = ProfileForm.new current_user.attributes
  end

  def update
    result = ProfileForm.new(params[:profile_form]).save do |form|
      current_user.update_attributes(form.attributes)
    end

    if result
      redirect_to ...
    else
      render 'edit'
    end
  end
end
  • When you need to work with data storage à la ActiveRecord.
class Flight
  include Granite::Form::Model

  attribute :airline, String
  attribute :number, String
  attribute :departure, Time
  attribute :arrival, Time

  validates :airline, :number, presence: true

  def id
    [airline, number].join('-')
  end

  def self.find id
    source = REDIS.get(id)
    instantiate(JSON.parse(source)) if source.present?
  end

  define_save do
    REDIS.set(id, attributes.to_json)
  end

  define_destroy do
    REDIS.del(id)
  end
end
  • When you need to embed objects in ActiveRecord models.
class Answer
  include Granite::Form::Model

  attribute :question_id, Integer
  attribute :content, String

  validates :question_id, :content, presence: true
end

class Quiz < ActiveRecord::Base
  embeds_many :answers

  validates :user_id, presence: true
  validates :answers, associated: true
end

quiz = Quiz.new
quiz.answers.build(question_id: 42, content: 'blabla')
quiz.save

Why?

Granite::Form is an `ActiveModel-based library that provides the following functionalities:

  • Standard form objects building toolkit: attributes with typecasting, validations, etc.
  • High-level universal ORM/ODM library using any data source (DB, http, redis, text files).
  • Embedding objects into ActiveRecord entities. Quite useful with PG JSON capabilities.

Key features:

  • Complete object lifecycle support: saving, updating, destroying.
  • Embedded and referenced associations.
  • Backend-agnostic named scopes.
  • Callbacks, validations and dirty attributes.

Installation

Add this line to your application's Gemfile:

gem 'granite-form'

And then execute:

$ bundle

Or install it yourself as:

$ gem install granite-form

Usage

Granite::Form has modular architecture, so it is required to include modules to obtain additional features. By default Granite::Form supports attributes definition and validations.

Attributes

Granite::Form provides several types of attributes and typecasts each attribute to its defined type upon initialization.

class Book
  include Granite::Form::Model

  attribute :title, String
  collection :author_ids, Integer
end

Attribute

attribute :full_name, String, default: 'John Talbot'

If type for an attribute is not set, it defaults to Object. It is therefore recommended to specify the type for every attribute explicitly.

The type is necessary for attribute typecasting. Here is the list of pre-defined basic typecasters:

[1] pry(main)> Granite::Form._typecasters.keys
=> ["Object", "String", "Array", "Hash", "Date", "DateTime", "Time", "ActiveSupport::TimeZone", "BigDecimal", "Float", "Integer", "Boolean", "Granite::Form::UUID"]

In addition, you can provide any class type when defining an attribute, but in that case you will be able to only assign instances of that specific class or nil:

attribute :template, MyCustomTemplateType
Defaults

It is possible to provide default values for attributes and they will act in the same way as ActiveRecord or Mongoid default values:

attribute :check, Boolean, default: false # Simply false by default
attribute :wday, Integer, default: ->{ today.wday } # Default evaluated in instance context
def calculate_today
  Time.zone.now.today
end
Enums

Enums restrict the scope of possible values for an attribute. If the assigned value is not included in the provided list, the attribute value is set to nil:

attribute :direction, String, enum: %w[north south east west]
Normalizers

Normalizers are applied last, modifying a typecast value. It is possible to provide a list of normalizers. They will be applied in the provided order. It is possible to pre-define normalizers to DRY code:

Granite::Form.normalizer(:trim) do |value, options, _attribute|
  value.first(options[:length] || 2)
end

attribute :title, String, normalizers: [->(value) { value.strip }, trim: {length: 80}]
Readonly
attribute :name, String, readonly: true # Readonly forever
attribute :name, String, readonly: :name_changed? # Conditional with calling method
attribute :name, String, readonly: -> { subject.present? } # Conditional with lambda

Collection

A collection is simply an array of equally-typed values:

class Panda
  include Granite::Form::Model

  collection :ids, Integer
end

A collection typecasts each value to the specified type. Also, it normalizes any given value to an array.

[1] pry(main)> Panda.new
=> #<Panda ids: []>
[2] pry(main)> Panda.new(ids: 42)
=> #<Panda ids: [42]>
[3] pry(main)> Panda.new(ids: [42, '33'])
=> #<Panda ids: [42, 33]>

Default and enum modifiers are applied on each value, normalizers are applied on the array.

Dictionary

A dictionary field is a hash of specified type values with string keys:

class Foo
  include Granite::Form::Model

  dictionary :ordering, String
end
[1] pry(main)> Foo.new
=> #<Foo ordering: {}>
[2] pry(main)> Foo.new(ordering: {name: :desc})
=> #<Foo ordering: {"name"=>"desc"}>

The keys list might be restricted with the :keys option. Default and enum modifiers are applied on each value, normalizers are applied on the hash.

Represents

represents provides an easy way to expose model attributes through an interface. It will automatically set the passed value to the represented object before validation. You can use any ActiveRecord, ActiveModel or Granite::Form object as a target of representation. The type of an attribute will be taken from it. If no type is defined, it will be Object by default. You can set the type explicitly by passing the type: TypeClass option. Represents will also add automatic validation of the target object.

class Person
  include Granite::Form::Model

  attribute :name, String
end

class Doctor
  include Granite::Form::Model
  include Granite::Form::Model::Representation

  attribute :person, Object
  represents :name, of: :person
end

person = Person.new(name: 'Walter Bishop')
# => #<Person name: "Walter Bishop">
Doctor.new(person: person).name
# => "Walter Bishop"
Doctor.new(person: person, name: 'Dr. Walter Bishop').name
# => "Dr. Walter Bishop"
person.name
# => "Dr. Walter Bishop"

Associations

Granite::Form provides a set of associations. There are two types: referenced and embedded. The closest example of referenced association is AcitveRecord's belongs_to. For embedded ones - Mongoid's embedded. Also these associations support accepts_nested_attributes calls.

EmbedsOne

embeds_one :profile

Defines singular embedded object. Might be defined inline:

embeds_one :profile do
  attribute :first_name, String
  attribute :last_name, String
end

Оptions:

  • :class_name - association class name
  • :validate - true or false
  • :default - default value for the association: an attributes hash or an instance of the defined class

EmbedsMany

embeds_many :tags

Defines a collection of embedded objects. Might be defined inline:

embeds_many :tags do
  attribute :identifier, String
end

Оptions:

  • :class_name - association class name
  • :validate - true or false
  • :default - default value for the association: an attributes hash or an instance of the defined class

ReferencesOne

references_one :user

Provides several methods to the object: #user, #user=, #user_id and #user_id=, similarly to an ActiveRecord association.

Оptions:

  • :class_name - association class name

  • :primary_key - the associated object's primary key name (:id by default):

    references_one :user, primary_key: :name

    Creates the following methods: #user, #user=, #user_name and #user_name=.

  • :reference_key - redefines #user_id and #user_id= method names completely.

  • :validate - true or false

  • :default - default value for the association: reference or the object itself

ReferencesMany

references_many :users

Provides several methods to the object: #users, #users=, #user_ids and #user_ids=, similarly to an ActiveRecord association.

Options:

  • :class_name - association class name

  • :primary_key - the associated object's primary key name (:id by default):

    references_many :users, primary_key: :name

    Creates the following methods: #users, #users=, #user_names and #user_names=.

  • :reference_key - redefines #user_ids and #user_ids= method names completely.

  • :validate - true or false

  • :default - default value for association: reference collection or objects themselves

Persistence Adapters

Adapter definition syntax:

class Mongoid::Document
  # anything that have similar interface to
  # Granite::Form::Model::Associations::PersistenceAdapters::Base
  def self.granite_persistence_adapter
    MongoidAdapter
  end
end

where ClassName - name of model class or one of ancestors data_source - name of data source class primary_key - key to search data scope_proc - additional proc for filtering

All requirements for the adapter interfaces are described in Granite::Form::Model::Associations::PersistenceAdapters::Base.

The adapter for ActiveRecord is Granite::Form::Model::Associations::PersistenceAdapters::ActiveRecord. All ActiveRecord models use PersistenceAdapters::ActiveRecord by default.

Primary

Persistence

Lifecycle

Callbacks

Dirty

Validations

Scopes

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Added some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create new Pull Request