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

05fdba1ae381f24512e977f8fe2697b4?s=128

Chris Salzberg

May 02, 2019
Tweet

Transcript

  1. The Elusive Attribute Chris Salzberg

  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)
  3. None
  4. None
  5. None
  6. None
  7. None
  8. None
  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
  10. None
  11. None
  12. None
  13. None
  14. @attributes Name: Chris Salzberg Handle: shioyama Born: Montreal, CA Based:

    Tokyo, JP Company: Degica Blog: dejimata.com Tags: Module Builder, Mobility
  15. 1. methods & columns

  16. None
  17. class Post < ApplicationRecord end < 6.0

  18. class Post < ApplicationRecord end Post.ancestors => [Post, Post::GeneratedAssociationMethods, #<AR::AttributeMethods::GeneratedAttributeMethods:...>,

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

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

    ApplicationRecord(abstract), ApplicationRecord::GeneratedAssociationMethods, ApplicationRecord::GeneratedAttributeMethods, ActiveRecord::Base, ... ] >= 6.0
  21. post = Post.new(title: "The Elusive Attribute")

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

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

  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
  25. None
  26. None
  27. post = Post.new(title: "The Elusive Attribute") mod = post.method(:title).owner

  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
  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 #=> []
  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'
  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 #=> ...
  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"
  33. None
  34. None
  35. cache = AR::Base.connection.schema_cache

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

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

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

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

    = Post.new #=> #<Post:0x00007f4364712df8>
  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='
  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
  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 #=> {}
  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" ... #=> #<Post:0x000055c134208af0>
  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'
  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 #=> ?
  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"
  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}
  48. result = AR::Base.connection.exec_query( 'SELECT posts.* FROM posts') #=> #<ActiveRecord::Result:0x...>

  49. result = AR::Base.connection.exec_query( 'SELECT posts.* FROM posts') #=> #<ActiveRecord::Result:0x...> result.columns

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

    #=> ["id", "title", "created_at", "updated_at"] result.rows #=> [[1, "The Elusive Attribute", "2019-03-15... Post.column_names #=> []
  51. None
  52. None
  53. None
  54. None
  55. Post.select('title as foo')

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

  57. None
  58. 2. Matchers & Dispatchers

  59. Constraints

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

  61. Constraints 1. Methods are fast, method missing is slow 2.

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

    schema is static, attributes are dynamic “front door” “back door”
  63. prefix: "" suffix: "_was" Matcher (Attribute Method)

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

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

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

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

    Dispatcher
  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
  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”
  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”
  71. prefix: "" suffix: "_was"

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

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

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

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

  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
  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)
  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”
  79. None
  80. None
  81. None
  82. None
  83. None
  84. None
  85. None
  86. None
  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
  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
  89. module ActiveModel::AttributeMethods included do class_attribute :attribute_method_matchers, instance_writer: false, default: [

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

    ClassMethods::AttributeMethodMatcher.new ] # ... end end
  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, "")
  92. None
  93. None
  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
  95. None
  96. None
  97. 3. A Better Frame

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

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

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

    post.foo = "This cannot be saved" post.foo_was
  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
  103. post = Post.select('1 as one').first post.one #=> 1 post.one =

    2 post.one_was post.one_change
  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]
  105. payments = Payment.select( 'amount, amount*0.08 as tax, amount*0.02 as tax_change')

  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 #=> ?
  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
  108. "tax", "attribute_change" regex: /^(.*)_change$/ match("tax_change")

  109. None
  110. DON’T JUST UNPACK IT,

  111. DON’T JUST UNPACK IT, FIX IT

  112. Chris Salzberg @shioyama /dejimata.com