Slide 1

Slide 1 text

Starving ActiveRecord Radoslav Stankov 21/05/2015

Slide 2

Slide 2 text

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

Slide 3

Slide 3 text

No content

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

No content

Slide 6

Slide 6 text

No content

Slide 7

Slide 7 text

No content

Slide 8

Slide 8 text

No content

Slide 9

Slide 9 text

No content

Slide 10

Slide 10 text

No content

Slide 11

Slide 11 text

No content

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

No content

Slide 14

Slide 14 text

No content

Slide 15

Slide 15 text

No content

Slide 16

Slide 16 text

No content

Slide 17

Slide 17 text

No content

Slide 18

Slide 18 text

How?

Slide 19

Slide 19 text

No content

Slide 20

Slide 20 text

No content

Slide 21

Slide 21 text

No content

Slide 22

Slide 22 text

ActiveRecord Objects

Slide 23

Slide 23 text

ActiveRecord Objects Other
 Objects Other
 Objects Other
 Objects Other
 Objects Other
 Objects Other
 Objects Other
 Objects

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

Value Objects

Slide 26

Slide 26 text

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

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

Service Objects

Slide 30

Slide 30 text

Service/UseCase/Interactors Objects

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

Form Objects

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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)

Slide 40

Slide 40 text

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

Slide 41

Slide 41 text

https://github.com/RStankov/MiniForm

Slide 42

Slide 42 text

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

Slide 43

Slide 43 text

Query Objects

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

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

Slide 46

Slide 46 text

https://github.com/rstankov/SearchObject

Slide 47

Slide 47 text

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

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

Presenter/Decorator Objects

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

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

Slide 52

Slide 52 text

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

Slide 53

Slide 53 text

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

Slide 54

Slide 54 text

https://github.com/drapergem/draper

Slide 55

Slide 55 text

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

Slide 56

Slide 56 text

Page Objects

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

Helper Objects

Slide 61

Slide 61 text

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

Slide 62

Slide 62 text

No content

Slide 63

Slide 63 text

Example

Slide 64

Slide 64 text

USER

Slide 65

Slide 65 text

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

Slide 66

Slide 66 text

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

Slide 67

Slide 67 text

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

Slide 68

Slide 68 text

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

Slide 69

Slide 69 text

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

Slide 70

Slide 70 text

No content

Slide 71

Slide 71 text

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)

Slide 72

Slide 72 text

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

Slide 73

Slide 73 text

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

Slide 74

Slide 74 text

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

Slide 75

Slide 75 text

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

Slide 76

Slide 76 text

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

Slide 77

Slide 77 text

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

Slide 78

Slide 78 text

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

Slide 79

Slide 79 text

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

Slide 80

Slide 80 text

class Size # ... end
 
 class Address # ... end

Slide 81

Slide 81 text

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

Slide 82

Slide 82 text

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

Slide 83

Slide 83 text

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

Slide 84

Slide 84 text

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

Slide 85

Slide 85 text

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

Slide 86

Slide 86 text

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

Slide 87

Slide 87 text

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

Slide 88

Slide 88 text

Never use accepts_nested_attributes_for

Slide 89

Slide 89 text

class SomeFormWhereWeDealWithHobbies # ... move hobbies logic here end

Slide 90

Slide 90 text

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

Slide 91

Slide 91 text

class UpdateAvatarForm # ... end

Slide 92

Slide 92 text

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

Slide 93

Slide 93 text

TopUsers UserSearch UserPresenter UserDeactivator Size Address RegistrationForm UpdatePasswordForm RequestPasswordResetForm ConfirmPasswordResetForm UpdateAvatarForm

Slide 94

Slide 94 text

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

Slide 95

Slide 95 text

Group by function, not by type

Slide 96

Slide 96 text

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

Slide 97

Slide 97 text

No content

Slide 98

Slide 98 text

No content

Slide 99

Slide 99 text

Overview

Slide 100

Slide 100 text

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

Slide 101

Slide 101 text

No content

Slide 102

Slide 102 text

No content

Slide 103

Slide 103 text

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

Slide 104

Slide 104 text

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

Slide 105

Slide 105 text

No content

Slide 106

Slide 106 text

No content

Slide 107

Slide 107 text

@rstankov Thanks :)

Slide 108

Slide 108 text

Questions?