Ruby Forum Ruby on Rails > Collection assignment to a has_many :through

Posted by Marcin Simonides (cinek)
on 12.08.2006 20:20
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.
Posted by Josh Susser (jsusser)
on 12.08.2006 22:31
Marcin Simonides 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 Susser
http://blog.hasmanythrough.com
Posted by Marcin Simonides (cinek)
on 13.08.2006 21:50
Josh Susser wrote:
> Marcin Simonides 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 :)

> 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 Simonides
Posted by Josh Susser (jsusser)
on 14.08.2006 01:10
Marcin Simonides wrote:
> Josh Susser wrote:
>> Marcin Simonides 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 :)
> 
>> 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 Susser
http://blog.hasmanythrough.com
Posted by Marcin Simonides (cinek)
on 14.08.2006 23:16
Josh Susser wrote:
> Marcin Simonides wrote:
>> Josh Susser wrote:
>>> Marcin Simonides 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? :)
--
Marcin Simonides
Posted by skwasha (Guest)
on 15.12.2006 04:29
Marcin Simonides 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? :)
> --
> Marcin Simonides

Did you ever get this fully worked out? I'm trying to do something
similar and could use any tips/advice you have. thanx ;)
Posted by Marcin Simonides (cinek)
on 15.12.2006 20:17
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 ;)

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

Please provide some more information on what you're trying to do.
--
Marcin Simonides
Posted by Damien Le berrigaud (dam5s)
on 02.11.2007 13:47
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