Created
May 23, 2013 11:51
-
-
Save oelmekki/5635530 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# A context encapsulate behavior for altering database | |
# in a specific situation. | |
# | |
# It is most notably responsible for validations and | |
# parameters filtering. You may also add in there all the | |
# custom logic that should be trigger on a specific context, | |
# like sending mails or updating other resources. | |
# | |
# | |
# # The problem | |
# | |
# Let say we have an User model. An user should have an email | |
# and a password. We want to have a special kind of user - a | |
# customer - which should also provide first name, last name | |
# and address. At this point, we have two choices : | |
# | |
# * making a subclass of User called Customer, with its own | |
# set of validations, thus using STI. | |
# | |
# * using an `:if` option in validation | |
# | |
# It would work, but what now if we want to introduce OfflineUser ? | |
# And of course, this user can be a customer, thus needing first | |
# name, last name and address, but not email and password, since | |
# this user does not log in. Then, we want an Admin user. Oh, and | |
# by the way : validations of user are not the same if user is | |
# updated by himself or by an admin. | |
# | |
# Using STI is quickly impossible because of single inheritence | |
# limitation of ruby. And using `:if` on validations ... quickly | |
# lead us to spaghetti code. | |
# | |
# We could use concerns, but now we have god objects : when an | |
# admin updates an user, we may want to log it in some way, or | |
# send mails, or update an other model. Our model should not have | |
# to know about this. | |
# | |
# | |
# # The solution | |
# | |
# Contexts let us say : in this particular context (say: UserSaving | |
# from AdminArea), user should have those validations, we expect | |
# those parameters and we'll process it that way. There is no more | |
# validations clashes, nor the temptation to add dangerous fields | |
# in `attr_accessible` because admin can updates them. | |
# | |
# Here is a simple example of context : | |
# | |
# ```ruby | |
# class FooSaving < Context | |
# validates_presence_of :name | |
# permit :name, :description | |
# end | |
# ``` | |
# | |
# You can use it in your FoosController that way : | |
# | |
# ```ruby | |
# class FoosController < ApplicationController | |
# def new | |
# @foo = Foo.new | |
# end | |
# | |
# def create | |
# @foo = Foo.new | |
# @handler = FooSaving.new( resource: @foo, params: params ) | |
# | |
# if @handler.save | |
# redirect_to foo_path( @foo ) | |
# else | |
# render :new | |
# end | |
# end | |
# end | |
# ``` | |
# | |
# So, context expects to be initialized with a resource and parameters. | |
# | |
# If parameters respect the convention of `#form_for`, ie in our example | |
# being `{foo: {name: '', description: ''}}`, our context can directly | |
# define which parameters are allowed using `Context.permit`. Passed values | |
# are the same you pass to `#permit` from strong_parameters. | |
# | |
# All validations you usually use in model are also allowed. | |
# | |
# Finally, `Context` provides a default `#save` method, which returns | |
# true or false, just like an ActiveRecord::Base model. | |
# | |
# To display validation errors in your view : | |
# | |
# ```erb | |
# <% if @handler and @handler.errors.any? %> | |
# <%= error_messages_for_handler @handler %> | |
# <% end %> | |
# ``` | |
# | |
# ## Variants | |
# | |
# A context may have several variants. For example, when saving a | |
# booking, we may do it with an associated fully filled existing user, | |
# or with a user which didn't have full info yet, or with a new | |
# user. | |
# | |
# Controller should not add logic to determine which context to use. | |
# Instead, we'll use a single context with variants, and the context | |
# is responsible to determine which variant we'll use : | |
# | |
# ```ruby | |
# class BookingSavingContext | |
# def save | |
# if user.new_record? | |
# use_variant :new_user | |
# else | |
# if user.first_name and user.last_name | |
# use_variant :complete_user | |
# else | |
# use_variant :incomplete_user | |
# end | |
# end | |
# | |
# variant.save | |
# end | |
# | |
# def user | |
# @user ||= begin | |
# resource.user or resource.build_user | |
# end | |
# end | |
# | |
# class NewUserVariant < Variant | |
# def save | |
# UserCreationContext.new( resource: user, params: params[ :user_attributes ] ).save && super | |
# end | |
# end | |
# | |
# class IncompleteUserVariant < Variant | |
# validates_associated :user | |
# permit :from_date, to_date, :user_id, { :user_attributes: [ :first_name, :last_name, :address ] } | |
# | |
# def save | |
# CustomerSavingContext.new( resource: user, params: params[ :user_attributes ] ).save && super | |
# end | |
# end | |
# | |
# class CompleteUserVariant < Variant | |
# permit :from_date, to_date, :user_id | |
# validates_associated :user | |
# end | |
# end | |
# ``` | |
# | |
# A variant can define its own validations and permitted parameters. | |
# | |
# Outside classes should not have to be aware of variants. Thus, accessing | |
# errors is still done the same way : | |
# | |
# ```erb | |
# <% if @handler and @handler.errors.any? %> | |
# <%= error_messages_for @handler %> | |
# <% end %> | |
# ``` | |
# | |
# | |
# ## Getting custom | |
# | |
# All of this are convention to help write less code, but you can | |
# do whatever you want in your context. Initializer parameters are | |
# an option hash, with no conventional options presence enforcement. | |
# | |
# Thus, you can initialize your context with whatever you want and | |
# retrieve it with the `#options` method. Context makes little sense | |
# to be inherited from if you do not provides a `:resource` key, though. | |
# | |
# You may want, for example, to modify parameters before using them. | |
# At any moment, you can access initial parameters with the `#raw_params` | |
# methods. | |
# | |
# If you want to dynamically select permitted params, you can | |
# override the `#params` method, either in Context or in Variant. | |
# | |
# ```ruby | |
# class UserSavingContext < Context | |
# validates_presence_of :email | |
# | |
# def params | |
# if raw_params[ :offline ] | |
# raw_params.require( :user ).permit( :email ) | |
# else | |
# raw_params.require( :user ).permit( :email, :password, :password_confirmation ) | |
# end | |
# end | |
# end | |
# ``` | |
# | |
class Context | |
include ActiveRecord::Validations | |
extend ActiveRecord::Translation | |
class_attribute :resource | |
attr_reader :options, :resource, :raw_params | |
attr_accessor :variant | |
module DefaultBehavior | |
def self.included( base ) | |
base.class_attribute :_permit | |
base.extend ClassMethods | |
end | |
def save | |
resource.attributes = params | |
resource.save! if valid? | |
end | |
def params | |
raw_params && raw_params.require( resource.class.name.demodulize.underscore ).permit( * self.class._permit ) | |
end | |
module ClassMethods | |
def permit( *args ) | |
self._permit = ( self._permit || [] ) + args | |
end | |
def model_name | |
resource.class.model_name | |
end | |
def lookup_ancestors | |
klass = resource.class | |
classes = [klass] | |
return classes if klass == ActiveRecord::Base | |
while klass != klass.base_class | |
classes << klass = klass.superclass | |
end | |
classes | |
end | |
end | |
end | |
include DefaultBehavior | |
def initialize( options = {} ) | |
@options, @resource, @raw_params = options, options[ :resource ], ActionController::Parameters.new( options[ :params ] ) | |
self.class.resource = resource | |
end | |
alias_method :ar_errors, :errors | |
alias_method :ar_valid?, :valid? | |
def errors | |
variant ? variant.errors : ar_errors | |
end | |
def valid? | |
variant ? variant.valid? : ar_valid? | |
end | |
private | |
def method_missing( method_name, *args, &block ) | |
resource.send( method_name, *args, &block ) | |
end | |
def use_variant( variant ) | |
unless variant.is_a? Class | |
variant = variant.to_s.camelize | |
variant += 'Variant' unless variant =~ /Variant$/ | |
variant = variant.context_constantize( self ) | |
end | |
self.variant = variant.new( self ) | |
end | |
class << self | |
def method_missing( method_name, *args, &block ) | |
resource.class.send( method_name, *args, &block ) | |
end | |
end | |
class Variant | |
include ActiveRecord::Validations | |
extend ActiveRecord::Translation | |
include DefaultBehavior | |
cattr_accessor :context | |
attr_reader :context | |
def initialize( context ) | |
@context = context | |
self.class.context = context | |
end | |
private | |
def method_missing( method_name, *args, &block ) | |
context.send( method_name, *args, &block ) | |
end | |
class << self | |
def method_missing( method_name, *args, &block ) | |
context.class.send( method_name, *args, &block ) | |
end | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment