From 6f08db05c00ea05c38d7d9d7bea757a903786a83 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Sat, 7 Jun 2014 13:46:22 -0600 Subject: Introduce an Attribute object to handle the type casting dance There's a lot more that can be moved to these, but this felt like a good place to introduce the object. Plans are: - Remove all knowledge of type casting from the columns, beyond a reference to the cast_type - Move type_cast_for_database to these objects - Potentially make them mutable, introduce a state machine, and have dirty checking handled here as well - Move `attribute`, `decorate_attribute`, and anything else that modifies types to mess with this object, not the columns hash - Introduce a collection object to manage these, reduce allocations, and not require serializing the types --- activerecord/lib/active_record.rb | 1 + activerecord/lib/active_record/attribute.rb | 56 ++++++++++++++++++++++ .../lib/active_record/attribute_methods.rb | 20 ++++---- .../attribute_methods/before_type_cast.rb | 6 ++- .../lib/active_record/attribute_methods/read.rb | 27 ++++------- .../lib/active_record/attribute_methods/write.rb | 15 +++--- .../connection_adapters/postgresql/oid/bytea.rb | 5 +- activerecord/lib/active_record/core.rb | 37 +++++++------- activerecord/lib/active_record/persistence.rb | 12 +++-- activerecord/lib/active_record/result.rb | 2 +- 10 files changed, 114 insertions(+), 67 deletions(-) create mode 100644 activerecord/lib/active_record/attribute.rb (limited to 'activerecord/lib') diff --git a/activerecord/lib/active_record.rb b/activerecord/lib/active_record.rb index f856c482d6..53fa132219 100644 --- a/activerecord/lib/active_record.rb +++ b/activerecord/lib/active_record.rb @@ -31,6 +31,7 @@ require 'active_record/version' module ActiveRecord extend ActiveSupport::Autoload + autoload :Attribute autoload :Base autoload :Callbacks autoload :Core diff --git a/activerecord/lib/active_record/attribute.rb b/activerecord/lib/active_record/attribute.rb new file mode 100644 index 0000000000..f78cde23c5 --- /dev/null +++ b/activerecord/lib/active_record/attribute.rb @@ -0,0 +1,56 @@ +module ActiveRecord + class Attribute # :nodoc: + class << self + def from_database(value, type) + FromDatabase.new(value, type) + end + + def from_user(value, type) + FromUser.new(value, type) + end + end + + attr_reader :value_before_type_cast, :type + + # This method should not be called directly. + # Use #from_database or #from_user + def initialize(value_before_type_cast, type) + @value_before_type_cast = value_before_type_cast + @type = type + end + + def value + # `defined?` is cheaper than `||=` when we get back falsy values + @value = type_cast(value_before_type_cast) unless defined?(@value) + @value + end + + def value_for_database + type.type_cast_for_database(value) + end + + def type_cast + raise NotImplementedError + end + + protected + + def initialize_dup(other) + if defined?(@value) && @value.duplicable? + @value = @value.dup + end + end + + class FromDatabase < Attribute + def type_cast(value) + type.type_cast_from_database(value) + end + end + + class FromUser < Attribute + def type_cast(value) + type.type_cast_from_user(value) + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods.rb b/activerecord/lib/active_record/attribute_methods.rb index e626227e7e..ddccb29d09 100644 --- a/activerecord/lib/active_record/attribute_methods.rb +++ b/activerecord/lib/active_record/attribute_methods.rb @@ -261,9 +261,9 @@ module ActiveRecord # If the result is true then check for the select case. # For queries selecting a subset of columns, return false for unselected columns. - # We check defined?(@raw_attributes) not to issue warnings if called on objects that + # We check defined?(@attributes) not to issue warnings if called on objects that # have been allocated but not yet initialized. - if defined?(@raw_attributes) && @raw_attributes.any? && self.class.column_names.include?(name) + if defined?(@attributes) && @attributes.any? && self.class.column_names.include?(name) return has_attribute?(name) end @@ -280,7 +280,7 @@ module ActiveRecord # person.has_attribute?('age') # => true # person.has_attribute?(:nothing) # => false def has_attribute?(attr_name) - @raw_attributes.has_key?(attr_name.to_s) + @attributes.has_key?(attr_name.to_s) end # Returns an array of names for the attributes available on this object. @@ -292,7 +292,7 @@ module ActiveRecord # person.attribute_names # # => ["id", "created_at", "updated_at", "name", "age"] def attribute_names - @raw_attributes.keys + @attributes.keys end # Returns a hash of all the attributes with their names as keys and the values of the attributes as values. @@ -400,11 +400,10 @@ module ActiveRecord protected - def clone_attributes(reader_method = :read_attribute, attributes = {}) # :nodoc: - attribute_names.each do |name| - attributes[name] = clone_attribute_value(reader_method, name) + def clone_attributes # :nodoc: + @attributes.each_with_object({}) do |(name, attr), h| + h[name] = attr.dup end - attributes end def clone_attribute_value(reader_method, attribute_name) # :nodoc: @@ -424,7 +423,7 @@ module ActiveRecord def attribute_method?(attr_name) # :nodoc: # We check defined? because Syck calls respond_to? before actually calling initialize. - defined?(@raw_attributes) && @raw_attributes.include?(attr_name) + defined?(@attributes) && @attributes.include?(attr_name) end private @@ -465,9 +464,6 @@ module ActiveRecord end def typecasted_attribute_value(name) - # FIXME: we need @attributes to be used consistently. - # If the values stored in @attributes were already typecasted, this code - # could be simplified read_attribute(name) end end diff --git a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb index 4365f5a1a1..d057f0941a 100644 --- a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb +++ b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb @@ -43,7 +43,9 @@ module ActiveRecord # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" # task.read_attribute_before_type_cast(:completed_on) # => "2012-10-21" def read_attribute_before_type_cast(attr_name) - @raw_attributes[attr_name.to_s] + if attr = @attributes[attr_name.to_s] + attr.value_before_type_cast + end end # Returns a hash of attributes before typecasting and deserialization. @@ -57,7 +59,7 @@ module ActiveRecord # task.attributes_before_type_cast # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>"2012-10-21", "created_at"=>nil, "updated_at"=>nil} def attributes_before_type_cast - @raw_attributes + @attributes.each_with_object({}) { |(k, v), h| h[k] = v.value_before_type_cast } end private diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb index 7b7e37884b..8c1cc128f7 100644 --- a/activerecord/lib/active_record/attribute_methods/read.rb +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -82,25 +82,16 @@ module ActiveRecord # it has been typecast (for example, "2004-12-12" in a date column is cast # to a date object, like Date.new(2004, 12, 12)). def read_attribute(attr_name) - # If it's cached, just return it - # We use #[] first as a perf optimization for non-nil values. See https://gist.github.com/jonleighton/3552829. name = attr_name.to_s - @attributes[name] || @attributes.fetch(name) { - column = @column_types_override[name] if @column_types_override - column ||= @column_types[name] - - return @raw_attributes.fetch(name) { - if name == 'id' && self.class.primary_key != name - read_attribute(self.class.primary_key) - end - } unless column - - value = @raw_attributes.fetch(name) { - return block_given? ? yield(name) : nil - } - - @attributes[name] = column.type_cast_from_database(value) - } + @attributes.fetch(name) { + if name == 'id' + return read_attribute(self.class.primary_key) + elsif block_given? && self.class.columns_hash.key?(name) + return yield(name) + else + return nil + end + }.value end private diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb index b72a6219b0..246a2cd8ba 100644 --- a/activerecord/lib/active_record/attribute_methods/write.rb +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -69,22 +69,19 @@ module ActiveRecord def write_attribute_with_type_cast(attr_name, value, should_type_cast) attr_name = attr_name.to_s attr_name = self.class.primary_key if attr_name == 'id' && self.class.primary_key - @attributes.delete(attr_name) - column = type_for_attribute(attr_name) + type = type_for_attribute(attr_name) unless has_attribute?(attr_name) || self.class.columns_hash.key?(attr_name) raise ActiveModel::MissingAttributeError, "can't write unknown attribute `#{attr_name}'" end - # If we're dealing with a binary column, write the data to the cache - # so we don't attempt to typecast multiple times. - if column.binary? - @attributes[attr_name] = value - elsif should_type_cast - @attributes[attr_name] = column.type_cast_from_user(value) + if should_type_cast + @attributes[attr_name] = Attribute.from_user(value, type) + else + @attributes[attr_name] = Attribute.from_database(value, type) end - @raw_attributes[attr_name] = value + value end end end diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb index 36c53d8732..89b203d2b1 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid/bytea.rb @@ -3,8 +3,9 @@ module ActiveRecord module PostgreSQL module OID # :nodoc: class Bytea < Type::Binary - def cast_value(value) - PGconn.unescape_bytea value + def type_cast_from_database(value) + return if value.nil? + PGconn.unescape_bytea(super) end end end diff --git a/activerecord/lib/active_record/core.rb b/activerecord/lib/active_record/core.rb index 7edaf256c7..834f6a7eb9 100644 --- a/activerecord/lib/active_record/core.rb +++ b/activerecord/lib/active_record/core.rb @@ -249,10 +249,13 @@ module ActiveRecord # # Instantiates a single new object # User.new(first_name: 'Jamie') def initialize(attributes = nil, options = {}) - defaults = self.class.raw_column_defaults.dup - defaults.each { |k, v| defaults[k] = v.dup if v.duplicable? } + defaults = {} + self.class.raw_column_defaults.each do |k, v| + default = v.duplicable? ? v.dup : v + defaults[k] = Attribute.from_database(default, type_for_attribute(k)) + end - @raw_attributes = defaults + @attributes = defaults @column_types_override = nil @column_types = self.class.column_types @@ -278,13 +281,12 @@ module ActiveRecord # post.init_with('attributes' => { 'title' => 'hello world' }) # post.title # => 'hello world' def init_with(coder) - @raw_attributes = coder['raw_attributes'] + @attributes = coder['attributes'] @column_types_override = coder['column_types'] @column_types = self.class.column_types init_internals - @attributes = coder['attributes'] if coder['attributes'] @new_record = coder['new_record'] self.class.define_attribute_methods @@ -323,12 +325,9 @@ module ActiveRecord ## def initialize_dup(other) # :nodoc: - cloned_attributes = other.clone_attributes(:read_attribute_before_type_cast) - - @raw_attributes = cloned_attributes - @raw_attributes[self.class.primary_key] = nil - @attributes = other.clone_attributes(:read_attribute) - @attributes[self.class.primary_key] = nil + pk = self.class.primary_key + @attributes = other.clone_attributes + @attributes[pk] = Attribute.from_database(nil, type_for_attribute(pk)) run_callbacks(:initialize) unless _initialize_callbacks.empty? @@ -354,7 +353,8 @@ module ActiveRecord # Post.new.encode_with(coder) # coder # => {"attributes" => {"id" => nil, ... }} def encode_with(coder) - coder['raw_attributes'] = @raw_attributes + # FIXME: Remove this when we better serialize attributes + coder['raw_attributes'] = attributes_before_type_cast coder['attributes'] = @attributes coder['column_types'] = @column_types_override coder['new_record'] = new_record? @@ -387,13 +387,13 @@ module ActiveRecord # accessible, even on destroyed records, but cloned models will not be # frozen. def freeze - @raw_attributes = @raw_attributes.clone.freeze + @attributes = @attributes.clone.freeze self end # Returns +true+ if the attributes hash has been frozen. def frozen? - @raw_attributes.frozen? + @attributes.frozen? end # Allows sort on objects @@ -422,9 +422,9 @@ module ActiveRecord # Returns the contents of the record as a nicely formatted string. def inspect - # We check defined?(@raw_attributes) not to issue warnings if the object is + # We check defined?(@attributes) not to issue warnings if the object is # allocated but not initialized. - inspection = if defined?(@raw_attributes) && @raw_attributes + inspection = if defined?(@attributes) && @attributes self.class.column_names.collect { |name| if has_attribute?(name) "#{name}: #{attribute_for_inspect(name)}" @@ -523,11 +523,10 @@ module ActiveRecord def init_internals pk = self.class.primary_key - @raw_attributes[pk] = nil unless @raw_attributes.key?(pk) + @attributes[pk] ||= Attribute.from_database(nil, type_for_attribute(pk)) @aggregation_cache = {} @association_cache = {} - @attributes = {} @readonly = false @destroyed = false @marked_for_destruction = false @@ -550,7 +549,7 @@ module ActiveRecord def thaw if frozen? - @raw_attributes = @raw_attributes.dup + @attributes = @attributes.dup end end end diff --git a/activerecord/lib/active_record/persistence.rb b/activerecord/lib/active_record/persistence.rb index 3ab8ec4836..c69a83c125 100644 --- a/activerecord/lib/active_record/persistence.rb +++ b/activerecord/lib/active_record/persistence.rb @@ -48,8 +48,14 @@ module ActiveRecord # how this "single-table" inheritance mapping is implemented. def instantiate(attributes, column_types = {}) klass = discriminate_class_for_record(attributes) + + attributes = attributes.each_with_object({}) do |(name, value), h| + type = column_types.fetch(name) { klass.type_for_attribute(name) } + h[name] = Attribute.from_database(value, type) + end + klass.allocate.init_with( - 'raw_attributes' => attributes, + 'attributes' => attributes, 'column_types' => column_types, 'new_record' => false, ) @@ -182,7 +188,6 @@ module ActiveRecord # So any change to the attributes in either instance will affect the other. def becomes(klass) became = klass.new - became.instance_variable_set("@raw_attributes", @raw_attributes) became.instance_variable_set("@attributes", @attributes) became.instance_variable_set("@changed_attributes", @changed_attributes) if defined?(@changed_attributes) became.instance_variable_set("@new_record", new_record?) @@ -399,11 +404,10 @@ module ActiveRecord self.class.unscoped { self.class.find(id) } end - @raw_attributes.update(fresh_object.instance_variable_get('@raw_attributes')) + @attributes.update(fresh_object.instance_variable_get('@attributes')) @column_types = self.class.column_types @column_types_override = fresh_object.instance_variable_get('@column_types_override') - @attributes = {} self end diff --git a/activerecord/lib/active_record/result.rb b/activerecord/lib/active_record/result.rb index ae53f66d7a..8c29c69a9b 100644 --- a/activerecord/lib/active_record/result.rb +++ b/activerecord/lib/active_record/result.rb @@ -95,7 +95,7 @@ module ActiveRecord @hash_rows ||= begin # We freeze the strings to prevent them getting duped when - # used as keys in ActiveRecord::Base's @raw_attributes hash + # used as keys in ActiveRecord::Base's @attributes hash columns = @columns.map { |c| c.dup.freeze } @rows.map { |row| # In the past we used Hash[columns.zip(row)] -- cgit v1.2.3