Single table inheritance (STI) is useful pattern. It helps organise codebase and split logic into different classes. Many programmers use it and like the pattern, but it has one disadvantage: you can’t preload associations by standard preload
method. Now, I show you the way to handle it.
Imagine, you have an Instagram style application where users are able to upload information and create posts. There are different types of post:
- post with one image
- multi images post
- and post with video
You codebase is based by STI pattern
# base class
class Post < ApplicationRecord
end
# standard post with one image
class ImagePost < Post
has_one :image, dependent: :destroy, foreign_key: :post_id
end
# multi images post
class MultiImagePost < Post
has_many :images, dependent: :destroy, foreign_key: :post_id
end
# post with video
class VideoPost < Post
has_one :video, dependent: :destroy, foreign_key: :post_id
end
Also Image
class has relation to Asset
class Image < ApplicationRecord
belongs_to :post, foreign_key: :post_id
has_one :asset
end
class Asset < ApplicationRecord
belongs_to :image
end
You have different feeds for each post type, and your application works perfectly.
ImagePost.preload(image: :asset).each do |item|
render 'image_post', item: item
end
# database queries:
# ImagePost Load (0.8ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" = ? [["type", "ImagePost"]]
# Image Load (1.4ms) SELECT "images".* FROM "images" WHERE "images"."post_id" IN (?, ?) [["post_id", 21], ["post_id", 22]]
# Asset Load (0.9ms) SELECT "assets".* FROM "assets" WHERE "assets"."image_id" IN (?, ?) [["image_id", 5], ["image_id", 6]]
MultiImagePost.preload(images: :asset).each do |item|
render 'multi_image_post', item: item
end
# database queries:
# MultiImagePost Load (0.7ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" = ? [["type", "MultiImagePost"]]
# Image Load (1.1ms) SELECT "images".* FROM "images" WHERE "images"."post_id" IN (?, ?) [["post_id", 20], ["post_id", 25]]
# Asset Load (1.0ms) SELECT "assets".* FROM "assets" WHERE "assets"."image_id" IN (?, ?, ?, ?) [["image_id", 3], ["image_id", 4], ["image_id", 7], ["image_id", 8]]
VideoPost.preload(:video).each do |item|
render 'video_post', item: item
end
# database queries:
# VideoPost Load (0.9ms) SELECT "posts".* FROM "posts" WHERE "posts"."type" = ? [["type", "VideoPost"]]
# Video Load (0.9ms) SELECT "videos".* FROM "videos" WHERE "videos"."post_id" IN (?, ?) [["post_id", 23], ["post_id", 24]]
The other day, a customer asked you to render all types of post in one feed. But there is one problem: you can’t use standard .preload
ActiveRecord::AssociationNotFoundError (Association named 'image' was not found on MultiImagePost; perhaps you misspelled it?)
What should you do? You can write your custom preload logic for new feed using ActiveRecord::Associations::Preloader
You should create new instance of the class and then use preload
method. It takes two parameters:
- Collection of AR-items
- Hash with relations that you want to preload. The code doesn’t look like rails-magic and you have to describe all relations explicitly, but it works perfectly.
posts = Post.all
preloader = ActiveRecord::Associations::Preloader.new
preloader.preload(posts.select{ |i| i.type == 'ImagePost' }, image: :asset)
preloader.preload(posts.select{ |i| i.type == 'MultiImagePost' }, images: :asset)
preloader.preload(posts.select{ |i| i.type == 'VideoPost' }, :video)
posts.each |item|
render 'post', item: item
end
Look into logs, Preloader
sends one query for each association, and you there aren’t N+1 queries
# 1) take posts from DB
Post Load (0.4ms) SELECT "posts".* FROM "posts"
# 2) load images for all ImagePosts
Image Load (1.5ms) SELECT "images".* FROM "images" WHERE "images"."post_id" IN (?, ?) [["post_id", 21], ["post_id", 22]]
# 3) load assets for them
Asset Load (1.0ms) SELECT "assets".* FROM "assets" WHERE "assets"."image_id" IN (?, ?) [["image_id", 5], ["image_id", 6]]
# 4) load images for all MultiImagePost
Image Load (0.4ms) SELECT "images".* FROM "images" WHERE "images"."post_id" IN (?, ?) [["post_id", 20], ["post_id", 25]]
# 4) load assets for them
Asset Load (0.2ms) SELECT "assets".* FROM "assets" WHERE "assets"."image_id" IN (?, ?, ?, ?) [["image_id", 3], ["image_id", 4], ["image_id", 7], ["image_id", 8]]
# 5) load videos for all VideoPosts
Video Load (0.4ms) SELECT "videos".* FROM "videos" WHERE "videos"."post_id" IN (?, ?) [["post_id", 23], ["post_id", 24]]
Top comments (2)
and when you want to have a belongs_to only on son of STI ?
Thanks you Vladislav. I'm faced the same problem, so your post was very useful for me :)