Skip to content

Instantly share code, notes, and snippets.

@estum
Last active December 3, 2021 08:44
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save estum/243de3152fbbcc150004cb65d728b91a to your computer and use it in GitHub Desktop.
Save estum/243de3152fbbcc150004cb65d728b91a to your computer and use it in GitHub Desktop.
ActiveRecord's JOIN clause string wrapper class
# 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