Collection assignment to a has_many :through

I’m working on a simple photo gallery in rails, it seems to be a good
project for a newbie.

I have photos and categories, many-to-many association. It worked well
with HABTM. Then I decided that it would be good to be able to change
order of the photos so that thumbnail pages would look less chaotic.
So I created a Layout model which is a join model (or whatever it is
called) that replaces HABTM relation. It has photo_id, category_id and
position. Photo has many layouts and many categories through layouts.
Category is analogous.

In this way I obtained a structure that works as the one before with one
exception: there is no Photo.categories= method when :through is used.

After hours of googling (no-one gives examples of creating/editing data
for such relationship, the documentation just says that
collection=objects method exists) I gave up and wrote my own accessor:

def categories=(collection)
self.layouts.clear
collection.find_all do |category|
layout = self.layouts.build(:category => category)
self.layouts << layout
end
end

Some questions arise however:

  1. Is there other possibility, more “standard”, that I could use to
    replace categories in photo with those from user form?
  2. If not, then what do you think about this solution (I’m new to both
    Rails and Ruby)?
  3. Is my abstraction correct? Maybe there is no collection= method
    because it the proper solution of my problem doesn’t need it? Here are
    some more details:

Each photo can have many categories.
Each category has many photos.
There is only one layout per category.
When user edits a photo (s)he selects categories in which it should be
present. List of selected categories is assigned into photo.categories.
This operation should also create a layout that binds this photo with
selected categories - regular many-to-many relationship.

My solution doesn’t take the layout, ie. the acts_as_list part, into
account. Maybe when I start implementing it, the whole situation
changes.

Marcin S. wrote:

I have photos and categories, many-to-many association. It worked well
with HABTM. Then I decided that it would be good to be able to change
order of the photos so that thumbnail pages would look less chaotic.
So I created a Layout model which is a join model (or whatever it is
called) that replaces HABTM relation. It has photo_id, category_id and
position. Photo has many layouts and many categories through layouts.
Category is analogous.

In this way I obtained a structure that works as the one before with one
exception: there is no Photo.categories= method when :through is used.

After hours of googling (no-one gives examples of creating/editing data
for such relationship, the documentation just says that
collection=objects method exists) I gave up and wrote my own accessor:

def categories=(collection)
self.layouts.clear
collection.find_all do |category|
layout = self.layouts.build(:category => category)
self.layouts << layout
end
end

Some questions arise however:

  1. Is there other possibility, more “standard”, that I could use to
    replace categories in photo with those from user form?
  2. If not, then what do you think about this solution (I’m new to both
    Rails and Ruby)?
  3. Is my abstraction correct? Maybe there is no collection= method
    because it the proper solution of my problem doesn’t need it? Here are
    some more details:

Each photo can have many categories.
Each category has many photos.
There is only one layout per category.
When user edits a photo (s)he selects categories in which it should be
present. List of selected categories is assigned into photo.categories.
This operation should also create a layout that binds this photo with
selected categories - regular many-to-many relationship.

My solution doesn’t take the layout, ie. the acts_as_list part, into
account. Maybe when I start implementing it, the whole situation
changes.

Read my explanation of what’s going on here:
http://blog.hasmanythrough.com/articles/2006/04/17/join-models-not-proxy-collections

Your accessor is a step in the right direction, but not exactly how I
would do it. You’ll find it has problems with keeping the database in
sync with the in-memory collections. Here is a tighter solution that
should do just what you want.

Photo.rb

def categories=(collection)
Layout.set_categories_for_photo(self, collection)
layouts.reset
categories.reset
end

Layout.rb:

def set_categories_for_photo(photo, categories)
old_categories = photo.categories
delete_from photo, (old_categories - categories)
add_to photo, (categories - old_categories)
end

def delete_from(photo, categories)
unless categories.empty?
delete_all [‘photo_id = ? and category_id in (?)’,
photo.id, categories.collect { |c| c.id }]
end
end

def add_to(photo, categories)
unless categories.empty?
self.transaction do
categories.each do |category|
next if photo.categories.include? category
create! :photo => photo, :category => category
end
end
end
end


Josh S.
http://blog.hasmanythrough.com

Josh S. wrote:

Marcin S. wrote:
[…]

def categories=(collection)
self.layouts.clear
collection.find_all do |category|
layout = self.layouts.build(:category => category)
self.layouts << layout
end
end

[…]

Read my explanation of what’s going on here:
http://blog.hasmanythrough.com/articles/2006/04/17/join-models-not-proxy-collections

I didn’t try using << operator, lucky me :slight_smile:

Your accessor is a step in the right direction, but not exactly how I
would do it. You’ll find it has problems with keeping the database in
sync with the in-memory collections. Here is a tighter solution that
should do just what you want.

Thanks. I’ve pasted your solution into my program. But despite reading
the code a few times I don’t understand, why is it better than mine in
terms of synchronization with database (mine is incorrect, because it
removes and then reinserts elements if they aren’t supposed to be
changed, which causes loss of information about position, yours is
correct in this regard).

I was planning on using transactions if I were to stick to my solution,
but I don’t think this would have anything to do with writing data to db
or not, just race conditions.

Also, why did you use resets here:

def categories=(collection)
Layout.set_categories_for_photo(self, collection)
layouts.reset
categories.reset
end

From what I’ve seen in the docs (or rather in the code) it clears error
conditions on Sybase connection adapter (if it’s the right reset I’ve
been looking at).

Sorry for those nagging questions, but I feel uneasy when I have a piece
of code in my application that I don’t fully understand.

Marcin S.

Josh S. wrote:

Marcin S. wrote:

Josh S. wrote:

Marcin S. wrote:
[…]

def categories=(collection)
self.layouts.clear
collection.find_all do |category|
layout = self.layouts.build(:category => category)
self.layouts << layout
end
end

[…]

The main synchronization issue has to do with the ActiveRecord
association caches. has_many keeps an array of associated objects in
memory. The << method lets you add to that array, but also updates the
foreign key of the associated object and saves it to the database. You
don’t get that with has_many :through, so you need to do a bit more work
to make sure the changes you are making to your Ruby objects get written
to the database. That’s the key part of what my code does.

Ok, but going back to my code above: when Photo’s layouts are inserted
with << then the cache for Photo.layouts and Photo.categories should be
ok, right? Or perhaps not, because updating and saving layouts with <<
doesn’t update categories?

Just to remind, the relations for Photo are this:
has_many :layouts
has_many :categories :through => layouts

Or maybe it’s more complicated and I should really dig into
documentation and some books? :slight_smile:

Marcin S.

Marcin S. wrote:

Josh S. wrote:

Marcin S. wrote:
[…]

def categories=(collection)
self.layouts.clear
collection.find_all do |category|
layout = self.layouts.build(:category => category)
self.layouts << layout
end
end

[…]

Read my explanation of what’s going on here:
http://blog.hasmanythrough.com/articles/2006/04/17/join-models-not-proxy-collections

I didn’t try using << operator, lucky me :slight_smile:

Your accessor is a step in the right direction, but not exactly how I
would do it. You’ll find it has problems with keeping the database in
sync with the in-memory collections. Here is a tighter solution that
should do just what you want.

Thanks. I’ve pasted your solution into my program. But despite reading
the code a few times I don’t understand, why is it better than mine in
terms of synchronization with database (mine is incorrect, because it
removes and then reinserts elements if they aren’t supposed to be
changed, which causes loss of information about position, yours is
correct in this regard).

The main synchronization issue has to do with the ActiveRecord
association caches. has_many keeps an array of associated objects in
memory. The << method lets you add to that array, but also updates the
foreign key of the associated object and saves it to the database. You
don’t get that with has_many :through, so you need to do a bit more work
to make sure the changes you are making to your Ruby objects get written
to the database. That’s the key part of what my code does.

I was planning on using transactions if I were to stick to my solution,
but I don’t think this would have anything to do with writing data to db
or not, just race conditions.

Also, why did you use resets here:

def categories=(collection)
Layout.set_categories_for_photo(self, collection)
layouts.reset
categories.reset
end

From what I’ve seen in the docs (or rather in the code) it clears error
conditions on Sybase connection adapter (if it’s the right reset I’ve
been looking at).

reset() is a method on the association proxy that clears the in-memory
cache collection of associated objects. Or more simply, “layouts.reset”
clears the layouts collection cache. Since you just modified the join
model, you need to clear the cache so that the next time you access the
association, it will get the fresh data from the database. Otherwise
you’ll have sync problems and will wonder where your objects went.


Josh S.
http://blog.hasmanythrough.com

Marcin S. wrote:

Ok, but going back to my code above: when Photo’s layouts are inserted
with << then the cache for Photo.layouts and Photo.categories should be
ok, right? Or perhaps not, because updating and saving layouts with <<
doesn’t update categories?

Just to remind, the relations for Photo are this:
has_many :layouts
has_many :categories :through => layouts

Or maybe it’s more complicated and I should really dig into
documentation and some books? :slight_smile:

Marcin S.

Did you ever get this fully worked out? I’m trying to do something
similar and could use any tips/advice you have. thanx :wink:

skwasha wrote:

Did you ever get this fully worked out? I’m trying to do something
similar and could use any tips/advice you have. thanx :wink:

Yes, the code Josh S. included earlier in this thread works fine.

Please provide some more information on what you’re trying to do.

Marcin S.

I made some “improvements” to this code for personnal purpose, you may
find it useful, it is available there:

http://www.webdrivenblog.com/2007/10/28/assigning-a-collection-to-has_many-through