Download - Refactoring ActiveRecord Models
Refactoring ActiveRecord
ModelsBen Hughes@rubiety
http://benhugh.es
Friday, April 22, 2011
Your Model Layer isImportant
Best practices are not discussed enough...
Friday, April 22, 2011
Organization & Style
Breaking up Models with Modularity
Extracting Repetition (Keeping Models DRY)
De-normalization Patterns
Using Callbacks & Validations with Care
Model Security & Constraints
Gotchas!
Friday, April 22, 2011
Opinions!
Some of the topics are opinionated and best practices can be argued from
multiple perspectives.
Would love to hear about alternative approaches after the talk!
Friday, April 22, 2011
Model Naming• Pluralize-able Noun:
InternationalProfile over InternationalOrderLogEntry over OrderHistoryAddressBookEntry over AddressBook
• Trade-off between context and brevity:ProductCategory vs. CategoryEmployeeGroup vs. GroupCustomerLocation vs. Location
• Use Explicit Join Model Naming:ProductCategoryAssignment over ProductCategoryProductBundleMember over BundleProduct
Friday, April 22, 2011
Model vs. Resource NamingExample::Application.routes.draw do resources :customers do resources :locations endend
class CustomerLocation < ActiveRecord::Baseend
class LocationsController < ActionController::Base def new @location = @customer.locations.build endend
# url_for([@customer, @location])# customer_customer_locations_path(@customer) => Wrong!
Friday, April 22, 2011
Model vs. Resource Namingclass CustomerLocation < ActiveRecord::Base def self.model_name ActiveSupport::ModelName.new("Location") endend
# url_for([@customer, @location])# customer_locations_path(@customer) => Right!
Friday, April 22, 2011
Attribute Naming• Underscore Casing:
first_name over firstnamezip_code over zipcode
• Err on the side of verbosity:phone_number over. phone_nopurchase_order_number over. po_num
• Optimize For String#humanize:address_2 over address2
• Reserve _id For True Foreign Keys:cim_profile_code over cim_profile_idtransaction_reference over transaction_id
• Be Consistent with name vs title, etc.
Friday, April 22, 2011
Association NamingAvoid Context-Redundancy:
class CustomerLocation < ActiveRecord::Base belongs_to :customerend
# Redundant:class Customer < ActiveRecord::Base has_many :customer_locationsend
@customer.customer_locations
# Preferred:class Customer < ActiveRecord::Base has_many :locations, :class_name => "CustomerLocation"end
@customer.locations
Friday, April 22, 2011
Method Implementations• Implement to_s
• Implement to_param
class Product < ActiveRecord::Base def to_s name endend
class Product < ActiveRecord::Base def to_param "#{id}-#{to_s.slugify}" endend
Friday, April 22, 2011
Be Consistent with Order
1. Module Inclusions
2. Attribute Protection: attr_accessible/attr_protected
3. Associations
4. Class-Level Method Invocations (acts_as_tree, etc.)
5. Scopes (Default Scope, then Named Scopes)
6. Callbacks, In Invocation Order
7. Any attr_accessor Declarations
8. Validations
9. Class Methods
10. Instance Methods (Starting with to_s, to_param)
For Example (My Personal Preference):
Friday, April 22, 2011
Model Modularityclass Order < ActiveRecord::Base include OrderWorkflow include OrderPayment ...end
# app/models/order_workflow.rbmodule OrderWorkflow end
# app/models/order_workflow.rbmodule OrderPayment end
Friday, April 22, 2011
Model Modularityclass Order < ActiveRecord::Base include Order::Workflow include Order::Payment ...end
# app/models/order/workflow.rbmodule Order::Workflow end
# app/models/order/payment.rbmodule Order::Payment end
Friday, April 22, 2011
Model Modularityclass Order < ActiveRecord::Base include Order::Workflow include Order::Payment ...end
# app/models/order/workflow.rbmodule Order::Workflow end
# app/models/order/payment.rbmodule Order::Payment end
# spec/models/order_spec.rbdescribe Order do end
# spec/models/order/workflow_spec.rbdescribe Order, "Workflow" do end
# spec/models/order/payment_spec.rbdescribe Order, "Payment" do end
Friday, April 22, 2011
Model Modularity# Traditional self.included hook:module Order::Workflow def self.included(base) base.send(:extend, ClassMethods) base.send(:include, InstanceMethods)
base.class_eval do state_machine :state, :initial => :new do ... end end end module ClassMethods ... end module InstanceMethods ... endend
Friday, April 22, 2011
Model Modularity# Using ActiveSupport::Concernmodule Order::Workflow extend ActiveSupport::Concern included do state_machine :state, :initial => :new do ... end end module ClassMethods ... end module InstanceMethods ... endend
Friday, April 22, 2011
Model Namespacingclass Enterprise::Base < ActiveRecord::Base establish_connection "enterprise_#{Rails.env}" self.abstract_class = true def self.model_name ActiveSupport::ModelName.new(self.name.split("::").last) endend
class Enterprise::Customer < Enterprise::Base has_many :locations, :class_name => "Enterprise::CustomerLocation"end
class Enterprise::CustomerLocation < Enterprise::Base belongs_to :customer, :class_name => "Enterprise::Customer"end
Friday, April 22, 2011
Extracting to Modulesmodule Votable def self.included(model) model.class_eval do has_many :votes, :class_name => 'ContentVote', :as => :votable end end def calculate_total_popularity ... endend
Friday, April 22, 2011
Small Methods with Verbose Names
• Easier to Test
• Better Bug Isolation from Traces
• Increases Self-Documentation Dramatically
class FileImport < ActiveRecord::Base has_attached_file :file validate :ensure_file_is_valid protected def ensure_file_is_valid ensure_rows_exist ensure_at_least_two_columns ensure_one_column_contains_unique_values end def ensure_rows_exist ... end def ensure_at_least_two_columns ... end def ensure_one_column_contains_unique_values ... endend
Friday, April 22, 2011
Inquiry Methodsclass Article < ActiveRecord::Base def published? published_at and published_at >= Time.zone.now endend
Friday, April 22, 2011
Composed Ofclass Customer < ActiveRecord::Base composed_of :balance, :class_name => "Money", :mapping => %w(balance amount) composed_of :address, :mapping => [ %w(address_street street), %w(address_city city) ]end class Address attr_reader :street, :city def initialize(street, city) @street, @city = street, city end
def close_to?(other_address) city == other_address.city end
def ==(other_address) city == other_address.city && street == other_address.street endend
Friday, April 22, 2011
Callbacks with Care
• Try to escape tunnel vision on one use case
• Can’t cleanly disable callbacks, unlike validations
• Conditionally run callbacks when appropriate
• Avoid sending e-mails in callbacks
• Not model-level functionality to begin with
• Edge cases and accidental sending - Importers!
Friday, April 22, 2011
Conditional Validationsclass Customer attr_accessor :managing validates_presence_of :first_name validates_presence_of :last_name with_options :unless => :managing do |o| o.validates_inclusion_of :city, :in => ["San Diego", "Rochester"] o.validates_length_of :biography, :minimum => 100 endend
@customer.managing = [email protected] = params[:customer]@customer.save
Friday, April 22, 2011
Conditional Callbacksclass Customer has_many :locations before_create :create_initial_locations attr_accessor :importing protected def create_initial_locations unless importing ... end endend
@customer.importing = true
Friday, April 22, 2011
Conditional Callbacksmodule Importable def importing @importing = true yield self @importing = nil endend
class Customer < ActiveRecord::Base include Importable ...end
@customer.importing(&:save)
Friday, April 22, 2011
De-normalization Patterns
Friday, April 22, 2011
Delegationclass Order < ActiveRecord::Base has_many :items, :class => "OrderItem"end
class OrderItem < ActiveRecord::Base belongs_to :order has_many :details, :class => "OrderItemDetail"end
class OrderItemDetail < ActiveRecord::Base belongs_to :order_item delegate :order, :to => :order_item, :allow_nil => trueend
@order_item_detail.order
Friday, April 22, 2011
“Initial” Related Modelclass Ticket < ActiveRecord::Base has_many :comments before_create :create_initial_comment attr_accessor :initial_comment protected def create_initial_comment comments.build(:comment => initial_comment) if initial_comment.present? endend
@ticket = Ticket.new(:initial_comment => "First")@[email protected] # => 1
Friday, April 22, 2011
Nested Attributesclass Bundle < ActiveRecord::Base has_many :products accepts_nested_attributes_for :products, :allow_destroy => trueend
Bundle.create( :name => "My Bundle", :products_attributes => [ {:name => "One", :price => 1}, {:name => "Two", :price => 2} ])
Friday, April 22, 2011
Array Virtual Attribute for has_manyclass Customer < ActiveRecord::Base has_many :contacts before_save :maintain_contact_emails def contact_emails @contact_emails || contacts.map(&:email) end def contact_emails=(value) @contact_emails = value end protected def maintain_contact_emails if @contact_emails @contact_emails.each do |email| contacts.build(:email => email) unless contacts.exists?(:email => email) end contacts.where(["email NOT IN (?)", @contact_emails]).each(&:mark_for_destruction) end endend
Friday, April 22, 2011
Array Virtual Attribute for has_manyclass OrderItem < ActiveRecord::Base has_many :details, :class_name => "OrderItemDetail" before_save :maintain_details attr_accessor :engravings def maintain_details if details.size < quantity (quantity - details.size).times { details.build } elsif details.size > quantity (details.size - quantity).times { details.last.mark_for_destruction } end if @engravings details.each_with_index do |detail, i| detail.engraving = @engravings[i] detail.save unless detail.new_record? end end ... endend
@order_item = @order.items.build( :quantity => 2, :engravings => ["Hello Ben", "Hello John"])
@order_item.save@order_item.details.count # => 2
Friday, April 22, 2011
Security & Constraints
Friday, April 22, 2011
Always use attr_accessible!Or maybe attr_protected...
class User < ActiveRecord::Baseend
class UsersController < ActionController::Base def create @user = User.create(params[:user]) endend
# POST /users# {# 'first_name' => 'Ben',# 'last_Name' => 'Hughes',# 'admin' => '1'# }
Friday, April 22, 2011
Always use attr_accessible!Perhaps with Ryan Bates’ trusted_params...
class User < ActiveRecord::Base attr_accessible :first_name, :last_nameend
class UsersController < ActionController::Base def create params[:user].trust if admin? params[:user].trust(:spam, :important) if moderator? @user = User.create(params[:user]) endend
Friday, April 22, 2011
Careful with send!
class UsersController < ActionController def show params[:fields_to_show].map do |field| @user.send(field) if @user.respond_to?(field) end.compact endend
# GET /users/1# fields_to_show => [# 'first_name',# 'last_name',# 'destroy' !!!!!# ]
Friday, April 22, 2011
Gotchas
!Friday, April 22, 2011
Callback Return Values
class User < ActiveRecord::Base before_save :do_something protected def do_something AnotherClass.do_something(self) endend
Friday, April 22, 2011
Use :select with Caution
class User < ActiveRecord::Base def name "#{first_name} #{last_name}" endend
User.select('last_name, anniversary').each do |user| user.nameend
# ActiveRecord::MissingAttributeError: missing attribute: first_name
Friday, April 22, 2011
Careful with after_initialize
class User < ActiveRecord::Base def after_initialize self.role = Role.find_by_name("Member") unless role endend
User.all
Friday, April 22, 2011
Scopes and Non-Deterministic Methods
class User < ActiveRecord::Base scope :active, where(["activated_at > ?", Time.zone.now])end
User.active
# Should Be:class User < ActiveRecord::Base scope :active, lambda { where(["activated_at > ?", Time.zone.now]) }end
Friday, April 22, 2011
That’s it!Questions/Comments?
Ben Hughes@rubiety
http://benhugh.es
Friday, April 22, 2011