pluckr 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +66 -0
- data/LICENSE.txt +21 -0
- data/PROMPT.md +112 -0
- data/README.md +436 -0
- data/lib/pluckr/aliases.rb +37 -0
- data/lib/pluckr/batch.rb +216 -0
- data/lib/pluckr/compiler/sql.rb +363 -0
- data/lib/pluckr/errors.rb +24 -0
- data/lib/pluckr/query.rb +207 -0
- data/lib/pluckr/reflection/association.rb +109 -0
- data/lib/pluckr/relation.rb +414 -0
- data/lib/pluckr/result/builder.rb +100 -0
- data/lib/pluckr/result/object.rb +78 -0
- data/lib/pluckr/schema/aggregate.rb +58 -0
- data/lib/pluckr/schema/conditions.rb +45 -0
- data/lib/pluckr/schema/definition.rb +289 -0
- data/lib/pluckr/schema/exists.rb +28 -0
- data/lib/pluckr/schema/field.rb +16 -0
- data/lib/pluckr/schema/node.rb +21 -0
- data/lib/pluckr/schema/one.rb +37 -0
- data/lib/pluckr/schema/scope.rb +34 -0
- data/lib/pluckr/version.rb +5 -0
- data/lib/pluckr.rb +45 -0
- metadata +125 -0
data/lib/pluckr/batch.rb
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
# Runs several unrelated aggregates over ActiveRecord relations in one
|
|
5
|
+
# statement:
|
|
6
|
+
#
|
|
7
|
+
# stats = Pluckr.batch do |b|
|
|
8
|
+
# b.count user.videos, as: :video_count
|
|
9
|
+
# b.sum user.videos.where(size: 100..), :size, as: :big_video_bytes
|
|
10
|
+
# b.exists user.photos, as: :has_photos
|
|
11
|
+
# b.avg user.videos, :size, as: :average_size
|
|
12
|
+
# b.count Account.active, as: :active_accounts
|
|
13
|
+
# end
|
|
14
|
+
#
|
|
15
|
+
# stats.video_count # => 3
|
|
16
|
+
#
|
|
17
|
+
# Each entry is whatever SQL the relation already compiles to, so default
|
|
18
|
+
# scopes, association scopes, `:through`, polymorphic and STI conditions are
|
|
19
|
+
# respected - ActiveRecord applied them before Pluckr saw the relation.
|
|
20
|
+
#
|
|
21
|
+
# This is the ad-hoc counterpart to `Pluckr::Query`: no class, no reuse, and
|
|
22
|
+
# bound to concrete records rather than to a row of a result set.
|
|
23
|
+
class Batch
|
|
24
|
+
# Eager loading is pointless inside a scalar aggregate subquery and joining
|
|
25
|
+
# the associated tables can change the row count; row locks are not allowed
|
|
26
|
+
# alongside an aggregate.
|
|
27
|
+
IGNORED_RELATION_VALUES = %i[includes eager_load preload lock].freeze
|
|
28
|
+
|
|
29
|
+
COLUMNLESS_OPERATIONS = %i[count exists].freeze
|
|
30
|
+
|
|
31
|
+
def self.build(&block)
|
|
32
|
+
raise ConfigurationError, "`Pluckr.batch` requires a block" unless block
|
|
33
|
+
|
|
34
|
+
batch = new
|
|
35
|
+
block.call(batch)
|
|
36
|
+
batch
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def initialize
|
|
40
|
+
@nodes = []
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# ---------------------------------------------------------------- DSL --
|
|
44
|
+
|
|
45
|
+
def count(source, column = nil, as:)
|
|
46
|
+
add(:count, source, column, as)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def sum(source, column, as:)
|
|
50
|
+
add(:sum, source, column, as)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def avg(source, column, as:)
|
|
54
|
+
add(:avg, source, column, as)
|
|
55
|
+
end
|
|
56
|
+
alias average avg
|
|
57
|
+
|
|
58
|
+
def min(source, column, as:)
|
|
59
|
+
add(:min, source, column, as)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def max(source, column, as:)
|
|
63
|
+
add(:max, source, column, as)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def exists(source, as:)
|
|
67
|
+
add(:exists, source, nil, as)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# ----------------------------------------------------------- execution --
|
|
71
|
+
|
|
72
|
+
def to_sql
|
|
73
|
+
compiler.aggregate_only_sql
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def fetch
|
|
77
|
+
scope = schema
|
|
78
|
+
compiler = Compiler::Sql.new(schema: scope)
|
|
79
|
+
row = Pluckr.select_all(compiler.connection, compiler.aggregate_only_sql,
|
|
80
|
+
name: "Pluckr::Batch", label: "Pluckr Batch").first
|
|
81
|
+
|
|
82
|
+
Result::Builder.new(scope, label: "Pluckr::Batch").build(row)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def entries
|
|
86
|
+
@nodes.map(&:output)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def inspect
|
|
90
|
+
"#<Pluckr::Batch #{entries.join(", ")}>"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
private
|
|
94
|
+
|
|
95
|
+
def add(operation, source, column, output)
|
|
96
|
+
relation = relation_for(operation, output, source)
|
|
97
|
+
column = validate_column!(operation, output, column, relation.klass)
|
|
98
|
+
validate_grouping!(operation, output, column, relation)
|
|
99
|
+
validate_projection!(operation, output, column, relation)
|
|
100
|
+
validate_connection!(output, relation)
|
|
101
|
+
validate_output!(output)
|
|
102
|
+
|
|
103
|
+
@nodes << Schema::Aggregate.new(
|
|
104
|
+
output: output,
|
|
105
|
+
operation: operation,
|
|
106
|
+
relation: normalize(relation),
|
|
107
|
+
column: column
|
|
108
|
+
)
|
|
109
|
+
self
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# The derived table has to expose exactly what the outer aggregate reads,
|
|
113
|
+
# without changing what the caller asked for.
|
|
114
|
+
def normalize(relation)
|
|
115
|
+
ignored = IGNORED_RELATION_VALUES.dup
|
|
116
|
+
# ORDER BY only matters when it decides which rows a limit/offset keeps.
|
|
117
|
+
ignored << :order unless relation.limit_value || relation.offset_value
|
|
118
|
+
relation = relation.unscope(*ignored)
|
|
119
|
+
|
|
120
|
+
# A projection is meaningful when it is what DISTINCT or GROUP BY applies
|
|
121
|
+
# to, and noise otherwise.
|
|
122
|
+
return relation if relation.select_values.any? && projection_matters?(relation)
|
|
123
|
+
|
|
124
|
+
return relation.unscope(:select) if relation.group_values.empty?
|
|
125
|
+
|
|
126
|
+
# `SELECT *` alongside GROUP BY is invalid on PostgreSQL and MySQL.
|
|
127
|
+
relation.unscope(:select).select(*relation.group_values)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def projection_matters?(relation)
|
|
131
|
+
relation.distinct_value || relation.group_values.any?
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def relation_for(operation, output, source)
|
|
135
|
+
return source.all if source.is_a?(Class) && source < ActiveRecord::Base
|
|
136
|
+
return source if source.is_a?(ActiveRecord::Relation)
|
|
137
|
+
|
|
138
|
+
raise ConfigurationError,
|
|
139
|
+
"`#{operation} ..., as: :#{output}` expects an ActiveRecord relation or model, " \
|
|
140
|
+
"got #{source.inspect}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# A grouped relation returns one row per group, so the only thing that can
|
|
144
|
+
# be asked of it is how many groups there are.
|
|
145
|
+
def validate_grouping!(operation, output, column, relation)
|
|
146
|
+
return if relation.group_values.empty?
|
|
147
|
+
return if COLUMNLESS_OPERATIONS.include?(operation) && column.nil?
|
|
148
|
+
|
|
149
|
+
raise ConfigurationError,
|
|
150
|
+
"`#{operation} ..., as: :#{output}`: the relation is grouped, so only `count` and " \
|
|
151
|
+
"`exists` (without a column) are meaningful - aggregate inside the relation instead"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# count and exists work without a column; the rest need one.
|
|
155
|
+
def validate_column!(operation, output, column, model)
|
|
156
|
+
if column.nil?
|
|
157
|
+
return nil if COLUMNLESS_OPERATIONS.include?(operation)
|
|
158
|
+
|
|
159
|
+
raise ConfigurationError, "`#{operation} ..., as: :#{output}` requires a column"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
unless model.column_names.include?(column.to_s)
|
|
163
|
+
raise UnknownField, "#{model.name} does not have column `#{column}`"
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
column
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# With DISTINCT the caller's projection is kept, so an aggregate column that
|
|
170
|
+
# is not part of it would not exist in the derived table.
|
|
171
|
+
def validate_projection!(operation, output, column, relation)
|
|
172
|
+
return if column.nil? || !relation.distinct_value
|
|
173
|
+
|
|
174
|
+
projected = relation.select_values.select { |value| value.is_a?(Symbol) || value.is_a?(String) }
|
|
175
|
+
return if projected.empty? || projected.map(&:to_s).include?(column.to_s)
|
|
176
|
+
|
|
177
|
+
raise ConfigurationError,
|
|
178
|
+
"`#{operation} ..., as: :#{output}`: the relation selects #{projected.join(", ")} " \
|
|
179
|
+
"with DISTINCT, so column `#{column}` is not available to aggregate"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# One statement means one connection.
|
|
183
|
+
def validate_connection!(output, relation)
|
|
184
|
+
return if @nodes.empty?
|
|
185
|
+
|
|
186
|
+
first = @nodes.first.relation.klass
|
|
187
|
+
return if first.connection_pool == relation.klass.connection_pool
|
|
188
|
+
|
|
189
|
+
raise ConfigurationError,
|
|
190
|
+
"`as: :#{output}`: #{relation.klass.name} and #{first.name} use different database " \
|
|
191
|
+
"connections, so they cannot be batched into one statement"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def validate_output!(output)
|
|
195
|
+
return unless @nodes.any? { |node| node.output == output.to_sym }
|
|
196
|
+
|
|
197
|
+
raise ConfigurationError, "duplicate output name `#{output}` in batch"
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Rebuilt per call: entries may be added between to_sql and fetch.
|
|
201
|
+
def schema
|
|
202
|
+
raise ConfigurationError, "batch is empty - add at least one aggregate" if @nodes.empty?
|
|
203
|
+
|
|
204
|
+
Schema::Scope.new(nil, @nodes.dup) # Scope freezes what it is given
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def compiler
|
|
208
|
+
Compiler::Sql.new(schema: schema)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# @see Pluckr::Batch
|
|
213
|
+
def self.batch(&block)
|
|
214
|
+
Batch.build(&block).fetch
|
|
215
|
+
end
|
|
216
|
+
end
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Compiler
|
|
5
|
+
# Turns a Schema::Scope into SQL.
|
|
6
|
+
#
|
|
7
|
+
# Everything is built with Arel and ActiveRecord, so PostgreSQL, MySQL and
|
|
8
|
+
# SQLite all get correctly quoted statements from the same code path.
|
|
9
|
+
#
|
|
10
|
+
# Rules of the MVP:
|
|
11
|
+
# * plain fields -> selected columns, never `SELECT users.*`
|
|
12
|
+
# * singular associations -> LEFT OUTER JOIN + hidden presence marker
|
|
13
|
+
# * exists -> correlated EXISTS subquery
|
|
14
|
+
# * count/sum/min/max -> scalar subquery (never JOIN + GROUP BY)
|
|
15
|
+
#
|
|
16
|
+
# The compiler only reads the AST. It never touches DSL state.
|
|
17
|
+
class Sql
|
|
18
|
+
attr_reader :schema, :source_model
|
|
19
|
+
|
|
20
|
+
def initialize(schema:, source_model: nil)
|
|
21
|
+
@schema = schema
|
|
22
|
+
@source_model = source_model
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Applies Pluckr projections/joins on top of a plain ActiveRecord relation.
|
|
26
|
+
#
|
|
27
|
+
# Compiled fresh every time: `scope:` callables may depend on runtime state
|
|
28
|
+
# and the compiler must stay free of shared mutable state.
|
|
29
|
+
def apply(relation)
|
|
30
|
+
validate_connections!
|
|
31
|
+
build = Build.new
|
|
32
|
+
walk(build, schema, [], source_model.arel_table)
|
|
33
|
+
|
|
34
|
+
relation.select(*build.projections).joins(build.joins)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# Single-statement SQL for aggregate-only (dashboard and batch) queries.
|
|
38
|
+
def aggregate_only_sql
|
|
39
|
+
validate_connections!
|
|
40
|
+
|
|
41
|
+
parts = schema.nodes.map.with_index(1) do |node, index|
|
|
42
|
+
"#{scalar_subquery(node, index)} AS #{quote_alias(node.output)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
"SELECT #{parts.join(", ")}"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def connection
|
|
49
|
+
connection_model.connection
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# A single statement cannot span two databases. Relation-rooted nodes are
|
|
53
|
+
# checked when they are added, in Pluckr::Batch.
|
|
54
|
+
def validate_connections!
|
|
55
|
+
models = ([source_model] + independent_models).compact.uniq
|
|
56
|
+
return if models.size < 2
|
|
57
|
+
|
|
58
|
+
pool = models.first.connection_pool
|
|
59
|
+
offender = models.find { |model| model.connection_pool != pool }
|
|
60
|
+
return unless offender
|
|
61
|
+
|
|
62
|
+
raise ConfigurationError,
|
|
63
|
+
"#{offender.name} and #{models.first.name} use different database connections, " \
|
|
64
|
+
"so they cannot be read in one statement"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def independent_models(scope = schema)
|
|
70
|
+
scope.nodes.flat_map do |node|
|
|
71
|
+
case node
|
|
72
|
+
when Schema::Aggregate then node.independent? && node.relation.nil? ? [node.model] : []
|
|
73
|
+
when Schema::One then independent_models(node.scope)
|
|
74
|
+
else []
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Accumulator for one compilation pass.
|
|
80
|
+
class Build
|
|
81
|
+
attr_reader :projections, :joins
|
|
82
|
+
|
|
83
|
+
def initialize
|
|
84
|
+
@projections = []
|
|
85
|
+
@joins = []
|
|
86
|
+
@sequence = 0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def next_sequence
|
|
90
|
+
@sequence += 1
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def walk(build, scope, path, table)
|
|
95
|
+
scope.nodes.each do |node|
|
|
96
|
+
case node
|
|
97
|
+
when Schema::Field
|
|
98
|
+
project build, table[node.column], Aliases.output(path, node.output)
|
|
99
|
+
when Schema::Exists
|
|
100
|
+
project build, exists_expression(build, node, table), Aliases.output(path, node.output)
|
|
101
|
+
when Schema::Aggregate
|
|
102
|
+
expression =
|
|
103
|
+
if node.independent?
|
|
104
|
+
scalar_subquery(node)
|
|
105
|
+
else
|
|
106
|
+
correlated_aggregate(build, node, table)
|
|
107
|
+
end
|
|
108
|
+
project build, expression, Aliases.output(path, node.output)
|
|
109
|
+
when Schema::One
|
|
110
|
+
walk_one(build, node, path, table)
|
|
111
|
+
else
|
|
112
|
+
raise Error, "unsupported schema node #{node.class}"
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def walk_one(build, node, path, owner_table)
|
|
118
|
+
return walk_picked(build, node, path, owner_table) if node.picked?
|
|
119
|
+
|
|
120
|
+
child_path = path + [node.output.to_s]
|
|
121
|
+
association = node.association
|
|
122
|
+
child_table = association.klass.arel_table.alias(Aliases.table(child_path))
|
|
123
|
+
|
|
124
|
+
condition = association.join_condition(child_table, owner_table)
|
|
125
|
+
sti = sti_condition(association.klass, child_table)
|
|
126
|
+
condition = condition.and(sti) if sti
|
|
127
|
+
|
|
128
|
+
build.joins << Arel::Nodes::OuterJoin.new(child_table, Arel::Nodes::On.new(condition))
|
|
129
|
+
|
|
130
|
+
# Presence marker: tells "no associated row" apart from "row with NULL columns".
|
|
131
|
+
project build, child_table[association.primary_key], Aliases.presence(child_path)
|
|
132
|
+
|
|
133
|
+
walk(build, node.scope, child_path, child_table)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# `first`/`last`: one row of a collection, without joining it. Each selected
|
|
137
|
+
# column is its own correlated subquery, ordered the same way and ending in
|
|
138
|
+
# the primary key, so every column comes from the same row.
|
|
139
|
+
#
|
|
140
|
+
# (SELECT "c"."body" FROM "comments" "c" WHERE "c"."user_id" = "users"."id"
|
|
141
|
+
# ORDER BY "c"."created_at" DESC, "c"."id" DESC LIMIT 1) AS "last_comment.body"
|
|
142
|
+
def walk_picked(build, node, path, owner_table)
|
|
143
|
+
child_path = path + [node.output.to_s]
|
|
144
|
+
|
|
145
|
+
# Presence marker: the row exists even if every selected column is NULL.
|
|
146
|
+
project build, picked_column(build, node, owner_table, node.association.primary_key),
|
|
147
|
+
Aliases.presence(child_path)
|
|
148
|
+
|
|
149
|
+
node.scope.nodes.each do |field|
|
|
150
|
+
project build, picked_column(build, node, owner_table, field.column),
|
|
151
|
+
Aliases.output(child_path, field.output)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def picked_column(build, node, owner_table, column)
|
|
156
|
+
association = node.association
|
|
157
|
+
table = subquery_table(build, association)
|
|
158
|
+
subquery = Arel::SelectManager.new(table)
|
|
159
|
+
.project(table[column])
|
|
160
|
+
.where(association.join_condition(table, owner_table))
|
|
161
|
+
subquery = restrict_to_sti(subquery, association.klass, table)
|
|
162
|
+
|
|
163
|
+
node.order.each { |name, direction| subquery.order(table[name].public_send(direction)) }
|
|
164
|
+
|
|
165
|
+
# Returned as Arel, not SQL: an STI type condition carries a bind
|
|
166
|
+
# parameter, and only the outer relation can render it.
|
|
167
|
+
Arel::Nodes::Grouping.new(subquery.take(1).ast)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def project(build, expression, output_alias)
|
|
171
|
+
build.projections << Arel::Nodes::As.new(expression, Arel.sql(quote_alias(output_alias)))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# EXISTS (SELECT 1 FROM photos WHERE photos.user_id = users.id LIMIT 1 OFFSET 0)
|
|
175
|
+
#
|
|
176
|
+
# The `LIMIT 1 OFFSET 0` is not decoration. Left alone, PostgreSQL turns a
|
|
177
|
+
# correlated EXISTS in the SELECT list into a hashed subplan - one full scan
|
|
178
|
+
# of the child table - which dominates a paged query. `OFFSET 0` is the
|
|
179
|
+
# standard optimisation fence: the plan becomes an index probe per row that
|
|
180
|
+
# stops at the first match. It is a no-op everywhere else, and valid on
|
|
181
|
+
# PostgreSQL, MySQL and SQLite. benchmarks/ has the numbers.
|
|
182
|
+
def exists_expression(build, node, owner_table)
|
|
183
|
+
subquery =
|
|
184
|
+
if node.relation_scoped?
|
|
185
|
+
correlated_subquery(build, node, owner_table) { Arel.sql("1") }
|
|
186
|
+
else
|
|
187
|
+
table = subquery_table(build, node.association)
|
|
188
|
+
manager = Arel::SelectManager.new(table)
|
|
189
|
+
.project(Arel.sql("1"))
|
|
190
|
+
.where(node.association.join_condition(table, owner_table))
|
|
191
|
+
restrict_to_sti(manager, node.association.klass, table)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
Arel::Nodes::Exists.new(subquery.take(1).skip(0).ast)
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# (SELECT COUNT(*) FROM videos WHERE videos.user_id = users.id)
|
|
198
|
+
def correlated_aggregate(build, node, owner_table)
|
|
199
|
+
if node.relation_scoped?
|
|
200
|
+
subquery = correlated_subquery(build, node, owner_table) { |table| aggregate_function(node, table) }
|
|
201
|
+
return Arel::Nodes::Grouping.new(subquery.ast)
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
table = subquery_table(build, node.association)
|
|
205
|
+
subquery = Arel::SelectManager.new(table)
|
|
206
|
+
.project(aggregate_function(node, table))
|
|
207
|
+
.where(node.association.join_condition(table, owner_table))
|
|
208
|
+
subquery = restrict_to_sti(subquery, node.association.klass, table)
|
|
209
|
+
|
|
210
|
+
Arel::Nodes::Grouping.new(subquery.ast)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# (SELECT COUNT(*) FROM accounts WHERE accounts.active = TRUE)
|
|
214
|
+
#
|
|
215
|
+
# Conditions are delegated to ActiveRecord, so values are quoted by the
|
|
216
|
+
# adapter - never string-interpolated here.
|
|
217
|
+
def scalar_subquery(node, index = 1)
|
|
218
|
+
return relation_subquery(node, index) if node.relation
|
|
219
|
+
|
|
220
|
+
relation = aggregate_relation(node, node.model.all)
|
|
221
|
+
relation = relation.select(aggregate_function(node, node.model.arel_table))
|
|
222
|
+
|
|
223
|
+
Arel.sql("(#{relation.to_sql})")
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# A scoped correlated subquery reads through a derived table:
|
|
227
|
+
#
|
|
228
|
+
# (SELECT COUNT(*)
|
|
229
|
+
# FROM (SELECT photos.* FROM photos INNER JOIN users ...) pluckr_sub_1
|
|
230
|
+
# WHERE pluckr_sub_1.user_id = users.id)
|
|
231
|
+
#
|
|
232
|
+
# An aggregate over a relation the caller already built. Its SQL is used
|
|
233
|
+
# verbatim, inside a derived table, so whatever ActiveRecord applied -
|
|
234
|
+
# default scopes, association scopes, :through joins, STI conditions -
|
|
235
|
+
# stays applied, and `limit`/`group` keep their meaning.
|
|
236
|
+
#
|
|
237
|
+
# (SELECT COUNT(*) FROM (SELECT videos.* FROM videos WHERE ...) pluckr_batch_1)
|
|
238
|
+
def relation_subquery(node, index)
|
|
239
|
+
name = "#{Aliases::TABLE_PREFIX}_batch_#{index}"
|
|
240
|
+
derived = Arel::Table.new(name)
|
|
241
|
+
from = Arel.sql("(#{node.relation.to_sql}) #{connection.quote_table_name(name)}")
|
|
242
|
+
|
|
243
|
+
return Arel.sql("EXISTS (#{Arel::SelectManager.new.from(from).project(Arel.sql("1")).take(1).to_sql})") if
|
|
244
|
+
node.operation == :exists
|
|
245
|
+
|
|
246
|
+
Arel.sql("(#{Arel::SelectManager.new.from(from).project(aggregate_function(node, derived)).to_sql})")
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# `where:`/`scope:` conditions name the model's real table, which may be the
|
|
250
|
+
# table being correlated to (a self-referential association) or one the scope
|
|
251
|
+
# joins in. Nesting them inside a derived table keeps those names in their own
|
|
252
|
+
# scope, so the correlation always binds to the outer row.
|
|
253
|
+
def correlated_subquery(build, node, owner_table)
|
|
254
|
+
klass = node.association.klass
|
|
255
|
+
inner = aggregate_relation(node, klass.all).select(klass.arel_table[Arel.star])
|
|
256
|
+
name = "#{Aliases::TABLE_PREFIX}_sub_#{build.next_sequence}"
|
|
257
|
+
derived = Arel::Table.new(name)
|
|
258
|
+
|
|
259
|
+
Arel::SelectManager.new
|
|
260
|
+
.from(Arel.sql("(#{inner.to_sql}) #{connection.quote_table_name(name)}"))
|
|
261
|
+
.project(yield(derived))
|
|
262
|
+
.where(node.association.join_condition(derived, owner_table))
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
# Whatever the scope selects, orders or limits is meaningless (or invalid)
|
|
266
|
+
# inside a scalar aggregate subquery.
|
|
267
|
+
def aggregate_relation(node, relation)
|
|
268
|
+
relation = narrow(node, relation)
|
|
269
|
+
reject_row_shaping!(node, relation)
|
|
270
|
+
|
|
271
|
+
relation.unscope(:select, :order)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def reject_row_shaping!(node, relation)
|
|
275
|
+
shaping = { limit: relation.limit_value, offset: relation.offset_value,
|
|
276
|
+
group: relation.group_values.presence }.compact.keys
|
|
277
|
+
return if shaping.empty?
|
|
278
|
+
|
|
279
|
+
raise ConfigurationError,
|
|
280
|
+
"`#{node.output}`: `scope:` may not use #{shaping.join("/")} - the subquery must " \
|
|
281
|
+
"return exactly one value"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def narrow(node, relation)
|
|
285
|
+
relation = apply_scope(node, relation)
|
|
286
|
+
conditions = node.resolved_where
|
|
287
|
+
|
|
288
|
+
conditions ? relation.where(conditions) : relation
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
def apply_scope(node, relation)
|
|
292
|
+
return relation unless node.scope
|
|
293
|
+
|
|
294
|
+
zero_arity = node.scope.respond_to?(:arity) && node.scope.arity.zero?
|
|
295
|
+
scoped = zero_arity ? node.scope.call : node.scope.call(relation)
|
|
296
|
+
|
|
297
|
+
unless scoped.is_a?(ActiveRecord::Relation) && scoped.klass == relation.klass
|
|
298
|
+
# Never `inspect` a relation here - that would run a query while raising
|
|
299
|
+
# a configuration error.
|
|
300
|
+
got = scoped.is_a?(ActiveRecord::Relation) ? "a relation for #{scoped.klass.name}" : scoped.inspect
|
|
301
|
+
|
|
302
|
+
raise ConfigurationError,
|
|
303
|
+
"`#{node.output}`: `scope:` must return an ActiveRecord::Relation for " \
|
|
304
|
+
"#{relation.klass.name}, got #{got}"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
scoped
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def aggregate_function(node, table)
|
|
311
|
+
case node.operation
|
|
312
|
+
when :count
|
|
313
|
+
node.column ? Arel::Nodes::Count.new([table[node.column]]) : Arel::Nodes::Count.new([Arel.star])
|
|
314
|
+
when :sum then Arel::Nodes::Sum.new([table[node.column]])
|
|
315
|
+
when :avg then Arel::Nodes::Avg.new([table[node.column]])
|
|
316
|
+
when :min then Arel::Nodes::Min.new([table[node.column]])
|
|
317
|
+
when :max then Arel::Nodes::Max.new([table[node.column]])
|
|
318
|
+
else
|
|
319
|
+
raise Error, "unsupported aggregate operation #{node.operation}"
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def restrict_to_sti(subquery, klass, table)
|
|
324
|
+
condition = sti_condition(klass, table)
|
|
325
|
+
condition ? subquery.where(condition) : subquery
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Single table inheritance: an association to an STI child must be
|
|
329
|
+
# restricted to its own types. `type_condition` accepts the aliased table,
|
|
330
|
+
# which is why Pluckr uses it instead of rebuilding the condition.
|
|
331
|
+
def sti_condition(klass, table)
|
|
332
|
+
return nil unless klass.finder_needs_type_condition?
|
|
333
|
+
|
|
334
|
+
klass.send(:type_condition, table)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def subquery_table(build, association)
|
|
338
|
+
association.klass.arel_table.alias("#{Aliases::TABLE_PREFIX}_sub_#{build.next_sequence}")
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def quote_alias(name)
|
|
342
|
+
name = name.to_s
|
|
343
|
+
limit = connection.max_identifier_length
|
|
344
|
+
|
|
345
|
+
if name.bytesize > limit
|
|
346
|
+
raise ConfigurationError,
|
|
347
|
+
"output alias `#{name}` is #{name.bytesize} bytes, over this database's " \
|
|
348
|
+
"#{limit}-byte identifier limit - shorten it with `as:`, or rename the " \
|
|
349
|
+
"enclosing `one` nodes if it is a nested path"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
connection.quote_column_name(name)
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
def connection_model
|
|
356
|
+
@connection_model ||=
|
|
357
|
+
source_model ||
|
|
358
|
+
schema.nodes.grep(Schema::Aggregate).map(&:target_model).first ||
|
|
359
|
+
ActiveRecord::Base
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
# Base class for every error raised by Pluckr.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a query is defined or used incorrectly.
|
|
8
|
+
class ConfigurationError < Error; end
|
|
9
|
+
|
|
10
|
+
# `field :nope` where the model has no such column.
|
|
11
|
+
class UnknownField < ConfigurationError; end
|
|
12
|
+
|
|
13
|
+
# `one :nope` / `exists :nope` where the model has no such association.
|
|
14
|
+
class UnknownAssociation < ConfigurationError; end
|
|
15
|
+
|
|
16
|
+
# Association exists but has the wrong cardinality for the DSL node used.
|
|
17
|
+
class InvalidAssociation < ConfigurationError; end
|
|
18
|
+
|
|
19
|
+
# Association exists but Pluckr cannot compile it yet (polymorphic, through, scoped).
|
|
20
|
+
class UnsupportedAssociation < ConfigurationError; end
|
|
21
|
+
|
|
22
|
+
# A root-record node was used in a query without `source`.
|
|
23
|
+
class MissingSource < ConfigurationError; end
|
|
24
|
+
end
|