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.

Search

Your Host

A picture of me

I am François Beausoleil, a Ruby on Rails and Scala developer. During the day, I work on Seevibes, a platform to measure social interactions related to TV shows. At night, I am interested many things. Read my biography.

Top Tags

Books I read and recommend

Links

Projects I work on

Projects I worked on