Home > Mobile >  Rails - Complicated sql query with query variables as scope
Rails - Complicated sql query with query variables as scope

Time:03-24

I need help with a scope in Rails.

I have the following postgresql query:

select
  name,
  coalesce(esp.description, eng.description) Description
from games g
left join 
  (select game_id, description from details where locale = 'en-GB') eng 
  on g.id = eng.game_id
left join
  (select game_id, description from details where locale = 'es-ES')
  esp on g.id = esp.game_id

What it does basically is getting the names (from table 'games') and the description (from another table called 'details') of a game, if the description is not available in spanish, it takes it in english.

I leave here both tables:

Games
id
name
Details
locale
description
game_id

You can test the query I mentioned before here with two premade games (one has spanish translation and the other doesn't): https://dbfiddle.uk/?rdbms=postgres_13&fiddle=381eb932e859dcfa9105d5d79a2c9c63

I would like to get the same result as a scope when retrieving the games in rails.

When I try:

scope :filter_by_description,
      -> {
        order("description, coalesce(currnt.locale, fallbck.locale) Locale FROM custom_apps c
        LEFT JOIN (SELECT * FROM custom_app_details WHERE locale = 'de-DE') currnt on c.id = currnt.custom_app_id
        LEFT JOIN (SELECT * FROM custom_app_details WHERE locale = 'en-GB') fallbck on c.id = fallbck.custom_app_id")
      }

I just get the following error:

ActiveRecord::UnknownAttributeReference at /games
Query method called with non-attribute argument(s): "order("description, coalesce(currnt.locale, fallbck.locale) Locale FROM custom_apps c LEFT JOIN (SELECT * FROM custom_app_details WHERE locale = 'de-DE') currnt on c.id = currnt.custom_app_id LEFT JOIN (SELECT * FROM custom_app_details WHERE locale = 'en-GB') fallbck on c.id = fallbck.custom_app_id")"

CodePudding user response:

One way to do this is by two lateral subqueries:

class Game < ApplicationRecord
  has_many :details

  # Gets games with a localized description
  # @param [String] preferred - the preferred locale for the description
  # @param [String] fallback  - the fallback locale for the description
  # @return [ActiveRecord::Relation]
  def self.with_localized_description(preferred: 'es-ES', fallback: 'en-GB')
    # This is the subquery we want to run for each row on the games table
    subquery = Detail.select(:description)
                     .where(Detail.arel_table[:game_id].eq(arel_table[:id]))
                     .limit(1)
    select(
      arel_table[Arel.star],
      # Feel free to use whatever alias you want
      'COALESCE(preferred_locale.description, fallback_locale.description) AS description'
    )
      .outer_lateral_joins(subquery.where(locale: preferred), name: 'preferred_locale')
      .outer_lateral_joins(subquery.where(locale: fallback), name: 'fallback_locale')
  end

  # @param [Arel::SelectCore]  - subquery  
  # @param [String] name       - the alias used for this table
  # @return [ActiveRecord::Relation]
  def self.outer_lateral_joins(subquery, name: '')
     # Rails doesn't have a decent way to write LEFT OUTER JOIN LATERAL - this is kind of hacky but works
    joins("LEFT OUTER JOIN LATERAL (#{subquery.to_sql}) #{name} ON true")
  end
end

The easiest way to describe this is that its like a SQL foreach loop. Each lateral join is telling Postgres to get a single row from the details table for each row on the games table.

Since this is a lateral join you can use preferred_locale and fallback_locale in the group, order or whatever clause you want just like if you were dealing with a column on the games table. Postgres also lets you use selected columns in the order clause.

For example:

Game.with_localized_description.order('fallback_locale.description')
Game.with_localized_description.order(:description)

ActiveRecord also makes any aliased columns in the select available as attributes on the records:

irb(main):047:0> mario = Game.with_localized_description.find_by(name: 'Mario Cart')
  Game Load (0.5ms)  SELECT "games".*, COALESCE(preferred_locale.description, fallback_locale.description) AS description FROM "games" LEFT OUTER JOIN LATERAL (SELECT * FROM "details" WHERE "details"."game_id" = "games"."id" AND "details"."locale" = 'es-ES' LIMIT 1) preferred_locale ON true LEFT OUTER JOIN LATERAL (SELECT * FROM "details" WHERE "details"."game_id" = "games"."id" AND "details"."locale" = 'en-GB' LIMIT 1) fallback_locale ON true WHERE "games"."name" = $1 LIMIT $2  [["name", "Mario Cart"], ["LIMIT", 1]]
=>
#<Game:0x00007fec80133700
...
irb(main):048:0> mario.description
=> "Un buen juego de carreras." 
irb(main):049:0> Game.with_localized_description.order(:description)
  Game Load (0.9ms)  SELECT "games".*, COALESCE(preferred_locale.description, fallback_locale.description) AS description FROM "games" LEFT OUTER JOIN LATERAL (SELECT * FROM "details" WHERE "details"."game_id" = "games"."id" AND "details"."locale" = 'es-ES' LIMIT 1) preferred_locale ON true LEFT OUTER JOIN LATERAL (SELECT * FROM "details" WHERE "details"."game_id" = "games"."id" AND "details"."locale" = 'en-GB' LIMIT 1) fallback_locale ON true ORDER BY "description" ASC
=>
[#<Game:0x000056162a59a3d0
  id: 1,
  name: "Mario Cart",
  created_at: Wed, 23 Mar 2022 12:11:41.658592000 UTC  00:00,
  updated_at: Wed, 23 Mar 2022 12:11:41.658592000 UTC  00:00>]

This method isn't written as a "scope" since the scope method is just syntactic sugar for writing class methods. Jamming this code into a lambda would just ruin the readibility of the code.

  • Related