appquery 0.8.0 β 0.9.0.rc1
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 +4 -4
- data/.worktree.yml.example +40 -0
- data/CHANGELOG.md +20 -0
- data/README.md +111 -1
- data/lib/app_query/base_query.rb +2 -1
- data/lib/app_query/mappable.rb +10 -21
- data/lib/app_query/tokenizer.rb +7 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +93 -13
- data/mise.local.toml.example +2 -2
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: de70b2690d884b36f8d29f8a2e76c7edefba25ca9f9fcc9785e70389c35f3aa0
|
|
4
|
+
data.tar.gz: 3c81f2fb6dd4679a86a99df91485df234ccfd449c6de0cb5317c6ca8f8e5751b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e2a0d5f7c2a320426cf292e4f5afdcbe8ca9d499eca85cb8139d7c44d0dc2498443fc70853bc274c6c912e9753ee7a176ad552d9e4f6fe0d0f1adc4d2ad00bba
|
|
7
|
+
data.tar.gz: 7a038760466d79804b06e64ba18d6161a2070fe728b2ec529a1dbd62c04729866b8af6def94f9dafcec7965d060710c46bbe3e874b6cb67df2e32413a4a62cc1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Worktree configuration for bonchi.
|
|
2
|
+
# See https://github.com/eval/bonchi
|
|
3
|
+
|
|
4
|
+
# Minimum bonchi version required.
|
|
5
|
+
# min_version: 0.6.0
|
|
6
|
+
|
|
7
|
+
# Files to copy from the main worktree before setup.
|
|
8
|
+
copy:
|
|
9
|
+
- mise.toml
|
|
10
|
+
- mise.local.toml
|
|
11
|
+
- _irbrc
|
|
12
|
+
- bin/worktree-dev
|
|
13
|
+
|
|
14
|
+
# Files to symlink from the main worktree (useful for large directories).
|
|
15
|
+
# link:
|
|
16
|
+
# - node_modules
|
|
17
|
+
|
|
18
|
+
# Env var names to allocate unique ports for (from global pool).
|
|
19
|
+
# ports:
|
|
20
|
+
# - PORT
|
|
21
|
+
|
|
22
|
+
# Regex replacements in copied files. Env vars ($VAR) are expanded.
|
|
23
|
+
# Short form:
|
|
24
|
+
# replace:
|
|
25
|
+
# .env.local:
|
|
26
|
+
# - "^PORT=.*": "PORT=$PORT"
|
|
27
|
+
# Full form (with optional missing: warn, default: halt):
|
|
28
|
+
# replace:
|
|
29
|
+
# .env.local:
|
|
30
|
+
# - match: "^PORT=.*"
|
|
31
|
+
# with: "PORT=$PORT"
|
|
32
|
+
# missing: warn
|
|
33
|
+
|
|
34
|
+
# Commands to run before the setup command (port env vars are available).
|
|
35
|
+
pre_setup:
|
|
36
|
+
- mise trust
|
|
37
|
+
- bin/setup
|
|
38
|
+
|
|
39
|
+
# The setup command to run (default: bin/setup).
|
|
40
|
+
setup: bin/worktree-dev
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### π₯ Breaking Changes
|
|
4
|
+
|
|
5
|
+
- β οΈ **`AppQuery::Mappable` extension API changed.**
|
|
6
|
+
Row-level middleware now appends a transformer to the underlying `Q`'s `row_builder` pipeline via an overridden `#query`, instead of overriding `select_all`/`select_one`. The previous pattern of overriding those two methods will silently do nothing on row-returning paths it didn't cover (`entries`, `first`, `last`, `take`, `with_select(non_nil).first`, β¦). Any custom middleware that overrode `select_all`/`select_one` should migrate to:
|
|
7
|
+
```ruby
|
|
8
|
+
def query
|
|
9
|
+
@query ||= super.tap { |q| q.row_builder << method(:build_row) }
|
|
10
|
+
end
|
|
11
|
+
```
|
|
12
|
+
- β οΈ **`Q#column` now raises `ArgumentError` for unknown columns.**
|
|
13
|
+
Previously, on SQLite, `q.column(:typo)` silently returned a row per record containing the *string* `"typo"` (the SQLite "double-quoted strings are identifiers OR string literals" quirk masked the missing column). It now pre-validates against `column_names` and raises with the available column list β consistently across SQLite and PostgreSQL.
|
|
14
|
+
|
|
15
|
+
### β¨ Features
|
|
16
|
+
|
|
17
|
+
- π§© **`AppQuery::RowBuilder`** β composable pipeline of row transformers exposed as `Q#row_builder`. Append with `q.row_builder << callable`; transformers run in registration order. Multiple row-level middlewares stack cleanly in `include` order. The pipeline is applied everywhere `Q` exposes rows (`entries`, `first`, `last`, `take`, `take_last`, `with_select(...).first`, β¦) and is independently copied across `deep_dup` so chained queries don't mutate their parent.
|
|
18
|
+
- π― **`Mappable` is now one method.** Maps everywhere β including `entries`, `last`, `take(n)`, `with_select("β¦").first` paths that previously slipped through. `raw` bypass still works.
|
|
19
|
+
- π **`Q#column` typo protection** β see breaking-change note above.
|
|
20
|
+
- π **Comments inside CTE selects** no longer break tokenization; the whole `(SELECT β¦ -- foo β¦ )` is preserved as a single `CTE_SELECT` token.
|
|
21
|
+
- Publishing gem requires MFA
|
|
22
|
+
|
|
3
23
|
## 0.8.0
|
|
4
24
|
|
|
5
25
|
**Releasedate**: 14-1-2026
|
data/README.md
CHANGED
|
@@ -290,6 +290,115 @@ end
|
|
|
290
290
|
|
|
291
291
|
See [the API docs](https://eval.github.io/appquery/AppQuery/RSpec/Helpers.html) for more RSpec examples.
|
|
292
292
|
|
|
293
|
+
### Writing a Middleware
|
|
294
|
+
|
|
295
|
+
A `BaseQuery` middleware is a `Module` you `include` into a query class. There are three layers to extend at, depending on *where* you want to act. The three compose cleanly on the same query class.
|
|
296
|
+
|
|
297
|
+
| You want to⦠| Layer | How |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| change/decorate each row | **row-level** | append to `q.row_builder` |
|
|
300
|
+
| filter, wrap, cap, paginate, cache the collection | **result-level** | override `#entries` (or `#first`, `#last`, β¦) |
|
|
301
|
+
| change the SQL/binds before it runs | **query-level** | override `#query`, return a different `Q` |
|
|
302
|
+
|
|
303
|
+
#### Row-level (the `row_builder` pipeline)
|
|
304
|
+
|
|
305
|
+
`Q#row_builder` is a composable pipeline of callables that each receive a row Hash and return whatever should replace it β a Hash, a `Data`, a Struct, your own model.
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
module Stamping
|
|
309
|
+
extend ActiveSupport::Concern
|
|
310
|
+
|
|
311
|
+
def query
|
|
312
|
+
@query ||= super.tap { |q| q.row_builder << ->(row) { row.merge("stamped_at" => Time.now) } }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
class ArticlesQuery < ApplicationQuery
|
|
317
|
+
include Stamping
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
ArticlesQuery.new.first # => {"id" => 1, ..., "stamped_at" => 2026-...}
|
|
321
|
+
ArticlesQuery.new.entries.first # same β every row-returning path flows through row_builder
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The pipeline propagates through `with_select(non_nil)`, `add_binds`, `with_binds`, `with_cast`, `with_sql`, and CTE focusing (`#cte`), so chained calls keep the same mapping. Each child gets an independent copy β mutating it doesn't affect the parent.
|
|
325
|
+
|
|
326
|
+
**Stacking row-level middlewares.** Transformers run in **`include` order** β earliest `include` first, latest `include` last. With
|
|
327
|
+
|
|
328
|
+
```ruby
|
|
329
|
+
class MyQuery < ApplicationQuery
|
|
330
|
+
include Stamping # runs first β its row goes intoβ¦
|
|
331
|
+
include AppQuery::Mappable # β¦which sees the stamped hash and builds an Item
|
|
332
|
+
end
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
`Stamping`'s lambda runs first, then `Mappable.build_row` consumes the already-stamped hash. β οΈ Once a transformer returns a non-Hash (e.g. a `Data`), downstream transformers see that object β so a hash-merging transformer placed *after* `Mappable` would fail. Order the chain accordingly.
|
|
336
|
+
|
|
337
|
+
**Doesn't fit row_builder:** filtering ("drop rows the viewer can't see") and collapsing rows. Both act on the collection, not a single row β use the result-level layer instead.
|
|
338
|
+
|
|
339
|
+
#### Result-level (wrap a row-returning method)
|
|
340
|
+
|
|
341
|
+
When you want to act on the whole collection β wrap, cap, cache, filter, paginate β override the row-returning method and call `super`. `super` returns rows that have *already* been through `row_builder`, so this composes with row-level middleware without thinking.
|
|
342
|
+
|
|
343
|
+
`Paginatable` is the canonical example (wraps `#entries` in a `PaginatedResult`). Some others:
|
|
344
|
+
|
|
345
|
+
```ruby
|
|
346
|
+
module Caching
|
|
347
|
+
# memoise the whole result on the instance
|
|
348
|
+
def entries = @_entries ||= super
|
|
349
|
+
def first = @_first ||= super
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
module ScopedToTenant
|
|
353
|
+
def entries = super.select { |r| r["tenant_id"] == Current.tenant.id }
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
module Capped
|
|
357
|
+
CapResult = Data.define(:records, :hit_cap?) do
|
|
358
|
+
include Enumerable
|
|
359
|
+
def each(&b) = records.each(&b)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def entries
|
|
363
|
+
rows = super
|
|
364
|
+
CapResult.new(records: rows.first(self.class.cap), hit_cap?: rows.size > self.class.cap)
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
#### Query-level (rewrite SQL/binds before execution)
|
|
370
|
+
|
|
371
|
+
When you want to change what the database actually sees β tenant scoping, soft-delete filtering, default ordering β override `#query` and return a transformed `Q`. Use `with_select` / `with_binds` / `add_binds` etc.; they propagate the `row_builder` pipeline via `deep_dup`, so row-level middleware keeps working.
|
|
372
|
+
|
|
373
|
+
```ruby
|
|
374
|
+
module TenantScoped
|
|
375
|
+
def query
|
|
376
|
+
@query ||= super.add_binds(tenant_id: Current.tenant.id)
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
module HidesDeleted
|
|
381
|
+
def query
|
|
382
|
+
@query ||= super.with_select("SELECT * FROM :_ WHERE deleted_at IS NULL")
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
#### Putting it together
|
|
388
|
+
|
|
389
|
+
All three layers can sit on one class:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
class ArticlesQuery < ApplicationQuery
|
|
393
|
+
include HidesDeleted # query-level: rewrites SQL
|
|
394
|
+
include Stamping # row-level: adds "stamped_at"
|
|
395
|
+
include AppQuery::Mappable # row-level: builds Item (must come after row-mutating middleware)
|
|
396
|
+
include AppQuery::Paginatable # result-level: wraps entries
|
|
397
|
+
end
|
|
398
|
+
```
|
|
399
|
+
|
|
400
|
+
Pipeline at run time, top to bottom: SQL is rewritten to filter `deleted_at IS NULL` β DB returns rows β each row gets `stamped_at` β each row becomes an `Item` β the array of Items is wrapped in a `PaginatedResult`.
|
|
401
|
+
|
|
293
402
|
## API Documentation
|
|
294
403
|
|
|
295
404
|
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
@@ -322,7 +431,8 @@ rake spec
|
|
|
322
431
|
bin/yard-dev
|
|
323
432
|
```
|
|
324
433
|
|
|
325
|
-
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
434
|
+
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
435
|
+
Using [bonchi](https://rubygems.org/gems/bonchi) allows for agentic working via git worktrees. See `.worktree.yml.example` for a config.
|
|
326
436
|
|
|
327
437
|
### Releasing
|
|
328
438
|
|
data/lib/app_query/base_query.rb
CHANGED
|
@@ -154,7 +154,8 @@ module AppQuery
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s,
|
|
157
|
+
delegate :cte, :entries, :with_select, :select_all, :select_one, :count, :to_s,
|
|
158
|
+
:column, :first, :last, :take, :take_last, :ids, :copy_to, to: :query
|
|
158
159
|
|
|
159
160
|
def query
|
|
160
161
|
@query ||= base_query
|
data/lib/app_query/mappable.rb
CHANGED
|
@@ -66,31 +66,20 @@ module AppQuery
|
|
|
66
66
|
self
|
|
67
67
|
end
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def
|
|
74
|
-
|
|
69
|
+
# Append our transform to the underlying Q's RowBuilder pipeline so every
|
|
70
|
+
# row-returning path (entries, first, last, take, with_select(...).first,
|
|
71
|
+
# β¦) sees mapped rows. Stacks with other row-level middlewares in
|
|
72
|
+
# include-order β earlier `include`s run first.
|
|
73
|
+
def query
|
|
74
|
+
@query ||= super.tap { |q| q.row_builder << method(:build_row) }
|
|
75
75
|
end
|
|
76
76
|
|
|
77
77
|
private
|
|
78
78
|
|
|
79
|
-
def
|
|
80
|
-
return
|
|
81
|
-
return
|
|
82
|
-
|
|
83
|
-
attrs = klass.members
|
|
84
|
-
result.transform! { |row| klass.new(**row.symbolize_keys.slice(*attrs)) }
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
def map_one(result)
|
|
88
|
-
return result if @raw
|
|
89
|
-
return result unless (klass = resolve_map_klass)
|
|
90
|
-
return result unless result
|
|
91
|
-
|
|
92
|
-
attrs = klass.members
|
|
93
|
-
klass.new(**result.symbolize_keys.slice(*attrs))
|
|
79
|
+
def build_row(row)
|
|
80
|
+
return row if @raw
|
|
81
|
+
return row unless (klass = resolve_map_klass)
|
|
82
|
+
klass.new(**row.symbolize_keys.slice(*klass.members))
|
|
94
83
|
end
|
|
95
84
|
|
|
96
85
|
def resolve_map_klass
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -257,9 +257,15 @@ module AppQuery
|
|
|
257
257
|
|
|
258
258
|
level = 1
|
|
259
259
|
loop do
|
|
260
|
-
read_until(
|
|
260
|
+
read_until(%r{\)|\(|'|--|/\*})
|
|
261
261
|
if eos?
|
|
262
262
|
err "CTE select ended prematurely"
|
|
263
|
+
elsif match?("--")
|
|
264
|
+
read_until(/\n/)
|
|
265
|
+
elsif match?(%r{/\*})
|
|
266
|
+
read_until(%r{\*/})
|
|
267
|
+
err "CTE select ended prematurely" if eos?
|
|
268
|
+
read_char 2
|
|
263
269
|
elsif match?(/'/)
|
|
264
270
|
# Skip string literal (handle escaped quotes '')
|
|
265
271
|
read_char
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -155,17 +155,62 @@ module AppQuery
|
|
|
155
155
|
Q.new("SELECT * FROM #{quote_table(name)}", name: "AppQuery.table(#{name})", **opts)
|
|
156
156
|
end
|
|
157
157
|
|
|
158
|
+
# Composable pipeline of row transformers.
|
|
159
|
+
#
|
|
160
|
+
# Appended via `<<` and applied in registration order β earliest pushed
|
|
161
|
+
# runs first, latest pushed runs last (so its return value is what callers
|
|
162
|
+
# see). An empty pipeline is a no-op.
|
|
163
|
+
#
|
|
164
|
+
# @example
|
|
165
|
+
# rb = AppQuery::RowBuilder.new
|
|
166
|
+
# rb << ->(row) { row.merge("a" => 1) }
|
|
167
|
+
# rb << ->(row) { row.merge("b" => 2) }
|
|
168
|
+
# rb.call({}) # => {"a" => 1, "b" => 2}
|
|
169
|
+
class RowBuilder
|
|
170
|
+
def initialize(procs = [])
|
|
171
|
+
@procs = procs
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Append a transformer. Returns self so `q.row_builder << proc` reads
|
|
175
|
+
# naturally and `<<=` also works.
|
|
176
|
+
def <<(callable)
|
|
177
|
+
@procs << callable
|
|
178
|
+
self
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def call(row)
|
|
182
|
+
@procs.reduce(row) { |acc, p| p.call(acc) }
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def empty? = @procs.empty?
|
|
186
|
+
|
|
187
|
+
def size = @procs.size
|
|
188
|
+
|
|
189
|
+
# Independent copy β used by Q#deep_dup so chained queries don't share
|
|
190
|
+
# the parent's pipeline.
|
|
191
|
+
def dup
|
|
192
|
+
self.class.new(@procs.dup)
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
158
196
|
class Result < ActiveRecord::Result
|
|
159
|
-
attr_accessor :cast
|
|
197
|
+
attr_accessor :cast, :row_builder
|
|
160
198
|
alias_method :cast?, :cast
|
|
161
199
|
|
|
162
|
-
def initialize(columns, rows, overrides = nil, cast: false)
|
|
200
|
+
def initialize(columns, rows, overrides = nil, cast: false, row_builder: nil)
|
|
163
201
|
super(columns, rows, overrides)
|
|
164
202
|
@cast = cast
|
|
203
|
+
@row_builder = row_builder
|
|
165
204
|
# Rails v6.1: prevent mutate on frozen object on #first
|
|
166
205
|
@hash_rows = [] if columns.empty?
|
|
167
206
|
end
|
|
168
207
|
|
|
208
|
+
# AR::Result#first reads @hash_rows directly when not yet memoized,
|
|
209
|
+
# bypassing our hash_rows override. Force it through.
|
|
210
|
+
def first
|
|
211
|
+
hash_rows.first
|
|
212
|
+
end
|
|
213
|
+
|
|
169
214
|
# Returns an array of values for a single column.
|
|
170
215
|
#
|
|
171
216
|
# @note If you only need a single column, prefer {Q#column} which selects
|
|
@@ -204,9 +249,13 @@ module AppQuery
|
|
|
204
249
|
private
|
|
205
250
|
|
|
206
251
|
# Override to provide indifferent access (string or symbol keys).
|
|
252
|
+
# Applies the RowBuilder pipeline lazily so callers see built rows
|
|
253
|
+
# everywhere (first, last, each, entries, to_a, [] β¦). An empty/absent
|
|
254
|
+
# builder is a no-op.
|
|
207
255
|
def hash_rows
|
|
208
256
|
@hash_rows ||= rows.map do |row|
|
|
209
|
-
columns.zip(row).to_h.with_indifferent_access
|
|
257
|
+
hash = columns.zip(row).to_h.with_indifferent_access
|
|
258
|
+
row_builder ? row_builder.call(hash) : hash
|
|
210
259
|
end
|
|
211
260
|
end
|
|
212
261
|
|
|
@@ -243,9 +292,9 @@ module AppQuery
|
|
|
243
292
|
end
|
|
244
293
|
end
|
|
245
294
|
|
|
246
|
-
def self.from_ar_result(r, cast = nil)
|
|
295
|
+
def self.from_ar_result(r, cast = nil, row_builder: nil)
|
|
247
296
|
if r.empty?
|
|
248
|
-
r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types)
|
|
297
|
+
r.columns.empty? ? EMPTY : new(r.columns, [], r.column_types, row_builder:)
|
|
249
298
|
else
|
|
250
299
|
cast &&= case cast
|
|
251
300
|
when Array
|
|
@@ -257,7 +306,7 @@ module AppQuery
|
|
|
257
306
|
end
|
|
258
307
|
if !cast || (cast.empty? && r.column_types.empty?)
|
|
259
308
|
# nothing to cast
|
|
260
|
-
new(r.columns, r.rows, r.column_types)
|
|
309
|
+
new(r.columns, r.rows, r.column_types, row_builder:)
|
|
261
310
|
else
|
|
262
311
|
overrides = (r.column_types || {}).merge(cast)
|
|
263
312
|
rows = r.cast_values(overrides)
|
|
@@ -267,7 +316,7 @@ module AppQuery
|
|
|
267
316
|
# > ActiveRecord::Base.connection.select_all("select array[1,2]").cast_values
|
|
268
317
|
# => [[1, 2]]
|
|
269
318
|
rows = rows.zip if r.columns.one?
|
|
270
|
-
new(r.columns, rows, overrides, cast: true)
|
|
319
|
+
new(r.columns, rows, overrides, cast: true, row_builder:)
|
|
271
320
|
end
|
|
272
321
|
end
|
|
273
322
|
end
|
|
@@ -317,6 +366,15 @@ module AppQuery
|
|
|
317
366
|
# @return [Boolean, Hash, Array] casting configuration
|
|
318
367
|
attr_reader :sql, :name, :filename, :binds, :cast
|
|
319
368
|
|
|
369
|
+
# Middleware extension point. The {RowBuilder} is a composable pipeline:
|
|
370
|
+
# middlewares append transformers with `q.row_builder << ->(row) { β¦ }`
|
|
371
|
+
# and the result is applied to every row everywhere Q exposes rows
|
|
372
|
+
# (entries, first, last, take, take_last, with_select(...).first, β¦).
|
|
373
|
+
# Propagated through {#deep_dup} (with an independent copy) so chained
|
|
374
|
+
# queries inherit the pipeline but don't mutate the parent's.
|
|
375
|
+
# @return [RowBuilder]
|
|
376
|
+
attr_accessor :row_builder
|
|
377
|
+
|
|
320
378
|
# Creates a new query object.
|
|
321
379
|
#
|
|
322
380
|
# @param sql [String] the SQL query string (may contain ERB)
|
|
@@ -330,13 +388,14 @@ module AppQuery
|
|
|
330
388
|
#
|
|
331
389
|
# @example With ERB and binds
|
|
332
390
|
# Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
|
|
333
|
-
def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
|
|
391
|
+
def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0, row_builder: nil)
|
|
334
392
|
@sql = sql
|
|
335
393
|
@name = name
|
|
336
394
|
@filename = filename
|
|
337
395
|
@binds = binds
|
|
338
396
|
@cast = cast
|
|
339
397
|
@cte_depth = cte_depth
|
|
398
|
+
@row_builder = row_builder || RowBuilder.new
|
|
340
399
|
@binds = binds_with_defaults(sql, binds)
|
|
341
400
|
end
|
|
342
401
|
|
|
@@ -359,8 +418,8 @@ module AppQuery
|
|
|
359
418
|
end
|
|
360
419
|
end
|
|
361
420
|
|
|
362
|
-
def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
|
|
363
|
-
self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
|
|
421
|
+
def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth, row_builder: self.row_builder.dup)
|
|
422
|
+
self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:, row_builder:)
|
|
364
423
|
end
|
|
365
424
|
|
|
366
425
|
# @!group Rendering
|
|
@@ -468,7 +527,7 @@ module AppQuery
|
|
|
468
527
|
ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
|
|
469
528
|
end
|
|
470
529
|
ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
|
|
471
|
-
Result.from_ar_result(result, cast)
|
|
530
|
+
Result.from_ar_result(result, cast, row_builder: aq.row_builder)
|
|
472
531
|
end
|
|
473
532
|
end
|
|
474
533
|
rescue NameError => e
|
|
@@ -492,7 +551,8 @@ module AppQuery
|
|
|
492
551
|
def select_one(s = nil, binds: {}, cast: self.cast)
|
|
493
552
|
with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
|
|
494
553
|
end
|
|
495
|
-
|
|
554
|
+
|
|
555
|
+
def first(...) = select_one(...)
|
|
496
556
|
|
|
497
557
|
# Executes the query and returns the last row.
|
|
498
558
|
#
|
|
@@ -550,8 +610,9 @@ module AppQuery
|
|
|
550
610
|
#
|
|
551
611
|
# @see #last
|
|
552
612
|
def take_last(n, s = nil, binds: {}, cast: self.cast)
|
|
613
|
+
offset_expr = greatest("(SELECT COUNT(*) FROM :_) - #{n.to_i}", "0")
|
|
553
614
|
with_select(s).select_all(
|
|
554
|
-
"SELECT * FROM :_ LIMIT #{n.to_i} OFFSET
|
|
615
|
+
"SELECT * FROM :_ LIMIT #{n.to_i} OFFSET #{offset_expr}",
|
|
555
616
|
binds:, cast:
|
|
556
617
|
).entries
|
|
557
618
|
end
|
|
@@ -652,7 +713,16 @@ module AppQuery
|
|
|
652
713
|
# @example Extract unique values
|
|
653
714
|
# AppQuery("SELECT * FROM products").column(:category, unique: true)
|
|
654
715
|
# # => ["Electronics", "Clothing", "Home"]
|
|
716
|
+
#
|
|
717
|
+
# @raise [ArgumentError] if the column doesn't exist in the (optionally
|
|
718
|
+
# selected) query. Pre-validating catches typos consistently across
|
|
719
|
+
# databases β e.g. without this, SQLite's "double-quoted strings"
|
|
720
|
+
# quirk would silently return rows of the column-name as a string.
|
|
655
721
|
def column(c, s = nil, binds: {}, unique: false)
|
|
722
|
+
available = column_names(s, binds:)
|
|
723
|
+
unless available.include?(c.to_s)
|
|
724
|
+
raise ArgumentError, "Unknown column #{c.inspect}. Available: #{available.inspect}."
|
|
725
|
+
end
|
|
656
726
|
quoted = quote_column(c)
|
|
657
727
|
select_expr = unique ? "DISTINCT #{quoted}" : quoted
|
|
658
728
|
with_select(s).select_all("SELECT #{select_expr} AS column FROM :_", binds:).column("column")
|
|
@@ -1222,6 +1292,16 @@ module AppQuery
|
|
|
1222
1292
|
def quote_column(name)
|
|
1223
1293
|
AppQuery.quote_column(name)
|
|
1224
1294
|
end
|
|
1295
|
+
|
|
1296
|
+
# Returns SQL for max(a, b) that works across adapters.
|
|
1297
|
+
# PostgreSQL uses GREATEST, SQLite uses MAX for scalar comparison.
|
|
1298
|
+
def greatest(a, b)
|
|
1299
|
+
if /sqlite/i.match?(ActiveRecord::Base.connection.adapter_name)
|
|
1300
|
+
"MAX(#{a}, #{b})"
|
|
1301
|
+
else
|
|
1302
|
+
"GREATEST(#{a}, #{b})"
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1225
1305
|
end
|
|
1226
1306
|
end
|
|
1227
1307
|
|
data/mise.local.toml.example
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: appquery
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.9.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -9,6 +9,20 @@ bindir: exe
|
|
|
9
9
|
cert_chain: []
|
|
10
10
|
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
11
|
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activerecord
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '7.0'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - ">="
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '7.0'
|
|
12
26
|
- !ruby/object:Gem::Dependency
|
|
13
27
|
name: appraisal
|
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -43,6 +57,7 @@ files:
|
|
|
43
57
|
- ".irbrc"
|
|
44
58
|
- ".rspec"
|
|
45
59
|
- ".standard.yml"
|
|
60
|
+
- ".worktree.yml.example"
|
|
46
61
|
- ".yard/templates/default/fulldoc/html/css/dark.css"
|
|
47
62
|
- ".yard/templates/default/fulldoc/html/js/app.js"
|
|
48
63
|
- ".yard/templates/default/fulldoc/html/setup.rb"
|
|
@@ -94,6 +109,7 @@ metadata:
|
|
|
94
109
|
homepage_uri: https://github.com/eval/appquery
|
|
95
110
|
source_code_uri: https://github.com/eval/appquery
|
|
96
111
|
changelog_uri: https://github.com/eval/appquery/blob/main/CHANGELOG.md
|
|
112
|
+
rubygems_mfa_required: 'true'
|
|
97
113
|
rdoc_options: []
|
|
98
114
|
require_paths:
|
|
99
115
|
- lib
|