diff options
Diffstat (limited to 'activerecord/lib/active_record/attribute_methods')
8 files changed, 753 insertions, 0 deletions
diff --git a/activerecord/lib/active_record/attribute_methods/before_type_cast.rb b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb new file mode 100644 index 0000000000..5941f51a1a --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/before_type_cast.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + # = Active Record Attribute Methods Before Type Cast + # + # ActiveRecord::AttributeMethods::BeforeTypeCast provides a way to + # read the value of the attributes before typecasting and deserialization. + # + # class Task < ActiveRecord::Base + # end + # + # task = Task.new(id: '1', completed_on: '2012-10-21') + # task.id # => 1 + # task.completed_on # => Sun, 21 Oct 2012 + # + # task.attributes_before_type_cast + # # => {"id"=>"1", "completed_on"=>"2012-10-21", ... } + # task.read_attribute_before_type_cast('id') # => "1" + # task.read_attribute_before_type_cast('completed_on') # => "2012-10-21" + # + # In addition to #read_attribute_before_type_cast and #attributes_before_type_cast, + # it declares a method for all attributes with the <tt>*_before_type_cast</tt> + # suffix. + # + # task.id_before_type_cast # => "1" + # task.completed_on_before_type_cast # => "2012-10-21" + module BeforeTypeCast + extend ActiveSupport::Concern + + included do + attribute_method_suffix "_before_type_cast" + attribute_method_suffix "_came_from_user?" + end + + # Returns the value of the attribute identified by +attr_name+ before + # typecasting and deserialization. + # + # class Task < ActiveRecord::Base + # end + # + # task = Task.new(id: '1', completed_on: '2012-10-21') + # task.read_attribute('id') # => 1 + # task.read_attribute_before_type_cast('id') # => '1' + # task.read_attribute('completed_on') # => Sun, 21 Oct 2012 + # 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) + @attributes[attr_name.to_s].value_before_type_cast + end + + # Returns a hash of attributes before typecasting and deserialization. + # + # class Task < ActiveRecord::Base + # end + # + # task = Task.new(title: nil, is_done: true, completed_on: '2012-10-21') + # task.attributes + # # => {"id"=>nil, "title"=>nil, "is_done"=>true, "completed_on"=>Sun, 21 Oct 2012, "created_at"=>nil, "updated_at"=>nil} + # 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 + @attributes.values_before_type_cast + end + + private + + # Handle *_before_type_cast for method_missing. + def attribute_before_type_cast(attribute_name) + read_attribute_before_type_cast(attribute_name) + end + + def attribute_came_from_user?(attribute_name) + @attributes[attribute_name].came_from_user? + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/dirty.rb b/activerecord/lib/active_record/attribute_methods/dirty.rb new file mode 100644 index 0000000000..45e4b8adfa --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/dirty.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "active_support/core_ext/module/attribute_accessors" + +module ActiveRecord + module AttributeMethods + module Dirty + extend ActiveSupport::Concern + + include ActiveModel::Dirty + + included do + if self < ::ActiveRecord::Timestamp + raise "You cannot include Dirty after Timestamp" + end + + class_attribute :partial_writes, instance_writer: false, default: true + + # Attribute methods for "changed in last call to save?" + attribute_method_affix(prefix: "saved_change_to_", suffix: "?") + attribute_method_prefix("saved_change_to_") + attribute_method_suffix("_before_last_save") + + # Attribute methods for "will change if I call save?" + attribute_method_affix(prefix: "will_save_change_to_", suffix: "?") + attribute_method_suffix("_change_to_be_saved", "_in_database") + end + + # <tt>reload</tt> the record and clears changed attributes. + def reload(*) + super.tap do + @previously_changed = ActiveSupport::HashWithIndifferentAccess.new + @mutations_before_last_save = nil + @attributes_changed_by_setter = ActiveSupport::HashWithIndifferentAccess.new + @mutations_from_database = nil + end + end + + # Did this attribute change when we last saved? + # + # This method is useful in after callbacks to determine if an attribute + # was changed during the save that triggered the callbacks to run. It can + # be invoked as +saved_change_to_name?+ instead of + # <tt>saved_change_to_attribute?("name")</tt>. + # + # ==== Options + # + # +from+ When passed, this method will return false unless the original + # value is equal to the given option + # + # +to+ When passed, this method will return false unless the value was + # changed to the given value + def saved_change_to_attribute?(attr_name, **options) + mutations_before_last_save.changed?(attr_name, **options) + end + + # Returns the change to an attribute during the last save. If the + # attribute was changed, the result will be an array containing the + # original value and the saved value. + # + # This method is useful in after callbacks, to see the change in an + # attribute during the save that triggered the callbacks to run. It can be + # invoked as +saved_change_to_name+ instead of + # <tt>saved_change_to_attribute("name")</tt>. + def saved_change_to_attribute(attr_name) + mutations_before_last_save.change_to_attribute(attr_name) + end + + # Returns the original value of an attribute before the last save. + # + # This method is useful in after callbacks to get the original value of an + # attribute before the save that triggered the callbacks to run. It can be + # invoked as +name_before_last_save+ instead of + # <tt>attribute_before_last_save("name")</tt>. + def attribute_before_last_save(attr_name) + mutations_before_last_save.original_value(attr_name) + end + + # Did the last call to +save+ have any changes to change? + def saved_changes? + mutations_before_last_save.any_changes? + end + + # Returns a hash containing all the changes that were just saved. + def saved_changes + mutations_before_last_save.changes + end + + # Will this attribute change the next time we save? + # + # This method is useful in validations and before callbacks to determine + # if the next call to +save+ will change a particular attribute. It can be + # invoked as +will_save_change_to_name?+ instead of + # <tt>will_save_change_to_attribute("name")</tt>. + # + # ==== Options + # + # +from+ When passed, this method will return false unless the original + # value is equal to the given option + # + # +to+ When passed, this method will return false unless the value will be + # changed to the given value + def will_save_change_to_attribute?(attr_name, **options) + mutations_from_database.changed?(attr_name, **options) + end + + # Returns the change to an attribute that will be persisted during the + # next save. + # + # This method is useful in validations and before callbacks, to see the + # change to an attribute that will occur when the record is saved. It can + # be invoked as +name_change_to_be_saved+ instead of + # <tt>attribute_change_to_be_saved("name")</tt>. + # + # If the attribute will change, the result will be an array containing the + # original value and the new value about to be saved. + def attribute_change_to_be_saved(attr_name) + mutations_from_database.change_to_attribute(attr_name) + end + + # Returns the value of an attribute in the database, as opposed to the + # in-memory value that will be persisted the next time the record is + # saved. + # + # This method is useful in validations and before callbacks, to see the + # original value of an attribute prior to any changes about to be + # saved. It can be invoked as +name_in_database+ instead of + # <tt>attribute_in_database("name")</tt>. + def attribute_in_database(attr_name) + mutations_from_database.original_value(attr_name) + end + + # Will the next call to +save+ have any changes to persist? + def has_changes_to_save? + mutations_from_database.any_changes? + end + + # Returns a hash containing all the changes that will be persisted during + # the next save. + def changes_to_save + mutations_from_database.changes + end + + # Returns an array of the names of any attributes that will change when + # the record is next saved. + def changed_attribute_names_to_save + mutations_from_database.changed_attribute_names + end + + # Returns a hash of the attributes that will change when the record is + # next saved. + # + # The hash keys are the attribute names, and the hash values are the + # original attribute values in the database (as opposed to the in-memory + # values about to be saved). + def attributes_in_database + mutations_from_database.changed_values + end + + private + def write_attribute_without_type_cast(attr_name, value) + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) + end + result = super(name, value) + clear_attribute_change(name) + result + end + + def _update_record(attribute_names = attribute_names_for_partial_writes) + affected_rows = super + changes_applied + affected_rows + end + + def _create_record(attribute_names = attribute_names_for_partial_writes) + id = super + changes_applied + id + end + + def attribute_names_for_partial_writes + partial_writes? ? changed_attribute_names_to_save : attribute_names + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/primary_key.rb b/activerecord/lib/active_record/attribute_methods/primary_key.rb new file mode 100644 index 0000000000..6af5346fa7 --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/primary_key.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "set" + +module ActiveRecord + module AttributeMethods + module PrimaryKey + extend ActiveSupport::Concern + + # Returns this record's primary key value wrapped in an array if one is + # available. + def to_key + key = id + [key] if key + end + + # Returns the primary key column's value. + def id + sync_with_transaction_state + primary_key = self.class.primary_key + _read_attribute(primary_key) if primary_key + end + + # Sets the primary key column's value. + def id=(value) + sync_with_transaction_state + primary_key = self.class.primary_key + _write_attribute(primary_key, value) if primary_key + end + + # Queries the primary key column's value. + def id? + sync_with_transaction_state + query_attribute(self.class.primary_key) + end + + # Returns the primary key column's value before type cast. + def id_before_type_cast + sync_with_transaction_state + read_attribute_before_type_cast(self.class.primary_key) + end + + # Returns the primary key column's previous value. + def id_was + sync_with_transaction_state + attribute_was(self.class.primary_key) + end + + # Returns the primary key column's value from the database. + def id_in_database + sync_with_transaction_state + attribute_in_database(self.class.primary_key) + end + + private + + def attribute_method?(attr_name) + attr_name == "id" || super + end + + module ClassMethods + ID_ATTRIBUTE_METHODS = %w(id id= id? id_before_type_cast id_was id_in_database).to_set + + def instance_method_already_implemented?(method_name) + super || primary_key && ID_ATTRIBUTE_METHODS.include?(method_name) + end + + def dangerous_attribute_method?(method_name) + super && !ID_ATTRIBUTE_METHODS.include?(method_name) + end + + # Defines the primary key field -- can be overridden in subclasses. + # Overwriting will negate any effect of the +primary_key_prefix_type+ + # setting, though. + def primary_key + @primary_key = reset_primary_key unless defined? @primary_key + @primary_key + end + + # Returns a quoted version of the primary key name, used to construct + # SQL statements. + def quoted_primary_key + @quoted_primary_key ||= connection.quote_column_name(primary_key) + end + + def reset_primary_key #:nodoc: + if base_class? + self.primary_key = get_primary_key(base_class.name) + else + self.primary_key = base_class.primary_key + end + end + + def get_primary_key(base_name) #:nodoc: + if base_name && primary_key_prefix_type == :table_name + base_name.foreign_key(false) + elsif base_name && primary_key_prefix_type == :table_name_with_underscore + base_name.foreign_key + else + if ActiveRecord::Base != self && table_exists? + pk = connection.schema_cache.primary_keys(table_name) + suppress_composite_primary_key(pk) + else + "id" + end + end + end + + # Sets the name of the primary key column. + # + # class Project < ActiveRecord::Base + # self.primary_key = 'sysid' + # end + # + # You can also define the #primary_key method yourself: + # + # class Project < ActiveRecord::Base + # def self.primary_key + # 'foo_' + super + # end + # end + # + # Project.primary_key # => "foo_id" + def primary_key=(value) + @primary_key = value && value.to_s + @quoted_primary_key = nil + @attributes_builder = nil + end + + private + + def suppress_composite_primary_key(pk) + return pk unless pk.is_a?(Array) + + warn <<~WARNING + WARNING: Active Record does not support composite primary key. + + #{table_name} has composite primary key. Composite primary key is ignored. + WARNING + end + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/query.rb b/activerecord/lib/active_record/attribute_methods/query.rb new file mode 100644 index 0000000000..6757e9b66a --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/query.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + module Query + extend ActiveSupport::Concern + + included do + attribute_method_suffix "?" + end + + def query_attribute(attr_name) + value = self[attr_name] + + case value + when true then true + when false, nil then false + else + column = self.class.columns_hash[attr_name] + if column.nil? + if Numeric === value || value !~ /[^0-9]/ + !value.to_i.zero? + else + return false if ActiveModel::Type::Boolean::FALSE_VALUES.include?(value) + !value.blank? + end + elsif value.respond_to?(:zero?) + !value.zero? + else + !value.blank? + end + end + end + + private + # Handle *? for method_missing. + def attribute?(attribute_name) + query_attribute(attribute_name) + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/read.rb b/activerecord/lib/active_record/attribute_methods/read.rb new file mode 100644 index 0000000000..ffac5313ad --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/read.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + module Read + extend ActiveSupport::Concern + + module ClassMethods # :nodoc: + private + + def define_method_attribute(name) + sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key + + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name} + #{sync_with_transaction_state} + name = #{attr_name_expr} + _read_attribute(name) { |n| missing_attribute(n, caller) } + end + RUBY + end + end + end + + # Returns the value of the attribute identified by <tt>attr_name</tt> after + # 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, &block) + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) + end + + primary_key = self.class.primary_key + name = primary_key if name == "id" && primary_key + sync_with_transaction_state if name == primary_key + _read_attribute(name, &block) + end + + # This method exists to avoid the expensive primary_key check internally, without + # breaking compatibility with the read_attribute API + def _read_attribute(attr_name, &block) # :nodoc + @attributes.fetch_value(attr_name.to_s, &block) + end + + alias :attribute :_read_attribute + private :attribute + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/serialization.rb b/activerecord/lib/active_record/attribute_methods/serialization.rb new file mode 100644 index 0000000000..6e0e90f39c --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/serialization.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + module Serialization + extend ActiveSupport::Concern + + class ColumnNotSerializableError < StandardError + def initialize(name, type) + super <<~EOS + Column `#{name}` of type #{type.class} does not support `serialize` feature. + Usually it means that you are trying to use `serialize` + on a column that already implements serialization natively. + EOS + end + end + + module ClassMethods + # If you have an attribute that needs to be saved to the database as an + # object, and retrieved as the same object, then specify the name of that + # attribute using this method and it will be handled automatically. The + # serialization is done through YAML. If +class_name+ is specified, the + # serialized object must be of that class on assignment and retrieval. + # Otherwise SerializationTypeMismatch will be raised. + # + # Empty objects as <tt>{}</tt>, in the case of +Hash+, or <tt>[]</tt>, in the case of + # +Array+, will always be persisted as null. + # + # Keep in mind that database adapters handle certain serialization tasks + # for you. For instance: +json+ and +jsonb+ types in PostgreSQL will be + # converted between JSON object/array syntax and Ruby +Hash+ or +Array+ + # objects transparently. There is no need to use #serialize in this + # case. + # + # For more complex cases, such as conversion to or from your application + # domain objects, consider using the ActiveRecord::Attributes API. + # + # ==== Parameters + # + # * +attr_name+ - The field name that should be serialized. + # * +class_name_or_coder+ - Optional, a coder object, which responds to +.load+ and +.dump+ + # or a class name that the object type should be equal to. + # + # ==== Example + # + # # Serialize a preferences attribute. + # class User < ActiveRecord::Base + # serialize :preferences + # end + # + # # Serialize preferences using JSON as coder. + # class User < ActiveRecord::Base + # serialize :preferences, JSON + # end + # + # # Serialize preferences as Hash using YAML coder. + # class User < ActiveRecord::Base + # serialize :preferences, Hash + # end + def serialize(attr_name, class_name_or_coder = Object) + # When ::JSON is used, force it to go through the Active Support JSON encoder + # to ensure special objects (e.g. Active Record models) are dumped correctly + # using the #as_json hook. + coder = if class_name_or_coder == ::JSON + Coders::JSON + elsif [:load, :dump].all? { |x| class_name_or_coder.respond_to?(x) } + class_name_or_coder + else + Coders::YAMLColumn.new(attr_name, class_name_or_coder) + end + + decorate_attribute_type(attr_name, :serialize) do |type| + if type_incompatible_with_serialize?(type, class_name_or_coder) + raise ColumnNotSerializableError.new(attr_name, type) + end + + Type::Serialized.new(type, coder) + end + end + + private + + def type_incompatible_with_serialize?(type, class_name) + type.is_a?(ActiveRecord::Type::Json) && class_name == ::JSON || + type.respond_to?(:type_cast_array, true) && class_name == ::Array + end + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb new file mode 100644 index 0000000000..294a3dc32c --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/time_zone_conversion.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + module TimeZoneConversion + class TimeZoneConverter < DelegateClass(Type::Value) # :nodoc: + def deserialize(value) + convert_time_to_time_zone(super) + end + + def cast(value) + return if value.nil? + + if value.is_a?(Hash) + set_time_zone_without_conversion(super) + elsif value.respond_to?(:in_time_zone) + begin + super(user_input_in_time_zone(value)) || super + rescue ArgumentError + nil + end + else + map_avoiding_infinite_recursion(super) { |v| cast(v) } + end + end + + private + + def convert_time_to_time_zone(value) + return if value.nil? + + if value.acts_like?(:time) + value.in_time_zone + elsif value.is_a?(::Float) + value + else + map_avoiding_infinite_recursion(value) { |v| convert_time_to_time_zone(v) } + end + end + + def set_time_zone_without_conversion(value) + ::Time.zone.local_to_utc(value).try(:in_time_zone) if value + end + + def map_avoiding_infinite_recursion(value) + map(value) do |v| + if value.equal?(v) + nil + else + yield(v) + end + end + end + end + + extend ActiveSupport::Concern + + included do + mattr_accessor :time_zone_aware_attributes, instance_writer: false, default: false + + class_attribute :skip_time_zone_conversion_for_attributes, instance_writer: false, default: [] + class_attribute :time_zone_aware_types, instance_writer: false, default: [ :datetime, :time ] + end + + module ClassMethods # :nodoc: + private + + def inherited(subclass) + super + # We need to apply this decorator here, rather than on module inclusion. The closure + # created by the matcher would otherwise evaluate for `ActiveRecord::Base`, not the + # sub class being decorated. As such, changes to `time_zone_aware_attributes`, or + # `skip_time_zone_conversion_for_attributes` would not be picked up. + subclass.class_eval do + matcher = ->(name, type) { create_time_zone_conversion_attribute?(name, type) } + decorate_matching_attribute_types(matcher, "_time_zone_conversion") do |type| + TimeZoneConverter.new(type) + end + end + end + + def create_time_zone_conversion_attribute?(name, cast_type) + enabled_for_column = time_zone_aware_attributes && + !skip_time_zone_conversion_for_attributes.include?(name.to_sym) + + enabled_for_column && time_zone_aware_types.include?(cast_type.type) + end + end + end + end +end diff --git a/activerecord/lib/active_record/attribute_methods/write.rb b/activerecord/lib/active_record/attribute_methods/write.rb new file mode 100644 index 0000000000..455e67e19b --- /dev/null +++ b/activerecord/lib/active_record/attribute_methods/write.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module ActiveRecord + module AttributeMethods + module Write + extend ActiveSupport::Concern + + included do + attribute_method_suffix "=" + end + + module ClassMethods # :nodoc: + private + + def define_method_attribute=(name) + sync_with_transaction_state = "sync_with_transaction_state" if name == primary_key + + ActiveModel::AttributeMethods::AttrNames.define_attribute_accessor_method( + generated_attribute_methods, name, writer: true, + ) do |temp_method_name, attr_name_expr| + generated_attribute_methods.module_eval <<-RUBY, __FILE__, __LINE__ + 1 + def #{temp_method_name}(value) + name = #{attr_name_expr} + #{sync_with_transaction_state} + _write_attribute(name, value) + end + RUBY + end + end + end + + # Updates the attribute identified by <tt>attr_name</tt> with the + # specified +value+. Empty strings for Integer and Float columns are + # turned into +nil+. + def write_attribute(attr_name, value) + name = attr_name.to_s + if self.class.attribute_alias?(name) + name = self.class.attribute_alias(name) + end + + primary_key = self.class.primary_key + name = primary_key if name == "id" && primary_key + sync_with_transaction_state if name == primary_key + _write_attribute(name, value) + end + + # This method exists to avoid the expensive primary_key check internally, without + # breaking compatibility with the write_attribute API + def _write_attribute(attr_name, value) # :nodoc: + @attributes.write_from_user(attr_name.to_s, value) + value + end + + private + def write_attribute_without_type_cast(attr_name, value) + name = attr_name.to_s + @attributes.write_cast_value(name, value) + value + end + + # Handle *= for method_missing. + def attribute=(attribute_name, value) + _write_attribute(attribute_name, value) + end + end + end +end |