rails icon indicating copy to clipboard operation
rails copied to clipboard

ActiveRecord::Normalization - Normalizations don't run on store-based attributes

Open bcollierjones opened this issue 2 years ago • 3 comments

Steps to reproduce

# frozen_string_literal: true

require "bundler/inline"

gemfile(true) do
  source "https://rubygems.org"

  git_source(:github) { |repo| "https://github.com/#{repo}.git" }

  gem "rails", github: "rails/rails", branch: "main"
  gem "sqlite3"
end

require "active_record"
require "minitest/autorun"
require "logger"

# This connection will do for database-independent bug reports.
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)

ActiveRecord::Schema.define do
  create_table :users, force: true do |t|
    t.string :extra_data
  end
end

class User < ActiveRecord::Base
  store :extra_data, accessors: [:fav_color, :homepage], coder: JSON
  store_accessor :extra_data, :email

  # Normalizations on store attributes DO NOT work
  normalizes :homepage, :email, with: -> { _1.strip.downcase }

  # Validations on store attributes DO work
  validates :fav_color, inclusion: {in: %w[red green blue]}, allow_nil: true
end

class BugTest < Minitest::Test
  def test_association_stuff
    user = User.create(homepage: "  http://Example.Com  ")

    user.fav_color = "red"
    user.email = "[email protected]"

    user.save! # no errors, color validation works

    assert_equal "http://example.com", user.homepage
    assert_equal "[email protected]", user.email
  end
end

Expected behavior

Both the homepage and email attributes should be normalized (stripped and downcased) when set. This is expected as validations work on store-based attributes.

Actual behavior

Normalizations don't appear to be running on store-based attributes. Not sure if this is a bug, an oversight, or intended behavior.

System configuration

Rails version: 7.1.1

Ruby version: 3.2.2

bcollierjones avatar Oct 24 '23 12:10 bcollierjones

Looking at the implementation, I am not sure there would be a simple solution to this problem, because accessors added via store's :accessors options are not "classic" attributes, compared to db-backed attribute or attributes defined via attribute method. normalizes does not work for attr_accessor defined attributes too.

You can solve your problem by something like:

normalizes :extra_data, with: ->(data) {
  data["homepage"] = data["homepage"].strip.downcase if data["homepage"]
  data["email"] = data["email"].strip.downcase if data["email"]
  data
}

Probably we should extend the docs for this case?

cc @jonathanhefner (as implementor of this feature https://github.com/rails/rails/pull/43945)

fatkodima avatar Oct 24 '23 15:10 fatkodima

@fatkodima is correct. normalizes "declares a normalization for one or more attributes", and store accessors are not implemented as attributes. However, as he mentions, the store itself is an attribute, so you can normalize it like in his example. If all of the stores members need the same normalization, you may be able to shorten the code a bit:

normalizes :extra_data, with: -> store { store.transform_values { _1.strip.downcase } }

Normalizations are implemented as attribute type decorations, so for normalizations to apply to store accessors, we would need to hook the accessors into the attribute system somehow. I haven't really thought this through, but if I were implementing the API from scratch, I would probably invert the relationship between "store accessors" and the store such that a store attribute is actually compiled from other attributes. For example, something like:

class User < ActiveRecord::Base
  attribute :fav_color, :string
  attribute :homepage, :string
  store :extra_data, from: [:fav_color, :homepage], coder: JSON
end

I'm not sure how feasible that would be though.

jonathanhefner avatar Oct 24 '23 17:10 jonathanhefner

Could this pain be eased, if ActiveRecord::Normalization were a ActiveModel::Normalization because then one could work around this by converting a store attribute to an ActiveModel-based serializer.

tmaier avatar Jun 25 '24 00:06 tmaier