An Intervention for ActiveRecord

An Intervention for ActiveRecord

Given at RailsConf 2013.

Let's be honest: ActiveRecord's got issues, and it's not going to deal with them on its own. It needs our help.

Don't think so? Let's take a closer look together. We'll examine the myriad of perils and pitfalls that await newbie and veteran alike, ranging from intentionally inconsistent behavior to subtle oddities arising from implementation details.

Of course, as with any intervention, we're only doing this because we care. At the very least, you'll learn something you didn't know about ActiveRecord, that helps you avoid these gotchas in your applications. But I hope that you'll leave inspired to contribute to ActiveRecord, engage in discussion about its direction with the core team, and therefore improve the lives of your fellow Rails developers.

WARNING: We will be reading the ActiveRecord code in this talk. Not for the faint of heart.

2274a7476f6d2ac7aedcdec0651d0542?s=128

Ernie Miller

April 30, 2013
Tweet

Transcript

  1. An Intervention for ActiveRecord Ernie Miller / @erniemiller / http://erniemiller.org

  2. Hi. https://github.com/ernie

  3. we’re hiring.* * “we’re hiring” is not part of the

    LivingSocial brand, but totally should be
  4. “You’re interesting to listen to.” ― Mom

  5. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC.
  6. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC. WE WILL BE READING ACTIVERECORD SOURCE CODE IN THIS TALK.
  7. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC. WE WILL BE READING ACTIVERECORD SOURCE CODE IN THIS TALK. A LOT OF IT.
  8. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC. WE WILL BE READING ACTIVERECORD SOURCE CODE IN THIS TALK. A LOT OF IT. THINGS MIGHT GET CRAZY.
  9. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC. WE WILL BE READING ACTIVERECORD SOURCE CODE IN THIS TALK. A LOT OF IT. THINGS MIGHT GET CRAZY. WILL
  10. WARNING THE WARNING IN THE TALK DESCRIPTION, WHICH STATED THAT

    THERE WOULD BE ACTIVERECORD SOURCE CODE IN THIS TALK, WAS NOT A SCARE TACTIC. WE WILL BE READING ACTIVERECORD SOURCE CODE IN THIS TALK. A LOT OF IT. THERE IS STILL TIME TO ESCAPE. THINGS MIGHT GET CRAZY. WILL
  11. WHAT IS ACTIVE RECORD?

  12. “An object that wraps a row in a database table

    or view, encapsulates the database access, and adds domain logic on that data.” A PATTERN
  13. “Active Record has the primary advantage of simplicity. It’s easy

    to build Active Records, and they are easy to understand.” A PATTERN
  14. A LIBRARY class User < ActiveRecord::Base # name, email, and

    timestamps end
  15. A LIBRARY class User < ActiveRecord::Base # name, email, and

    timestamps end User.ancestors.size # => 57 User.methods.size # => 559 User.instance_methods.size # => 352
  16. A LIBRARY $ cloc lib/active_record ------------------------------ Language files code ------------------------------

    Ruby 147 15836 YAML 1 11 ------------------------------ SUM: 148 15847 ------------------------------
  17. “Active Record has the primary advantage of simplicity. It’s easy

    to build Active Records, and they are easy to understand.”
  18. $ cloc lib/active_record ------------------------------ Language files code ------------------------------ Ruby 147

    15836 YAML 1 11 ------------------------------ SUM: 148 15847 ------------------------------ User.ancestors.size # => 57 User.methods.size # => 559 User.instance_methods.size # => 352
  19. simplicity

  20. 57 ancestors 559 class methods 352 instance methods

  21. easy to understand

  22. 148 files 15,847 lines of code

  23. ActiveRecord Active Record

  24. ActiveRecord Active Record ≠

  25. I ActiveRecord

  26. ...but...

  27. BUT

  28. ActiveRecord has issues

  29. None
  30. None
  31. I HAVE A THEORY

  32. NOBODY UNDERSTANDS ACTIVERECORD

  33. None
  34. YOU ARE NOT THE EXCEPTION

  35. YOU ARE NOT THE EXCEPTION No. You’re not.

  36. Library LOC ActionMailer 527 ActiveModel 1,601 ActionController 2,557 ActionView 6,317

    ActionDispatch 7,313 ActiveSupport 9,150 ActiveRecord 15,847
  37. “Magic is not inherently a bad word.” ― ActiveRecord README

  38. None
  39. None
  40. None
  41. None
  42. None
  43. MAGIC REQUIRES CONVENTIONS

  44. MAGIC REQUIRES CONVENTIONS CONVENTIONS REQUIRE OPINIONS

  45. MAGIC REQUIRES CONVENTIONS OPINIONS ARE NOT CREATED EQUAL CONVENTIONS REQUIRE

    OPINIONS
  46. HOW TO CREATE A LEARNING CURVE

  47. HOW TO CREATE A LEARNING CURVE 1.Find things your users

    understand.
  48. HOW TO CREATE A LEARNING CURVE 1.Find things your users

    understand. 2.Ignore those things.
  49. OBJECT INITIALIZATION

  50. class User < ActiveRecord::Base def initialize(*) super self.name ||= '<name

    not provided>' end end user = User.new user.name # => "<name not provided>"
  51. class User < ActiveRecord::Base def initialize(*) super self.name ||= '<name

    not provided>' end end user = User.new user.name # => "<name not provided>" user = User.where(name: nil).first user.name # => nil
  52. class User < ActiveRecord::Base after_initialize :set_defaults def set_defaults self.name ||=

    '<name not provided>' end end user = User.new user.name # => "<name not provided>" user = User.where(name: nil).first user.name # => "<name not provided>"
  53. core.rb: def initialize(attributes = nil) defaults = self.class.column_defaults.dup defaults.each {

    |k, v| defaults[k] = v.dup if v.duplicable? } @attributes = self.class.initialize_attributes(defaults) @columns_hash = self.class.column_types.dup init_internals init_changed_attributes ensure_proper_type populate_with_current_scope_attributes assign_attributes(attributes) if attributes yield self if block_given? run_callbacks :initialize unless _initialize_callbacks.empty? end
  54. core.rb: def init_with(coder) @attributes = self.class.initialize_attributes( coder['attributes'] ) @columns_hash =

    self.class.column_types.merge( coder['column_types'] || {} ) init_internals @new_record = false run_callbacks :find run_callbacks :initialize self end
  55. core.rb: def init_with(coder) @attributes = self.class.initialize_attributes( coder['attributes'] ) @columns_hash =

    self.class.column_types.merge( coder['column_types'] || {} ) init_internals @new_record = false run_callbacks :find run_callbacks :initialize self end
  56. core.rb: def init_with(coder) @attributes = self.class.initialize_attributes( coder['attributes'] ) @columns_hash =

    self.class.column_types.merge( coder['column_types'] || {} ) init_internals @new_record = false run_callbacks :find run_callbacks :initialize self end
  57. persistence.rb: def instantiate(record, column_types = {}) klass = discriminate_class_for_record(record) column_types

    = klass.decorate_columns(column_types) klass.allocate.init_with( 'attributes' => record, 'column_types' => column_types ) end def find_by_sql(sql, binds = []) result_set = connection.select_all( sanitize_sql(sql), "#{name} Load", binds ) column_types = result_set.column_types result_set.map { |record| instantiate(record, column_types) } end querying.rb: * * Deprecation warning omitted
  58. persistence.rb: def instantiate(record, column_types = {}) klass = discriminate_class_for_record(record) column_types

    = klass.decorate_columns(column_types) klass.allocate.init_with( 'attributes' => record, 'column_types' => column_types ) end def find_by_sql(sql, binds = []) result_set = connection.select_all( sanitize_sql(sql), "#{name} Load", binds ) column_types = result_set.column_types result_set.map { |record| instantiate(record, column_types) } end querying.rb: * * Deprecation warning omitted
  59. persistence.rb: def instantiate(record, column_types = {}) klass = discriminate_class_for_record(record) column_types

    = klass.decorate_columns(column_types) klass.allocate.init_with( 'attributes' => record, 'column_types' => column_types ) end def find_by_sql(sql, binds = []) result_set = connection.select_all( sanitize_sql(sql), "#{name} Load", binds ) column_types = result_set.column_types result_set.map { |record| instantiate(record, column_types) } end querying.rb: * * Deprecation warning omitted
  60. TRADE-OFFS Understand them, then question them.

  61. ASSOCIATIONS

  62. ASSOCIATIONS Simple.

  63. ASSOCIATIONS Simple.Until they aren’t.

  64. POP QUIZ!

  65. class User < ActiveRecord::Base has_many :posts def assign_posts(post_or_posts) posts =

    Array(post_or_posts) posts.each { |post| post.published = false } self.posts += posts end end user.assign_posts posts user.posts.all? { |post| post.user_id == user.id } Q:
  66. A:

  67. I HAVE ABSOLUTELY NO IDEA. A:

  68. I HAVE ABSOLUTELY NO IDEA. A: Extra credit: “You’re not

    even testing the right attribute, genius.”
  69. Already Persisted? Already Persisted? After Assignment After Assignment User Posts

    Posts saved ID match
  70. class User < ActiveRecord::Base has_many :posts has_many :published_posts, -> {

    where published: true }, class_name: 'Post' end User.joins(:posts).where(posts: {title: 'zomg first post!'}) SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."title" = 'zomg first post!'
  71. class User < ActiveRecord::Base has_many :posts has_many :published_posts, -> {

    where published: true }, class_name: 'Post' end User.joins(:posts).where(posts: {title: 'zomg first post!'}) User.joins(:published_posts). where(published_posts: {title: 'zomg first post!'}) SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."title" = 'zomg first post!'
  72. class User < ActiveRecord::Base has_many :posts has_many :published_posts, -> {

    where published: true }, class_name: 'Post' end User.joins(:posts).where(posts: {title: 'zomg first post!'}) User.joins(:published_posts). where(published_posts: {title: 'zomg first post!'}) SELECT "users".* FROM "users" INNER JOIN "posts" ON "posts"."user_id" = "users"."id" WHERE "posts"."title" = 'zomg first post!' ActiveRecord::StatementInvalid
  73. relation/query_methods.rb: def build_where(opts, other = []) case opts when String,

    Array [@klass.send(:sanitize_sql, other.empty? ? opts : ([opts] + other))] when Hash attributes = @klass.send(:expand_hash_conditions_for_aggregates, opts) attributes.values.grep(ActiveRecord::Relation) do |rel| self.bind_values += rel.bind_values end PredicateBuilder.build_from_hash(klass, attributes, table) else [opts] end end
  74. relation/query_methods.rb: def build_where(opts, other = []) case opts when String,

    Array [@klass.send(:sanitize_sql, other.empty? ? opts : ([opts] + other))] when Hash attributes = @klass.send(:expand_hash_conditions_for_aggregates, opts) attributes.values.grep(ActiveRecord::Relation) do |rel| self.bind_values += rel.bind_values end PredicateBuilder.build_from_hash(klass, attributes, table) else [opts] end end
  75. relation/predicate_builder.rb: def self.build_from_hash(klass, attributes, default_table) queries = [] attributes.each do

    |column, value| table = default_table if value.is_a?(Hash) if value.empty? queries << '1=0' else table = Arel::Table.new(column, default_table.engine) association = klass.reflect_on_association(column.to_sym) value.each do |k, v| queries.concat expand(association && association.klass, table, k, v) end end else # ... end end queries end
  76. relation/predicate_builder.rb: def self.build_from_hash(klass, attributes, default_table) queries = [] attributes.each do

    |column, value| table = default_table if value.is_a?(Hash) if value.empty? queries << '1=0' else table = Arel::Table.new(column, default_table.engine) association = klass.reflect_on_association(column.to_sym) value.each do |k, v| queries.concat expand(association && association.klass, table, k, v) end end else # ... end end queries end
  77. class User < ActiveRecord::Base has_many :contributions has_many :posts, through: :contributions

    end class Post < ActiveRecord::Base has_many :contributions has_many :contributors, through: :contributions, source: :user end class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :role, presence: true end
  78. class User < ActiveRecord::Base has_many :contributions has_many :posts, through: :contributions

    end class Post < ActiveRecord::Base has_many :contributions has_many :contributors, through: :contributions, source: :user end class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :role, presence: true end
  79. post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false

  80. post = Post.new(params[:post]) post.contributors = [current_user] post.contributions.first.role = 'author' #

    => NoMethodError: undefined method `role=' for nil:NilClass post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false
  81. post = Post.new(params[:post]) post.contributors = [current_user] post.contributions.first.role = 'author' #

    => NoMethodError: undefined method `role=' for nil:NilClass contribution = current_user.contributions.build(role: 'author') contribution.build_post(params[:post]) contribution.save # => true post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false
  82. class Post < ActiveRecord::Base has_many :contributions has_many :contributors, through: :contributions,

    source: :user has_many :authorships, -> { where role: 'author' }, class_name: 'Contribution' has_many :authors, through: :authorships, source: :user end post = Post.new(params[:post]) post.authors = [current_user] post.save # => true
  83. module ActiveRecord module Associations class HasManyThroughAssociation < HasManyAssociation # ...

    def concat_records(records) ensure_not_nested records = super if owner.new_record? && records records.flatten.each do |record| build_through_record(record) end end records end # ... end end end
  84. module ActiveRecord module Associations class HasManyThroughAssociation < HasManyAssociation # ...

    def concat_records(records) ensure_not_nested records = super if owner.new_record? && records records.flatten.each do |record| build_through_record(record) end end records end # ... end end end
  85. None
  86. None
  87. None
  88. None
  89. THE TESTS

  90. THE TESTS They lie.

  91. class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user_id,

    presence: true validates :post_id, presence: true end
  92. class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user_id,

    presence: true validates :post_id, presence: true end post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false
  93. class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user_id,

    presence: true validates :post_id, presence: true end post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false
  94. class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user_id,

    presence: true validates :post_id, presence: true end post = Post.new(params[:post]) post.contributors = [current_user] post.save # => false post.errors.messages # => {:contributions => ["is invalid"]}
  95. INVERSE_OF

  96. INVERSE_OF The oldest ActiveRecord feature you aren’t using

  97. user = User.first contribution = user.contributions.first user.equal?(contribution.user) # => false

  98. user = User.first contribution = user.contributions.first user.equal?(contribution.user) # => false

  99. None
  100. None
  101. class Post < ActiveRecord::Base has_many :contributions, inverse_of: :post has_many :contributors,

    through: :contributions, source: :user end class User < ActiveRecord::Base has_many :contributions, inverse_of: :user has_many :posts, through: :contributions end class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user, presence: true validates :post, presence: true end
  102. class Post < ActiveRecord::Base has_many :contributions, inverse_of: :post has_many :contributors,

    through: :contributions, source: :user end class User < ActiveRecord::Base has_many :contributions, inverse_of: :user has_many :posts, through: :contributions end class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user, presence: true validates :post, presence: true end
  103. class Post < ActiveRecord::Base has_many :contributions, inverse_of: :post has_many :contributors,

    through: :contributions, source: :user end class User < ActiveRecord::Base has_many :contributions, inverse_of: :user has_many :posts, through: :contributions end class Contribution < ActiveRecord::Base belongs_to :user belongs_to :post validates :user, presence: true validates :post, presence: true end
  104. Deep Thoughts

  105. Deep Thoughts on VALIDATIONS

  106. Deep Thoughts on VALIDATIONS

  107. Deep Thoughts on VALIDATIONS Validations live in Ruby.

  108. Deep Thoughts on VALIDATIONS

  109. Deep Thoughts on VALIDATIONS They should care about Ruby objects.

  110. Deep Thoughts on VALIDATIONS

  111. Deep Thoughts on VALIDATIONS “association_id” is an implementation detail.

  112. BUT MY ROFLSCALES!!!

  113. BUT MY ROFLSCALES!!! They’ll be fine.

  114. VALIDATES UNIQUENESS OF

  115. VALIDATES UNIQUENESS OF Doesn’t.

  116. VALIDATES UNIQUENESS OF Doesn’t. You know.

  117. VALIDATES UNIQUENESS OF Doesn’t. You know. Validate uniqueness of.

  118. None
  119. “Using this validation method [...] does not guarantee the absence

    of duplicate record insertions.” ― validations/uniqueness.rb
  120. None
  121. “The best way to work around this problem is to

    add a unique index to the database table.” ― validations/uniqueness.rb
  122. class User < ActiveRecord::Base validates :email, uniqueness: true end class

    CreateUsers < ActiveRecord::Migration def change create_table :users do |t| t.string :name t.string :email end add_index :users, :email, unique: true end end
  123. class User < ActiveRecord::Base def save(*) super rescue ActiveRecord::RecordNotUnique =>

    e errors.add(:email, :taken) false end end User.create name: 'Ernie Miller', email: 'ernie@erniemiller.org' # => true user = User.create name: 'Ernie Miller', email: 'ernie@erniemiller.org' # => false user.errors.messages # => {:email => ["has already been taken"]}
  124. class Post < ActiveRecord::Base validates :title, uniqueness: { scope: :subtitle

    } end
  125. class Post < ActiveRecord::Base def save(*) super rescue ActiveRecord::RecordNotUnique =>

    e attr_name, *scope = e.message. match(/Key \(([^\)]+)\)/)[1].split(/,\s*/) message = errors.generate_message(attr_name, :taken) if scope.any? message << " (scope: #{scope.to_sentence})" end errors.add attr_name, message false end end post = Post.create(title: 'Hello', subtitle: 'World!') # => false post.errors.messages # => {:title => ["has already been taken (scope: subtitle)"]}
  126. class Post < ActiveRecord::Base def save(*) super rescue ActiveRecord::RecordNotUnique =>

    e attr_name, *scope = e.message. match(/Key \(([^\)]+)\)/)[1].split(/,\s*/) message = errors.generate_message(attr_name, :taken) if scope.any? message << " (scope: #{scope.to_sentence})" end errors.add attr_name, message false end end post = Post.create(title: 'Hello', subtitle: 'World!') # => false post.errors.messages # => {:title => ["has already been taken (scope: subtitle)"]}
  127. RELATION#MERGE

  128. RELATION#MERGE You’re using it.

  129. RELATION#MERGE You’re using it. A lot.

  130. def merged_wheres values[:where] ||= [] if values[:where].empty? || relation.where_values.empty? relation.where_values

    + values[:where] else # Remove equalities from the existing relation with a LHS which is # present in the relation being merged in. seen = Set.new values[:where].each { |w| if w.respond_to?(:operator) && w.operator == :== seen << w.left end } relation.where_values.reject { |w| w.respond_to?(:operator) && w.operator == :== && seen.include?(w.left) } + values[:where] end end relation/merger.rb:
  131. # We need to be able to support merging two

    relations without having # to get our hooks too deeply into Active Record. That proves to be # easier said than done. I hate Relation#merge. If Squeel has a # nemesis, Relation#merge would be it. # # Whatever code you see here currently is my current best attempt at # coexisting peacefully with said nemesis. def merge(r, equalities_resolved = false) if ::ActiveRecord::Relation === r && !equalities_resolved if self.table_name != r.table_name super(r.visited) else merge_resolving_duplicate_squeel_equalities(r) end else super(r) end end squeel/adapters/active_record/relation_extensions.rb:
  132. MERGES.

  133. SO

  134. MANY

  135. FREAKING

  136. MERGES.

  137. DEFAULT SCOPE

  138. DEFAULT SCOPE Can we please just kill it, already?

  139. Relation#exec_queries def exec_queries default_scoped = with_default_scope if default_scoped.equal?(self) @records =

    eager_loading? ? find_with_associations : @klass.find_by_sql(arel, bind_values) preload = preload_values preload += includes_values unless eager_loading? preload.each do |associations| ActiveRecord::Associations::Preloader.new(@records, associations).run end # @readonly_value is true only if set explicitly. @implicit_readonly is true if there # are JOINS and no explicit SELECT. readonly = readonly_value.nil? ? @implicit_readonly : readonly_value @records.each { |record| record.readonly! } if readonly else @records = default_scoped.to_a end @loaded = true @records end relation.rb:
  140. Relation#exec_queries def exec_queries default_scoped = with_default_scope if default_scoped.equal?(self) @records =

    eager_loading? ? find_with_associations : @klass.find_by_sql(arel, bind_values) preload = preload_values preload += includes_values unless eager_loading? preload.each do |associations| ActiveRecord::Associations::Preloader.new(@records, associations).run end # @readonly_value is true only if set explicitly. @implicit_readonly is true if there # are JOINS and no explicit SELECT. readonly = readonly_value.nil? ? @implicit_readonly : readonly_value @records.each { |record| record.readonly! } if readonly else @records = default_scoped.to_a end @loaded = true @records end relation.rb:
  141. Relation#with_default_scope def with_default_scope #:nodoc: if default_scoped? && default_scope = klass.send(:build_default_scope)

    default_scope = default_scope.merge(self) default_scope.default_scoped = false default_scope else self end end relation.rb:
  142. Relation#with_default_scope def with_default_scope #:nodoc: if default_scoped? && default_scope = klass.send(:build_default_scope)

    default_scope = default_scope.merge(self) default_scope.default_scoped = false default_scope else self end end relation.rb:
  143. Relation#build_default_scope def build_default_scope # :nodoc: if !Base.is_a?(method(:default_scope).owner) # The user

    has defined their own default scope method, so call that evaluate_default_scope { default_scope } elsif default_scopes.any? evaluate_default_scope do default_scopes.inject(relation) do |default_scope, scope| if !scope.is_a?(Relation) && scope.respond_to?(:call) default_scope.merge(unscoped { scope.call }) else default_scope.merge(scope) end end end end end scoping/default.rb:
  144. Relation#build_default_scope def build_default_scope # :nodoc: if !Base.is_a?(method(:default_scope).owner) # The user

    has defined their own default scope method, so call that evaluate_default_scope { default_scope } elsif default_scopes.any? evaluate_default_scope do default_scopes.inject(relation) do |default_scope, scope| if !scope.is_a?(Relation) && scope.respond_to?(:call) default_scope.merge(unscoped { scope.call }) else default_scope.merge(scope) end end end end end scoping/default.rb:
  145. Relation#build_default_scope def build_default_scope # :nodoc: if !Base.is_a?(method(:default_scope).owner) # The user

    has defined their own default scope method, so call that evaluate_default_scope { default_scope } elsif default_scopes.any? evaluate_default_scope do default_scopes.inject(relation) do |default_scope, scope| if !scope.is_a?(Relation) && scope.respond_to?(:call) default_scope.merge(unscoped { scope.call }) else default_scope.merge(scope) end end end end end scoping/default.rb:
  146. scope(name, scope_options = {}) “Adds a class method for retrieving

    and querying objects. A scope represents a narrowing of a database query.” ― scoping/named.rb
  147. SCOPES

  148. SCOPES Narrow your result set.

  149. SCOPES Narrow your result set. Just kidding.

  150. class User < ActiveRecord::Base default_scope -> { where(name: 'Jim') }

    scope :bobs, -> { where(name: 'Bob') } scope :toms, -> { where(name: 'Tom') } end User.where(name: 'Joe') User.bobs User.bobs.toms User.bobs.toms.where(name: 'Joe') User.where(name: 'Joe').bobs => Joe => Bob => Tom => Tom, Joe => Bob 4.0.0.beta1
  151. None
  152. class User < ActiveRecord::Base default_scope -> { where(name: 'Jim') }

    scope :bobs, -> { where(name: 'Bob') } scope :toms, -> { where(name: 'Tom') } end User.where(name: 'Joe') User.bobs User.bobs.toms User.bobs.toms.where(name: 'Joe') User.where(name: 'Joe').bobs => Joe => Bob => Bob, Tom => Bob, Tom, Joe => Joe, Bob 4.0.0.rc1
  153. CALCULATIONS

  154. CALCULATIONS Those are the things you do with numbers, right?

  155. class Post < ActiveRecord::Base belongs_to :user def self.number_of_pages(page_size = 20)

    number_of_posts = self.count full_pages = number_of_posts / page_size if (number_of_posts % page_size).zero? full_pages else # We will have a page with remainders full_pages + 1 end end end
  156. class Post < ActiveRecord::Base belongs_to :user def self.number_of_pages(page_size = 20)

    number_of_posts = self.count full_pages = number_of_posts / page_size if (number_of_posts % page_size).zero? full_pages else # We will have a page with remainders full_pages + 1 end end end NoMethodError: undefined method `/' for #<Hash:...>
  157. def perform_calculation(operation, column_name, options = {}) operation = operation.to_s.downcase distinct

    = self.distinct_value if operation == "count" column_name ||= (select_for_count || :all) unless arel.ast.grep(Arel::Nodes::OuterJoin).empty? distinct = true end column_name = primary_key if column_name == :all && distinct distinct = nil if column_name =~ /\s*DISTINCT\s+/i end if group_values.any? execute_grouped_calculation(operation, column_name, distinct) else execute_simple_calculation(operation, column_name, distinct) end end relation/calculations.rb: * * Deprecation warning omitted
  158. def perform_calculation(operation, column_name, options = {}) operation = operation.to_s.downcase distinct

    = self.distinct_value if operation == "count" column_name ||= (select_for_count || :all) unless arel.ast.grep(Arel::Nodes::OuterJoin).empty? distinct = true end column_name = primary_key if column_name == :all && distinct distinct = nil if column_name =~ /\s*DISTINCT\s+/i end if group_values.any? execute_grouped_calculation(operation, column_name, distinct) else execute_simple_calculation(operation, column_name, distinct) end end relation/calculations.rb: * * Deprecation warning omitted
  159. relation/calculations.rb: def execute_grouped_calculation(operation, column_name, distinct) #:nodoc: group_attrs = group_values if

    group_attrs.first.respond_to?(:to_sym) association = @klass.reflect_on_association(group_attrs.first.to_sym) associated = group_attrs.size == 1 && association && association.macro == :belongs_to # only count belongs_to associations group_fields = Array(associated ? association.foreign_key : group_attrs) else group_fields = group_attrs end group_aliases = group_fields.map { |field| column_alias_for(field) } group_columns = group_aliases.zip(group_fields).map { |aliaz,field| [aliaz, field] } group = group_fields if operation == 'count' && column_name == :all aggregate_alias = 'count_all' else aggregate_alias = column_alias_for([operation, column_name].join(' ')) end select_values = [
  160. relation/calculations.rb: def execute_grouped_calculation(operation, column_name, distinct) #:nodoc: group_attrs = group_values if

    group_attrs.first.respond_to?(:to_sym) association = @klass.reflect_on_association(group_attrs.first.to_sym) associated = group_attrs.size == 1 && association && association.macro == :belongs_to # only count belongs_to associations group_fields = Array(associated ? association.foreign_key : group_attrs) else group_fields = group_attrs end group_aliases = group_fields.map { |field| column_alias_for(field) } group_columns = group_aliases.zip(group_fields).map { |aliaz,field| [aliaz, field] } group = group_fields if operation == 'count' && column_name == :all aggregate_alias = 'count_all' else aggregate_alias = column_alias_for([operation, column_name].join(' ')) end select_values = [
  161. if association key_ids = calculated_data.collect { |row| row[group_aliases.first] } key_records

    = association.klass.base_class.find(key_ids) key_records = Hash[key_records.map { |r| [r.id, r] }] end Hash[calculated_data.map do |row| key = group_columns.map { |aliaz, col_name| column = calculated_data.column_types.fetch(aliaz) do column_for(col_name) end type_cast_calculated_value(row[aliaz], column) } key = key.first if key.size == 1 key = key_records[key] if associated [ key, type_cast_calculated_value( row[aggregate_alias], column_for(column_name), operation ) ] end] end
  162. if association key_ids = calculated_data.collect { |row| row[group_aliases.first] } key_records

    = association.klass.base_class.find(key_ids) key_records = Hash[key_records.map { |r| [r.id, r] }] end Hash[calculated_data.map do |row| key = group_columns.map { |aliaz, col_name| column = calculated_data.column_types.fetch(aliaz) do column_for(col_name) end type_cast_calculated_value(row[aliaz], column) } key = key.first if key.size == 1 key = key_records[key] if associated [ key, type_cast_calculated_value( row[aggregate_alias], column_for(column_name), operation ) ] end] end
  163. TL;DR

  164. None
  165. CALLBACKS

  166. CALLBACKS You’ll be sorry.

  167. CALLBACKS You’ll be sorry. Don’t say I didn’t warn you.

  168. “That's a total of twelve callbacks, which gives you immense

    power to react and prepare for each state in the Active Record life cycle.” ― active_record/callbacks.rb
  169. class User < ActiveRecord::Base before_save NameSayer.new before_save :say_my_name before_save {

    |record| puts "My name is #{record.name}" } before_save 'puts "My name is #{self.name}"' # YES, REALLY def say_my_name puts "My name is #{self.name}" end end class NameSayer def before_save(record) puts "My name is #{record.name}" end end
  170. class User < ActiveRecord::Base before_save NameSayer.new before_save :say_my_name before_save {

    |record| puts "My name is #{record.name}" } before_save 'puts "My name is #{self.name}"' # YES, REALLY def say_my_name puts "My name is #{self.name}" end end class NameSayer def before_save(record) puts "My name is #{record.name}" end end
  171. class User < ActiveRecord::Base before_save NameSayer.new before_save :say_my_name before_save {

    |record| puts "My name is #{record.name}" } before_save 'puts "My name is #{self.name}"' # YES, REALLY def say_my_name puts "My name is #{self.name}" end end class NameSayer def before_save(record) puts "My name is #{record.name}" end end User.new._save_callbacks.class # => ActiveSupport::Callbacks::CallbackChain
  172. active_support/callbacks.rb: class CallbackChain < Array #:nodoc:# # ... def compile

    method = ["value = nil", "halted = false"] callbacks = "value = !halted && (!block_given? || yield)" reverse_each do |callback| callbacks = callback.apply(callbacks) end method << callbacks method << "value" method.join("\n") end # ... end
  173. active_support/callbacks.rb: class CallbackChain < Array #:nodoc:# # ... def compile

    method = ["value = nil", "halted = false"] callbacks = "value = !halted && (!block_given? || yield)" reverse_each do |callback| callbacks = callback.apply(callbacks) end method << callbacks method << "value" method.join("\n") end # ... end
  174. def apply(code) case @kind when :before <<-RUBY_EVAL if !halted &&

    #{@compiled_options} # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = #{@filter} halted = (#{chain.config[:terminator]}) if halted halted_callback_hook(#{@raw_filter.inspect.inspect}) end end #{code} RUBY_EVAL when :after <<-RUBY_EVAL #{code} if #{!chain.config[:skip_after_callbacks_if_terminated] || "!halted"} && #{@compiled_options} #{@filter} end RUBY_EVAL when :around name = define_conditional_callback <<-RUBY_EVAL active_support/callbacks.rb:
  175. def apply(code) case @kind when :before <<-RUBY_EVAL if !halted &&

    #{@compiled_options} # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = #{@filter} halted = (#{chain.config[:terminator]}) if halted halted_callback_hook(#{@raw_filter.inspect.inspect}) end end #{code} RUBY_EVAL when :after <<-RUBY_EVAL #{code} if #{!chain.config[:skip_after_callbacks_if_terminated] || "!halted"} && #{@compiled_options} #{@filter} end RUBY_EVAL when :around name = define_conditional_callback <<-RUBY_EVAL active_support/callbacks.rb:
  176. def apply(code) case @kind when :before <<-RUBY_EVAL if !halted &&

    #{@compiled_options} # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = #{@filter} halted = (#{chain.config[:terminator]}) if halted halted_callback_hook(#{@raw_filter.inspect.inspect}) end end #{code} RUBY_EVAL when :after <<-RUBY_EVAL #{code} if #{!chain.config[:skip_after_callbacks_if_terminated] || "!halted"} && #{@compiled_options} #{@filter} end RUBY_EVAL when :around name = define_conditional_callback <<-RUBY_EVAL active_support/callbacks.rb:
  177. def apply(code) case @kind when :before <<-RUBY_EVAL if !halted &&

    #{@compiled_options} # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = #{@filter} halted = (#{chain.config[:terminator]}) if halted halted_callback_hook(#{@raw_filter.inspect.inspect}) end end #{code} RUBY_EVAL when :after <<-RUBY_EVAL #{code} if #{!chain.config[:skip_after_callbacks_if_terminated] || "!halted"} && #{@compiled_options} #{@filter} end RUBY_EVAL when :around name = define_conditional_callback <<-RUBY_EVAL active_support/callbacks.rb:
  178. halted = (#{chain.config[:terminator]}) if halted halted_callback_hook(#{@raw_filter.inspect.inspect}) end end #{code} RUBY_EVAL

    when :after <<-RUBY_EVAL #{code} if #{!chain.config[:skip_after_callbacks_if_terminated] || "!halted"} && #{@compiled_options} #{@filter} end RUBY_EVAL when :around name = define_conditional_callback <<-RUBY_EVAL #{name}(halted) do #{code} value end RUBY_EVAL end end
  179. active_support/callbacks.rb: class Callback #:nodoc:# @@_callback_sequence = 0 attr_accessor :chain, :filter,

    :kind, :options, :klass, :raw_filter def initialize(chain, filter, kind, options, klass) @chain, @kind, @klass = chain, kind, klass deprecate_per_key_option(options) normalize_options!(options) @raw_filter, @options = filter, options @filter = _compile_filter(filter) recompile_options! end # ... end
  180. active_support/callbacks.rb: class Callback #:nodoc:# @@_callback_sequence = 0 attr_accessor :chain, :filter,

    :kind, :options, :klass, :raw_filter def initialize(chain, filter, kind, options, klass) @chain, @kind, @klass = chain, kind, klass deprecate_per_key_option(options) normalize_options!(options) @raw_filter, @options = filter, options @filter = _compile_filter(filter) recompile_options! end # ... end
  181. active_support/callbacks.rb: class Callback #:nodoc:# @@_callback_sequence = 0 attr_accessor :chain, :filter,

    :kind, :options, :klass, :raw_filter def initialize(chain, filter, kind, options, klass) @chain, @kind, @klass = chain, kind, klass deprecate_per_key_option(options) normalize_options!(options) @raw_filter, @options = filter, options @filter = _compile_filter(filter) recompile_options! end # ... end
  182. active_support/callbacks.rb: def recompile_options! conditions = ["true"] unless options[:if].empty? conditions <<

    Array(_compile_filter(options[:if])) end unless options[:unless].empty? conditions << Array(_compile_filter(options[:unless])). map {|f| "!#{f}"} end @compiled_options = conditions.flatten.join(" && ") end
  183. active_support/callbacks.rb: def recompile_options! conditions = ["true"] unless options[:if].empty? conditions <<

    Array(_compile_filter(options[:if])) end unless options[:unless].empty? conditions << Array(_compile_filter(options[:unless])). map {|f| "!#{f}"} end @compiled_options = conditions.flatten.join(" && ") end
  184. def _compile_filter(filter) @_is_object_filter = false case filter when Array filter.map

    {|f| _compile_filter(f)} when Symbol filter when String "(#{filter})" when Proc method_name = "_callback_#{@kind}_#{next_id}" @klass.send(:define_method, method_name, &filter) return method_name if filter.arity <= 0 method_name << (filter.arity == 1 ? "(self)" : " self, Proc.new ") else method_name = _method_name_for_object_filter(kind, filter) @_is_object_filter = true @klass.send(:define_method, "#{method_name}_object") { filter } _normalize_legacy_filter(kind, filter) scopes = Array(chain.config[:scope]) method_to_call = scopes.map{ |s| s.is_a?(Symbol) ? send(s) : s }.join("_") @klass.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 def #{method_name}(&blk) active_support/callbacks.rb:
  185. when String "(#{filter})" when Proc method_name = "_callback_#{@kind}_#{next_id}" @klass.send(:define_method, method_name,

    &filter) return method_name if filter.arity <= 0 method_name << (filter.arity == 1 ? "(self)" : " self, Proc.new ") else method_name = _method_name_for_object_filter(kind, filter) @_is_object_filter = true @klass.send(:define_method, "#{method_name}_object") { filter } _normalize_legacy_filter(kind, filter) scopes = Array(chain.config[:scope]) method_to_call = scopes.map{ |s| s.is_a?(Symbol) ? send(s) : s }.join("_") @klass.class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 def #{method_name}(&blk) #{method_name}_object.send(:#{method_to_call}, self, &blk) end RUBY_EVAL method_name end end
  186. value = nil halted = false if !halted && true

    # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = _callback_before_15 halted = (result == false) if halted halted_callback_hook("#<NameSayer:0x007fa08a63c2e8>") end end if !halted && true # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = say_my_name halted = (result == false) if halted halted_callback_hook(":say_my_name") end end if !halted && true # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = _callback_before_17(self)
  187. if !halted && true # This double assignment is to

    prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = _callback_before_17(self) halted = (result == false) if halted halted_callback_hook("#<Proc:0x007fa08a642d78@...>") end end if !halted && true # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = (puts "My name is #{self.name}") halted = (result == false) if halted halted_callback_hook("\"puts \\\"My name is \\\#{self.name}\\\"\"") end end value = !halted && (!block_given? || yield) value
  188. if !halted && true # This double assignment is to

    prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = _callback_before_17(self) halted = (result == false) if halted halted_callback_hook("#<Proc:0x007fa08a642d78@...>") end end if !halted && true # This double assignment is to prevent warnings in 1.9.3 as # the `result` variable is not always used except if the # terminator code refers to it. result = result = (puts "My name is #{self.name}") halted = (result == false) if halted halted_callback_hook("\"puts \\\"My name is \\\#{self.name}\\\"\"") end end value = !halted && (!block_given? || yield) value
  189. active_support/callbacks.rb: module ClassMethods def __define_callbacks(kind, object) #:nodoc: name = __callback_runner_name(kind)

    unless object.respond_to?(name, true) str = object.send("_#{kind}_callbacks").compile class_eval <<-RUBY_EVAL, __FILE__, __LINE__ + 1 def #{name}() #{str} end protected :#{name} RUBY_EVAL end name end end def run_callbacks(kind, &block) runner_name = self.class.__define_callbacks(kind, self) send(runner_name, &block) end
  190. active_record/callbacks.rb: module Callbacks def destroy #:nodoc: run_callbacks(:destroy) { super }

    end def touch(*) #:nodoc: run_callbacks(:touch) { super } end private def create_or_update #:nodoc: run_callbacks(:save) { super } end def create_record #:nodoc: run_callbacks(:create) { super } end def update_record(*) #:nodoc: run_callbacks(:update) { super } end end
  191. A NOVEL IDEA!

  192. INHERITANCE AND SUPER

  193. BEFORE FILTER!!! class User < ActiveRecord::Base def create_or_update say_my_name &&

    super end def say_my_name puts "My name is #{name}"; true end end
  194. BEFORE FILTER!!! class User < ActiveRecord::Base def create_or_update say_my_name &&

    super end def say_my_name puts "My name is #{name}"; true end end
  195. AFTER FILTER!!! class User < ActiveRecord::Base def create_or_update super &&

    final_stuff or raise ActiveRecord::Rollback end def final_stuff puts "IMPORTANT FINAL STUFF" end end
  196. AFTER FILTER!!! class User < ActiveRecord::Base def create_or_update super &&

    final_stuff or raise ActiveRecord::Rollback end def final_stuff puts "IMPORTANT FINAL STUFF" end end
  197. AROUND FILTER!!! class User < ActiveRecord::Base def create_or_update puts "GET

    READY" unless sneak_attack? super puts "WASN'T THAT EXCITING?" if awesome? end end
  198. AROUND FILTER!!! class User < ActiveRecord::Base def create_or_update puts "GET

    READY" unless sneak_attack? super puts "WASN'T THAT EXCITING?" if awesome? end end
  199. “That's a total of twelve callbacks, which gives you immense

    power to react and prepare for each state in the Active Record life cycle.” ― active_record/callbacks.rb
  200. “That’s Ruby, which gives you immense power to do, you

    know, pretty much anything, ever.” ― me
  201. SO?

  202. ACTIVERECORD WORKS WELL ENOUGH

  203. IT’S BIG AND COMPLEX, CHANGE WILL BE HARD

  204. CHANGES WILL INTENTIONALLY BREAK MY STUFF

  205. CHANGES WILL ACCIDENTALLY BREAK MY STUFF

  206. USE SOMETHING ELSE WHEN YOU NEED TO

  207. LEGACY ACTIVERECORD IS

  208. LEGACY ACTIVERECORD IS OUR

  209. IT DESERVES BETTER

  210. NEW USERS DESERVE BETTER

  211. WE CAN DO BETTER

  212. WRITE SOME DOCS

  213. WRITE SOME TESTS

  214. TRIAGE SOME ISSUES

  215. PARTICIPATE IN THE DISCUSSION

  216. BETTER STARTS NOW.

  217. Ernie Miller / @erniemiller / http://erniemiller.org THANK YOU!