Last active
December 3, 2021 08:44
-
-
Save estum/243de3152fbbcc150004cb65d728b91a to your computer and use it in GitHub Desktop.
ActiveRecord's JOIN clause string wrapper class
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# frozen_string_literal: true | |
class Joint < String | |
_patterns = [[/LATERAL$/, 'ON true'], [/^(?:NATURAL|CROSS)/, '']].deep_freeze! | |
_exception = "expected one non-blank argument of `on' or `using' unless type matches one of: #{_patterns.map(&:first).inspect}" | |
FALLBACK_BOOL_EXP = -> (type) do | |
case type | |
when _patterns[0][0]; _patterns[0][1] | |
when _patterns[1][0]; _patterns[1][1] | |
else raise ArgumentError, _exception | |
end | |
end.freeze | |
private_constant :FALLBACK_BOOL_EXP | |
SQL_TEMPLATE = '%{type} %{cond} %{bool_exp}' | |
private_constant :SQL_TEMPLATE | |
mattr_accessor :valid_types, instance_writer: false, default: Set.new | |
# Defines a singleton class method which is shorthand to Joint.new | |
# with the same join type. Adds it to the list of valid types. | |
# @param method_name [Symbol] Name of singleton method | |
# @param join_type [String] Manual associate the join expression | |
# @!macro [attach] define_join_type | |
# @!method $1(key, **options) | |
# @!scope class | |
# @see #initialize | |
# @example | |
# Joint.$1(:comments, on: 'comments.id = posts.id') | |
# # => $2 comments ON comments.id = posts.id | |
# @return [Joint.new(key, **options, type: '$2')] | |
def self.define_join_type(method_name, join_type) | |
valid_types << join_type | |
class_eval <<~RUBY, __FILE__, __LINE__ + 1 | |
def self.#{method_name}(key, **options) | |
new(key, **options, type: "#{join_type}") | |
end | |
RUBY | |
end | |
private_class_method :define_join_type | |
# @!group Type Shorthands | |
define_join_type :inner, 'INNER JOIN' | |
define_join_type :left, 'LEFT JOIN' | |
define_join_type :left_outer, 'LEFT OUTER JOIN' | |
define_join_type :right, 'RIGHT JOIN' | |
define_join_type :right_outer, 'RIGHT OUTER JOIN' | |
define_join_type :full, 'FULL JOIN' | |
define_join_type :full_outer, 'FULL OUTER JOIN' | |
define_join_type :cross, 'CROSS JOIN' | |
define_join_type :natural, 'NATURAL JOIN' | |
define_join_type :natural_inner, 'NATURAL INNER JOIN' | |
define_join_type :natural_left_outer, 'NATURAL LEFT OUTER JOIN' | |
define_join_type :natural_right_outer, 'NATURAL RIGHT OUTER JOIN' | |
define_join_type :lateral, 'JOIN LATERAL' | |
define_join_type :left_lateral, 'LEFT JOIN LATERAL' | |
# @!endgroup | |
valid_types.freeze | |
# @param [Symbol|String] table_alias | |
# @overload as(table_alias, columns_list) | |
# @param [Array<Symbol|String>] columns_list | |
# @example | |
# Joint.as(:dict, %i(key value)) # => "as(key, value)" | |
# @overload as(table_alias, columns_hash) | |
# @param [Hash] columns_hash | |
# @example | |
# Joint.as(:dict, key: :text, value: :int) # => "as(key text, value int)" | |
def self.as(table_alias, columns) | |
columns = columns.map { |k, v| "#{k} #{v}" } if Hash === columns | |
format "%s(%s)", table_alias, Array.wrap(columns).join(", ") | |
end | |
def self.values(*list) | |
"(VALUES %s)" % list.map { |item| "(%s)" % Array.wrap(item).join(", ") }.join(", ") | |
end | |
def self.lateral_jsonb(key, table_name, column_name, set: false, left: false, **structure) | |
method_name = left ? :left_lateral : :lateral | |
jsonb_method = %(jsonb_to_record#{'set' if set}) | |
public_send(method_name, key, | |
cond: "#{jsonb_method}(#{table_name}.#{column_name})", | |
as: as(key, structure), | |
on: "true" | |
) | |
end | |
def self.lateral_jsonb_elements(key, table_name, column_name, _on:, left: false, columns: []) | |
method_name = left ? :left_lateral : :lateral | |
public_send(method_name, key, | |
cond: "jsonb_array_elements(#{table_name}.#{column_name})", | |
as: as(key, columns), | |
on: "true" | |
) | |
end | |
def self.left_lateral_jsonb(*args, **structure) | |
lateral_jsonb(*args, left: true, **structure) | |
end | |
# @!scope instance | |
# @!attribute [r] key | |
# @return [Symbol] key associated with the instantiated join expression | |
attr_reader :key | |
# @!attribute [r] options | |
# @return [Hash{type: String, cond: (String|), }] options of join expression | |
attr_reader :options | |
# @example INNER JOIN users ON users.id = posts.user_id | |
# Joint.new(:users, on: "users.id = posts.user_id") | |
# @example LEFT OUTER JOIN users ON users.id = posts.user_id | |
# Joint.left_outer(:users, on: "users.id = posts.user_id") | |
# @example INNER JOIN (SELECT id, name FROM users WHERE type = 'admin') AS admins ON admins.id = posts.user_id | |
# Joint.inner(:admins, cond: "(SELECT id, name FROM users WHERE type = 'admin')", as: "admins", on: "admins.id = posts.user_id") | |
# | |
# @overload Joint.new(key, **opts) | |
# Create a keyed join expression | |
# @param [Symbol] key | |
# @param [Hash] opts | |
# @option opts [String] :type ('INNER JOIN') | |
# @option opts [String] :cond ('') | |
# @option opts [String] :on ('') | |
# @option opts [String] :exp ('') | |
# @option opts [String] :using ('') | |
# @option opts [String] :as ('') | |
def initialize(key, **opts) | |
@key = key.to_sym | |
@options = { type: "INNER JOIN", cond: Cond.new(@key.to_s, @key), bool_exp: nil } | |
normalize_and_set(**opts) | |
super(compose_sql) | |
end | |
# Changes clause on-the-fly: | |
# | |
# @example INNER JOIN users ON users.id = posts.user_id | |
# j = Joint.inner(:users, _on: "users.id = posts.user_id") | |
# @example LEFT OUTER JOIN users ON users.id = posts.user_id | |
# j.change(type: "LEFT OUTER") | |
def change(**new_options) | |
normalize_and_set(**new_options) | |
replace(compose_sql) | |
end | |
# Overrides the Object#== method to easily find a join clause in | |
# ActiveRecord::Relation#joins_values by the given key. | |
# | |
# @example | |
# relation = Post.joins(Joint.inner(:users, on: "users.id = posts.user_id")) | |
# joint = relation.joins_values[relation.joins_values.index(:users)] # => get a joint's instance from relation's joins | |
# joint.change(type: "LEFT OUTER") # => now a relation will join left outer | |
# relation.joins_values.delete(:users) # => deletes a join clause by the key | |
def ==(other) | |
case other | |
when Symbol then other == to_sym | |
when Hash then other == to_h | |
else super | |
end | |
end | |
def to_sym | |
@key | |
end | |
def to_h | |
@options.dup | |
end | |
private | |
def compose_sql | |
SQL_TEMPLATE % @options | |
end | |
def normalize_and_set(**opts) | |
opts.each do |key, value| | |
case key | |
when :type | |
value = value.upcase | |
unless valid_types.include?(value) | |
raise ArgumentError, "invalid join type: `#{value}'" | |
end | |
@options[:type] = value | |
when :cond | |
@options[:cond].expr = value | |
when :on, :exp | |
@options[:bool_exp] = "ON #{normalize_bool_exp(value)}" | |
when :using | |
@options[:bool_exp] = "USING(#{value})" | |
when :as | |
@options[:cond].as = value | |
when :parens | |
@options[:cond].parens = value | |
else | |
raise ArgumentError, "unexpected kwarg `#{key}'" | |
end | |
end | |
@options[:bool_exp] ||= FALLBACK_BOOL_EXP[@options[:type]] | |
end | |
def normalize_bool_exp(value) | |
case value | |
when String, Symbol | |
value | |
when Hash | |
value.map { |l, r| "#{@options[:cond].as}.#{l} = #{r}" }.join(" AND ") | |
when Arel::Nodes::Node | |
value.to_sql | |
else | |
raise ArgumentError, "expeted String, Symbol, Hash or Arel::Nodes::Node; given #{value.class}" | |
end | |
end | |
class Cond < String | |
SPACE = ' ' | |
PARENS_RE = /^\(.*\)$/ | |
AS_TPL = '%s AS %s' | |
PARENS_TPL = '(%s)' | |
Complex = lambda { |cond| cond.relation? || cond.expr.include?(SPACE) } | |
Aliased = lambda { |cond| cond.expr != cond.as } | |
attr_reader :expr, :as, :parens | |
def initialize(*args) | |
@expr, @as = args | |
super(compose) | |
end | |
def relation? | |
ActiveRecord::Relation === @expr | |
end | |
def complex? | |
relation? || @expr.include?(SPACE) | |
end | |
def parens?(expr) | |
PARENS_RE.match?(expr) | |
end | |
%i[expr as parens].each do |attr_name| | |
class_eval <<~RUBY, __FILE__, __LINE__ + 1 | |
def #{attr_name}=(value) | |
@#{attr_name} = value | |
replace(compose) | |
end | |
RUBY | |
end | |
private | |
def compose | |
case self | |
when Complex; format(AS_TPL, decorated_expr(@expr), @as) | |
when Aliased; format(AS_TPL, @expr, @as) | |
else @expr | |
end | |
end | |
def decorated_expr(expr) | |
expr = @expr.to_sql if relation? | |
wrap_in_parens?(expr) ? PARENS_TPL % expr : expr | |
end | |
def wrap_in_parens?(expr) | |
complex? && !parens?(expr) && @parens != false | |
end | |
end | |
end |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment