rails/activemodel/CHANGELOG.md
Jonathan Hefner 28ebf3c81c Avoid double cast in types that only override cast
Follow-up to #44625.

In #44625, the `SerializeCastValue` module was added to allow types to
avoid a redundant call to `cast` when serializing a value for the
database.  Because it introduced a new method (`serialize_cast_value`)
that was not part of the `ActiveModel::Type::Value` contract, it was
designed to be opt-in.  Furthermore, to guard against incompatible
`serialize` and `serialize_cast_value` implementations in types that
override `serialize` but (unintentionally) inherit `serialize_cast_value`,
types were required to explicitly include the `SerializeCastValue`
module to activate the optimization.  i.e. It was not sufficient just to
have `SerializeCastValue` in the ancestor chain.

The `SerializeCastValue` module is not part of the public API, and there
are no plans to change that, which meant user-created custom types could
not benefit from this optimization.

This commit changes the opt-in condition such that it is sufficient for
the owner of the `serialize_cast_value` method to be the same or below
the owner of the `serialize` method in the ancestor chain.  This means
a user-created type that only overrides `cast`, **not** `serialize`,
will now benefit from the optimization.  For example, a type like:

  ```ruby
  class DowncasedString < ActiveModel::Type::String
    def cast(value)
      super&.downcase
    end
  end
  ```

As demonstrated in the benchmark below, this commit does not change the
current performance of the built-in Active Model types.  However, for a
simple custom type like `DowncasedString`, the performance of
`value_for_database` is twice as fast.  For types with more expensive
`cast` operations, the improvement may be greater.

**Benchmark**

  ```ruby
  # frozen_string_literal: true

  require "benchmark/ips"
  require "active_model"

  class DowncasedString < ActiveModel::Type::String
    def cast(value)
      super&.downcase
    end
  end

  ActiveModel::Type.register(:downcased_string, DowncasedString)

  VALUES = {
    my_big_integer: "123456",
    my_boolean: "true",
    my_date: "1999-12-31",
    my_datetime: "1999-12-31 12:34:56 UTC",
    my_decimal: "123.456",
    my_float: "123.456",
    my_immutable_string: "abcdef",
    my_integer: "123456",
    my_string: "abcdef",
    my_time: "1999-12-31T12:34:56.789-10:00",
    my_downcased_string: "AbcDef",
  }

  TYPES = VALUES.to_h { |name, value| [name, name.to_s.delete_prefix("my_").to_sym] }

  class MyModel
    include ActiveModel::API
    include ActiveModel::Attributes

    TYPES.each do |name, type|
      attribute name, type
    end
  end

  attribute_set = MyModel.new(VALUES).instance_variable_get(:@attributes)

  TYPES.each do |name, type|
    attribute = attribute_set[name.to_s]

    Benchmark.ips do |x|
      x.report(type.to_s) { attribute.value_for_database }
    end
  end
  ```

**Before**

  ```
          big_integer      2.986M (± 1.2%) i/s -     15.161M in   5.078972s
              boolean      2.980M (± 1.1%) i/s -     15.074M in   5.059456s
                 date      2.960M (± 1.1%) i/s -     14.831M in   5.011355s
             datetime      1.368M (± 0.9%) i/s -      6.964M in   5.092074s
              decimal      2.930M (± 1.2%) i/s -     14.911M in   5.089048s
                float      2.932M (± 1.3%) i/s -     14.713M in   5.018512s
     immutable_string      3.013M (± 1.3%) i/s -     15.239M in   5.058085s
              integer      1.603M (± 0.8%) i/s -      8.096M in   5.052046s
               string      2.977M (± 1.1%) i/s -     15.168M in   5.094874s
                 time      1.338M (± 0.9%) i/s -      6.699M in   5.006046s
     downcased_string      1.394M (± 0.9%) i/s -      7.034M in   5.046972s
  ```

**After**

  ```
          big_integer      3.016M (± 1.0%) i/s -     15.238M in   5.053005s
              boolean      2.965M (± 1.3%) i/s -     15.037M in   5.071921s
                 date      2.924M (± 1.0%) i/s -     14.754M in   5.046294s
             datetime      1.435M (± 0.9%) i/s -      7.295M in   5.082498s
              decimal      2.950M (± 0.9%) i/s -     14.800M in   5.017225s
                float      2.964M (± 0.9%) i/s -     14.987M in   5.056405s
     immutable_string      2.907M (± 1.4%) i/s -     14.677M in   5.049194s
              integer      1.638M (± 0.9%) i/s -      8.227M in   5.022401s
               string      2.971M (± 1.0%) i/s -     14.891M in   5.011709s
                 time      1.454M (± 0.9%) i/s -      7.384M in   5.079993s
     downcased_string      2.939M (± 0.9%) i/s -     14.872M in   5.061100s
  ```
2022-10-16 16:06:16 -05:00

3.6 KiB

  • Custom attribute types that inherit from Active Model built-in types and do not override the serialize method will now benefit from an optimization when serializing attribute values for the database.

    For example, with a custom type like the following:

    class DowncasedString < ActiveModel::Type::String
      def cast(value)
        super&.downcase
      end
    end
    
    ActiveRecord::Type.register(:downcased_string, DowncasedString)
    
    class User < ActiveRecord::Base
      attribute :email, :downcased_string
    end
    
    user = User.new(email: "FooBar@example.com")
    

    Serializing the email attribute for the database will be roughly twice as fast. More expensive cast operations will likely see greater improvements.

    Jonathan Hefner

  • has_secure_password now supports password challenges via a password_challenge accessor and validation.

    A password challenge is a safeguard to verify that the current user is actually the password owner. It can be used when changing sensitive model fields, such as the password itself. It is different than a password confirmation, which is used to prevent password typos.

    When password_challenge is set, the validation checks that the value's digest matches the currently persisted password_digest (i.e. password_digest_was).

    This allows a password challenge to be done as part of a typical update call, just like a password confirmation. It also allows a password challenge error to be handled in the same way as other validation errors.

    For example, in the controller, instead of:

    password_params = params.require(:password).permit(
      :password_challenge,
      :password,
      :password_confirmation,
    )
    
    password_challenge = password_params.delete(:password_challenge)
    @password_challenge_failed = !current_user.authenticate(password_challenge)
    
    if !@password_challenge_failed && current_user.update(password_params)
      # ...
    end
    

    You can now write:

    password_params = params.require(:password).permit(
      :password_challenge,
      :password,
      :password_confirmation,
    ).with_defaults(password_challenge: "")
    
    if current_user.update(password_params)
      # ...
    end
    

    And, in the view, instead of checking @password_challenge_failed, you can render an error for the password_challenge field just as you would for other form fields, including utilizing config.action_view.field_error_proc.

    Jonathan Hefner

  • Support infinite ranges for LengthValidators :in/:within options

    validates_length_of :first_name, in: ..30
    

    fatkodima

  • Add support for beginless ranges to inclusivity/exclusivity validators:

    validates_inclusion_of :birth_date, in: -> { (..Date.today) }
    

    Bo Jeanes

  • Make validators accept lambdas without record argument

    # Before
    validates_comparison_of :birth_date, less_than_or_equal_to: ->(_record) { Date.today }
    
    # After
    validates_comparison_of :birth_date, less_than_or_equal_to: -> { Date.today }
    

    fatkodima

  • Fix casting long strings to Date, Time or DateTime

    fatkodima

  • Use different cache namespace for proxy calls

    Models can currently have different attribute bodies for the same method names, leading to conflicts. Adding a new namespace :active_model_proxy fixes the issue.

    Chris Salzberg

Please check 7-0-stable for previous changes.