$30 off During Our Annual Pro Sale. View Details »

The Elusive Attribute

The Elusive Attribute

Talk on the internals of attributes in ActiveModel and ActiveRecord at RailsConf 2019.

Video: https://www.youtube.com/watch?v=PNNrmNTQx2s
Summary: https://railsconf.com/program/sessions#session-751

References:
- Give GeneratedAttributeMethods module a name: https://github.com/rails/rails/pull/35595
- Make plain matcher match first, not last: https://github.com/rails/rails/pull/36005 (PR #36005, mentioned in slide 77)
- Velocipedia: https://www.behance.net/gallery/35437979/Velocipedia

Chris Salzberg

May 02, 2019
Tweet

More Decks by Chris Salzberg

Other Decks in Programming

Transcript

  1. The Elusive
    Attribute
    Chris Salzberg

    View Slide

  2. As we know, there are known knowns; there are
    things we know we know. We also know there are
    known unknowns; that is to say we know there
    are some things we do not know.
    But there are also unknown unknowns - the ones
    we don't know we don't know.
    - Donald Rumsfeld
    United States Secretary of Defense (2003)

    View Slide

  3. View Slide

  4. View Slide

  5. View Slide

  6. View Slide

  7. View Slide

  8. View Slide

  9. People who are really into bikes [...] start out
    from the frame. They represent that small
    percentage of people who get the drawing perfectly
    right. Most other people tend to start from what
    they are most sure of, so for nearly everyone it’s
    the wheels. [...] I saw many people drawing all the
    rest in the hope that positioning handlebar, saddle
    and pedals would help them figure out the frame.
    - Gianluca Gimini

    View Slide

  10. View Slide

  11. View Slide

  12. View Slide

  13. View Slide

  14. @attributes
    Name: Chris Salzberg
    Handle: shioyama
    Born: Montreal, CA
    Based: Tokyo, JP
    Company: Degica
    Blog: dejimata.com
    Tags: Module Builder, Mobility

    View Slide

  15. 1. methods & columns

    View Slide

  16. View Slide

  17. class Post < ApplicationRecord
    end
    < 6.0

    View Slide

  18. class Post < ApplicationRecord
    end
    Post.ancestors
    => [Post,
    Post::GeneratedAssociationMethods,
    #,
    ApplicationRecord(abstract),
    ApplicationRecord::GeneratedAssociationMethods,
    #,
    AR::Base,
    ... ]
    < 6.0

    View Slide

  19. class Post < ApplicationRecord
    end
    Post.ancestors
    => [Post,
    Post::GeneratedAssociationMethods,
    #,
    ApplicationRecord(abstract),
    ApplicationRecord::GeneratedAssociationMethods,
    #,
    AR::Base,
    ... ]
    < 6.0

    View Slide

  20. class Post < ApplicationRecord
    end
    Post.ancestors
    => [Post,
    Post::GeneratedAssociationMethods,
    Post::GeneratedAttributeMethods,
    ApplicationRecord(abstract),
    ApplicationRecord::GeneratedAssociationMethods,
    ApplicationRecord::GeneratedAttributeMethods,
    ActiveRecord::Base,
    ... ]
    >= 6.0

    View Slide

  21. post = Post.new(title: "The Elusive Attribute")

    View Slide

  22. post = Post.new(title: "The Elusive Attribute")
    post.method(:title)
    => #)

    View Slide

  23. post = Post.new(title: "The Elusive Attribute")
    post.method(:title).owner
    => Post::GeneratedAttributeMethods

    View Slide

  24. => [
    ...
    :title_before_type_cast,
    :title_came_from_user?,
    :title?,
    :title_changed?,
    :title_change,
    :title_will_change!,
    :title_was,
    :title_previously_changed?, ... ]
    post = Post.new(title: "The Elusive Attribute")
    post.method(:title).owner.instance_methods
    Attribute
    Methods

    View Slide

  25. View Slide

  26. View Slide

  27. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner

    View Slide

  28. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner
    mod.instance_methods.each do |meth|
    mod.send(:remove_method, meth)
    end

    View Slide

  29. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner
    mod.instance_methods.each do |meth|
    mod.send(:remove_method, meth)
    end
    mod.instance_methods #=> []

    View Slide

  30. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner
    mod.instance_methods.each do |meth|
    mod.send(:remove_method, meth)
    end
    mod.instance_methods
    post.method(:title)
    # raises NameError: undefined method `title'

    View Slide

  31. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner
    mod.instance_methods.each do |meth|
    mod.send(:remove_method, meth)
    end
    mod.instance_methods
    post.method(:title)
    post.title
    #=> ...

    View Slide

  32. post = Post.new(title: "The Elusive Attribute")
    mod = post.method(:title).owner
    mod.instance_methods.each do |meth|
    mod.send(:remove_method, meth)
    end
    mod.instance_methods
    post.method(:title)
    post.title
    #=> "The Elusive Attribute"

    View Slide

  33. View Slide

  34. View Slide

  35. cache = AR::Base.connection.schema_cache

    View Slide

  36. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    #=> {}

    View Slide

  37. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}

    View Slide

  38. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    Post
    #=> Post()

    View Slide

  39. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.new
    #=> #

    View Slide

  40. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.new
    post.method(:title=)
    # raises NameError: undefined method `title='

    View Slide

  41. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.new
    post.method(:title=)
    post.title = "The Elusive Attribute"
    # raises ActiveModel::UnknownAttributeError

    View Slide

  42. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.new
    post.method(:title=)
    post.title = "The Elusive Attribute"
    post.attributes
    #=> {}

    View Slide

  43. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.first
    # SELECT "posts".* FROM "posts" ...
    #=> #

    View Slide

  44. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.first
    post.method(:title)
    # raises NameError: undefined method `title'

    View Slide

  45. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.first
    post.method(:title)
    post.title
    #=> ?

    View Slide

  46. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.first
    post.method(:title)
    post.title
    #=> "The Elusive Attribute"

    View Slide

  47. cache = AR::Base.connection.schema_cache
    columns_hash =
    cache.instance_variable_get(:@columns_hash)
    columns_hash["posts"] = {}
    post = Post.first
    post.method(:title)
    post.title
    post.attributes
    #=> {"id"=>1, "title"=>"The Elusive Attribute",
    # "created_at"=>2019-03-13 05:09:10 UTC,
    # "updated_at"=>2019-03-29 07:57:54 UTC}

    View Slide

  48. result = AR::Base.connection.exec_query(
    'SELECT posts.* FROM posts')
    #=> #

    View Slide

  49. result = AR::Base.connection.exec_query(
    'SELECT posts.* FROM posts')
    #=> #
    result.columns
    #=> ["id", "title", "created_at", "updated_at"]
    result.rows
    #=> [[1, "The Elusive Attribute", "2019-03-15...

    View Slide

  50. result = AR::Base.connection.exec_query(
    'SELECT posts.* FROM posts')
    #=> #
    result.columns
    #=> ["id", "title", "created_at", "updated_at"]
    result.rows
    #=> [[1, "The Elusive Attribute", "2019-03-15...
    Post.column_names
    #=> []

    View Slide

  51. View Slide

  52. View Slide

  53. View Slide

  54. View Slide

  55. Post.select('title as foo')

    View Slide

  56. post = Post.select('title as foo').first
    post.foo
    #=> "The Elusive Attribute"

    View Slide

  57. View Slide

  58. 2. Matchers & Dispatchers

    View Slide

  59. Constraints

    View Slide

  60. Constraints
    1. Methods are fast, method missing is slow

    View Slide

  61. Constraints
    1. Methods are fast, method missing is slow
    2. schema is static, attributes are dynamic

    View Slide

  62. Constraints
    1. Methods are fast, method missing is slow
    2. schema is static, attributes are dynamic
    “front door”
    “back door”

    View Slide

  63. prefix: ""
    suffix: "_was"
    Matcher
    (Attribute Method)

    View Slide

  64. prefix: ""
    suffix: "_was"
    "title"
    attr_name

    View Slide

  65. prefix: ""
    suffix: "_was"
    "title_was"
    "title"
    method_name

    View Slide

  66. prefix: ""
    suffix: "_was"
    "title_was"
    "attribute_was"
    "title"
    target

    View Slide

  67. prefix: ""
    suffix: "_was"
    def title_was(*args)
    attribute_was("title", *args)
    end
    "title"
    Dispatcher

    View Slide

  68. “Front Door”
    attribute_method_matchers.each do |matcher|
    method_name = matcher.method_name(attr_name)
    define_proxy_call true,
    generated_attribute_methods,
    method_name,
    matcher.target,
    attr_name.to_s
    end

    View Slide

  69. “Front Door”
    attribute_method_matchers.each do |matcher|
    method_name = matcher.method_name(attr_name)
    define_proxy_call true,
    generated_attribute_methods,
    method_name,
    matcher.target,
    attr_name.to_s
    end
    “title”

    View Slide

  70. “Front Door”
    attribute_method_matchers.each do |matcher|
    method_name = matcher.method_name(attr_name)
    define_proxy_call true,
    generated_attribute_methods,
    method_name,
    matcher.target,
    attr_name.to_s
    end
    “title_was”
    “attribute_was”
    “title”

    View Slide

  71. prefix: ""
    suffix: "_was"

    View Slide

  72. regex:
    /^(.*)_was$/

    View Slide

  73. "foo_was"
    regex:
    /^(.*)_was$/
    method_name

    View Slide

  74. "foo"
    regex:
    /^(.*)_was$/
    match("foo_was")
    attr_name

    View Slide

  75. "foo", "attribute_was"
    regex:
    /^(.*)_was$/
    match("foo_was")
    target

    View Slide

  76. “Back Door”
    def attribute_method_matchers_matching(method_name)
    matchers = attribute_method_matchers.
    partition(&:plain?).reverse.flatten(1)
    matchers.map { |matcher|
    matcher.match(method_name)
    }.compact
    end

    View Slide

  77. “Back Door”
    def attribute_method_matchers_matching(method_name)
    matchers = attribute_method_matchers.
    partition(&:plain?).reverse.flatten(1)
    matchers.map { |matcher|
    matcher.match(method_name)
    }.compact
    end
    (See PR #36005)

    View Slide

  78. “Back Door”
    def attribute_method_matchers_matching(method_name)
    matchers = attribute_method_matchers.
    partition(&:plain?).reverse.flatten(1)
    matchers.map { |matcher|
    matcher.match(method_name)
    }.compact
    end
    “foo_was”

    View Slide

  79. View Slide

  80. View Slide

  81. View Slide

  82. View Slide

  83. View Slide

  84. View Slide

  85. View Slide

  86. View Slide

  87. module ActiveModel::Dirty
    extend ActiveSupport::Concern
    include ActiveModel::AttributeMethods
    included do
    attribute_method_suffix "_changed?", "_change",
    "_will_change!", "_was"
    # Dispatch target for *_was attribute methods.
    def attribute_was(attr)
    attribute_changed?(attr) ?
    changed_attributes[attr] :
    _read_attribute(attr)
    end

    View Slide

  88. module ActiveModel::Dirty
    extend ActiveSupport::Concern
    include ActiveModel::AttributeMethods
    included do
    attribute_method_suffix "_changed?", "_change",
    "_will_change!", "_was"
    # Dispatch target for *_was attribute methods.
    def attribute_was(attr)
    attribute_changed?(attr) ?
    changed_attributes[attr] :
    _read_attribute(attr)
    end

    View Slide

  89. module ActiveModel::AttributeMethods
    included do
    class_attribute :attribute_method_matchers,
    instance_writer: false,
    default: [
    ClassMethods::AttributeMethodMatcher.new
    ]
    # ...
    end
    end

    View Slide

  90. module ActiveModel::AttributeMethods
    included do
    class_attribute :attribute_method_matchers,
    instance_writer: false,
    default: [
    ClassMethods::AttributeMethodMatcher.new
    ]
    # ...
    end
    end

    View Slide

  91. module ActiveModel::AttributeMethods
    included do
    class_attribute :attribute_method_matchers,
    instance_writer: false,
    default: [
    ClassMethods::AttributeMethodMatcher.new
    ]
    # ...
    end
    end
    def initialize(options = {})
    @prefix = options.fetch(:prefix, "")
    @suffix = options.fetch(:suffix, "")

    View Slide

  92. View Slide

  93. View Slide

  94. module ActiveRecord
    module AttributeMethods
    module Read
    # ...
    def _read_attribute(attr_name, &b)
    @attributes.fetch_value(attr_name.to_s, &b)
    end
    alias :attribute :_read_attribute
    private :attribute
    end
    end
    end

    View Slide

  95. View Slide

  96. View Slide

  97. 3. A Better Frame

    View Slide

  98. View Slide

  99. post = Post.select('title as foo').first
    post.foo
    #=> "The Elusive Attribute"

    View Slide

  100. post = Post.select('title as foo').first
    post.foo
    #=> "The Elusive Attribute"
    post.foo = "This cannot be saved"

    View Slide

  101. post = Post.select('title as foo').first
    post.foo
    #=> "The Elusive Attribute"
    post.foo = "This cannot be saved"
    post.foo_was

    View Slide

  102. post = Post.select('title as foo').first
    post.foo
    #=> "The Elusive Attribute"
    post.foo = "This cannot be saved"
    post.foo_was
    post.foo_change

    View Slide

  103. post = Post.select('1 as one').first
    post.one
    #=> 1
    post.one = 2
    post.one_was
    post.one_change

    View Slide

  104. post = Post.select('1 as one').first
    post.one
    #=> 1
    post.one = 2 # post.one #=> 2
    post.one_was #=> 1
    post.one_change #=> [1, 2]

    View Slide

  105. payments = Payment.select(
    'amount,
    amount*0.08 as tax,
    amount*0.02 as tax_change')

    View Slide

  106. payments = Payment.select(
    'amount,
    amount*0.08 as tax,
    amount*0.02 as tax_change')
    payment = payments.first
    payment.amount #=> 100
    payment.tax #=> 8
    payment.tax_change #=> ?

    View Slide

  107. payments = Payment.select(
    'amount,
    amount*0.08 as tax,
    amount*0.02 as tax_change')
    payment = payments.first
    payment.amount #=> 100
    payment.tax #=> 8
    payment.tax_change #=> nil

    View Slide

  108. "tax", "attribute_change"
    regex:
    /^(.*)_change$/
    match("tax_change")

    View Slide

  109. View Slide

  110. DON’T JUST
    UNPACK IT,

    View Slide

  111. DON’T JUST
    UNPACK IT,
    FIX IT

    View Slide

  112. Chris Salzberg
    @shioyama /dejimata.com

    View Slide