activerecord-typedstore icon indicating copy to clipboard operation
activerecord-typedstore copied to clipboard

ActiveRecord::NotNullViolation Error When Saving jsonb Column in Rails 7 and Above

Open duckworth opened this issue 11 months ago • 5 comments

I just uncovered some unexpected behavior in using typed_store on a new model with single nullable attribute for the first time since rails 7 and though I would file this. Prior to Rails 7 using typed_store on a jsonb column:

t.jsonb :properties, null: false, default: {}

the model saved and used the default value from the DB correctly. After switching to Rails 7 it fails as Rails explicitly tries to insert NULL.

PG::NotNullViolation: ERROR:  null value in column "properties" of relation "products" violates not-null constraint (ActiveRecord::NotNullViolation)
DETAIL:  Failing row contains (1, Sample Product, null).

here is a reproducible scenario that works in Rails 6.1.7.10 but fails in Rails 7.0.8.7 (or 7.1, 8.01):

# Gemfile
# source 'https://rubygems.org'
# gem 'rails', '7.0.8.7'
# gem 'pg'
# gem 'activerecord-typedstore'

require 'bundler/setup'

require 'active_record'
require 'activerecord-typedstore'

# Configure the database connection
ActiveRecord::Base.establish_connection(
  adapter:  'postgresql',
  host:     'localhost',
  database: 'typedstore_test',
  port:     5432
)

# Define the schema
ActiveRecord::Schema.define do
  create_table :products, force: true do |t|
    t.string :name
    t.jsonb :properties, null: false, default: {}
  end
end

# Define the model
class Product < ActiveRecord::Base
  typed_store :properties, coder: ActiveRecord::TypedStore::IdentityCoder do |s|
    s.string :some_property
  end
end

# Test the behavior
product = Product.new(name: 'Sample Product')
product.save
puts "Product properties after creation: #{product.properties.inspect}"

duckworth avatar Jan 13 '25 16:01 duckworth

Pretty sure regular store and serialize both require the column to be nulable, and that's why.

I haven't double checked but I highly doubt this could be changed on the typedstore side. Is there a reason you don't want the column to be nullable?

byroot avatar Jan 13 '25 16:01 byroot

Is there a reason you don't want the column to be nullable?

I recall early on running into issues with queries using postgres functions like jsonb_set which are strict (returning NULL when given NULL), where allowing a nullable JSONB column can cause silent no-ops and complicate logic, so we generally use NOT NULL with a default that simplifies such handling.

duckworth avatar Jan 13 '25 19:01 duckworth

I see. I guess it wouldn't be hard to just replace ActiveRecord::TypedStore::IdentityCoder by something that's the same but returns an empty hash instead of nil.

I don't think it's for the gem to do that though, you can do it in your project. so I'll close.

byroot avatar Jan 13 '25 19:01 byroot

ActiveRecord::TypedStore::IdentityCoder already returns an empty hash. Still trying to track down where the behavior is coming from as: store_accessor :properties, :some_property with column: t.jsonb :properties, null: false, default: {} does not cause an error.

duckworth avatar Jan 13 '25 19:01 duckworth

@byroot The issue appears to originate in:

    if ActiveRecord.version.segments.first >= 7
      def attribute_names_for_partial_inserts
        # Contrary to all vanilla Rails types, typedstore attribute have an inherent default
        # value that doesn't match the database column default.
        # As such we need to insert them on partial inserts even if they weren't changed.
        super | self.class.typed_stores.keys.map(&:to_s)
      end

If I update it to reject nil or empty hash it saves correctly as the attribute name is not included:

      def attribute_names_for_partial_inserts
        super | self.class.typed_stores.keys.map(&:to_s).reject do |store|
          value = read_attribute(store)
          puts "Checking attribute #{store}: value=#{value.inspect}"
          value.nil? || value == {}
        end
      end

but there is still a deeper issue I think related to serialize, as even just accessing the properties attribute will cause it to get added to the attribute_names_for_partial_inserts and insert null instead of using what is in IdentityCoder.

duckworth avatar Jan 13 '25 21:01 duckworth