Complex Has Many Associations with ActiveRecord
Most people know how to setup relationships between models just fine, they'll be able to setup a one to one / one to many / many to many / polymorphic relationship between model. However most people don't realise that ActiveRecord is much more powerful than that, you can actually build complex relationships that will help you 'design' you application's DSL to be more beautiful, in this post we look at how we use the options for has_many and belongs_to to build complex relationships between models.
has_many with Conditions
So lets say for example you have a Category model and a Post belongs to category you would normally define the relationship something like this.
1 2 3 4 5 6 7 |
class Category < ActiveRecord::Base has_many :posts end class Post < ActiveRecord::Base belongs_to :category end |
Now what if you wanted only posts that are popular? for a certain category, you could use the 'conditions' option with has_many and do something like this
1 2 3 4 5 |
class Category < ActiveRecord::Base has_many :posts has_many :popular_posts, class_name: "Post", conditions: ["likes > ?", 100 ] # update 1, fix typo missing class_name option thx to our reader! end |
At this point some may ask "but isn't that what scopes are for?", sure you could use scopes for this kind of thing however when your building active record queries and scopes and you want to do joins or includes you can't use scopes, for example lets say from the category index page we want to get all the comments that have been approved by the moderator from posts that has more than 100 likes and you want to load the categories while at the same time eagerly load the comments with the conditions I mentioned above as well you can do it this way:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class Category < ActiveRecord::Base has_many :posts has_many :popular_posts, class_name: "Post", conditions: ["likes > ?", 100] # This will create a efficient query for you without you having to # chain multiple scopes and you can do joins and includes # on this too! has_many :popular_posts_comments, through: :popular_posts, source: :comments, conditions: { comments: { approved: true } } end class Post < ActiveRecord::Base has_many :comments end class Comment < ActiveRecord::Base belongs_to :post end |
In your controller you can then do something like this:
1 |
@categories = Category.includes(:popular_posts_comments) |
The statement above will load all the categories along with all the posts and comments that satisfy the conditions i mentioned above in 2 queries, very efficient!
Lets have a look at the resulting SQLs
1 2 3 4 5 6 7 8 9 10 11 12 |
Category.includes(:popular_posts_comments) # Category Load (0.2ms) SELECT "categories".* FROM "categories" # SQL (0.3ms) SELECT "posts"."id" AS t0_r0, "posts"."name" AS t0_r1, "posts"."price" AS t0_r2, "posts"."description" AS t0_r3, "posts"."created_at" AS t0_r4, "posts"."updated_at" AS t0_r5, "posts"."category_id" AS t0_r6, "posts"."likes" AS t0_r7, "comments"."id" AS t1_r0, "comments"."body" AS t1_r1, "comments"."post_id" AS t1_r2, "comments"."created_at" AS t1_r3, "comments"."updated_at" AS t1_r4, "comments"."approved" AS t1_r5 FROM "posts" LEFT OUTER JOIN "comments" ON "comments"."post_id" = "posts"."id" WHERE "posts"."category_id" IN (1, 2) AND (likes > 100) AND ("comments"."approved" = 't') # load a comment category = Category.first category.popular_posts # Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."category_id" = 1 AND (likes > 100) category.popular_posts_comments # Comment Load (0.1ms) SELECT "comments".* FROM "comments" INNER JOIN "posts" ON "comments"."post_id" = "posts"."id" WHERE "posts"."category_id" = 1 AND ("comments"."approved" = 't') AND (likes > 100) |
Don't get me wrong here, scopes still have their place however sometimes you just need some of these advanced functionality like eager loading or joins which is not achievable via scopes.
One more thing
If you define your relationships and conditions using only ruby without having to resort to SQL there is a bonus for you see the following example
1 2 3 4 5 6 7 |
class Post < ActiveRecord::Base has_many :comments # using only ruby without resorting to SQL to define the conditions has_many :approved_comments, class_name: "Comment", conditions: { approved: true } end |
With this relationship defined we can do the following
1 2 3 4 5 6 7 |
# load a post post = Post.first # creating a comment with the approved_comments association will approve the comment straight away. post.approved_comments.create(body: "oh yeah i've been approved straight away") # SQL (0.9ms) INSERT INTO "comments" ("approved", "body", "created_at", "post_id", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["approved", true], ["body", "test comment"], ["created_at", Sun, 16 Sep 2012 19:16:12 UTC +00:00], ["post_id", 1], ["updated_at", Sun, 16 Sep 2012 19:16:12 UTC +00:00]] # (1.4ms) COMMIT |
I am not sure why the same doesn't work if you use SQL in the conditions perhaps its a bug in active record, if anyone knows anything about why this is let me know!
TL;DR
- Create custom has_many associations with conditions
- Defining custom has_many associations allows us to use it to do joins and includes
- Defining has_many relationships with conditions that are only ruby will allow you to create records in the database with those conditions straight away.
Conclusion
There is alot to digest here and might take alot of experimentation but these options for defining custom relationships can make it extremely easy to build advanced complex queries that are fast and efficient and allow you to clean up your application's DSL, if you would like to learn more about active record associations and all the options it has I highly recommend you taking a look at this page ActiveRecord::Associations::ClassMethods