ActiveRecord::NotNullViolation Error When Saving jsonb Column in Rails 7 and Above
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}"
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?
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.
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.
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.
@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.