Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Starving ActiveRecord

Starving ActiveRecord

Some techniques to deal with fat ActiveRecord models.

Radoslav Stankov

May 21, 2015
Tweet

More Decks by Radoslav Stankov

Other Decks in Technology

Transcript

  1. Starving ActiveRecord
    Radoslav Stankov 21/05/2015

    View full-size slide

  2. Radoslav Stankov
    @rstankov

    http://rstankov.com

    http://blog.rstankov.com

    http://github.com/rstankov

    View full-size slide

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

    View full-size slide

  4. https://www.youtube.com/watch?v=v6UhbI84nmA

    View full-size slide

  5. ActiveRecord
    Objects

    View full-size slide

  6. ActiveRecord
    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects

    View full-size slide

  7. • Value objects
    • Service objects
    • Form objects
    • Query objects
    • Presenter/Decorator objects
    • Page object
    • Helper objects

    View full-size slide

  8. Value Objects

    View full-size slide

  9. 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

    View full-size slide

  10. 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

    View full-size slide

  11. 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

    View full-size slide

  12. Service Objects

    View full-size slide

  13. Service/UseCase/Interactors
    Objects

    View full-size slide

  14. 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

    View full-size slide

  15. 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

    View full-size slide

  16. module DoSomeThing
    extend self
    def call(...)
    ...
    end
    private
    ...
    end

    View full-size slide

  17. https://github.com/gitlabhq/gitlabhq/tree/master/app/services

    View full-size slide

  18. Form Objects

    View full-size slide

  19. 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

    View full-size slide

  20. 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

    View full-size slide

  21. class RegistrationController < ApplicationController
    def create
    @registration = RegistrationForm.new(registration_param)
    @registration.save
    respond_with @registration
    end
    end

    View full-size slide

  22. 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)

    View full-size slide

  23. 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

    View full-size slide

  24. https://github.com/RStankov/MiniForm

    View full-size slide

  25. 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

    View full-size slide

  26. Query Objects

    View full-size slide

  27. 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

    View full-size slide

  28. products = ProductRanking.new.paginate params[:page]
    products = ProductRanking.new(Product.visible).page params[:page]

    View full-size slide

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

    View full-size slide

  30. 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

    View full-size slide

  31. 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

    View full-size slide

  32. Presenter/Decorator
    Objects

    View full-size slide

  33. 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

    View full-size slide

  34. 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

    View full-size slide

  35. # /apps/helpers/decorate_helper.rb
    module DecorateHelper
    def decorate_article(article)
    yield ArticleDecorator.new(article, self)
    end
    end

    View full-size slide

  36. <%= decorate_article @article do |article| %>
    <%= article.headline_tag %>
    <%= article.status_tag %>
    <%= article.cotnent_tag %>
    <%= article.render_comments %>
    <% end %>

    View full-size slide

  37. https://github.com/drapergem/draper

    View full-size slide

  38. 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

    View full-size slide

  39. Page Objects

    View full-size slide

  40. 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

    View full-size slide

  41. 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

    View full-size slide

  42. class NewsShowPage
    # ...
    def cache_key
    # ...
    end
    end

    View full-size slide

  43. Helper Objects

    View full-size slide

  44. 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

    View full-size slide

  45. 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

    View full-size slide

  46. 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

    View full-size slide

  47. 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

    View full-size slide

  48. 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

    View full-size slide

  49. 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

    View full-size slide

  50. 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)

    View full-size slide

  51. module TopUsers
    extend self
    COUNT = 10
    def all
    User.find_by_sql <<-SQL
    SQL
    end
    end

    View full-size slide

  52. 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

    View full-size slide

  53. 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

    View full-size slide

  54. 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

    View full-size slide

  55. 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

    View full-size slide

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

    View full-size slide

  57. module UserDeactivator
    def call(user, activate:)
    # moved user.activate / user.deactivate
    end
    end

    View full-size slide

  58. 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?
    # ...

    View full-size slide

  59. class Size
    # ...
    end


    class Address
    # ...
    end

    View full-size slide

  60. 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
    # ...

    View full-size slide

  61. 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

    View full-size slide

  62. 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

    View full-size slide

  63. 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

    View full-size slide

  64. 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

    View full-size slide

  65. class RequestPasswordResetForm
    # ...
    end
    class ConfirmPasswordResetForm
    # ...
    end

    View full-size slide

  66. 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

    View full-size slide

  67. Never use
    accepts_nested_attributes_for

    View full-size slide

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

    View full-size slide

  69. 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

    View full-size slide

  70. class UpdateAvatarForm
    # ...
    end

    View full-size slide

  71. 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

    View full-size slide

  72. TopUsers
    UserSearch
    UserPresenter
    UserDeactivator
    Size
    Address
    RegistrationForm
    UpdatePasswordForm
    RequestPasswordResetForm
    ConfirmPasswordResetForm
    UpdateAvatarForm

    View full-size slide

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

    View full-size slide

  74. Group by function,
    not by type

    View full-size slide

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

    View full-size slide

  76. • Value objects
    • Service objects
    • Form objects
    • Query objects
    • Presenter/Decorator objects
    • Page object
    • Helper objects

    View full-size slide

  77. https://speakerdeck.com/rstankov/starving-activerecord

    View full-size slide

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

    View full-size slide

  79. @rstankov
    Thanks :)

    View full-size slide