appquery 0.4.0 → 0.5.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 +4 -4
- data/.irbrc +9 -0
- data/CHANGELOG.md +46 -0
- data/README.md +77 -87
- data/lib/app_query/render_helpers.rb +49 -11
- data/lib/app_query/rspec/helpers.rb +9 -1
- data/lib/app_query/tokenizer.rb +2 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +227 -130
- data/mise.toml +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0ca8349f2df1ddf7e2248314238dd72ff5623d2924f1265f385cb1dfde8b274d
|
|
4
|
+
data.tar.gz: 12440ed32a10ca28acd01df89175906e2ed2f77105905ed44f905bc54def4c5d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 8154cfbf83c7327cc6bdfbe501430007fb4ed9a5ecfb65f6d751358e3721ca853b9d4655968955147fa2e3aa4ea898db9f71bd3da19647e4e550d2887121daab
|
|
7
|
+
data.tar.gz: a51fc511fa7b5da2bf03c4d63910abb1bb980b95fc300bf188a8749e726e965d781d14d28ffa3240dbca515c7d2bb0f5e83041eae61bb6ea7e815a1f7a13d19f
|
data/.irbrc
CHANGED
|
@@ -17,4 +17,13 @@ IRB.conf[:PROMPT][:APPQUERY] = {
|
|
|
17
17
|
PROMPT_C: "#{db_indicator}appquery* ",
|
|
18
18
|
RETURN: "=> %s\n"
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
# copy from history easier as prompt is not repeated across lines
|
|
22
|
+
IRB.conf[:PROMPT][:CLEAN] = {
|
|
23
|
+
PROMPT_I: ">> ",
|
|
24
|
+
PROMPT_S: " ", # same width as ">> "
|
|
25
|
+
PROMPT_C: " ",
|
|
26
|
+
PROMPT_N: " ",
|
|
27
|
+
RETURN: "=> %s\n"
|
|
28
|
+
}
|
|
20
29
|
IRB.conf[:PROMPT_MODE] = :APPQUERY
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.5.0] - 2025-12-21
|
|
4
|
+
|
|
5
|
+
### 💥 Breaking Changes
|
|
6
|
+
|
|
7
|
+
- 🔄 **`select:` keyword argument removed** — use positional argument instead
|
|
8
|
+
```ruby
|
|
9
|
+
# before
|
|
10
|
+
query.select_all(select: "SELECT * FROM :_")
|
|
11
|
+
# after
|
|
12
|
+
query.select_all("SELECT * FROM :_")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### ✨ Features
|
|
16
|
+
|
|
17
|
+
- 🍾 **Add paginate ERB-helper**
|
|
18
|
+
```ruby
|
|
19
|
+
SELECT * FROM articles
|
|
20
|
+
<%= paginate(page: 1, per_page: 15) %>
|
|
21
|
+
# SELECT * FROM articles LIMIT 15 OFFSET 0
|
|
22
|
+
```
|
|
23
|
+
- 🧰 **Resolve query without extension**
|
|
24
|
+
`AppQuery[:weekly_sales]` loads `weekly_sales.sql` or `weekly_sales.sql.erb`.
|
|
25
|
+
- 🔗 **Nested result queries** via `with_select` — chain transformations using `:_` placeholder to reference the previous result
|
|
26
|
+
```ruby
|
|
27
|
+
active_users = AppQuery("SELECT * FROM users").with_select("SELECT * FROM :_ WHERE active")
|
|
28
|
+
active_users.count("SELECT * FROM :_ WHERE admin")
|
|
29
|
+
```
|
|
30
|
+
- 🚀 **New methods**: `#column`, `#ids`, `#count`, `#entries` — efficient shortcuts that only fetch what you need
|
|
31
|
+
```ruby
|
|
32
|
+
query.column(:email) # SELECT email only
|
|
33
|
+
query.ids # SELECT id only
|
|
34
|
+
query.count # SELECT COUNT(*) only
|
|
35
|
+
query.entries # shorthand for select_all.entries
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### 🐛 Fixes
|
|
39
|
+
|
|
40
|
+
- 🔧 Fix leading whitespace in `prepend_cte` causing parse errors
|
|
41
|
+
- 🔧 Fix binds being reset when no placeholders found
|
|
42
|
+
- ⚡ `select_one` now uses `LIMIT 1` for better performance
|
|
43
|
+
|
|
44
|
+
### 📚 Documentation
|
|
45
|
+
|
|
46
|
+
- 📖 Revised README with cleaner intro and examples
|
|
47
|
+
- 🏠 Added example Rails app in `examples/demo`
|
|
48
|
+
|
|
3
49
|
## [0.4.0] - 2025-12-15
|
|
4
50
|
|
|
5
51
|
### features
|
data/README.md
CHANGED
|
@@ -3,63 +3,40 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/appquery)
|
|
4
4
|
[](https://eval.github.io/appquery/)
|
|
5
5
|
|
|
6
|
-
A
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
- **...helpers to rewrite a query for introspection during development and testing**
|
|
39
|
-
See what a CTE yields: `query.select_all(select: "SELECT * FROM some_cte")`.
|
|
40
|
-
Query the end result: `query.select_one(select: "SELECT COUNT(*) FROM _ WHERE ...")`.
|
|
41
|
-
Append/prepend CTEs:
|
|
42
|
-
```ruby
|
|
43
|
-
query.prepend_cte(<<~CTE)
|
|
44
|
-
articles(id, title) AS (
|
|
45
|
-
VALUES(1, 'Some title'),
|
|
46
|
-
(2, 'Another article'))
|
|
47
|
-
CTE
|
|
48
|
-
```
|
|
49
|
-
- **...rspec generators and helpers**
|
|
50
|
-
```ruby
|
|
51
|
-
RSpec.describe "AppQuery reports/weekly", type: :query do
|
|
52
|
-
describe "CTE some_cte" do
|
|
53
|
-
# see what this CTE yields
|
|
54
|
-
expect(described_query.select_all(select: "select * from some_cte")).to \
|
|
55
|
-
include(a_hash_including("id" => 1))
|
|
56
|
-
|
|
57
|
-
# shorter: the query and CTE are derived from the describe-descriptions so this suffices:
|
|
58
|
-
expect(select_all).to include ...
|
|
59
|
-
```
|
|
6
|
+
A Ruby gem for working with raw SQL in Rails. Store queries in `app/queries/`, execute them with proper type casting, and filter/transform results using CTEs.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Load and execute
|
|
10
|
+
week = AppQuery[:weekly_sales].with_binds(week: 1, year: 2025)
|
|
11
|
+
week.entries
|
|
12
|
+
#=> [{"week" => 2025-01-13, "category" => "Electronics", "revenue" => 12500, "target_met" => true}, ...]
|
|
13
|
+
|
|
14
|
+
# Filter results (query wraps in CTE, :_ references it)
|
|
15
|
+
week.count("SELECT * FROM :_ WHERE NOT target_met")
|
|
16
|
+
#=> 3
|
|
17
|
+
|
|
18
|
+
# Extract a column efficiently (only fetches that column)
|
|
19
|
+
week.column(:category)
|
|
20
|
+
#=> ["Electronics", "Clothing", "Home & Garden"]
|
|
21
|
+
|
|
22
|
+
# Named binds with defaults
|
|
23
|
+
AppQuery[:weekly_sales].select_all(binds: {min_revenue: 5000})
|
|
24
|
+
|
|
25
|
+
# ERB templating
|
|
26
|
+
AppQuery("SELECT * FROM contracts <%= order_by(ordering) %>")
|
|
27
|
+
.render(ordering: {year: :desc}).select_all
|
|
28
|
+
|
|
29
|
+
# Custom type casting
|
|
30
|
+
AppQuery("SELECT metadata FROM products").select_all(cast: {"metadata" => ActiveRecord::Type::Json.new})
|
|
31
|
+
|
|
32
|
+
# Inspect/mock CTEs for testing
|
|
33
|
+
query.prepend_cte("sales AS (SELECT * FROM mock_data)")
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
**Highlights**: query files with generator · `select_all`/`select_one`/`select_value`/`count`/`column`/`ids` · query transformation via CTEs · immutable (derive new queries from existing) · named binds · ERB helpers (`order_by`, `paginate`, `values`, `bind`) · automatic + custom type casting · RSpec integration
|
|
60
37
|
|
|
61
38
|
> [!IMPORTANT]
|
|
62
|
-
> **Status**: alpha. API might change. See the CHANGELOG for breaking changes when upgrading.
|
|
39
|
+
> **Status**: alpha. API might change. See [the CHANGELOG](./CHANGELOG.md) for breaking changes when upgrading.
|
|
63
40
|
>
|
|
64
41
|
|
|
65
42
|
## Rationale
|
|
@@ -104,14 +81,23 @@ The prompt indicates what adapter the example uses:
|
|
|
104
81
|
=> "2025-05-10"
|
|
105
82
|
|
|
106
83
|
# binds
|
|
107
|
-
|
|
108
|
-
[postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
|
|
109
|
-
# named binds
|
|
84
|
+
## named binds
|
|
110
85
|
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
111
86
|
|
|
87
|
+
## not all binds need to be provided (ie they are nil by default) - so defaults can be added in SQL:
|
|
88
|
+
[postgresql]> AppQuery(<<~SQL).select_all(binds: {ts1: 2.day.ago, ts2: Time.now}).column("series")
|
|
89
|
+
SELECT
|
|
90
|
+
generate_series(:ts1::timestamp, COALESCE(:ts2, :ts2::timestamp, COALESCE(:interval, '5 minutes')::interval)
|
|
91
|
+
AS series
|
|
92
|
+
SQL
|
|
93
|
+
|
|
112
94
|
# casting
|
|
113
|
-
|
|
114
|
-
|
|
95
|
+
## Cast values are used by default:
|
|
96
|
+
[postgresql]> AppQuery(%{select date('now')}).select_first
|
|
97
|
+
=> {"today" => Sat, 10 May 2025}
|
|
98
|
+
## compare ActiveRecord
|
|
99
|
+
[postgresql]> ActiveRecord::Base.connection.select_first(%{select date('now') as today})
|
|
100
|
+
=> {"today" => "2025-12-20"}
|
|
115
101
|
|
|
116
102
|
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
117
103
|
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
@@ -134,10 +120,12 @@ casts = {"today" => ActiveRecord::Type::Date.new}
|
|
|
134
120
|
SQL
|
|
135
121
|
|
|
136
122
|
## query the articles-CTE
|
|
137
|
-
[postgresql]> q.select_all(
|
|
123
|
+
[postgresql]> q.select_all(%{select * from articles where id < 2}).to_a
|
|
138
124
|
|
|
139
|
-
## query the end-result (available
|
|
140
|
-
[postgresql]> q.select_one(
|
|
125
|
+
## query the end-result (available via the placeholder ':_')
|
|
126
|
+
[postgresql]> q.select_one(%{select * from :_ limit 1})
|
|
127
|
+
### shorthand for that
|
|
128
|
+
[postgresql]> q.first
|
|
141
129
|
|
|
142
130
|
## ERB templating
|
|
143
131
|
# Extract a query from q that can be sorted dynamically:
|
|
@@ -164,7 +152,7 @@ SQL
|
|
|
164
152
|
### ...in a Rails project
|
|
165
153
|
|
|
166
154
|
> [!NOTE]
|
|
167
|
-
> The included [example Rails app](./examples/
|
|
155
|
+
> The included [example Rails app](./examples/demo) contains all data and queries described below.
|
|
168
156
|
|
|
169
157
|
Create a query:
|
|
170
158
|
```bash
|
|
@@ -174,15 +162,15 @@ rails g query recent_articles
|
|
|
174
162
|
Have some SQL (for SQLite, in this example):
|
|
175
163
|
```sql
|
|
176
164
|
-- app/queries/recent_articles.sql
|
|
177
|
-
WITH settings(
|
|
178
|
-
values(datetime('now', '-6 months'))
|
|
165
|
+
WITH settings(min_published_on) as (
|
|
166
|
+
values(COALESCE(:since, datetime('now', '-6 months')))
|
|
179
167
|
),
|
|
180
168
|
|
|
181
169
|
recent_articles(article_id, article_title, article_published_on, article_url) AS (
|
|
182
170
|
SELECT id, title, published_on, url
|
|
183
171
|
FROM articles
|
|
184
172
|
RIGHT JOIN settings
|
|
185
|
-
WHERE published_on >
|
|
173
|
+
WHERE published_on > settings.min_published_on
|
|
186
174
|
),
|
|
187
175
|
|
|
188
176
|
tags_by_article(article_id, tags) AS (
|
|
@@ -201,7 +189,7 @@ JOIN tags_by_article USING(article_id),
|
|
|
201
189
|
WHERE EXISTS (
|
|
202
190
|
SELECT 1
|
|
203
191
|
FROM json_each(tags)
|
|
204
|
-
WHERE json_each.value LIKE
|
|
192
|
+
WHERE json_each.value LIKE :tag OR :tag IS NULL
|
|
205
193
|
)
|
|
206
194
|
GROUP BY recent_articles.article_id
|
|
207
195
|
ORDER BY recent_articles.article_published_on
|
|
@@ -248,28 +236,31 @@ AppQuery[:recent_articles].select_all.entries
|
|
|
248
236
|
...
|
|
249
237
|
]
|
|
250
238
|
|
|
251
|
-
# we can provide a different cut off date via binds
|
|
252
|
-
AppQuery[:recent_articles].select_all(binds:
|
|
239
|
+
# we can provide a different cut off date via binds:
|
|
240
|
+
AppQuery[:recent_articles].select_all(binds: {since: 1.month.ago}).entries
|
|
253
241
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
|
|
242
|
+
# NOTE: by default the binds get initialized with nil, e.g. for this example {since: nil, tag: nil}
|
|
243
|
+
# This prevents you from having to provide all binds every time. Default values are put in the SQL (via COALESCE).
|
|
257
244
|
```
|
|
258
245
|
|
|
259
|
-
We can also dig deeper by query-ing the result, i.e. the CTE
|
|
246
|
+
We can also dig deeper by query-ing the result, i.e. the CTE `:_`:
|
|
260
247
|
|
|
261
248
|
```ruby
|
|
262
|
-
AppQuery[:recent_articles].select_one(
|
|
249
|
+
AppQuery[:recent_articles].select_one("select count(*) as cnt from :_")
|
|
263
250
|
# => {"cnt" => 13}
|
|
264
251
|
|
|
265
252
|
# For these kind of aggregate queries, we're only interested in the value:
|
|
266
|
-
AppQuery[:recent_articles].select_value(
|
|
253
|
+
AppQuery[:recent_articles].select_value("select count(*) from :_")
|
|
267
254
|
# => 13
|
|
255
|
+
|
|
256
|
+
# but there's also the shorthand #count (which takes a sub-select):
|
|
257
|
+
AppQuery[:recent_articles].count #=> 13
|
|
258
|
+
AppQuery[:recent_articles].count(binds: {since: 0}) #=> 275
|
|
268
259
|
```
|
|
269
260
|
|
|
270
261
|
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
|
|
271
262
|
```ruby
|
|
272
|
-
puts AppQuery[:recent_articles].with_select("select
|
|
263
|
+
puts AppQuery[:recent_articles].with_select("select id from :_")
|
|
273
264
|
```
|
|
274
265
|
|
|
275
266
|
|
|
@@ -277,15 +268,15 @@ puts AppQuery[:recent_articles].with_select("select * from _")
|
|
|
277
268
|
|
|
278
269
|
You can select from a CTE similarly:
|
|
279
270
|
```ruby
|
|
280
|
-
AppQuery[:recent_articles].select_all(
|
|
271
|
+
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
|
|
281
272
|
# => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
|
|
282
273
|
...]
|
|
283
274
|
|
|
284
275
|
# NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
|
|
285
276
|
types = {"tags" => ActiveRecord::Type::Json.new}
|
|
286
|
-
AppQuery[:recent_articles].select_all(
|
|
277
|
+
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast: types)
|
|
287
278
|
|
|
288
|
-
1)
|
|
279
|
+
1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
|
|
289
280
|
AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
|
|
290
281
|
# => {"json_build_object"=>{"a"=>1, "b"=>true}}
|
|
291
282
|
```
|
|
@@ -294,7 +285,7 @@ Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyon
|
|
|
294
285
|
|
|
295
286
|
```ruby
|
|
296
287
|
AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
|
|
297
|
-
settings(
|
|
288
|
+
settings(min_published_on) as (
|
|
298
289
|
values(datetime('now', '-12 months'))
|
|
299
290
|
)
|
|
300
291
|
SQL
|
|
@@ -306,10 +297,10 @@ You could even mock existing tables (using PostgreSQL):
|
|
|
306
297
|
sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
|
|
307
298
|
{id: 2, title: "Another title", published_on: 1.months.ago}]
|
|
308
299
|
# show the provided cutoff date works
|
|
309
|
-
AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds:
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
300
|
+
AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds: {since: 6.weeks.ago, articles: JSON[sample_articles]}).entries
|
|
301
|
+
articles AS (
|
|
302
|
+
SELECT * from json_to_recordset(:articles) AS x(id int, title text, published_on timestamp)
|
|
303
|
+
)
|
|
313
304
|
CTE
|
|
314
305
|
```
|
|
315
306
|
|
|
@@ -329,7 +320,7 @@ require "rails_helper"
|
|
|
329
320
|
RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
|
|
330
321
|
describe "CTE articles" do
|
|
331
322
|
specify do
|
|
332
|
-
expect(described_query.select_all(
|
|
323
|
+
expect(described_query.select_all("select * from :cte")).to \
|
|
333
324
|
include(a_hash_including("article_id" => 1))
|
|
334
325
|
|
|
335
326
|
# short version: query, cte and select are all implied from descriptions
|
|
@@ -347,7 +338,6 @@ There's some sugar:
|
|
|
347
338
|
When doing `select_all`, you can rewrite the `SELECT` of the query by passing `select`. There's no need to use the full name of the CTE as the spec-description contains the name (i.e. "articles" in "CTE articles").
|
|
348
339
|
- default_binds
|
|
349
340
|
The `binds`-value used when not explicitly provided.
|
|
350
|
-
E.g. given a query with a where-clause `WHERE published_at > COALESCE($1::timestamp, NOW() - '3 month'::interval)`, when setting `defaults_binds: [nil]` then `select_all` works like `select_all(binds: [nil])`.
|
|
351
341
|
|
|
352
342
|
## API Documentation
|
|
353
343
|
|
|
@@ -155,32 +155,70 @@ module AppQuery
|
|
|
155
155
|
# order_by(year: :desc, month: :desc)
|
|
156
156
|
# #=> "ORDER BY year DESC, month DESC"
|
|
157
157
|
#
|
|
158
|
-
# @example Mixed directions
|
|
159
|
-
# order_by(published_on: :desc, title: :asc)
|
|
160
|
-
# #=> "ORDER BY published_on DESC, title ASC"
|
|
161
|
-
#
|
|
162
158
|
# @example Column without direction (uses database default)
|
|
163
159
|
# order_by(id: nil)
|
|
164
160
|
# #=> "ORDER BY id"
|
|
165
161
|
#
|
|
162
|
+
# @example SQL literal
|
|
163
|
+
# order_by("RANDOM()")
|
|
164
|
+
# #=> "ORDER BY RANDOM()"
|
|
165
|
+
#
|
|
166
166
|
# @example In an ERB template with a variable
|
|
167
167
|
# SELECT * FROM articles
|
|
168
168
|
# <%= order_by(ordering) %>
|
|
169
169
|
#
|
|
170
170
|
# @example Making it optional (when ordering may not be provided)
|
|
171
|
-
# <%= @
|
|
171
|
+
# <%= @order.presence && order_by(ordering) %>
|
|
172
172
|
#
|
|
173
173
|
# @example With default fallback
|
|
174
|
-
# <%= order_by(@order || {id: :desc}) %>
|
|
174
|
+
# <%= order_by(@order.presence || {id: :desc}) %>
|
|
175
175
|
#
|
|
176
176
|
# @raise [ArgumentError] if hash is blank (nil, empty, or not present)
|
|
177
177
|
#
|
|
178
178
|
# @note The hash must not be blank. Use conditional ERB for optional ordering.
|
|
179
|
-
def order_by(
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
179
|
+
def order_by(order)
|
|
180
|
+
usage = <<~USAGE
|
|
181
|
+
Provide columns to sort by, e.g. order_by(id: :asc), or SQL-literal, e.g. order_by("RANDOM()") (got #{order.inspect}).
|
|
182
|
+
USAGE
|
|
183
|
+
raise ArgumentError, usage unless order.present?
|
|
184
|
+
|
|
185
|
+
case order
|
|
186
|
+
when String then "ORDER BY #{order}"
|
|
187
|
+
when Hash
|
|
188
|
+
"ORDER BY " + order.map do |k, v|
|
|
189
|
+
v.nil? ? k : [k, v.upcase].join(" ")
|
|
190
|
+
end.join(", ")
|
|
191
|
+
else
|
|
192
|
+
raise ArgumentError, usage
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Generates a LIMIT/OFFSET clause for pagination.
|
|
197
|
+
#
|
|
198
|
+
# @param page [Integer] the page number (1-indexed)
|
|
199
|
+
# @param per_page [Integer] the number of items per page
|
|
200
|
+
# @return [String] the LIMIT/OFFSET clause
|
|
201
|
+
#
|
|
202
|
+
# @example Basic pagination
|
|
203
|
+
# paginate(page: 1, per_page: 25)
|
|
204
|
+
# #=> "LIMIT 25 OFFSET 0"
|
|
205
|
+
#
|
|
206
|
+
# @example Second page
|
|
207
|
+
# paginate(page: 2, per_page: 25)
|
|
208
|
+
# #=> "LIMIT 25 OFFSET 25"
|
|
209
|
+
#
|
|
210
|
+
# @example In an ERB template
|
|
211
|
+
# SELECT * FROM articles
|
|
212
|
+
# ORDER BY created_at DESC
|
|
213
|
+
# <%= paginate(page: page, per_page: per_page) %>
|
|
214
|
+
#
|
|
215
|
+
# @raise [ArgumentError] if page or per_page is not a positive integer
|
|
216
|
+
def paginate(page:, per_page:)
|
|
217
|
+
raise ArgumentError, "page must be a positive integer (got #{page.inspect})" unless page.is_a?(Integer) && page > 0
|
|
218
|
+
raise ArgumentError, "per_page must be a positive integer (got #{per_page.inspect})" unless per_page.is_a?(Integer) && per_page > 0
|
|
219
|
+
|
|
220
|
+
offset = (page - 1) * per_page
|
|
221
|
+
"LIMIT #{per_page} OFFSET #{offset}"
|
|
184
222
|
end
|
|
185
223
|
|
|
186
224
|
private
|
|
@@ -5,6 +5,10 @@ module AppQuery
|
|
|
5
5
|
self.class.default_binds
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
def default_vars
|
|
9
|
+
self.class.default_vars
|
|
10
|
+
end
|
|
11
|
+
|
|
8
12
|
def expand_select(s)
|
|
9
13
|
s.gsub(":cte", cte_name)
|
|
10
14
|
end
|
|
@@ -24,7 +28,7 @@ module AppQuery
|
|
|
24
28
|
def described_query(select: nil)
|
|
25
29
|
select ||= "SELECT * FROM :cte" if cte_name
|
|
26
30
|
select &&= expand_select(select) if cte_name
|
|
27
|
-
self.class.described_query.with_select(select)
|
|
31
|
+
self.class.described_query.render(default_vars).with_select(select)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def cte_name
|
|
@@ -72,6 +76,10 @@ module AppQuery
|
|
|
72
76
|
metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
def default_vars
|
|
80
|
+
metadatas.find { _1[:default_vars] }&.[](:default_vars) || {}
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
def included(klass)
|
|
76
84
|
super
|
|
77
85
|
# Inject classmethods into the group.
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -95,8 +95,9 @@ module AppQuery
|
|
|
95
95
|
if eos?
|
|
96
96
|
emit_token "COMMA", v: ","
|
|
97
97
|
emit_token "WHITESPACE", v: "\n"
|
|
98
|
+
elsif match?(/\s/)
|
|
99
|
+
push_return :lex_prepend_cte, :lex_whitespace
|
|
98
100
|
else
|
|
99
|
-
# emit_token "WHITESPACE", v: " "
|
|
100
101
|
push_return :lex_prepend_cte, :lex_recursive_cte
|
|
101
102
|
end
|
|
102
103
|
end
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -72,21 +72,44 @@ module AppQuery
|
|
|
72
72
|
|
|
73
73
|
# Loads a query from a file in the configured query path.
|
|
74
74
|
#
|
|
75
|
+
# When no extension is provided, tries `.sql` first, then `.sql.erb`.
|
|
76
|
+
# Raises an error if both files exist (ambiguous).
|
|
77
|
+
#
|
|
75
78
|
# @param query_name [String, Symbol] the query name or path (without extension)
|
|
76
79
|
# @param opts [Hash] additional options passed to {Q#initialize}
|
|
77
80
|
# @return [Q] a new query object loaded from the file
|
|
78
81
|
#
|
|
79
|
-
# @example Load a
|
|
82
|
+
# @example Load a .sql file
|
|
80
83
|
# AppQuery[:invoices] # loads app/queries/invoices.sql
|
|
81
84
|
#
|
|
85
|
+
# @example Load a .sql.erb file (when .sql doesn't exist)
|
|
86
|
+
# AppQuery[:dynamic_report] # loads app/queries/dynamic_report.sql.erb
|
|
87
|
+
#
|
|
82
88
|
# @example Load from a subdirectory
|
|
83
89
|
# AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
|
|
84
90
|
#
|
|
85
91
|
# @example Load with explicit extension
|
|
86
92
|
# AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
|
|
93
|
+
#
|
|
94
|
+
# @raise [Error] if both `.sql` and `.sql.erb` files exist for the same name
|
|
87
95
|
def self.[](query_name, **opts)
|
|
88
|
-
|
|
89
|
-
|
|
96
|
+
base = Pathname.new(configuration.query_path) / query_name.to_s
|
|
97
|
+
|
|
98
|
+
full_path = if File.extname(query_name.to_s).empty?
|
|
99
|
+
sql_path = base.sub_ext(".sql").expand_path
|
|
100
|
+
erb_path = base.sub_ext(".sql.erb").expand_path
|
|
101
|
+
sql_exists = sql_path.exist?
|
|
102
|
+
erb_exists = erb_path.exist?
|
|
103
|
+
|
|
104
|
+
if sql_exists && erb_exists
|
|
105
|
+
raise Error, "Ambiguous query name #{query_name.inspect}: both #{sql_path} and #{erb_path} exist"
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
sql_exists ? sql_path : erb_path
|
|
109
|
+
else
|
|
110
|
+
base.expand_path
|
|
111
|
+
end
|
|
112
|
+
|
|
90
113
|
Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
|
|
91
114
|
end
|
|
92
115
|
|
|
@@ -186,14 +209,14 @@ module AppQuery
|
|
|
186
209
|
# @return [String] the SQL string
|
|
187
210
|
# @return [Array, Hash] bind parameters
|
|
188
211
|
# @return [Boolean, Hash, Array] casting configuration
|
|
189
|
-
attr_reader :name, :
|
|
212
|
+
attr_reader :sql, :name, :filename, :binds, :cast
|
|
190
213
|
|
|
191
214
|
# Creates a new query object.
|
|
192
215
|
#
|
|
193
216
|
# @param sql [String] the SQL query string (may contain ERB)
|
|
194
217
|
# @param name [String, nil] optional name for logging
|
|
195
218
|
# @param filename [String, nil] optional filename for ERB error reporting
|
|
196
|
-
# @param binds [
|
|
219
|
+
# @param binds [Hash, nil] bind parameters for the query
|
|
197
220
|
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
198
221
|
#
|
|
199
222
|
# @example Simple query
|
|
@@ -201,28 +224,38 @@ module AppQuery
|
|
|
201
224
|
#
|
|
202
225
|
# @example With ERB and binds
|
|
203
226
|
# Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
|
|
204
|
-
def initialize(sql, name: nil, filename: nil, binds:
|
|
227
|
+
def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
|
|
205
228
|
@sql = sql
|
|
206
229
|
@name = name
|
|
207
230
|
@filename = filename
|
|
208
231
|
@binds = binds
|
|
209
232
|
@cast = cast
|
|
233
|
+
@cte_depth = cte_depth
|
|
234
|
+
@binds = binds_with_defaults(sql, binds)
|
|
210
235
|
end
|
|
211
236
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
237
|
+
attr_reader :cte_depth
|
|
238
|
+
|
|
239
|
+
def to_arel
|
|
240
|
+
if binds.presence
|
|
241
|
+
Arel::Nodes::BoundSqlLiteral.new sql, [], binds
|
|
242
|
+
else
|
|
243
|
+
# TODO: add retryable? available from >=7.1
|
|
244
|
+
Arel::Nodes::SqlLiteral.new(sql)
|
|
245
|
+
end
|
|
215
246
|
end
|
|
216
|
-
private :deep_dup
|
|
217
247
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
248
|
+
private def binds_with_defaults(sql, binds)
|
|
249
|
+
if (named_binds = sql.scan(/:(?<!::)([a-zA-Z]\w*)/).flatten.map(&:to_sym).uniq.presence)
|
|
250
|
+
named_binds.zip(Array.new(named_binds.count)).to_h.merge(binds.to_h)
|
|
251
|
+
else
|
|
252
|
+
binds.to_h
|
|
222
253
|
end
|
|
223
|
-
self
|
|
224
254
|
end
|
|
225
|
-
|
|
255
|
+
|
|
256
|
+
def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
|
|
257
|
+
self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
|
|
258
|
+
end
|
|
226
259
|
|
|
227
260
|
# @!group Rendering
|
|
228
261
|
|
|
@@ -267,12 +300,7 @@ module AppQuery
|
|
|
267
300
|
sql = to_erb.result(helper.get_binding)
|
|
268
301
|
collected = helper.collected_binds
|
|
269
302
|
|
|
270
|
-
with_sql(sql).
|
|
271
|
-
# Merge collected binds with existing binds (convert array to hash if needed)
|
|
272
|
-
existing = @binds.is_a?(Hash) ? @binds : {}
|
|
273
|
-
new_binds = existing.merge(collected)
|
|
274
|
-
q.instance_variable_set(:@binds, new_binds) if new_binds.any?
|
|
275
|
-
end
|
|
303
|
+
with_sql(sql).add_binds(**collected)
|
|
276
304
|
end
|
|
277
305
|
|
|
278
306
|
def to_erb
|
|
@@ -306,15 +334,12 @@ module AppQuery
|
|
|
306
334
|
|
|
307
335
|
# Executes the query and returns all matching rows.
|
|
308
336
|
#
|
|
309
|
-
# @param binds [Array, Hash, nil] bind parameters (positional or named)
|
|
310
337
|
# @param select [String, nil] override the SELECT clause
|
|
338
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
311
339
|
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
312
340
|
# @return [Result] the query results with optional type casting
|
|
313
341
|
#
|
|
314
|
-
# @example
|
|
315
|
-
# AppQuery("SELECT * FROM users WHERE id = $1").select_all(binds: [1])
|
|
316
|
-
#
|
|
317
|
-
# @example Named binds
|
|
342
|
+
# @example (Named) binds
|
|
318
343
|
# AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
|
|
319
344
|
#
|
|
320
345
|
# @example With type casting
|
|
@@ -325,37 +350,17 @@ module AppQuery
|
|
|
325
350
|
# AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
|
|
326
351
|
#
|
|
327
352
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
328
|
-
# @raise [ArgumentError] if mixing positional binds with collected named binds
|
|
329
353
|
#
|
|
330
354
|
# TODO: have aliases for common casts: select_all(cast: {"today" => :date})
|
|
331
|
-
def select_all(
|
|
332
|
-
with_select(
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
if @binds.is_a?(Hash) && @binds.any?
|
|
336
|
-
raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
|
|
337
|
-
end
|
|
338
|
-
# Positional binds using $1, $2, etc.
|
|
339
|
-
ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
|
|
340
|
-
Result.from_ar_result(result, cast)
|
|
341
|
-
end
|
|
355
|
+
def select_all(s = nil, binds: {}, cast: self.cast)
|
|
356
|
+
add_binds(**binds).with_select(s).render({}).then do |aq|
|
|
357
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
358
|
+
aq.to_arel
|
|
342
359
|
else
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
Arel.sql(aq.to_s, **merged_binds)
|
|
348
|
-
else
|
|
349
|
-
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
|
|
350
|
-
end
|
|
351
|
-
ActiveRecord::Base.connection.select_all(sql, name).then do |result|
|
|
352
|
-
Result.from_ar_result(result, cast)
|
|
353
|
-
end
|
|
354
|
-
else
|
|
355
|
-
ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
|
|
356
|
-
Result.from_ar_result(result, cast)
|
|
357
|
-
end
|
|
358
|
-
end
|
|
360
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
|
|
361
|
+
end
|
|
362
|
+
ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
|
|
363
|
+
Result.from_ar_result(result, cast)
|
|
359
364
|
end
|
|
360
365
|
end
|
|
361
366
|
rescue NameError => e
|
|
@@ -366,23 +371,24 @@ module AppQuery
|
|
|
366
371
|
|
|
367
372
|
# Executes the query and returns the first row.
|
|
368
373
|
#
|
|
369
|
-
# @param binds [
|
|
374
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
370
375
|
# @param select [String, nil] override the SELECT clause
|
|
371
376
|
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
372
377
|
# @return [Hash, nil] the first row as a hash, or nil if no results
|
|
373
378
|
#
|
|
374
379
|
# @example
|
|
375
|
-
# AppQuery("SELECT * FROM users WHERE id =
|
|
380
|
+
# AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
|
|
376
381
|
# # => {"id" => 1, "name" => "Alice"}
|
|
377
382
|
#
|
|
378
383
|
# @see #select_all
|
|
379
|
-
def select_one(
|
|
380
|
-
select_all(binds:,
|
|
384
|
+
def select_one(s = nil, binds: {}, cast: self.cast)
|
|
385
|
+
with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
|
|
381
386
|
end
|
|
387
|
+
alias_method :first, :select_one
|
|
382
388
|
|
|
383
389
|
# Executes the query and returns the first value of the first row.
|
|
384
390
|
#
|
|
385
|
-
# @param binds [
|
|
391
|
+
# @param binds [Hash, nil] named bind parameters
|
|
386
392
|
# @param select [String, nil] override the SELECT clause
|
|
387
393
|
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
388
394
|
# @return [Object, nil] the first value, or nil if no results
|
|
@@ -392,13 +398,94 @@ module AppQuery
|
|
|
392
398
|
# # => 42
|
|
393
399
|
#
|
|
394
400
|
# @see #select_one
|
|
395
|
-
def select_value(
|
|
396
|
-
select_one(binds:,
|
|
401
|
+
def select_value(s = nil, binds: {}, cast: self.cast)
|
|
402
|
+
select_one(s, binds:, cast:)&.values&.first
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Returns the count of rows from the query.
|
|
406
|
+
#
|
|
407
|
+
# Wraps the query in a CTE and selects only the count, which is more
|
|
408
|
+
# efficient than fetching all rows via `select_all.count`.
|
|
409
|
+
#
|
|
410
|
+
# @param s [String, nil] optional SELECT to apply before counting
|
|
411
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
412
|
+
# @return [Integer] the count of rows
|
|
413
|
+
#
|
|
414
|
+
# @example Simple count
|
|
415
|
+
# AppQuery("SELECT * FROM users").count
|
|
416
|
+
# # => 42
|
|
417
|
+
#
|
|
418
|
+
# @example Count with filtering
|
|
419
|
+
# AppQuery("SELECT * FROM users")
|
|
420
|
+
# .with_select("SELECT * FROM :_ WHERE active")
|
|
421
|
+
# .count
|
|
422
|
+
# # => 10
|
|
423
|
+
def count(s = nil, binds: {})
|
|
424
|
+
with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
# Returns an array of values for a single column.
|
|
428
|
+
#
|
|
429
|
+
# Wraps the query in a CTE and selects only the specified column, which is
|
|
430
|
+
# more efficient than fetching all columns via `select_all.column(name)`.
|
|
431
|
+
# The column name is safely quoted, making this method safe for user input.
|
|
432
|
+
#
|
|
433
|
+
# @param c [String, Symbol] the column name to extract
|
|
434
|
+
# @param s [String, nil] optional SELECT to apply before extracting
|
|
435
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
436
|
+
# @return [Array] the column values
|
|
437
|
+
#
|
|
438
|
+
# @example Extract a single column
|
|
439
|
+
# AppQuery("SELECT id, name FROM users").column(:name)
|
|
440
|
+
# # => ["Alice", "Bob", "Charlie"]
|
|
441
|
+
#
|
|
442
|
+
# @example With additional filtering
|
|
443
|
+
# AppQuery("SELECT * FROM users").column(:email, "SELECT * FROM :_ WHERE active")
|
|
444
|
+
# # => ["alice@example.com", "bob@example.com"]
|
|
445
|
+
def column(c, s = nil, binds: {})
|
|
446
|
+
quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
|
|
447
|
+
with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column")
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Returns an array of id values from the query.
|
|
451
|
+
#
|
|
452
|
+
# Convenience method equivalent to `column(:id)`. More efficient than
|
|
453
|
+
# fetching all columns via `select_all.column("id")`.
|
|
454
|
+
#
|
|
455
|
+
# @param s [String, nil] optional SELECT to apply before extracting
|
|
456
|
+
# @param binds [Hash, nil] bind parameters to add
|
|
457
|
+
# @return [Array] the id values
|
|
458
|
+
#
|
|
459
|
+
# @example Get all user IDs
|
|
460
|
+
# AppQuery("SELECT * FROM users").ids
|
|
461
|
+
# # => [1, 2, 3]
|
|
462
|
+
#
|
|
463
|
+
# @example With filtering
|
|
464
|
+
# AppQuery("SELECT * FROM users").ids("SELECT * FROM :_ WHERE active")
|
|
465
|
+
# # => [1, 3]
|
|
466
|
+
def ids(s = nil, binds: {})
|
|
467
|
+
column(:id, s, binds:)
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
# Executes the query and returns results as an Array of Hashes.
|
|
471
|
+
#
|
|
472
|
+
# Shorthand for `select_all(...).entries`. Accepts the same arguments as
|
|
473
|
+
# {#select_all}.
|
|
474
|
+
#
|
|
475
|
+
# @return [Array<Hash>] the query results as an array
|
|
476
|
+
#
|
|
477
|
+
# @example
|
|
478
|
+
# AppQuery("SELECT * FROM users").entries
|
|
479
|
+
# # => [{"id" => 1, "name" => "Alice"}, {"id" => 2, "name" => "Bob"}]
|
|
480
|
+
#
|
|
481
|
+
# @see #select_all
|
|
482
|
+
def entries(...)
|
|
483
|
+
select_all(...).entries
|
|
397
484
|
end
|
|
398
485
|
|
|
399
486
|
# Executes an INSERT query.
|
|
400
487
|
#
|
|
401
|
-
# @param binds [
|
|
488
|
+
# @param binds [Hash, nil] bind parameters for the query
|
|
402
489
|
# @param returning [String, nil] columns to return (Rails 7.1+ only)
|
|
403
490
|
# @return [Integer, Object] the inserted ID or returning value
|
|
404
491
|
#
|
|
@@ -419,30 +506,22 @@ module AppQuery
|
|
|
419
506
|
#
|
|
420
507
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
421
508
|
# @raise [ArgumentError] if returning is used with Rails < 7.1
|
|
422
|
-
def insert(binds:
|
|
509
|
+
def insert(binds: {}, returning: nil)
|
|
423
510
|
# ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
|
|
424
511
|
if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
|
|
425
512
|
raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
|
|
426
513
|
end
|
|
427
514
|
|
|
428
|
-
binds
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
437
|
-
ActiveRecord::Base.connection.insert(sql, name, returning:)
|
|
438
|
-
else
|
|
439
|
-
ActiveRecord::Base.connection.insert(sql, name)
|
|
440
|
-
end
|
|
441
|
-
elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
442
|
-
# pk is the less flexible returning
|
|
443
|
-
ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
|
|
515
|
+
with_binds(**binds).render({}).then do |aq|
|
|
516
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
517
|
+
aq.to_arel
|
|
518
|
+
else
|
|
519
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
|
|
520
|
+
end
|
|
521
|
+
if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
522
|
+
ActiveRecord::Base.connection.insert(sql, name, returning:)
|
|
444
523
|
else
|
|
445
|
-
ActiveRecord::Base.connection.insert(
|
|
524
|
+
ActiveRecord::Base.connection.insert(sql, name)
|
|
446
525
|
end
|
|
447
526
|
end
|
|
448
527
|
rescue NameError => e
|
|
@@ -453,7 +532,7 @@ module AppQuery
|
|
|
453
532
|
|
|
454
533
|
# Executes an UPDATE query.
|
|
455
534
|
#
|
|
456
|
-
# @param binds [
|
|
535
|
+
# @param binds [Hash, nil] bind parameters for the query
|
|
457
536
|
# @return [Integer] the number of affected rows
|
|
458
537
|
#
|
|
459
538
|
# @example With named binds
|
|
@@ -465,19 +544,14 @@ module AppQuery
|
|
|
465
544
|
# .update(binds: ["New Title", 1])
|
|
466
545
|
#
|
|
467
546
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
468
|
-
def update(binds:
|
|
469
|
-
binds
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
473
|
-
Arel.sql(aq.to_s, **binds)
|
|
474
|
-
else
|
|
475
|
-
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
|
|
476
|
-
end
|
|
477
|
-
ActiveRecord::Base.connection.update(sql, name)
|
|
547
|
+
def update(binds: {})
|
|
548
|
+
with_binds(**binds).render({}).then do |aq|
|
|
549
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
550
|
+
aq.to_arel
|
|
478
551
|
else
|
|
479
|
-
ActiveRecord::Base.
|
|
552
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
|
|
480
553
|
end
|
|
554
|
+
ActiveRecord::Base.connection.update(sql, name)
|
|
481
555
|
end
|
|
482
556
|
rescue NameError => e
|
|
483
557
|
raise e unless e.instance_of?(NameError)
|
|
@@ -486,7 +560,7 @@ module AppQuery
|
|
|
486
560
|
|
|
487
561
|
# Executes a DELETE query.
|
|
488
562
|
#
|
|
489
|
-
# @param binds [
|
|
563
|
+
# @param binds [Hash, nil] bind parameters for the query
|
|
490
564
|
# @return [Integer] the number of deleted rows
|
|
491
565
|
#
|
|
492
566
|
# @example With named binds
|
|
@@ -496,19 +570,14 @@ module AppQuery
|
|
|
496
570
|
# AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
|
|
497
571
|
#
|
|
498
572
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
499
|
-
def delete(binds:
|
|
500
|
-
binds
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
504
|
-
Arel.sql(aq.to_s, **binds)
|
|
505
|
-
else
|
|
506
|
-
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
|
|
507
|
-
end
|
|
508
|
-
ActiveRecord::Base.connection.delete(sql, name)
|
|
573
|
+
def delete(binds: {})
|
|
574
|
+
with_binds(**binds).render({}).then do |aq|
|
|
575
|
+
sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
|
|
576
|
+
aq.to_arel
|
|
509
577
|
else
|
|
510
|
-
ActiveRecord::Base.
|
|
578
|
+
ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
|
|
511
579
|
end
|
|
580
|
+
ActiveRecord::Base.connection.delete(sql, name)
|
|
512
581
|
end
|
|
513
582
|
rescue NameError => e
|
|
514
583
|
raise e unless e.instance_of?(NameError)
|
|
@@ -547,16 +616,29 @@ module AppQuery
|
|
|
547
616
|
|
|
548
617
|
# Returns a new query with different bind parameters.
|
|
549
618
|
#
|
|
550
|
-
# @param binds [
|
|
551
|
-
# @return [Q] a new query object with the
|
|
619
|
+
# @param binds [Hash, nil] the bind parameters
|
|
620
|
+
# @return [Q] a new query object with the binds replaced
|
|
552
621
|
#
|
|
553
622
|
# @example
|
|
554
|
-
# query = AppQuery("SELECT
|
|
555
|
-
# query.with_binds(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
623
|
+
# query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
|
|
624
|
+
# query.with_binds(bar: 2).binds
|
|
625
|
+
# # => {foo: nil, bar: 2}
|
|
626
|
+
def with_binds(**binds)
|
|
627
|
+
deep_dup(binds:)
|
|
628
|
+
end
|
|
629
|
+
alias_method :replace_binds, :with_binds
|
|
630
|
+
|
|
631
|
+
# Returns a new query with binds added.
|
|
632
|
+
#
|
|
633
|
+
# @param binds [Hash, nil] the bind parameters to add
|
|
634
|
+
# @return [Q] a new query object with the added binds
|
|
635
|
+
#
|
|
636
|
+
# @example
|
|
637
|
+
# query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
|
|
638
|
+
# query.add_binds(bar: 2).binds
|
|
639
|
+
# # => {foo: 1, bar: 2}
|
|
640
|
+
def add_binds(**binds)
|
|
641
|
+
deep_dup(binds: self.binds.merge(binds))
|
|
560
642
|
end
|
|
561
643
|
|
|
562
644
|
# Returns a new query with different cast settings.
|
|
@@ -568,9 +650,7 @@ module AppQuery
|
|
|
568
650
|
# query = AppQuery("SELECT created_at FROM users")
|
|
569
651
|
# query.with_cast(false).select_all # disable casting
|
|
570
652
|
def with_cast(cast)
|
|
571
|
-
deep_dup
|
|
572
|
-
_1.instance_variable_set(:@cast, cast)
|
|
573
|
-
end
|
|
653
|
+
deep_dup(cast:)
|
|
574
654
|
end
|
|
575
655
|
|
|
576
656
|
# Returns a new query with different SQL.
|
|
@@ -578,31 +658,48 @@ module AppQuery
|
|
|
578
658
|
# @param sql [String] the new SQL string
|
|
579
659
|
# @return [Q] a new query object with the specified SQL
|
|
580
660
|
def with_sql(sql)
|
|
581
|
-
deep_dup
|
|
582
|
-
_1.instance_variable_set(:@sql, sql)
|
|
583
|
-
end
|
|
661
|
+
deep_dup(sql:)
|
|
584
662
|
end
|
|
585
663
|
|
|
586
664
|
# Returns a new query with a modified SELECT statement.
|
|
587
665
|
#
|
|
588
|
-
#
|
|
589
|
-
#
|
|
666
|
+
# Wraps the current SELECT in a numbered CTE and applies the new SELECT.
|
|
667
|
+
# CTEs are named `_`, `_1`, `_2`, etc. Use `:_` in the new SELECT to
|
|
668
|
+
# reference the previous result.
|
|
590
669
|
#
|
|
591
670
|
# @param sql [String, nil] the new SELECT statement (nil returns self)
|
|
592
671
|
# @return [Q] a new query object with the modified SELECT
|
|
593
672
|
#
|
|
594
|
-
# @example
|
|
595
|
-
# AppQuery("SELECT
|
|
596
|
-
# # => "WITH _ AS (\n SELECT
|
|
673
|
+
# @example Single transformation
|
|
674
|
+
# AppQuery("SELECT * FROM users").with_select("SELECT COUNT(*) FROM :_")
|
|
675
|
+
# # => "WITH _ AS (\n SELECT * FROM users\n)\nSELECT COUNT(*) FROM _"
|
|
676
|
+
#
|
|
677
|
+
# @example Chained transformations
|
|
678
|
+
# AppQuery("SELECT * FROM users")
|
|
679
|
+
# .with_select("SELECT * FROM :_ WHERE active")
|
|
680
|
+
# .with_select("SELECT COUNT(*) FROM :_")
|
|
681
|
+
# # => WITH _ AS (SELECT * FROM users),
|
|
682
|
+
# # _1 AS (SELECT * FROM _ WHERE active)
|
|
683
|
+
# # SELECT COUNT(*) FROM _1
|
|
597
684
|
def with_select(sql)
|
|
598
685
|
return self if sql.nil?
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
686
|
+
|
|
687
|
+
# First CTE is "_", then "_1", "_2", etc.
|
|
688
|
+
current_cte = (cte_depth == 0) ? "_" : "_#{cte_depth}"
|
|
689
|
+
|
|
690
|
+
# Replace :_ with the current CTE name
|
|
691
|
+
processed_sql = sql.gsub(/:_\b/, current_cte)
|
|
692
|
+
|
|
693
|
+
# Wrap current SELECT in numbered CTE
|
|
694
|
+
new_cte = "#{current_cte} AS (\n #{select}\n)"
|
|
695
|
+
|
|
696
|
+
append_cte(new_cte).then do |q|
|
|
697
|
+
# Replace the SELECT token with processed_sql and increment depth
|
|
698
|
+
new_sql = q.tokens.each_with_object([]) do |token, acc|
|
|
699
|
+
v = (token[:t] == "SELECT") ? processed_sql : token[:v]
|
|
602
700
|
acc << v
|
|
603
|
-
end.join
|
|
604
|
-
|
|
605
|
-
append_cte("_ as (\n #{select}\n)").with_select(sql)
|
|
701
|
+
end.join
|
|
702
|
+
q.deep_dup(sql: new_sql, cte_depth: cte_depth + 1)
|
|
606
703
|
end
|
|
607
704
|
end
|
|
608
705
|
|
data/mise.toml
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.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -73,7 +73,7 @@ licenses:
|
|
|
73
73
|
metadata:
|
|
74
74
|
homepage_uri: https://github.com/eval/appquery
|
|
75
75
|
source_code_uri: https://github.com/eval/appquery
|
|
76
|
-
changelog_uri: https://github.com/eval/
|
|
76
|
+
changelog_uri: https://github.com/eval/appquery/blob/main/CHANGELOG.md
|
|
77
77
|
rdoc_options: []
|
|
78
78
|
require_paths:
|
|
79
79
|
- lib
|