Vlado Cingel
vlado@cingel.hr
Makes it super simple to organise SQL queries using Scopes and Relations
class Post
has_many :comments
scope :published, -> { where(published: true) }
scope :not_published, -> { where(published: false) }
scope :from_category, ->(category) { where(category: category) }
scope :latest, -> { limit(10) }
end
class Comment
belongs_to :post
scope :approved, -> { where(approved: true) }
end
Post.published
Comment.approved
Post.from_category("ruby").latest.merge(Post.published)
Comment.approved.joins(:post).where(post: Post.published)
pots = Post.find(1)
post.comments.approved
post.comments.approved.where(approved_at: (1.week.ago..Time.zone.today))
posts = Post.published.from_category("ruby")
posts.count
posts.average("words_count")
posts.min("words_count")
posts.max("words_count")
posts.sum("words_count")
Post.not_published.delete_all
Post.not_published.update_all(published: true)
post.comments.approved.create(content: "New approved comment")
# app/controllers/posts_controller.rb
@posts = Post.published
# app/views/posts/index.html.erb
<% cache(@posts) do %>
<% @posts.each do |post| %>
<%= post.title %>
...
<% end %>
<% end %>
What to do when ActiveRecord just isn't enough?
class Clinic
has_many :case_numbers
end
class CaseNumber
belongs_to :clinic
end
class ClinicsSqlBuilder
QUERY = <<-SQL
WITH relevant_clinics AS (
SELECT clinics.id, clinics.name, SUM(case_numbers.count) AS cases_count FROM clinics INNER JOIN case_numbers ON case_numbers.clinic_id = clinics.id WHERE case_numbers.code LIKE :code GROUP BY clinics.id
), stats AS (
SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_2, PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_1 FROM relevant_clinics
)
SELECT id, name, cases_count, CASE ... END AS RANK FROM relevant_clinics ORDER BY cases_count DESC
SQL
def sql_for_clinics_matching(code)
ActiveRecord::Base.sanitize_sql_array([QUERY, code: "#{code}%"])
end
end
sql_builder = ClinicsSqlBuilder.new
Clinic.find_by_sql(sql_builder.sql_for_clinics_matching("F4"))
class ClinicsSqlBuilder
RELEVANT_CLINICS_CTE = <<-SQL
WITH relevant_clinics AS (
SELECT clinics.id, clinics.name, SUM(case_numbers.count) AS cases_count FROM clinics INNER JOIN case_numbers ON case_numbers.clinic_id = clinics.id WHERE case_numbers.code LIKE :code GROUP BY clinics.id
), stats AS (
SELECT PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_2, PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_1 FROM relevant_clinics
)
SQL
def sql_for_clinics_matching(code)
sql = <<-SQL
#{RELEVANT_CLINICS_CTE}
SELECT id, name, cases_count, CASE ... END AS RANK FROM relevant_clinics ORDER BY cases_count DESC
SQL
ActiveRecord::Base.sanitize_sql_array([sql, code: "#{code}%"])
end
def count_sql_for_clinics_matching(code)
sql = <<-SQL
#{RELEVANT_CLINICS_CTE}
SELECT count(*) FROM relevant_clinics ORDER BY cases_count DESC
SQL
ActiveRecord::Base.sanitize_sql_array([sql, code: "#{code}%"])
end
end
Iteration 1: Plain SQL
sql_builder = ClinicSqlBuilder.new
Clinic.find_by_sql(sql_builder.sql_for_clinics_matching("F4"))
Clinic.count_by_sql(sql_builder.sql_for_clinics_matching("F4"))
Chainable | 😞 |
Calculations | 😞 |
Create, Update, Delete | 😞 |
Lazy Load | 😞 |
Cache | 😞 |
Clinic.arel_table # => Arel::Table
Clinic.arel_table[:name] # => Arel::Attributes::Attribute
Clinic.arel_table.project(Arel.star) # => Arel::SelectManager
Clinic.all.arel # => Arel::SelectManager
clinics_table = Clinic.arel_table
clinics_table.where(clinics_table[:name].eq("Rebro"))
# SELECT * FROM clinics WHERE clinics.name = 'Rebro'
class Clinic
has_many :case_numbers
scope :matches_medical_topic, -> (q) {
joins(:case_numbers)
.where(CaseNumber.arel_table[:code].matches("#{q}%"))
}
end
Clinic.matches_medical_topic("f4")
# => ActiveRecord::Relation
# SELECT "clinics".* FROM "clinics"
# INNER JOIN "case_numbers" ON "case_numbers"."clinic_id" = "clinics"."id"
# WHERE "case_numbers"."code" ILIKE 'f4%'
class Clinic
has_many :case_numbers
scope :matches_medical_topic, -> (q) { joins(:case_numbers).where(CaseNumber.arel_table[:code].matches("#{q}%")) }
def self.search(q)
relevant_clinics_table = Arel::Table.new(:relevant_clinics)
relevat_clinics_expression = matches_medical_topic(q)
.select(arel_table[:id], arel_table[:name], CaseNumber.arel_table[:count].sum.as("cases_count"))
.group("clinics.id")
.arel
stats_table = Arel::Table.new(:stats)
stats_expression = select("PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_2")
.select("PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_1")
.from(relevant_clinics_table)
.arel
statement = arel_table
.project(
relevant_clinics_table[:id],
relevant_clinics_table[:name],
relevant_clinics_table[:cases_count],
"CASE WHEN cases_count > (SELECT quartile_2 FROM stats) THEN 'High' WHEN cases_count > (SELECT quartile_1 FROM stats) THEN 'Average' ELSE 'Low' END as rank"
)
.with([
Arel::Nodes::As.new(relevant_clinics_table, relevat_clinics_expression),
Arel::Nodes::As.new(stats_table, stats_expression)
])
.from(relevant_clinics_table)
.order(relevant_clinics_table[:cases_count].desc)
find_by_sql(statement.to_sql)
end
end
Clinic.search("f4")
case_numbers = CaseNumber.arel_table
cte_table = Arel::Table.new(:cool_case_numbers)
cte_table_expression = case_numbers.where(case_numbers[:code].eq("cool"))
clinics = Clinic.arel_table
clinic_ids = clinics
.project(clinics[:id])
.join(cte_table).on(clinics[:id].eq(cte_table[:clinic_id]))
.with(Arel::Node::As.new(cte_table, cte_expression))
Clinic.where(clinics[:id].in(clinic_ids))
Chainable | 😐 |
Calculations | 😐 |
Create, Update, Delete | 😐 |
Lazy Load | 😐 |
Cache | 😐 |
How hard can it be to add CTE support to Rails?
.with
is already in Arel ...
module ActiveRecord
module Querying
delegate :with, to: :all
end
class Relation
def with(opts, *rest)
return self if opts.blank?
spawn.with!(opts, *rest)
end
def with!(opts, *rest)
self.with_values += [opts] + rest
self
end
def with_values
@values[:with] || []
end
def with_values=(values)
raise ImmutableRelation if @loaded
@values[:with] = values
end
def build_arel(aliases)
arel = super(aliases)
build_with!(arel) if @values[:with]
arel
end
def build_with!(arel)
return if with_values.empty?
with_statements = with_values.map do |with_value|
with_value.map do |name, expression|
table = Arel::Table.new(name)
select = Arel::Nodes::SqlLiteral.new("(#{expression.to_sql})")
Arel::Nodes::As.new(table, select)
end
end
arel.with with_statements
end
end
end
class Clinic
has_many :case_numbers
scope :matches_medical_topic, -> (q) {
joins(:case_numbers).where(CaseNumber.arel_table[:code].matches("#{q}%"))
}
def self.search(q)
relevant_clinics = matches_medical_topic(q)
.select(arel_table[:id], arel_table[:name], CaseNumber.arel_table[:count].sum.as("cases_count"))
.group(arel_table[:id])
stats = select(
"PERCENTILE_CONT(0.25) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_2",
"PERCENTILE_CONT(0.50) WITHIN GROUP (ORDER BY cases_count DESC) AS quartile_1"
)
.from("relevant_clinics")
with(relevant_clinics: relevant_clinics, stats: stats)
.from("relevant_clinics clinics")
.order("cases_count DESC")
end
end
Chainable | 😀 |
Calculations | 😀 |
Create, Update, Delete | 😀 |
Lazy Load | 😀 |
Cache | 😀 |
CREATE VIEW cool_clinics AS (
SELECT sum(count) ...
)
SELECT * FROM cool_clinics
... and answers maybe :)