DEV Community

loading...
Cover image for How to preload associations for STI-model

How to preload associations for STI-model

Vladislav Kopylov
Senior Ruby developer
・3 min read

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

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?)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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]]
Enter fullscreen mode Exit fullscreen mode

Discussion (1)

Collapse
mainameiz profile image
Nikolay Markov

Thanks you Vladislav. I'm faced the same problem, so your post was very useful for me :)