We received a nasty Hoptoad notification today at AdGear :
1 ActiveRecord::StatementInvalid: PGError: ERROR: 2 duplicate key value violates unique constraint "index_placement_rules_on_type_and_placement_id_and_bookable_typ" : 3 INSERT INTO "placement_rules" ("kind", "created_at", "placement_id", "bookable_type", "bookable_id", "updated_at") VALUES(E'PlacementInclusion', '2010-06-02 19:29:24.479983', 2320, E'Site', 162, '2010-06-02 19:29:24.479983') RETURNING "id"
This manifested itself when a customer called and told us he’d found our 500 error page. Ooops.
Our problem turned out to be a misunderstanding of autosave association validations.
Autosave associations are a wonderful beast: with a single #save call, the nested entities are saved within the same transaction, without any intervention on the developer’s part. Unfortunately, validations behave differently than what we expected.
For the sake of argument, let’s use these models:
1 class Order < ActiveRecord::Base 2 has_many :items 3 end 4 5 class OrderItem < ActiveRecord::Base 6 belongs_to :order 7 8 validates_presence_of :product_number 9 validates_uniqueness_of :product_number, :scope => :order 10 end
If you build an Order and add two items, you’ll have troubles, unless you wrap your save in a transaction block:
1 > o = Order.new 2 > 2.times { o.items << Item.new(:product_number => "A-113") } 3 > o.save 4 # Validates entities 5 Item Load (0.2ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id IS NULL) LIMIT 1 6 Item Load (0.1ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id IS NULL) LIMIT 1 7 Order Create (0.7ms) INSERT INTO "orders" ("customer_number", "created_at", "updated_at") VALUES(NULL, '2010-06-02 19:15:57', '2010-06-02 19:15:57') 8 # Validate 1st entity again 9 Item Load (0.1ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id = 1) LIMIT 1 10 Item Create (0.1ms) INSERT INTO "items" ("created_at", "order_id", "updated_at", "product_number") VALUES('2010-06-02 19:15:57', 1, '2010-06-02 19:15:57', 'A-113') 11 # Validate 2nd entity again, but fails this time around 12 Item Load (0.1ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id = 1) LIMIT 1 13 => [false, true] 14 > o.items.map(&:errors).flatten.map(&:full_messages) 15 => [[], ["Product number has already been taken"]]
A partially saved order is rarely what you’re looking for. Adding the :autosave => true option…
1 class Order < ActiveRecord::Base 2 has_many :items, :autosave => true 3 end
Generates a very different SQL trace:
1 > o = Order.new 2 > 2.times { o.items << Item.new(:product_number => "A-113") } 3 > o.save 4 # Validate associated entities... 5 Item Load (0.2ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id IS NULL) LIMIT 1 6 Item Load (0.1ms) SELECT "items".id FROM "items" WHERE ("items"."product_number" = 'A-113' AND "items".order_id IS NULL) LIMIT 1 7 Order Create (0.4ms) INSERT INTO "orders" ("customer_number", "created_at", "updated_at") VALUES(NULL, '2010-06-02 20:03:12', '2010-06-02 20:03:12') 8 # Then save them, even though they're invalid. 9 Item Create (0.1ms) INSERT INTO "items" ("created_at", "order_id", "updated_at", "product_number") VALUES('2010-06-02 20:03:12', 2, '2010-06-02 20:03:12', 'A-113') 10 Item Create (0.1ms) INSERT INTO "items" ("created_at", "order_id", "updated_at", "product_number") VALUES('2010-06-02 20:03:12', 2, '2010-06-02 20:03:12', 'A-113') 11 => true
This is even worse: instead of a partial save, ActiveRecord told us that everything was good. This is how ActiveRecord behaves:
1 if order.save then 2 order.items.each(&:save) if order.items.all?(&:valid?) 3 end
What we need instead is to hook into the autosave callback chain:
1 class Order < ActiveRecord::Base 2 has_many :items, :autosave => true 3 4 private 5 6 # items is the has_many name. If you had posts, it would be 7 # validate_associated_records_for_posts. If you have multiple 8 # autosave associations, each can have a method such as this 9 # one to handle it's validation needs. 10 def validate_associated_records_for_items 11 product_number_errors = false 12 items.group_by(&:product_number).reject {|_, group| group.length <= 1}.each do |product_number, group| 13 product_number_errors = true 14 errors.add_to_base "Product #{product_number} used #{group.length} times on the order" 15 end 16 17 errors.add_to_base("Each product number can be used at most once per order") if product_number_errors 18 end 19 end
With this validation in place, validations will run on the full collection in a single pass, rather than piecemeal.
1 > o = Order.new 2 > 2.times { o.items << Item.new(:product_number => "A-113") } 3 > o.save # Will run in-memory validations 4 => false 5 > pp o.errors.full_messages 6 ["Product A-113 used 2 times on the order", 7 "Each product number can be used at most once per order"]
Notice I didn’t say anything about the validates_uniqueness_of validation in Item. I left it in place. It doesn’t harm anything, and if you ever create items without going through the parent model, your code is ready to take care of itself.