Skip to content

Commit 305913e

Browse files
author
Carlos Silva
committed
Allow CTEs to be used as the from of queries
1 parent 2dc33b5 commit 305913e

File tree

6 files changed

+141
-51
lines changed

6 files changed

+141
-51
lines changed

lib/torque/postgresql/auxiliary_statement.rb

Lines changed: 50 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -125,18 +125,18 @@ def initialize(*, **options)
125125
end
126126

127127
# Build the statement on the given arel and return the WITH statement
128-
def build(base)
128+
def build(base, joining = true)
129129
@bound_attributes.clear
130130
@join_sources.clear
131131

132132
# Prepare all the data for the statement
133133
prepare(base, configure(base, self))
134134

135135
# Add the join condition to the list
136-
@join_sources << build_join(base)
136+
@join_sources << build_join(base) if joining
137137

138138
# Return the statement with its dependencies
139-
[@dependencies, ::Arel::Nodes::As.new(table, build_query(base))]
139+
[@dependencies, ::Arel::Nodes::As.new(table, build_query(base, joining))]
140140
end
141141

142142
private
@@ -151,7 +151,7 @@ def prepare(base, settings)
151151

152152
# Call a proc to get the real query
153153
if @query.respond_to?(:call)
154-
call_args = @query.try(:arity) === 0 ? [] : [OpenStruct.new(@args)]
154+
call_args = @query.try(:arity) === 0 ? nil : [OpenStruct.new(@args)]
155155
@query = @query.call(*call_args)
156156
end
157157

@@ -171,18 +171,19 @@ def prepare(base, settings)
171171
end
172172

173173
# Build the string or arel query
174-
def build_query(base)
174+
def build_query(base, joining = true)
175175
# Expose columns and get the list of the ones for select
176-
columns = expose_columns(base, @query.try(:arel_table))
176+
columns = expose_columns(base, @query.try(:arel_table), joining)
177177

178178
# Prepare the query depending on its type
179179
if @query.is_a?(String)
180180
args = @args.map{ |k, v| [k, base.connection.quote(v)] }.to_h
181181
::Arel.sql("(#{@query})" % args)
182182
elsif relation_query?(@query)
183183
@query = @query.where(@where) if @where.present?
184+
@query = add_selected_columns(@query, columns, joining)
184185
@bound_attributes.concat(@query.send(:bound_attributes))
185-
@query.select(*columns).arel
186+
@query.arel
186187
else
187188
raise ArgumentError, <<-MSG.squish
188189
Only String and ActiveRecord::Base objects are accepted as query objects,
@@ -235,26 +236,14 @@ def build_join(base)
235236
arel_join.new(table, table.create_on(conditions))
236237
end
237238

238-
# Get the class of the join on arel
239-
def arel_join
240-
case @join_type
241-
when :inner then ::Arel::Nodes::InnerJoin
242-
when :left then ::Arel::Nodes::OuterJoin
243-
when :right then ::Arel::Nodes::RightOuterJoin
244-
when :full then ::Arel::Nodes::FullOuterJoin
245-
else
246-
raise ArgumentError, <<-MSG.squish
247-
The '#{@join_type}' is not implemented as a join type.
248-
MSG
249-
end
250-
end
251-
252239
# Mount the list of selected attributes
253-
def expose_columns(base, query_table = nil)
240+
def expose_columns(base, query_table = nil, joining = true)
254241
# Add the columns necessary for the join
255-
list = @join_sources.each_with_object(@select) do |join, hash|
256-
join.right.expr.children.each do |item|
257-
hash[item.left.name] = nil if item.left.relation.eql?(table)
242+
list = !joining ? @select : begin
243+
@join_sources.each_with_object(@select) do |join, hash|
244+
join.right.expr.children.each do |item|
245+
hash[item.left.name] = nil if item.left.relation.eql?(table)
246+
end
258247
end
259248
end
260249

@@ -268,6 +257,31 @@ def expose_columns(base, query_table = nil)
268257
end
269258
end
270259

260+
# Project a column on a given table, or use the column table
261+
def project(column, arel_table = nil)
262+
if column.respond_to?(:as)
263+
return column
264+
elsif (as_string = TABLE_COLUMN_AS_STRING.match(column.to_s))
265+
column = as_string[2]
266+
arel_table = ::Arel::Table.new(as_string[1]) unless as_string[1].nil?
267+
end
268+
269+
arel_table ||= table
270+
arel_table[column.to_s]
271+
end
272+
273+
# Add the provided +columns+ to the provided +query+ in a way that
274+
# complies with the given +joining+ condition
275+
def add_selected_columns(query, columns, joining)
276+
query.tap do
277+
if joining
278+
query._select!(*columns)
279+
else
280+
query.select_extra_values += columns
281+
end
282+
end
283+
end
284+
271285
# Ensure that all the dependencies are loaded in the base relation
272286
def ensure_dependencies(list, base)
273287
with_options = list.extract_options!.to_a
@@ -288,17 +302,18 @@ def ensure_dependencies(list, base)
288302
end
289303
end
290304

291-
# Project a column on a given table, or use the column table
292-
def project(column, arel_table = nil)
293-
if column.respond_to?(:as)
294-
return column
295-
elsif (as_string = TABLE_COLUMN_AS_STRING.match(column.to_s))
296-
column = as_string[2]
297-
arel_table = ::Arel::Table.new(as_string[1]) unless as_string[1].nil?
305+
# Get the class of the join on arel
306+
def arel_join
307+
case @join_type
308+
when :inner then ::Arel::Nodes::InnerJoin
309+
when :left then ::Arel::Nodes::OuterJoin
310+
when :right then ::Arel::Nodes::RightOuterJoin
311+
when :full then ::Arel::Nodes::FullOuterJoin
312+
else
313+
raise ArgumentError, <<-MSG.squish
314+
The '#{@join_type}' is not implemented as a join type.
315+
MSG
298316
end
299-
300-
arel_table ||= table
301-
arel_table[column.to_s]
302317
end
303318
end
304319
end

lib/torque/postgresql/auxiliary_statement/recursive.rb

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,33 +26,33 @@ def initialize(*, **options)
2626
private
2727

2828
# Build the string or arel query
29-
def build_query(base)
29+
def build_query(base, joining = true)
3030
# Expose columns and get the list of the ones for select
31-
columns = expose_columns(base, @query.try(:arel_table))
31+
columns = expose_columns(base, @query.try(:arel_table), joining)
3232
sub_columns = columns.dup
3333
type = @union_all.present? ? 'all' : ''
3434

3535
# Build any extra columns that are dynamic and from the recursion
36-
extra_columns(base, columns, sub_columns)
36+
extra_columns(base, columns, sub_columns, joining)
3737

3838
# Prepare the query depending on its type
3939
if @query.is_a?(String) && @sub_query.is_a?(String)
4040
args = @args.each_with_object({}) { |h, (k, v)| h[k] = base.connection.quote(v) }
4141
::Arel.sql("(#{@query} UNION #{type.upcase} #{@sub_query})" % args)
4242
elsif relation_query?(@query)
4343
@query = @query.where(@where) if @where.present?
44+
@query = add_selected_columns(@query, columns, joining)
4445
@bound_attributes.concat(@query.send(:bound_attributes))
4546

4647
if relation_query?(@sub_query)
48+
sub_query = add_selected_columns(@sub_query, sub_columns, joining).arel
49+
sub_query = sub_query.from([@sub_query.arel_table, table])
4750
@bound_attributes.concat(@sub_query.send(:bound_attributes))
48-
49-
sub_query = @sub_query.select(*sub_columns).arel
50-
sub_query.from([@sub_query.arel_table, table])
5151
else
5252
sub_query = ::Arel.sql(@sub_query)
5353
end
5454

55-
@query.select(*columns).arel.union(type, sub_query)
55+
@query.arel.union(type, sub_query)
5656
else
5757
raise ArgumentError, <<-MSG.squish
5858
Only String and ActiveRecord::Base objects are accepted as query and sub query
@@ -104,17 +104,17 @@ def prepare_sub_query(base, settings)
104104
end
105105
elsif @sub_query.respond_to?(:call)
106106
# Call a proc to get the real sub query
107-
call_args = @sub_query.try(:arity) === 0 ? [] : [OpenStruct.new(@args)]
107+
call_args = @sub_query.try(:arity) === 0 ? nil : [OpenStruct.new(@args)]
108108
@sub_query = @sub_query.call(*call_args)
109109
end
110110
end
111111

112112
# Add depth and path if they were defined in settings
113-
def extra_columns(base, columns, sub_columns)
113+
def extra_columns(base, columns, sub_columns, joining)
114114
return if @query.is_a?(String) || @sub_query.is_a?(String)
115115

116116
# Add the connect attribute to the query
117-
if defined?(@connect)
117+
if defined?(@connect) && joining
118118
columns.unshift(@query.arel_table[@connect[0]])
119119
sub_columns.unshift(@sub_query.arel_table[@connect[0]])
120120
end

lib/torque/postgresql/base.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def dynamic_attribute(name, &block)
279279
# Changes the type of the join
280280
#
281281
# query key:
282-
# Save the query command to be performand
282+
# Save the query command to be performed
283283
#
284284
# requires key:
285285
# Indicates dependencies with another statements

lib/torque/postgresql/relation/auxiliary_statement.rb

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,16 @@ def auxiliary_statements_values; get_value(:auxiliary_statements); end
1010
# :nodoc:
1111
def auxiliary_statements_values=(value); set_value(:auxiliary_statements, value); end
1212

13+
# Hook into the +from+ method to allow querying from a CTE
14+
def from(value, subquery_name = nil, **options)
15+
if value.is_a?(Symbol) && auxiliary_statements_list.key?(value)
16+
value = auxiliary_statements_list[value]
17+
value = value.new(**options)
18+
end
19+
20+
super(value, subquery_name)
21+
end
22+
1323
# Set use of an auxiliary statement
1424
def with(*args, **settings)
1525
spawn.with!(*args, **settings)
@@ -38,10 +48,29 @@ def bound_attributes
3848

3949
# Hook arel build to add the distinct on clause
4050
def build_arel(*)
51+
# Check if CTE was included as part of the from of the query
52+
from = from_clause.value
53+
from_cte = from.is_a?(PostgreSQL::AuxiliaryStatement)
54+
55+
# Build the arel normally and then get the type of the statements
4156
arel = super
4257
type = auxiliary_statement_type
43-
sub_queries = build_auxiliary_statements(arel)
44-
sub_queries.nil? ? arel : arel.with(*type, *sub_queries)
58+
59+
# Build all the statements and add them to the arel
60+
sub_queries = build_auxiliary_statements(arel).flatten
61+
sub_queries << from.build(self, false) if from_cte
62+
arel.with(*type, *sub_queries) unless sub_queries.empty?
63+
arel
64+
end
65+
66+
# Intercept when the FROM clause is being generated to properly build
67+
# from a setup CTE
68+
def build_from
69+
opts = from_clause.value
70+
return super unless opts.is_a?(PostgreSQL::AuxiliaryStatement)
71+
72+
name = from_clause.name
73+
name ? opts.table.as(name.to_s) : opts.table_name
4574
end
4675

4776
# Instantiate one or more auxiliary statements for the given +klass+
@@ -60,7 +89,7 @@ def instantiate_auxiliary_statements(*args, **options)
6089

6190
# Build all necessary data for auxiliary statements
6291
def build_auxiliary_statements(arel)
63-
return unless auxiliary_statements_values.present?
92+
return [] unless auxiliary_statements_values.present?
6493
auxiliary_statements_values.map do |klass|
6594
klass.build(self).tap { arel.join_sources.concat(klass.join_sources) }
6695
end
@@ -69,7 +98,8 @@ def build_auxiliary_statements(arel)
6998
# Return recursive if any auxiliary statement is recursive
7099
def auxiliary_statement_type
71100
klass = PostgreSQL::AuxiliaryStatement::Recursive
72-
:recursive if auxiliary_statements_values.any?(klass)
101+
:recursive if auxiliary_statements_values.any?(klass) ||
102+
from_clause.value.is_a?(klass)
73103
end
74104

75105
# Throw an error showing that an auxiliary statement of the given

lib/torque/postgresql/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,6 @@
22

33
module Torque
44
module PostgreSQL
5-
VERSION = '3.2.2'
5+
VERSION = '3.2.3'
66
end
77
end

spec/tests/auxiliary_statement_spec.rb

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,17 @@
2727
expect(subject.with(:comments).arel.to_sql).to eql(result)
2828
end
2929

30+
it 'can be used as the from clause' do
31+
klass.send(:auxiliary_statement, :admin_users) do |cte|
32+
cte.query User.where(role: :admin)
33+
end
34+
35+
result = 'WITH "admin_users" AS'
36+
result << ' (SELECT "users".* FROM "users" WHERE "users"."role" = $1)'
37+
result << ' SELECT "users".* FROM "admin_users" AS users'
38+
expect(subject.from(:admin_users, :users).arel.to_sql).to eql(result)
39+
end
40+
3041
it 'can perform more complex queries' do
3142
klass.send(:auxiliary_statement, :comments) do |cte|
3243
cte.query Comment.distinct_on(:user_id).order(:user_id, id: :desc)
@@ -456,6 +467,21 @@
456467
expect(subject.with(:all_categories).arel.to_sql).to eql(result)
457468
end
458469

470+
it 'can be used as the from clause' do
471+
Category.send(:recursive_auxiliary_statement, :all_categories) do |cte|
472+
cte.query Category.all
473+
end
474+
475+
result = 'WITH RECURSIVE "all_categories" AS ('
476+
result << ' SELECT "categories".* FROM "categories"'
477+
result << ' WHERE "categories"."parent_id" IS NULL'
478+
result << ' UNION'
479+
result << ' SELECT "categories".* FROM "categories", "all_categories"'
480+
result << ' WHERE "categories"."parent_id" = "all_categories"."id"'
481+
result << ' ) SELECT "categories".* FROM "all_categories" AS categories'
482+
expect(Category.from(:all_categories, :categories).arel.to_sql).to eql(result)
483+
end
484+
459485
it 'allows connect to be set to something different using a single value' do
460486
klass.send(:recursive_auxiliary_statement, :all_categories) do |cte|
461487
cte.query Category.all
@@ -615,6 +641,25 @@
615641
expect(subject.with(:all_categories).arel.to_sql).to eql(result)
616642
end
617643

644+
it 'can be used as the from clause with depth and path' do
645+
Category.send(:recursive_auxiliary_statement, :all_categories) do |cte|
646+
cte.query Category.all
647+
cte.with_depth
648+
cte.with_path
649+
end
650+
651+
result = 'WITH RECURSIVE "all_categories" AS ('
652+
result << ' SELECT "categories".*, 0 AS depth, ARRAY["categories"."id"]::varchar[] AS path'
653+
result << ' FROM "categories"'
654+
result << ' WHERE "categories"."parent_id" IS NULL'
655+
result << ' UNION'
656+
result << ' SELECT "categories".*, ("all_categories"."depth" + 1) AS depth, array_append("all_categories"."path", "categories"."id"::varchar) AS path'
657+
result << ' FROM "categories", "all_categories"'
658+
result << ' WHERE "categories"."parent_id" = "all_categories"."id"'
659+
result << ' ) SELECT "categories".* FROM "all_categories" AS categories'
660+
expect(Category.from(:all_categories, :categories).arel.to_sql).to eql(result)
661+
end
662+
618663
it 'works with string queries' do
619664
klass.send(:recursive_auxiliary_statement, :all_categories) do |cte|
620665
cte.query 'SELECT * FROM categories WHERE a IS NULL'

0 commit comments

Comments
 (0)