Starving ActiveRecord

Starving ActiveRecord

Some techniques to deal with fat ActiveRecord models.

7a0e72a6f55811246bb5d9a946fd2e49?s=128

Radoslav Stankov

May 21, 2015
Tweet

Transcript

  1. 3.
  2. 5.
  3. 6.
  4. 7.
  5. 8.
  6. 9.
  7. 10.
  8. 11.
  9. 13.
  10. 14.
  11. 15.
  12. 16.
  13. 17.
  14. 18.
  15. 19.
  16. 20.
  17. 21.
  18. 24.

    • Value objects • Service objects • Form objects •

    Query objects • Presenter/Decorator objects • Page object • Helper objects
  19. 26.

    class Size include Comparable SIZES = [ s: 'small' m:

    'medium' l: 'large' xl: 'XL' xxl: 'XXL' xxxl: 'XXL' ] GENDERS = [ f: 'female', m: 'male', u: 'unisex' ] GENDER_KEYS = SIZES.keys SIZE_KEYS = SIZES.keys attr_reader :size, :gender class << self def groups SIZE_KEYS.map(&:to_s) end def genders GENDER_KEYS.map(&:to_s) end end def initialize(size, gender) @size = size.to_sym @gender = gender.to_sym
  20. 27.

    class << self def groups SIZE_KEYS.map(&:to_s) end def genders GENDER_KEYS.map(&:to_s)

    end end def initialize(size, gender) @size = size.to_sym @gender = gender.to_sym raise ArgumentError unless SIZES[@size].present? raise ArgumentError unless GENDERS[@gender].present? end def name "#{GENDERS[gender]} #{SIZES[size]}" end def <=>(other) [GENDER_KEYS.index(gender), SIZE_KEYS.index(size)] <=> [GENDER_KEYS.index(other.gender), SIZE_KEYS.i end def eql?(other) gender == other.gender && size == other.size end def to_s name end end
  21. 28.

    class Size module WithSize def self.included(model) model.validates :size_group, inclusion: {in:

    Size.groups} model.validates :size_gender, inclusion: {in: Size.genders} model.composed_of :size, mapping: [%w(size_group size_group), %w(size_gender size_gender)], allow_nil: true, constructor: lambda { |group, gender| Size.new(group, gender) } model.extend ClassMethods end module ClassMethods def by_size # TODO: nasty SQL :P end end end end
  22. 31.

    module UserDeactivator extend self def call(user, active: active) return if

    user.active == active user.update! active: active user.posts.update_all active: active user.votes.update_all active: active user.comments.update_all active: active user.following_association.update_all active: active user.followers_association.update_all active: active end end
  23. 32.

    module SendPrivateMessage extend self def call(message, recipient, sender) return :invalid

    unless recipient.present? return :invalid unless recipient.receive_private_messages? message = Message.new message: message, sender: sender, recipient: recipient return :not_saved unless message.save MessageMailer.user_message(message).deliver_later Metrics.track_create message, by: sender :success end end
  24. 36.

    class RegistrationController < ApplicationController def create @user = User.new(user_params) @user.account

    = Account.new(account_params) @user.account.owner = @user if @user.valid? && @user.account.valid? @user.save! @user.account.save! AccountWasCreated.perform_later(account) redirect_to user_path(@user) else render :new end end end
  25. 37.

    class RegistrationController < ApplicationController def create @registration = RegistrationForm.new(registration_param) if

    @registration.save redirect_to user_path(@registration.user) else render :new end end end
  26. 39.

    class RegistrationForm include ActiveModel::Model attr_reader :user, :account attr_accessor :first_name, :last_name,

    :email, :name, :plan, :terms_of_service # user validation validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true # account validation validates :account_name, presence: true # form custom validation validates :plan, inclusion: {in AccountPlan::VALUES} validates :terms_of_service, acceptance: true # ensure uniqueness validate :ensure_unique_user_email validate :ensure_unique_account_name def initialize @user = User.new @account = Account.new owner: @user end def update(attributes) attributes.each do |name, value| public_send "#{name}=", value end if valid? & user.update(user_attributes) && account.update(account_attributes) account.users << user AccountWasCreated.perform_later(account)
  27. 40.

    def initialize @user = User.new @account = Account.new owner: @user

    end def update(attributes) attributes.each do |name, value| public_send "#{name}=", value end if valid? & user.update(user_attributes) && account.update(account_attributes) account.users << user AccountWasCreated.perform_later(account) else false end end private def ensure_unique_user_email errors.add :email, 'already taken' if User.where(email: email).any? end def ensure_unique_account_name errors.add :name, 'already taken' if Account.where(name: name).any? end def user_attributes {first_name: first_name, last_name: last_name, email: email} end def account_attributes {plan: plan, name: name} end end
  28. 42.

    class RegistrationForm include MiniForm::Model model :user, attributes: %i(first_name last_name email),

    save: true model :account, attributes: %i(name plan), save: true attributes :terms_of_service validates :plan, inclusion: {in AccountPlan::VALUES} validates :terms_of_service, acceptance: true def initialize @user = User.new @account = Account.new owner: @user end def perform account.users << user AccountWasCreated.perform_later(account) end end
  29. 44.

    module ProductRanking def initialize(scope = Product) @scope = Product end

    def all query end def page(page, per_page: 10) query.per_page(per_page).page(page) end def find_each(&block) query.find_each(&block) end private def query # really nasty SQL SELECT end end
  30. 47.

    class PostSearch include SearchObject.module scope { Post.all } option(:name) {

    |scope, value| scope.where name: value } option(:created_at) { |scope, dates| scope.created_after dates } option(:published, false) { |scope, value| value ? scope.unopened : scope.opened } end
  31. 48.

    search = PostSearch.new filters: params[:filters] # accessing search options search.name

    # => name option search.created_at # => created at option # accessing results search.count # => number of found results search.results? # => is there any results found search.results # => found results # params for url generations search.params # => option values search.params opened: false # => overwrites the 'opened' option
  32. 50.

    class ArticlePresenter attr_reader :article delegate :title, :headline, to: :article def

    initialize(article) @article = article end def publication_status if published? "Published at #{published_at}" else "Unpublished" end end def content BodyFormat.call(article.content) end private def published_at article.published_at.strftime("%A, %B %e") end end
  33. 51.

    class ArticleDecorator attr_reader :article, :view delegate :title, :headline, to: :article

    def initialize(article, view) @article = article @view = view end def publication_status if published? "Published at #{published_at}" else "Unpublished" end end def publication_status_tag @view.content_tag :span, class: 'article-status', publication_status end def headline_tag @view.content_tag :h1, title end def render_comments @view.render 'comments/list', comments: article.comments end private def published_at article.published_at.strftime("%A, %B %e") end end
  34. 53.

    <%= decorate_article @article do |article| %> <%= article.headline_tag %> <%=

    article.status_tag %> <%= article.cotnent_tag %> <%= article.render_comments %> <% end %>
  35. 55.

    class ArticleDecorator < Draper::Decorator delegate_all def publication_status if published? "Published

    at #{published_at}" else "Unpublished" end end private def published_at object.published_at.strftime("%A, %B %e") end end
  36. 57.

    def show @news = News.find params[:id] @comments = @news.comments.page(params[:page]) @ads

    = Advertisement.for(current_user, @news) @related_news = @news.related_news end
  37. 58.

    class NewsShowPage attr_reader :news def initialize(news, current_user, params) @news =

    news @user = current_user @params = params end def comments @comments ||= news.comments.page(@params[:page]) end def ads @ads ||= Advertisement.for(@user, news) end def related_news @related_news ||= news.related_news end end
  38. 61.

    module TourHelper def with_tour yield ShowTour.new(self) end class ShowTour def

    initialize(context) @context = context @just_registered = context.session.delete(:show_tour) end def show? @just_registered || @context.cookies[:hide_tour].blank? end def scene @just_registered ? 5 : 1 end def show_hi_user? @just_registered end def max_scene @context.logged_in? 5 : 6 end def link text = show? ? 'Hide tour' : 'Show tour' @context.link_to text, '#', class: 'focus js-toggle-tour' end end end
  39. 62.
  40. 63.
  41. 64.
  42. 65.

    class User < ActiveRecord::Base # # long list of relationships

    # validates :plan, inclusion: {in AccountPlan::VALUES} validates :terms_of_service, acceptance: true validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true validates :password, presence: true if: :password_required? validate :old_password_correct, on: :update validates :gender, in: %w(m f u) validates :terms_of_service, acceptance: true, on: :create before_create :record_registration_ip after_create :reset_perishable_token! after_create :send_wellcome_email before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true
  43. 66.

    class << self def ten_most_popular find_by_sql <<-SQL SQL end def

    search(fields) scope = all scope = find_by_name(scope, fields[:name]) if fields[:name] scope = find_by_email(scope, fields[:email]) if fields[:email] # ... scope end private def find_by_name(scope, value) scope.where('first_name LIKE ? OR last_name LIKE ?', value, value) end def find_by_email(scope, email) scope.where(email: email) end end def tshirt_size # ... end
  44. 67.

    end def tshirt_size # ... end def shipping_address # ...

    end def payment_address # ... end def name "#{first_name} #{last_name}" end def user_status if trashed? 'Deleted user' else if !active? 'Inactive user' else if spammer? 'Spammer' else 'Normal user' end end
  45. 68.

    end end def activate # ... end def deactivate #

    ... end def require_password_change(password) # ... end private def password_required? # ... end def record_registration_ip # ... end def old_password_correct # ... end def enqueue_avatar_update
  46. 69.

    def deactivate # ... end def require_password_change(password) # ... end

    private def password_required? # ... end def record_registration_ip # ... end def old_password_correct # ... end def enqueue_avatar_update # ... end end
  47. 70.
  48. 71.

    after_create :reset_perishable_token! after_create :send_wellcome_email before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update,

    if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader class << self def ten_most_popular find_by_sql <<-SQL SQL end def search(fields) scope = all scope = find_by_name(scope, fields[:name]) if fields[:name] scope = find_by_email(scope, fields[:email]) if fields[:email] # ... scope end private def find_by_name(scope, value)
  49. 73.

    class << self def search(fields) scope = all scope =

    find_by_name(scope, fields[:name]) if fields[:name] scope = find_by_email(scope, fields[:email]) if fields[:email] # ... scope end private def find_by_name(scope, value) scope.where('first_name LIKE ? OR last_name LIKE ?', value, value) end def find_by_email(scope, email) scope.where(email: email) end end def tshirt_size # ... end def shipping_address # ... end def payment_address # ... end def name "#{first_name} #{last_name}" end
  50. 74.

    module UserSearch include SearchObject.module scope { User.all } option(:name) {

    |scope, value| scope.where 'first_name LIKE ? OR last_name LIKE ?', option(:email) { |scope, value| scope.where email: value } # ... end
  51. 75.

    accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def tshirt_size #

    ... end def shipping_address # ... end def payment_address # ... end def name "#{first_name} #{last_name}" end def user_status if trashed? 'Deleted user' else if !active? 'Inactive user' else if spammer? 'Spammer' else 'Normal user' end end def activate # ... end
  52. 76.

    class UserPresenter < SimpleDelegator def name "#{first_name} #{last_name}" end def

    status if trashed? 'Deleted user' else if !active? 'Inactive user' else if spammer? 'Spammer' else 'Normal user' end end end
  53. 77.

    before_create :record_registration_ip after_create :reset_perishable_token! after_create :send_wellcome_email before_update :send_password_instructions, if: :password_token_changed?

    before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def tshirt_size # ... end def shipping_address # ... end def payment_address # ... end def activate # ... end def deactivate # ... end def require_password_change(password) # ... end
  54. 79.

    validates :gender, in: %w(m f u) validates :terms_of_service, acceptance: true,

    on: :create before_create :record_registration_ip after_create :reset_perishable_token! after_create :send_wellcome_email before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def tshirt_size # ... end def shipping_address # ... end def payment_address # ... end def require_password_change(password) # ... end private def password_required? # ...
  55. 81.

    validates :plan, inclusion: {in AccountPlan::VALUES} validates :terms_of_service, acceptance: true validates

    :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true validates :terms_of_service, acceptance: true, on: :create validates :password, presence: true if: :password_required? validate :old_password_correct, on: :update before_create :record_registration_ip after_create :reset_perishable_token! after_create :send_wellcome_email before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def require_password_change(password) # ... end private def password_required? # ... end def record_registration_ip # ...
  56. 82.

    class RegistrationForm include MiniForm::Model model :user, attributes: %i(model attributes), save:

    true attributes :terms_of_service, :password, :password_confirmation validates :terms_of_service, acceptance: true validates :password, presence: true, confirmation: true def initialize @user = User.new end def before_update record_registration_ip end def perform reset_perishable_token! send_wellcome_email end private def record_registration_ip # ... end end
  57. 83.

    Size.apply_to self, :tshirt_size Address.apply_to self, :payment Address.apply_to self, :shipping validates

    :plan, inclusion: {in AccountPlan::VALUES} validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true validates :password, presence: true if: :password_required? validate :old_password_correct, on: :update before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def require_password_change(password) # ... end private def password_required? # ... end def old_password_correct # ... end def enqueue_avatar_update
  58. 84.

    class UpdatePasswordForm include MiniForm::Model attributes :current_password, :password, :password_confirmation validates :password,

    presence: true, confirmation: true validate :ensure_current_password_is_correct attr_reader :user def initialize(user) @user = user end def preform # things ActiveModel::SecurePassword # will take care for proper password storage user.update! password: password end private def ensure_current_password_is_correct # ... end end
  59. 85.

    class User < ActiveRecord::Base # # long list of relationships

    # Size.apply_to self, :tshirt_size Address.apply_to self, :payment Address.apply_to self, :shipping validates :plan, inclusion: {in AccountPlan::VALUES} validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader def require_password_change(password) # ... end private def enqueue_avatar_update # ... end end
  60. 87.

    class User < ActiveRecord::Base # # long list of relationships

    # Size.apply_to self, :tshirt_size Address.apply_to self, :payment Address.apply_to self, :shipping validates :plan, inclusion: {in AccountPlan::VALUES} validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true before_update :send_password_instructions, if: :password_token_changed? before_update :enqueue_avatar_update, if: :image_changed? accepts_nested_attributes_for :hobbies, allow_destroy: true mount_uploader :avatar, Users::AvatarUploader private def enqueue_avatar_update # ... end end
  61. 90.

    class User < ActiveRecord::Base # # long list of relationships

    # Size.apply_to self, :tshirt_size Address.apply_to self, :payment Address.apply_to self, :shipping validates :plan, inclusion: {in AccountPlan::VALUES} validates :terms_of_service, acceptance: true validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true before_update :enqueue_avatar_update, if: :image_changed? mount_uploader :avatar, Users::AvatarUploader private def enqueue_avatar_update # ... end end
  62. 92.

    class User < ActiveRecord::Base # # long list of relationships

    # Size.apply_to self, :tshirt_size Address.apply_to self, :payment Address.apply_to self, :shipping validates :plan, inclusion: {in AccountPlan::VALUES} validates :first_name, presence: true validates :last_name, presence: true validates :email, presence: true, email: true, unique: true end
  63. 97.
  64. 98.
  65. 99.
  66. 100.

    • Value objects • Service objects • Form objects •

    Query objects • Presenter/Decorator objects • Page object • Helper objects
  67. 101.
  68. 102.
  69. 105.
  70. 106.
  71. 108.