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.
@@ -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