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
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
# Immutable, chainable wrapper around the root ActiveRecord relation.
|
|
5
|
+
#
|
|
6
|
+
# Root filtering/ordering/pagination is delegated to ActiveRecord - Pluckr
|
|
7
|
+
# only adds its projections and joins when the query is compiled.
|
|
8
|
+
class Relation
|
|
9
|
+
include Enumerable
|
|
10
|
+
|
|
11
|
+
attr_reader :query_class, :relation
|
|
12
|
+
|
|
13
|
+
def initialize(query_class, relation)
|
|
14
|
+
@query_class = query_class
|
|
15
|
+
@relation = relation
|
|
16
|
+
freeze
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# `where` with no arguments returns a chain, so `where.not(...)` works like
|
|
20
|
+
# it does on an ActiveRecord relation.
|
|
21
|
+
def where(*args, **options, &block)
|
|
22
|
+
return WhereChain.new(self) if args.empty? && options.empty? && block.nil?
|
|
23
|
+
|
|
24
|
+
spawn(relation.where(*args, **options, &block))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def order(...)
|
|
28
|
+
spawn(relation.order(...))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def limit(value)
|
|
32
|
+
spawn(relation.limit(value))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def offset(value)
|
|
36
|
+
spawn(relation.offset(value))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def to_sql
|
|
40
|
+
query_class.compiler.apply(relation).to_sql
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# One statement, one round trip, plain result objects.
|
|
44
|
+
def fetch
|
|
45
|
+
builder = query_class.result_builder
|
|
46
|
+
rows = Pluckr.select_all(query_class.compiler.connection, to_sql, name: query_class.name)
|
|
47
|
+
rows.map { |row| builder.build(row) }
|
|
48
|
+
end
|
|
49
|
+
alias to_a fetch
|
|
50
|
+
|
|
51
|
+
def each(&block)
|
|
52
|
+
fetch.each(&block)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Enumerable would answer these by building every result object first, which
|
|
56
|
+
# is the opposite of the point. They are questions about the root rows, so
|
|
57
|
+
# ActiveRecord answers them with `COUNT(*)`/`SELECT 1`, without Pluckr's
|
|
58
|
+
# projections.
|
|
59
|
+
# A block (or an Enumerable pattern) is a question about the results, so it
|
|
60
|
+
# goes back to Enumerable - exactly where ActiveRecord sends it.
|
|
61
|
+
def count(*args)
|
|
62
|
+
return super if block_given?
|
|
63
|
+
|
|
64
|
+
relation.count(*args)
|
|
65
|
+
end
|
|
66
|
+
alias size count
|
|
67
|
+
|
|
68
|
+
def exists?(...)
|
|
69
|
+
relation.exists?(...)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def any?(*args)
|
|
73
|
+
return super if args.any? || block_given?
|
|
74
|
+
|
|
75
|
+
exists?
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def none?(*args)
|
|
79
|
+
return super if args.any? || block_given?
|
|
80
|
+
|
|
81
|
+
!exists?
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def empty?
|
|
85
|
+
!exists?
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def explain(...)
|
|
89
|
+
query_class.compiler.apply(relation).explain(...)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# ActiveRecord orders by primary key when the relation has no order of its
|
|
93
|
+
# own, so that `first` means something. Same here.
|
|
94
|
+
def first(limit = nil)
|
|
95
|
+
results = ordered_by_primary_key.limit(within_limit(limit || 1)).fetch
|
|
96
|
+
|
|
97
|
+
limit ? results : results.first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def first!
|
|
101
|
+
first || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# The other end of the same order, read in one `LIMIT` rather than by
|
|
105
|
+
# hydrating the chain and dropping all but the tail. A chain that already
|
|
106
|
+
# pages is the exception ActiveRecord makes too: its own `limit` cannot be
|
|
107
|
+
# replaced by ours, so the page is read and the tail taken from it.
|
|
108
|
+
def last(limit = nil)
|
|
109
|
+
return last_of_page(limit) if paginated?(relation)
|
|
110
|
+
|
|
111
|
+
results = spawn(ordered_by_primary_key.relation.reverse_order).limit(limit || 1).fetch
|
|
112
|
+
|
|
113
|
+
limit ? results.reverse : results.first
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def last!
|
|
117
|
+
last || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Enumerable's `take` would read the whole chain to keep the front of it.
|
|
121
|
+
def take(limit = nil)
|
|
122
|
+
results = self.limit(limit || 1).fetch
|
|
123
|
+
|
|
124
|
+
limit ? results : results.first
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def take!
|
|
128
|
+
take || raise(ActiveRecord::RecordNotFound, "Couldn't find #{model.name}")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Two rows is all it takes to answer either question - unless the chain
|
|
132
|
+
# already limits itself to fewer, in which case that limit is the answer.
|
|
133
|
+
def one?(*args)
|
|
134
|
+
return super if args.any? || block_given?
|
|
135
|
+
|
|
136
|
+
limited_count == 1
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def many?(*args)
|
|
140
|
+
return super if args.any? || block_given?
|
|
141
|
+
|
|
142
|
+
limited_count > 1
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# `find` is the primary key lookup; a block is Enumerable's `find`, which is
|
|
146
|
+
# where ActiveRecord sends it too. Several ids (or an array of them) come
|
|
147
|
+
# back as an array, in the order they were asked for.
|
|
148
|
+
def find(*args, &block)
|
|
149
|
+
return super if block
|
|
150
|
+
raise ArgumentError, "wrong number of arguments (given 0, expected 1+)" if args.empty?
|
|
151
|
+
|
|
152
|
+
key = single_primary_key
|
|
153
|
+
ids = args.size == 1 ? args.first : args
|
|
154
|
+
return for_ids(ids, key) if ids.is_a?(Array)
|
|
155
|
+
raise ActiveRecord::RecordNotFound, "Couldn't find #{model.name} without an ID" if ids.nil?
|
|
156
|
+
|
|
157
|
+
where(key => ids).first || raise(not_found(key, ids))
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The arity is ActiveRecord's: at least one condition, or `ArgumentError`.
|
|
161
|
+
def find_by(arg, *args)
|
|
162
|
+
where(arg, *args).first
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def find_by!(arg, *args)
|
|
166
|
+
find_by(arg, *args) ||
|
|
167
|
+
raise(ActiveRecord::RecordNotFound,
|
|
168
|
+
"Couldn't find #{model.name} with #{[arg, *args].map(&:inspect).join(", ")}")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Reads the whole chain in pages, so an export does not build every result
|
|
172
|
+
# object at once. Like ActiveRecord's, the batches are ordered by primary
|
|
173
|
+
# key and any order of your own is ignored - unlike ActiveRecord's, the page
|
|
174
|
+
# after the first is a keyset seek (`id > last`), never an OFFSET.
|
|
175
|
+
def find_each(batch_size: 1_000, &block)
|
|
176
|
+
assert_batchable!(batch_size)
|
|
177
|
+
return to_enum(:find_each, batch_size: batch_size) unless block_given?
|
|
178
|
+
|
|
179
|
+
in_batches(of: batch_size) { |results| results.each(&block) }
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def in_batches(of: 1_000)
|
|
183
|
+
assert_batchable!(of)
|
|
184
|
+
return to_enum(:in_batches, of: of) unless block_given?
|
|
185
|
+
|
|
186
|
+
key = single_primary_key
|
|
187
|
+
output = primary_key_output(key)
|
|
188
|
+
scope = spawn(relation.reorder(key => :asc))
|
|
189
|
+
cursor = nil
|
|
190
|
+
|
|
191
|
+
loop do
|
|
192
|
+
page = cursor ? scope.where(model.arel_table[key].gt(cursor)) : scope
|
|
193
|
+
results = page.limit(of).fetch
|
|
194
|
+
break if results.empty?
|
|
195
|
+
|
|
196
|
+
yield results
|
|
197
|
+
break if results.size < of
|
|
198
|
+
|
|
199
|
+
cursor = results.last.public_send(output)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# See Query.for. An unloaded Relation is filtered in SQL (subquery), never
|
|
204
|
+
# loaded into Ruby; a loaded one already has its records, so they are used.
|
|
205
|
+
# A paginated one is resolved to primary keys first - see
|
|
206
|
+
# for_paginated_relation.
|
|
207
|
+
def for(records)
|
|
208
|
+
key = single_primary_key
|
|
209
|
+
|
|
210
|
+
if records.is_a?(self.class)
|
|
211
|
+
raise ConfigurationError, "`for` expects #{model.name} records, not another Pluckr query; use `fetch`"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
return for_relation(records, key) if records.is_a?(ActiveRecord::Relation) && !records.loaded?
|
|
215
|
+
return for_many(records.to_a, key) if records.is_a?(Enumerable)
|
|
216
|
+
|
|
217
|
+
for_one(records, key)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def inspect
|
|
221
|
+
"#<Pluckr::Relation #{query_class.name} #{to_sql}>"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def spawn(new_relation)
|
|
225
|
+
self.class.new(query_class, new_relation)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# Mirrors ActiveRecord::QueryMethods::WhereChain for the operations Pluckr
|
|
229
|
+
# supports on the root relation.
|
|
230
|
+
class WhereChain
|
|
231
|
+
def initialize(pluckr_relation)
|
|
232
|
+
@pluckr_relation = pluckr_relation
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def not(...)
|
|
236
|
+
@pluckr_relation.spawn(@pluckr_relation.relation.where.not(...))
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def model
|
|
243
|
+
query_class.source_model
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Reading the front of a page cannot reach past its end, the way
|
|
247
|
+
# ActiveRecord's `find_nth_with_limit` clamps.
|
|
248
|
+
def within_limit(wanted)
|
|
249
|
+
relation.limit_value ? [relation.limit_value, wanted].min : wanted
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Both mirror ActiveRecord: a `limit` of the caller's own is never widened
|
|
253
|
+
# to make an answer easier to reach.
|
|
254
|
+
def limited_count
|
|
255
|
+
relation.limit_value ? relation.count : relation.limit(2).count
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def last_of_page(limit)
|
|
259
|
+
results = ordered_by_primary_key.fetch
|
|
260
|
+
|
|
261
|
+
limit ? results.last(limit) : results.last
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Qualified, because `one` nodes join tables that have an `id` too, and per
|
|
265
|
+
# column, because a primary key can be composite.
|
|
266
|
+
def ordered_by_primary_key
|
|
267
|
+
return self unless relation.order_values.empty?
|
|
268
|
+
|
|
269
|
+
order(*Array(model.primary_key).map { |key| model.arel_table[key].asc })
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def single_primary_key
|
|
273
|
+
key = model.primary_key
|
|
274
|
+
if key.is_a?(Array)
|
|
275
|
+
raise ConfigurationError,
|
|
276
|
+
"#{model.name} has a composite primary key, which Pluckr cannot look a row up by; " \
|
|
277
|
+
"use `where(...)` with `fetch`/`first` instead"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
key
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def for_one(record, key)
|
|
284
|
+
assert_record!(record)
|
|
285
|
+
find(record[key])
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def for_many(records, key)
|
|
289
|
+
records.each { |record| assert_record!(record) }
|
|
290
|
+
for_ids(records.map { |record| record[key] }, key)
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
# Records the caller handed over must all come back - a missing one is a
|
|
294
|
+
# deleted record, and silence would hide it. A relation is a set of
|
|
295
|
+
# conditions instead, so rows the query itself filters out are simply not
|
|
296
|
+
# part of the answer, exactly as they are not for an unpaginated one.
|
|
297
|
+
def for_ids(ids, key, strict: true)
|
|
298
|
+
return [] if ids.empty?
|
|
299
|
+
|
|
300
|
+
index = index_by_primary_key(where(key => ids).fetch, key)
|
|
301
|
+
return ids.filter_map { |id| index[id] } unless strict
|
|
302
|
+
|
|
303
|
+
ids.map { |id| index[id] || raise(not_found(key, id)) }
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# A relation only contributes its conditions here: `select`/`order` and the
|
|
307
|
+
# preloading of rows Pluckr never instantiates are dropped, and `lock` is
|
|
308
|
+
# not carried into a read model. What cannot be dropped without changing
|
|
309
|
+
# which rows come back has to be loaded by the caller instead.
|
|
310
|
+
UNUSED_RELATION_VALUES = %i[select includes eager_load preload order lock].freeze
|
|
311
|
+
|
|
312
|
+
# A paginated relation keeps its order, so it cannot lose it to a subquery -
|
|
313
|
+
# nor to the joins that order may depend on. Only the projection and the
|
|
314
|
+
# lock go; `pluck` ignores a preload it does not need.
|
|
315
|
+
PAGINATED_RELATION_VALUES = %i[select lock].freeze
|
|
316
|
+
|
|
317
|
+
def for_relation(records, key)
|
|
318
|
+
unless records.klass <= model
|
|
319
|
+
raise ConfigurationError,
|
|
320
|
+
"`for` expects a relation for #{model.name}, got #{records.klass}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
assert_subqueryable!(records)
|
|
324
|
+
return for_paginated_relation(records, key) if paginated?(records)
|
|
325
|
+
scope = records.except(*UNUSED_RELATION_VALUES)
|
|
326
|
+
# `offset(0)` skips nothing, but ActiveRecord still emits a LIMIT for it,
|
|
327
|
+
# and MySQL cannot subquery a LIMIT at all.
|
|
328
|
+
scope = scope.except(:offset) if scope.offset_value&.zero?
|
|
329
|
+
|
|
330
|
+
where(key => scope.select(key)).fetch
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def paginated?(records)
|
|
334
|
+
records.limit_value || records.offset_value.to_i.positive?
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Which rows a page holds depends on the order the subquery cannot carry -
|
|
338
|
+
# and MySQL rejects `IN (SELECT ... LIMIT ...)` outright. So the page is
|
|
339
|
+
# resolved to primary keys first: one extra, narrow statement, and the page
|
|
340
|
+
# and its order are the relation's own. See `page_keys` for the one shape
|
|
341
|
+
# that has to be loaded to be paged at all.
|
|
342
|
+
def for_paginated_relation(records, key)
|
|
343
|
+
scope = records.except(*PAGINATED_RELATION_VALUES)
|
|
344
|
+
|
|
345
|
+
for_ids(page_keys(scope, key), key, strict: false)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
# A relation that joins to preload counts its `LIMIT` in parent rows, and
|
|
349
|
+
# only ActiveRecord knows how to page that - `pluck` would join first and
|
|
350
|
+
# cut the page mid fan-out, handing back duplicates and too many of them.
|
|
351
|
+
# So that page is resolved the way ActiveRecord resolves it, by loading it;
|
|
352
|
+
# the caller asked for preloaded records anyway. Every other relation keeps
|
|
353
|
+
# the cheap read: keys only, nothing instantiated.
|
|
354
|
+
def page_keys(scope, key)
|
|
355
|
+
return scope.pluck(key) unless scope.eager_loading? || scope.includes_values.any?
|
|
356
|
+
|
|
357
|
+
scope.map { |record| record[key] }
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# A grouped relation selects the primary key it does not group by, which is
|
|
361
|
+
# not valid SQL - as a subquery or plucked.
|
|
362
|
+
def assert_subqueryable!(records)
|
|
363
|
+
return if records.group_values.empty?
|
|
364
|
+
|
|
365
|
+
raise ConfigurationError,
|
|
366
|
+
"`for` cannot subquery a relation with group/having; pass the records themselves: " \
|
|
367
|
+
"`for(relation.to_a)`"
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Batching owns the ordering and the paging, so a chain that already pages
|
|
371
|
+
# would silently get a different answer than `fetch` gives. A page size that
|
|
372
|
+
# is not a positive integer reads nothing at all, or reads everything.
|
|
373
|
+
def assert_batchable!(size)
|
|
374
|
+
unless size.is_a?(Integer) && size.positive?
|
|
375
|
+
raise ArgumentError, "page size must be a positive integer, got #{size.inspect}"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
return unless paginated?(relation)
|
|
379
|
+
|
|
380
|
+
raise ConfigurationError,
|
|
381
|
+
"`find_each`/`in_batches` page the whole chain themselves; " \
|
|
382
|
+
"drop the `limit`/`offset` or use `fetch`"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def not_found(key, id)
|
|
386
|
+
ActiveRecord::RecordNotFound.new(
|
|
387
|
+
"Couldn't find #{model.name} with '#{key}'=#{id}", model.name, key, id
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def index_by_primary_key(results, key)
|
|
392
|
+
output = primary_key_output(key)
|
|
393
|
+
results.to_h { |row| [row[output], row] }
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
# Bulk `.for` reorders rows to match the input and batching seeks past the
|
|
397
|
+
# last one read; both need the primary key on the result.
|
|
398
|
+
def primary_key_output(key)
|
|
399
|
+
field = query_class.pluckr_schema.fields.find { |node| node.column.to_s == key.to_s }
|
|
400
|
+
return field.output if field
|
|
401
|
+
|
|
402
|
+
raise ConfigurationError,
|
|
403
|
+
"#{query_class.name} needs `field :#{key}` (or `field :#{key}, as: ...`) " \
|
|
404
|
+
"to align several results with the rows they came from"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def assert_record!(record)
|
|
408
|
+
return if record.is_a?(model)
|
|
409
|
+
|
|
410
|
+
got = record.is_a?(ActiveRecord::Base) ? record.class.name : record.inspect
|
|
411
|
+
raise ConfigurationError, "`for` expects #{model.name} records, got #{got}"
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Result
|
|
5
|
+
# Turns one flat database row into nested result objects.
|
|
6
|
+
#
|
|
7
|
+
# Result classes and per-column casters are built once per schema level when
|
|
8
|
+
# the builder is created, so hydration itself is hash lookups plus a cast.
|
|
9
|
+
class Builder
|
|
10
|
+
BOOLEAN = ActiveModel::Type::Boolean.new
|
|
11
|
+
INTEGER = ActiveModel::Type::Integer.new
|
|
12
|
+
# AVG of an integer column is not an integer; ActiveRecord answers a
|
|
13
|
+
# BigDecimal here and so does Pluckr.
|
|
14
|
+
DECIMAL = ActiveRecord::Type::Decimal.new
|
|
15
|
+
|
|
16
|
+
# Casts one column of a row. `default` covers SQL returning NULL where the
|
|
17
|
+
# equivalent ActiveRecord call would not: SUM over no rows is NULL in SQL
|
|
18
|
+
# and 0 in `relation.sum(:column)`.
|
|
19
|
+
Caster = Struct.new(:type, :default) do
|
|
20
|
+
def call(value)
|
|
21
|
+
return default if value.nil?
|
|
22
|
+
|
|
23
|
+
type ? type.deserialize(value) : value
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(schema, label: "Pluckr::Result")
|
|
28
|
+
@schema = schema
|
|
29
|
+
@classes = {}
|
|
30
|
+
@casters = {}
|
|
31
|
+
prepare(schema, label)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param row [Hash] flat row keyed by SQL alias
|
|
35
|
+
def build(row)
|
|
36
|
+
hydrate(@schema, [], row)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def prepare(scope, label)
|
|
42
|
+
@classes[scope] = Object.build_class(scope.outputs, label)
|
|
43
|
+
@casters[scope] = scope.nodes.to_h { |node| [node.output, caster_for(node, scope.model)] }
|
|
44
|
+
|
|
45
|
+
scope.ones.each do |node|
|
|
46
|
+
prepare(node.scope, "#{label}.#{node.output}")
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# SQLite and MySQL hand back 1/0 where PostgreSQL hands back true/false,
|
|
51
|
+
# and raw SQL carries no column type, so Pluckr casts values itself.
|
|
52
|
+
def caster_for(node, model)
|
|
53
|
+
case node
|
|
54
|
+
when Schema::Field then Caster.new(model.type_for_attribute(node.column))
|
|
55
|
+
when Schema::Exists then Caster.new(BOOLEAN)
|
|
56
|
+
when Schema::Aggregate then aggregate_caster(node)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def aggregate_caster(node)
|
|
61
|
+
case node.operation
|
|
62
|
+
when :count then Caster.new(INTEGER)
|
|
63
|
+
when :exists then Caster.new(BOOLEAN)
|
|
64
|
+
when :avg then Caster.new(DECIMAL) # nil for no rows, like `relation.average`
|
|
65
|
+
else
|
|
66
|
+
type = node.target_model.type_for_attribute(node.column)
|
|
67
|
+
# `sum` answers 0 for no rows, like ActiveRecord; `min`/`max` stay nil,
|
|
68
|
+
# because no row means no minimum.
|
|
69
|
+
Caster.new(type, node.operation == :sum ? type.cast(0) : nil)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def hydrate(scope, path, row)
|
|
74
|
+
casters = @casters[scope]
|
|
75
|
+
attributes = {}
|
|
76
|
+
|
|
77
|
+
scope.nodes.each do |node|
|
|
78
|
+
attributes[node.output] =
|
|
79
|
+
if node.is_a?(Schema::One)
|
|
80
|
+
hydrate_one(node, path, row)
|
|
81
|
+
else
|
|
82
|
+
casters[node.output].call(row[Aliases.output(path, node.output)])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
@classes[scope].new(attributes)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def hydrate_one(node, path, row)
|
|
90
|
+
child_path = path + [node.output.to_s]
|
|
91
|
+
# No presence marker -> no associated row at all (as opposed to a row
|
|
92
|
+
# whose selected columns happen to be NULL).
|
|
93
|
+
return nil if row[Aliases.presence(child_path)].nil?
|
|
94
|
+
|
|
95
|
+
hydrate(node.scope, child_path, row)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Result
|
|
5
|
+
# Immutable, ActiveRecord-free value object.
|
|
6
|
+
#
|
|
7
|
+
# One subclass is generated per schema level, with a plain reader per
|
|
8
|
+
# selected output. Internal aliases and presence markers never reach here.
|
|
9
|
+
class Object
|
|
10
|
+
# @param outputs [Array<Symbol>] readers this level exposes
|
|
11
|
+
# @param label [String] shown by #inspect
|
|
12
|
+
def self.build_class(outputs, label)
|
|
13
|
+
Class.new(self) do
|
|
14
|
+
define_singleton_method(:pluckr_outputs) { outputs }
|
|
15
|
+
define_singleton_method(:pluckr_label) { label }
|
|
16
|
+
|
|
17
|
+
outputs.each do |output|
|
|
18
|
+
define_method(output) { @attributes[output] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.pluckr_outputs
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def self.pluckr_label
|
|
28
|
+
"Pluckr::Result"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(attributes)
|
|
32
|
+
@attributes = attributes.freeze
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def [](key)
|
|
37
|
+
@attributes[key.to_sym]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
@attributes.transform_values do |value|
|
|
42
|
+
value.is_a?(Object) ? value.to_h : value
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
alias to_hash to_h
|
|
46
|
+
|
|
47
|
+
# Without this, ActiveSupport serialises the object's ivars and
|
|
48
|
+
# `render json: result` emits {"attributes": {...}}. `to_json` is left to
|
|
49
|
+
# ActiveSupport, which routes it back here with the caller's options.
|
|
50
|
+
def as_json(options = nil)
|
|
51
|
+
to_h.as_json(options)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def keys
|
|
55
|
+
@attributes.keys
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def ==(other)
|
|
59
|
+
other.class == self.class && other.to_h == to_h
|
|
60
|
+
end
|
|
61
|
+
alias eql? ==
|
|
62
|
+
|
|
63
|
+
def hash
|
|
64
|
+
[self.class, to_h].hash
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def inspect
|
|
68
|
+
pairs = @attributes.map { |key, value| "#{key}=#{value.inspect}" }
|
|
69
|
+
"#<#{self.class.pluckr_label} #{pairs.join(", ")}>"
|
|
70
|
+
end
|
|
71
|
+
alias to_s inspect
|
|
72
|
+
|
|
73
|
+
def pretty_print(pp)
|
|
74
|
+
pp.text(inspect)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Schema
|
|
5
|
+
# Two flavours share this node:
|
|
6
|
+
#
|
|
7
|
+
# count :videos # correlated: association is set
|
|
8
|
+
# count :users, from: User # independent: model is set
|
|
9
|
+
#
|
|
10
|
+
# Both compile to a scalar subquery in the SELECT list, never to a JOIN +
|
|
11
|
+
# GROUP BY, so root rows are never multiplied.
|
|
12
|
+
class Aggregate < Node
|
|
13
|
+
include Conditions
|
|
14
|
+
|
|
15
|
+
OPERATIONS = %i[count sum avg min max exists].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :operation, :association, :model, :relation, :column, :where, :scope
|
|
18
|
+
|
|
19
|
+
def initialize(output:, operation:, association: nil, model: nil, relation: nil,
|
|
20
|
+
column: nil, where: nil, scope: nil)
|
|
21
|
+
super(output: output)
|
|
22
|
+
@operation = operation.to_sym
|
|
23
|
+
unless OPERATIONS.include?(@operation)
|
|
24
|
+
raise ConfigurationError, "unsupported aggregate operation `#{operation}`"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@association = association
|
|
28
|
+
@model = model
|
|
29
|
+
@relation = relation
|
|
30
|
+
@column = column&.to_sym
|
|
31
|
+
@where = where.respond_to?(:call) ? where : where&.dup&.freeze
|
|
32
|
+
@scope = scope
|
|
33
|
+
freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Independent aggregates are not rooted in the source record.
|
|
37
|
+
def independent?
|
|
38
|
+
!model.nil? || !relation.nil?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def correlated?
|
|
42
|
+
!association.nil?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# True when the subquery must be built from an ActiveRecord relation
|
|
46
|
+
# rather than straight Arel.
|
|
47
|
+
def relation_scoped?
|
|
48
|
+
!scope.nil? || !where.nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# Target model for column validation / table resolution.
|
|
53
|
+
def target_model
|
|
54
|
+
model || relation&.klass || association.klass
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pluckr
|
|
4
|
+
module Schema
|
|
5
|
+
# `where:` on an aggregate or existence node.
|
|
6
|
+
#
|
|
7
|
+
# A Hash is validated once, when the schema is defined, and is therefore
|
|
8
|
+
# frozen at load time - `where: { created_at: 1.week.ago.. }` would keep
|
|
9
|
+
# asking about the week before boot, forever.
|
|
10
|
+
#
|
|
11
|
+
# A callable is invoked on every compilation instead, so runtime values stay
|
|
12
|
+
# runtime values:
|
|
13
|
+
#
|
|
14
|
+
# count :recent_orders, from: Order, where: -> { { created_at: 1.week.ago.. } }
|
|
15
|
+
module Conditions
|
|
16
|
+
def dynamic_where?
|
|
17
|
+
where.respond_to?(:call)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Hash, nil] the conditions to apply to this compilation
|
|
21
|
+
def resolved_where
|
|
22
|
+
return where unless dynamic_where?
|
|
23
|
+
|
|
24
|
+
conditions = where.call
|
|
25
|
+
|
|
26
|
+
unless conditions.is_a?(Hash)
|
|
27
|
+
raise ConfigurationError,
|
|
28
|
+
"`#{output}`: a `where:` lambda must return a Hash of column => value, " \
|
|
29
|
+
"got #{conditions.inspect}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
conditions.each_key { |key| validate_column!(key) }
|
|
33
|
+
conditions
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def validate_column!(name)
|
|
39
|
+
return if target_model.column_names.include?(name.to_s)
|
|
40
|
+
|
|
41
|
+
raise UnknownField, "#{target_model.name} does not have column `#{name}`"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|