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 Slide

  2. Radoslav Stankov
    @rstankov

    http://rstankov.com

    http://blog.rstankov.com

    http://github.com/rstankov

    View Slide

  3. View Slide

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

    View Slide

  5. View Slide

  6. View Slide

  7. View Slide

  8. View Slide

  9. View Slide

  10. View Slide

  11. View Slide

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

    View Slide

  13. View Slide

  14. View Slide

  15. View Slide

  16. View Slide

  17. View Slide

  18. How?

    View Slide

  19. View Slide

  20. View Slide

  21. View Slide

  22. ActiveRecord
    Objects

    View Slide

  23. ActiveRecord
    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects
    Other

    Objects

    View Slide

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

    View Slide

  25. Value Objects

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  29. Service Objects

    View Slide

  30. Service/UseCase/Interactors
    Objects

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  35. Form Objects

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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)

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  43. Query Objects

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  49. Presenter/Decorator
    Objects

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  56. Page Objects

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  60. Helper Objects

    View Slide

  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

    View Slide

  62. View Slide

  63. Example

    View Slide

  64. USER

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  70. View Slide

  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)

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  80. class Size
    # ...
    end


    class Address
    # ...
    end

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

  88. Never use
    accepts_nested_attributes_for

    View Slide

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

    View Slide

  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

    View Slide

  91. class UpdateAvatarForm
    # ...
    end

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  95. Group by function,
    not by type

    View Slide

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

    View Slide

  97. View Slide

  98. View Slide

  99. Overview

    View Slide

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

    View Slide

  101. View Slide

  102. View Slide

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

    View Slide

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

    View Slide

  105. View Slide

  106. View Slide

  107. @rstankov
    Thanks :)

    View Slide

  108. Questions?

    View Slide