Starving ActiveRecord

Starving ActiveRecord

Some techniques to deal with fat ActiveRecord models.

7a0e72a6f55811246bb5d9a946fd2e49?s=128

Radoslav Stankov

May 21, 2015
Tweet

Transcript

  1. Starving ActiveRecord Radoslav Stankov 21/05/2015

  2. Radoslav Stankov @rstankov http://rstankov.com
 http://blog.rstankov.com http://github.com/rstankov

  3. None
  4. https://www.youtube.com/watch?v=p5EIrSM8dCA

  5. None
  6. None
  7. None
  8. None
  9. None
  10. None
  11. None
  12. https://www.youtube.com/watch?v=v6UhbI84nmA

  13. None
  14. None
  15. None
  16. None
  17. None
  18. How?

  19. None
  20. None
  21. None
  22. ActiveRecord Objects

  23. ActiveRecord Objects Other
 Objects Other
 Objects Other
 Objects Other
 Objects

    Other
 Objects Other
 Objects Other
 Objects
  24. • Value objects • Service objects • Form objects •

    Query objects • Presenter/Decorator objects • Page object • Helper objects
  25. Value Objects

  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
  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
  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
  29. Service Objects

  30. Service/UseCase/Interactors Objects

  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
  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
  33. module DoSomeThing extend self def call(...) ... end private ...

    end
  34. https://github.com/gitlabhq/gitlabhq/tree/master/app/services

  35. Form Objects

  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
  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
  38. class RegistrationController < ApplicationController def create @registration = RegistrationForm.new(registration_param) @registration.save

    respond_with @registration end end
  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)
  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
  41. https://github.com/RStankov/MiniForm

  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
  43. Query Objects

  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
  45. products = ProductRanking.new.paginate params[:page] products = ProductRanking.new(Product.visible).page params[:page]

  46. https://github.com/rstankov/SearchObject

  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
  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
  49. Presenter/Decorator Objects

  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
  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
  52. # /apps/helpers/decorate_helper.rb module DecorateHelper def decorate_article(article) yield ArticleDecorator.new(article, self) end

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

    article.status_tag %> <%= article.cotnent_tag %> <%= article.render_comments %> <% end %>
  54. https://github.com/drapergem/draper

  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
  56. Page Objects

  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
  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
  59. class NewsShowPage # ... def cache_key # ... end end

  60. Helper Objects

  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
  62. None
  63. Example

  64. USER

  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
  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
  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
  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
  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
  70. None
  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)
  72. module TopUsers extend self COUNT = 10 def all User.find_by_sql

    <<-SQL SQL end end
  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
  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
  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
  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
  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
  78. module UserDeactivator def call(user, activate:) # moved user.activate / user.deactivate

    end end
  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? # ...
  80. class Size # ... end
 
 class Address # ...

    end
  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 # ...
  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
  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
  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
  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
  86. class RequestPasswordResetForm # ... end class ConfirmPasswordResetForm # ... end

  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
  88. Never use accepts_nested_attributes_for

  89. class SomeFormWhereWeDealWithHobbies # ... move hobbies logic here end

  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
  91. class UpdateAvatarForm # ... end

  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
  93. TopUsers UserSearch UserPresenter UserDeactivator Size Address RegistrationForm UpdatePasswordForm RequestPasswordResetForm ConfirmPasswordResetForm

    UpdateAvatarForm
  94. Queries::TopUsers Queries::UserSearch Presenters::UserPresenter Decorators::UserDeactivator Values::Size Values::Address Forms::RegistrationForm Forms::UpdatePasswordForm Forms::RequestPasswordResetForm Forms::ConfirmPasswordResetForm

    Forms::UpdateAvatarForm
  95. Group by function, not by type

  96. Ranking::TopUsers User::Search User::Presenter User::Deactivator Size Address Registration::Form PasswordReset::RequestForm PasswordReset::ResetForm Profile::UpdatePasswordForm

    Profile::UpdateAvatarForm
  97. None
  98. None
  99. Overview

  100. • Value objects • Service objects • Form objects •

    Query objects • Presenter/Decorator objects • Page object • Helper objects
  101. None
  102. None
  103. https://speakerdeck.com/rstankov/starving-activerecord

  104. https://github.com/rstankov/talks-code

  105. None
  106. None
  107. @rstankov Thanks :)

  108. Questions?