appquery 0.3.0 → 0.4.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 +20 -0
- data/.yardopts +11 -0
- data/Appraisals +14 -4
- data/CHANGELOG.md +7 -2
- data/README.md +45 -253
- data/lib/app_query/render_helpers.rb +204 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +478 -40
- data/rakelib/gem.rake +25 -0
- data/rakelib/yard.rake +17 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e96dbd6cf521caaee6d919ee3dd7fc796bb92f48ef3e135271b8b82408fb6bcc
|
|
4
|
+
data.tar.gz: 0c2250d2a62773ae26ff79469ea6b7e0a63ac246f2824518495a0f4f2ef2b13c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 14e0146910530087f2c56478e5c507a74ad9b6ccfea81a5c52e46fe24c07f4ef232db86056e1b97c7b7ed9ac976543b8e9c0ac4fcfcc90805aa9d668b426e061
|
|
7
|
+
data.tar.gz: 21e8afa59badf98018ba334960b3a6f301511a84305ea4941627f9d9dce3a1b24a23e52201c2a14f0a8912efbdaf9da1742b010acc364ceb72560a7910cab8e2
|
data/.irbrc
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
puts "Loading #{__FILE__}"
|
|
2
|
+
# put overrides/additions in '_irbrc'
|
|
3
|
+
|
|
4
|
+
IRB.conf[:HISTORY_FILE] = "#{ENV["PROJECT_ROOT"]}/tmp/.irb_history"
|
|
5
|
+
|
|
6
|
+
# Custom IRB prompt showing database adapter
|
|
7
|
+
db_indicator = begin
|
|
8
|
+
adapter = ActiveRecord::Base.connection.adapter_name.downcase
|
|
9
|
+
"\e[33m[#{adapter}]\e[0m "
|
|
10
|
+
rescue ActiveRecord::ConnectionNotEstablished, NameError
|
|
11
|
+
"\e[34m[no-db]\e[0m "
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
IRB.conf[:PROMPT][:APPQUERY] = {
|
|
15
|
+
PROMPT_I: "#{db_indicator}appquery> ",
|
|
16
|
+
PROMPT_S: "#{db_indicator}appquery%l ",
|
|
17
|
+
PROMPT_C: "#{db_indicator}appquery* ",
|
|
18
|
+
RETURN: "=> %s\n"
|
|
19
|
+
}
|
|
20
|
+
IRB.conf[:PROMPT_MODE] = :APPQUERY
|
data/.yardopts
ADDED
data/Appraisals
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
appraise "rails-70" do
|
|
2
|
-
gem "rails", "~> 7.0"
|
|
2
|
+
gem "rails", "~> 7.0.0"
|
|
3
|
+
gem "sqlite3", "~> 1.4"
|
|
3
4
|
end
|
|
4
5
|
|
|
5
6
|
appraise "rails-71" do
|
|
6
|
-
gem "rails", "~> 7.1"
|
|
7
|
+
gem "rails", "~> 7.1.0"
|
|
7
8
|
end
|
|
8
9
|
|
|
9
10
|
appraise "rails-72" do
|
|
10
|
-
gem "rails", "~> 7.2"
|
|
11
|
+
gem "rails", "~> 7.2.0"
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
appraise "rails-80" do
|
|
14
|
-
gem "rails", "~> 8.0"
|
|
15
|
+
gem "rails", "~> 8.0.0"
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
appraise "rails-81" do
|
|
19
|
+
gem "rails", "~> 8.1.0"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
appraise "rails-head" do
|
|
23
|
+
gem "activerecord", github: "rails/rails"
|
|
24
|
+
gem "rails", github: "rails/rails"
|
|
15
25
|
end
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
-
## [0.
|
|
3
|
+
## [0.4.0] - 2025-12-15
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
### features
|
|
6
|
+
|
|
7
|
+
- add insert, update and delete
|
|
8
|
+
- API docs at [eval.github.io/appquery](https://eval.github.io/appquery)
|
|
9
|
+
- add ERB-helpers [values, bind and quote ](https://eval.github.io/appquery/AppQuery/RenderHelpers.html).
|
|
10
|
+
- enabled trusted publishing to rubygems.org
|
data/README.md
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
# AppQuery - raw SQL 🥦, cooked :stew:
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/appquery)
|
|
4
|
+
[](https://eval.github.io/appquery/)
|
|
4
5
|
|
|
5
|
-
A Rubygem :gem: that makes working with raw SQL
|
|
6
|
+
A Rubygem :gem: that makes working with raw SQL queries in Rails projects convenient.
|
|
6
7
|
Specifically it provides:
|
|
7
8
|
- **...a dedicated folder for queries**
|
|
8
9
|
e.g. `app/queries/reports/weekly.sql` is instantiated via `AppQuery["reports/weekly"]`.
|
|
9
|
-
|
|
10
|
-
```
|
|
11
|
-
$ rails generate query reports/weekly
|
|
12
|
-
create app/queries/reports/weekly.sql
|
|
13
|
-
invoke rspec
|
|
14
|
-
create spec/queries/reports/weekly_query_spec.rb
|
|
15
|
-
```
|
|
10
|
+
|
|
16
11
|
- **...ERB templating**
|
|
17
12
|
Simple ERB templating with helper-functions:
|
|
18
13
|
```sql
|
|
@@ -26,7 +21,9 @@ Specifically it provides:
|
|
|
26
21
|
- **...positional and named binds**
|
|
27
22
|
Intuitive binds:
|
|
28
23
|
```ruby
|
|
29
|
-
AppQuery(
|
|
24
|
+
AppQuery(<<~SQL).select_value(binds: {interval: '1 day'})
|
|
25
|
+
select now() - (:interval)::interval as some_date
|
|
26
|
+
SQL
|
|
30
27
|
AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
|
|
31
28
|
select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
|
|
32
29
|
SQL
|
|
@@ -48,8 +45,8 @@ Specifically it provides:
|
|
|
48
45
|
VALUES(1, 'Some title'),
|
|
49
46
|
(2, 'Another article'))
|
|
50
47
|
CTE
|
|
51
|
-
```
|
|
52
|
-
- **...rspec
|
|
48
|
+
```
|
|
49
|
+
- **...rspec generators and helpers**
|
|
53
50
|
```ruby
|
|
54
51
|
RSpec.describe "AppQuery reports/weekly", type: :query do
|
|
55
52
|
describe "CTE some_cte" do
|
|
@@ -95,60 +92,65 @@ Testdriving can be easily done from the console. Either by cloning this reposito
|
|
|
95
92
|
```
|
|
96
93
|
</details>
|
|
97
94
|
|
|
98
|
-
The
|
|
95
|
+
The prompt indicates what adapter the example uses:
|
|
99
96
|
|
|
100
97
|
```ruby
|
|
101
98
|
# showing select_(all|one|value)
|
|
102
|
-
> AppQuery(%{select date('now') as today}).select_all.to_a
|
|
99
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.to_a
|
|
103
100
|
=> [{"today" => "2025-05-10"}]
|
|
104
|
-
> AppQuery(%{select date('now') as today}).select_one
|
|
101
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
105
102
|
=> {"today" => "2025-05-10"}
|
|
106
|
-
> AppQuery(%{select date('now') as today}).select_value
|
|
103
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
107
104
|
=> "2025-05-10"
|
|
108
105
|
|
|
109
106
|
# binds
|
|
110
107
|
# positional binds
|
|
111
|
-
> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
|
|
108
|
+
[postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
|
|
112
109
|
# named binds
|
|
113
|
-
> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
110
|
+
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
114
111
|
|
|
115
112
|
# casting
|
|
116
|
-
> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
|
|
113
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
|
|
117
114
|
=> [{"today" => Sat, 10 May 2025}]
|
|
118
115
|
|
|
119
116
|
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
120
|
-
sqlite> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
117
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
121
118
|
=> {"today" => "2025-05-12"}
|
|
122
119
|
## Providing per-column-casts fixes this:
|
|
123
120
|
casts = {"today" => ActiveRecord::Type::Date.new}
|
|
124
|
-
sqlite> AppQuery(%{select date('now') as today}).select_one(cast: casts)
|
|
121
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
|
|
125
122
|
=> {"today" => Mon, 12 May 2025}
|
|
126
123
|
|
|
124
|
+
|
|
127
125
|
# rewriting queries (using CTEs)
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
126
|
+
[postgresql]> articles = [
|
|
127
|
+
[1, "Using my new static site generator", 2.months.ago.to_date],
|
|
128
|
+
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
129
|
+
[3, "Another article", 2.weeks.ago.to_date]
|
|
130
|
+
]
|
|
131
|
+
[postgresql]> q = AppQuery(<<~SQL, cast: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
|
|
132
|
+
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
133
133
|
select * from articles order by id DESC
|
|
134
134
|
SQL
|
|
135
135
|
|
|
136
136
|
## query the articles-CTE
|
|
137
|
-
q.select_all(select: %{select * from articles where id < 2}).to_a
|
|
137
|
+
[postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
|
|
138
138
|
|
|
139
139
|
## query the end-result (available as the CTE named '_')
|
|
140
|
-
q.select_one(select: %{select * from _ limit 1})
|
|
140
|
+
[postgresql]> q.select_one(select: %{select * from _ limit 1})
|
|
141
141
|
|
|
142
142
|
## ERB templating
|
|
143
143
|
# Extract a query from q that can be sorted dynamically:
|
|
144
|
-
q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
|
|
145
|
-
q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
|
|
144
|
+
[postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
|
|
145
|
+
[postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
|
|
146
|
+
|
|
146
147
|
# shows latest articles first, and titles sorted alphabetically
|
|
147
148
|
# for articles published on the same date.
|
|
148
149
|
# order_by raises when it's passed something that would result in just `ORDER BY`:
|
|
149
|
-
q2.render(order: {})
|
|
150
|
+
[postgresql]> q2.render(order: {})
|
|
151
|
+
|
|
150
152
|
# doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
|
|
151
|
-
q2.select_all.entries
|
|
153
|
+
[postgresql]> q2.select_all.entries
|
|
152
154
|
|
|
153
155
|
# NOTE you can use both `order` and `@order`: local variables like `order` are required,
|
|
154
156
|
# while instance variables like `@order` are optional.
|
|
@@ -249,7 +251,8 @@ AppQuery[:recent_articles].select_all.entries
|
|
|
249
251
|
# we can provide a different cut off date via binds^1:
|
|
250
252
|
AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
|
|
251
253
|
|
|
252
|
-
1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
|
|
254
|
+
1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
|
|
255
|
+
$1 and $2 (which our query can deal with).
|
|
253
256
|
For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
|
|
254
257
|
```
|
|
255
258
|
|
|
@@ -346,229 +349,15 @@ There's some sugar:
|
|
|
346
349
|
The `binds`-value used when not explicitly provided.
|
|
347
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])`.
|
|
348
351
|
|
|
349
|
-
##
|
|
350
|
-
|
|
351
|
-
### generic
|
|
352
|
-
|
|
353
|
-
<details>
|
|
354
|
-
<summary><code>AppQuery(sql) ⇒ AppQuery::Q</code></summary>
|
|
355
|
-
|
|
356
|
-
### Examples
|
|
357
|
-
|
|
358
|
-
```ruby
|
|
359
|
-
AppQuery("some sql")
|
|
360
|
-
```
|
|
361
|
-
</details>
|
|
362
|
-
|
|
363
|
-
### module AppQuery
|
|
364
|
-
|
|
365
|
-
<details>
|
|
366
|
-
<summary><code>AppQuery[query_name] ⇒ AppQuery::Q</code></summary>
|
|
367
|
-
|
|
368
|
-
### Examples
|
|
369
|
-
|
|
370
|
-
```ruby
|
|
371
|
-
AppQuery[:recent_articles]
|
|
372
|
-
AppQuery["export/articles"]
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
</details>
|
|
376
|
-
|
|
377
|
-
<details>
|
|
378
|
-
<summary><code>AppQuery.configure {|Configuration| ... } ⇒ void </code></summary>
|
|
379
|
-
|
|
380
|
-
Configure AppQuery.
|
|
381
|
-
|
|
382
|
-
### Examples
|
|
383
|
-
|
|
384
|
-
```ruby
|
|
385
|
-
AppQuery.configure do |cfg|
|
|
386
|
-
cfg.query_path = "db/queries" # default: "app/queries"
|
|
387
|
-
end
|
|
388
|
-
```
|
|
389
|
-
|
|
390
|
-
</details>
|
|
391
|
-
|
|
392
|
-
<details>
|
|
393
|
-
<summary><code>AppQuery.configuration ⇒ AppQuery::Configuration </code></summary>
|
|
394
|
-
|
|
395
|
-
Get configuration
|
|
396
|
-
|
|
397
|
-
### Examples
|
|
398
|
-
|
|
399
|
-
```ruby
|
|
400
|
-
AppQuery.configure do |cfg|
|
|
401
|
-
cfg.query_path = "db/queries" # default: "app/queries"
|
|
402
|
-
end
|
|
403
|
-
AppQuery.configuration
|
|
404
|
-
```
|
|
405
|
-
|
|
406
|
-
</details>
|
|
407
|
-
|
|
408
|
-
### class AppQuery::Q
|
|
409
|
-
|
|
410
|
-
Instantiate via `AppQuery(sql)` or `AppQuery[:query_file]`.
|
|
411
|
-
|
|
412
|
-
<details>
|
|
413
|
-
<summary><code>AppQuery::Q#cte_names ⇒ [Array< String >] </code></summary>
|
|
414
|
-
|
|
415
|
-
Returns names of CTEs in query.
|
|
416
|
-
|
|
417
|
-
### Examples
|
|
418
|
-
|
|
419
|
-
```ruby
|
|
420
|
-
AppQuery("select * from articles").cte_names # => []
|
|
421
|
-
AppQuery("with foo as(select 1) select * from foo").cte_names # => ["foo"]
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
</details>
|
|
425
|
-
|
|
426
|
-
<details>
|
|
427
|
-
<summary><code>AppQuery::Q#recursive? ⇒ Boolean </code></summary>
|
|
428
|
-
|
|
429
|
-
Returns whether or not the WITH-clause is recursive or not.
|
|
430
|
-
|
|
431
|
-
### Examples
|
|
432
|
-
|
|
433
|
-
```ruby
|
|
434
|
-
AppQuery("select * from articles").recursive? # => false
|
|
435
|
-
AppQuery("with recursive foo as(select 1) select * from foo") # => true
|
|
436
|
-
```
|
|
437
|
-
|
|
438
|
-
</details>
|
|
439
|
-
|
|
440
|
-
<details>
|
|
441
|
-
<summary><code>AppQuery::Q#select ⇒ String </code></summary>
|
|
352
|
+
## API Documentation
|
|
442
353
|
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
### Examples
|
|
446
|
-
|
|
447
|
-
```ruby
|
|
448
|
-
AppQuery("select * from articles") # => "select * from articles"
|
|
449
|
-
AppQuery("with foo as(select 1) select * from foo") # => "select * from foo"
|
|
450
|
-
```
|
|
451
|
-
|
|
452
|
-
</details>
|
|
453
|
-
|
|
454
|
-
#### query execution
|
|
455
|
-
|
|
456
|
-
<details>
|
|
457
|
-
<summary><code>AppQuery::Q#select_all(select: nil, binds: [], cast: false) ⇒ AppQuery::Result</code></summary>
|
|
458
|
-
|
|
459
|
-
`select` replaces the existing select. The existing select is wrapped in a CTE named `_`.
|
|
460
|
-
`binds` array with values for any (positional) placeholder in the query.
|
|
461
|
-
`cast` boolean or `Hash` indicating whether or not (and how) to cast. E.g. `{"some_column" => ActiveRecord::Type::Date.new}`.
|
|
462
|
-
|
|
463
|
-
### Examples
|
|
464
|
-
|
|
465
|
-
```ruby
|
|
466
|
-
# SQLite
|
|
467
|
-
aq = AppQuery(<<~SQL)
|
|
468
|
-
with data(id, title) as (
|
|
469
|
-
values('1', 'Some title'),
|
|
470
|
-
('2', 'Another title')
|
|
471
|
-
)
|
|
472
|
-
select * from data
|
|
473
|
-
where id=?1 or ?1 is null
|
|
474
|
-
SQL
|
|
475
|
-
|
|
476
|
-
# selecting from the select
|
|
477
|
-
aq.select_all(select: "select * from _ where id > 1").entries #=> [{...}]
|
|
478
|
-
|
|
479
|
-
# selecting from a CTE
|
|
480
|
-
aq.select_all(select: "select id from data").entries
|
|
481
|
-
|
|
482
|
-
# casting
|
|
483
|
-
aq.select_all(select: "select id from data", cast: {"id" => ActiveRecord::Type::Integer.new})
|
|
484
|
-
|
|
485
|
-
# binds
|
|
486
|
-
aq.select_all(binds: ['2'])
|
|
487
|
-
```
|
|
488
|
-
|
|
489
|
-
</details>
|
|
490
|
-
|
|
491
|
-
<details>
|
|
492
|
-
<summary><code>AppQuery::Q#select_one(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
|
|
493
|
-
|
|
494
|
-
First result from `AppQuery::Q#select_all`.
|
|
495
|
-
|
|
496
|
-
See examples from `AppQuery::Q#select_all`.
|
|
497
|
-
|
|
498
|
-
</details>
|
|
499
|
-
|
|
500
|
-
<details>
|
|
501
|
-
<summary><code>AppQuery::Q#select_value(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
|
|
502
|
-
|
|
503
|
-
First value from `AppQuery::Q#select_one`. Typically for selects like `select count(*) ...`, `select min(article_published_on) ...`.
|
|
504
|
-
|
|
505
|
-
See examples from `AppQuery::Q#select_all`.
|
|
506
|
-
|
|
507
|
-
</details>
|
|
508
|
-
|
|
509
|
-
#### query rewriting
|
|
510
|
-
|
|
511
|
-
<details>
|
|
512
|
-
<summary><code>AppQuery::Q#with_select(sql) ⇒ AppQuery::Q</code></summary>
|
|
513
|
-
|
|
514
|
-
Returns new instance with provided select. The existing select is available via CTE `_`.
|
|
515
|
-
|
|
516
|
-
### Examples
|
|
517
|
-
|
|
518
|
-
```ruby
|
|
519
|
-
puts AppQuery("select 1").with_select("select 2")
|
|
520
|
-
WITH _ as (
|
|
521
|
-
select 1
|
|
522
|
-
)
|
|
523
|
-
select 2
|
|
524
|
-
```
|
|
525
|
-
|
|
526
|
-
</details>
|
|
527
|
-
|
|
528
|
-
<details>
|
|
529
|
-
<summary><code>AppQuery::Q#prepend_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
530
|
-
|
|
531
|
-
Returns new instance with provided CTE.
|
|
532
|
-
|
|
533
|
-
### Examples
|
|
534
|
-
|
|
535
|
-
```ruby
|
|
536
|
-
query.prepend_cte("foo as (values(1, 'Some article'))").cte_names # => ["foo", "existing_cte"]
|
|
537
|
-
```
|
|
538
|
-
|
|
539
|
-
</details>
|
|
540
|
-
|
|
541
|
-
<details>
|
|
542
|
-
<summary><code>AppQuery::Q#append_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
543
|
-
|
|
544
|
-
Returns new instance with provided CTE.
|
|
545
|
-
|
|
546
|
-
### Examples
|
|
547
|
-
|
|
548
|
-
```ruby
|
|
549
|
-
query.append_cte("foo as (values(1, 'Some article'))").cte_names # => ["existing_cte", "foo"]
|
|
550
|
-
```
|
|
551
|
-
|
|
552
|
-
</details>
|
|
553
|
-
|
|
554
|
-
<details>
|
|
555
|
-
<summary><code>AppQuery::Q#replace_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
556
|
-
|
|
557
|
-
Returns new instance with replaced CTE. Raises `ArgumentError` when CTE does not already exist.
|
|
558
|
-
|
|
559
|
-
### Examples
|
|
560
|
-
|
|
561
|
-
```ruby
|
|
562
|
-
query.replace_cte("recent_articles as (select values(1, 'Some article'))")
|
|
563
|
-
```
|
|
564
|
-
|
|
565
|
-
</details>
|
|
354
|
+
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
566
355
|
|
|
567
356
|
## Compatibility
|
|
568
357
|
|
|
569
358
|
- 💾 tested with **SQLite** and **PostgreSQL**
|
|
570
|
-
- 🚆 tested with Rails
|
|
571
|
-
- 💎 requires Ruby
|
|
359
|
+
- 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
|
|
360
|
+
- 💎 requires Ruby **>=v3.2**
|
|
572
361
|
Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
|
|
573
362
|
|
|
574
363
|
## Development
|
|
@@ -581,11 +370,14 @@ Using [mise](https://mise.jdx.dev/) for env-vars recommended.
|
|
|
581
370
|
|
|
582
371
|
The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
|
|
583
372
|
```bash
|
|
584
|
-
$
|
|
585
|
-
$
|
|
373
|
+
$ bin/console sqlite3::memory:
|
|
374
|
+
$ bin/console postgres://localhost:5432/some_db
|
|
586
375
|
|
|
587
376
|
# more details
|
|
588
|
-
$
|
|
377
|
+
$ bin/console -h
|
|
378
|
+
|
|
379
|
+
# when needing an appraisal, use bin/run (this ensures signals are handled correctly):
|
|
380
|
+
$ bin/run rails_head console
|
|
589
381
|
```
|
|
590
382
|
|
|
591
383
|
### various
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AppQuery
|
|
4
|
+
# Provides helper methods for rendering SQL templates in ERB.
|
|
5
|
+
#
|
|
6
|
+
# These helpers are available within ERB templates when using {Q#render}.
|
|
7
|
+
# They provide safe SQL construction with parameterized queries.
|
|
8
|
+
#
|
|
9
|
+
# @note These methods require +@collected_binds+ (Hash) and
|
|
10
|
+
# +@placeholder_counter+ (Integer) instance variables to be initialized
|
|
11
|
+
# in the including context.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage in an ERB template
|
|
14
|
+
# SELECT * FROM users WHERE name = <%= bind(name) %>
|
|
15
|
+
# <%= order_by(sorting) %>
|
|
16
|
+
#
|
|
17
|
+
# @see Q#render
|
|
18
|
+
module RenderHelpers
|
|
19
|
+
# Quotes a value for safe inclusion in SQL using ActiveRecord's quoting.
|
|
20
|
+
#
|
|
21
|
+
# Use this helper when you need to embed a literal value directly in SQL
|
|
22
|
+
# rather than using a bind parameter. This is useful for values that need
|
|
23
|
+
# to be visible in the SQL string itself.
|
|
24
|
+
#
|
|
25
|
+
# @param value [Object] the value to quote (typically a String or Number)
|
|
26
|
+
# @return [String] the SQL-safe quoted value
|
|
27
|
+
#
|
|
28
|
+
# @example Quoting a string with special characters
|
|
29
|
+
# quote("Let's learn SQL!") #=> "'Let''s learn SQL!'"
|
|
30
|
+
#
|
|
31
|
+
# @example In an ERB template
|
|
32
|
+
# INSERT INTO articles (title) VALUES(<%= quote(title) %>)
|
|
33
|
+
#
|
|
34
|
+
# @note Prefer {#bind} for parameterized queries when possible, as it
|
|
35
|
+
# provides better security and query plan caching.
|
|
36
|
+
#
|
|
37
|
+
# @see #bind
|
|
38
|
+
def quote(value)
|
|
39
|
+
ActiveRecord::Base.connection.quote(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Creates a named bind parameter placeholder and collects the value.
|
|
43
|
+
#
|
|
44
|
+
# This is the preferred way to include dynamic values in SQL queries.
|
|
45
|
+
# The value is collected internally and a placeholder (e.g., +:b1+) is
|
|
46
|
+
# returned for insertion into the SQL template.
|
|
47
|
+
#
|
|
48
|
+
# @param value [Object] the value to bind (any type supported by ActiveRecord)
|
|
49
|
+
# @return [String] the placeholder string (e.g., ":b1", ":b2", etc.)
|
|
50
|
+
#
|
|
51
|
+
# @example Basic bind usage
|
|
52
|
+
# bind("Some title") #=> ":b1" (with "Some title" added to collected binds)
|
|
53
|
+
#
|
|
54
|
+
# @example In an ERB template
|
|
55
|
+
# SELECT * FROM videos WHERE title = <%= bind(title) %>
|
|
56
|
+
# # Results in: SELECT * FROM videos WHERE title = :b1
|
|
57
|
+
# # With binds: {b1: <value of title>}
|
|
58
|
+
#
|
|
59
|
+
# @example Multiple binds
|
|
60
|
+
# SELECT * FROM t WHERE a = <%= bind(val1) %> AND b = <%= bind(val2) %>
|
|
61
|
+
# # Results in: SELECT * FROM t WHERE a = :b1 AND b = :b2
|
|
62
|
+
#
|
|
63
|
+
# @see #values for binding multiple values in a VALUES clause
|
|
64
|
+
# @see #quote for embedding quoted literals directly
|
|
65
|
+
def bind(value)
|
|
66
|
+
collect_bind(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generates a SQL VALUES clause from a collection with automatic bind parameters.
|
|
70
|
+
#
|
|
71
|
+
# Supports three input formats:
|
|
72
|
+
# 1. *Array of Arrays* - Simple row data without column names
|
|
73
|
+
# 2. *Array of Hashes* - Row data with automatic column name extraction
|
|
74
|
+
# 3. *Collection with block* - Custom value transformation per row
|
|
75
|
+
#
|
|
76
|
+
# @param coll [Array<Array>, Array<Hash>] the collection of row data
|
|
77
|
+
# @param skip_columns [Boolean] when true, omits the column name list
|
|
78
|
+
# (useful for UNION ALL or CTEs where column names are defined elsewhere)
|
|
79
|
+
# @yield [item] optional block to transform each item into an array of SQL expressions
|
|
80
|
+
# @yieldparam item [Object] each item from the collection
|
|
81
|
+
# @yieldreturn [Array<String>] array of SQL expressions for the row values
|
|
82
|
+
# @return [String] the complete VALUES clause SQL fragment
|
|
83
|
+
#
|
|
84
|
+
# @example Array of arrays (simplest form)
|
|
85
|
+
# values([[1, "Title A"], [2, "Title B"]])
|
|
86
|
+
# #=> "VALUES (:b1, :b2),\n(:b3, :b4)"
|
|
87
|
+
# # binds: {b1: 1, b2: "Title A", b3: 2, b4: "Title B"}
|
|
88
|
+
#
|
|
89
|
+
# @example Array of hashes (with automatic column names)
|
|
90
|
+
# values([{id: 1, title: "Video A"}, {id: 2, title: "Video B"}])
|
|
91
|
+
# #=> "(id, title) VALUES (:b1, :b2),\n(:b3, :b4)"
|
|
92
|
+
#
|
|
93
|
+
# @example Hashes with mixed keys (NULL for missing values)
|
|
94
|
+
# values([{title: "A"}, {title: "B", published_on: "2024-01-01"}])
|
|
95
|
+
# #=> "(title, published_on) VALUES (:b1, NULL),\n(:b2, :b3)"
|
|
96
|
+
#
|
|
97
|
+
# @example Skip columns for UNION ALL
|
|
98
|
+
# SELECT id FROM articles UNION ALL <%= values([{id: 1}], skip_columns: true) %>
|
|
99
|
+
# #=> "SELECT id FROM articles UNION ALL VALUES (:b1)"
|
|
100
|
+
#
|
|
101
|
+
# @example With block for custom expressions
|
|
102
|
+
# values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] }
|
|
103
|
+
# #=> "VALUES (:b1, 'Escaped Title', now()), (:b2, 'Other', now())"
|
|
104
|
+
#
|
|
105
|
+
# @example In a CTE
|
|
106
|
+
# WITH articles(id, title) AS (<%= values(data) %>)
|
|
107
|
+
# SELECT * FROM articles
|
|
108
|
+
#
|
|
109
|
+
# @see #bind for individual value binding
|
|
110
|
+
# @see #quote for quoting literal values
|
|
111
|
+
#
|
|
112
|
+
# TODO: Add types: parameter to cast bind placeholders (needed for UNION ALL
|
|
113
|
+
# where PG can't infer types). E.g. values([[1]], types: [:integer])
|
|
114
|
+
# would generate VALUES (:b1::integer)
|
|
115
|
+
def values(coll, skip_columns: false, &block)
|
|
116
|
+
first = coll.first
|
|
117
|
+
|
|
118
|
+
# For hash collections, collect all unique keys
|
|
119
|
+
if first.is_a?(Hash) && !block
|
|
120
|
+
all_keys = coll.flat_map(&:keys).uniq
|
|
121
|
+
|
|
122
|
+
rows = coll.map do |row|
|
|
123
|
+
vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
|
|
124
|
+
"(#{vals.join(", ")})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
|
|
128
|
+
"#{columns}VALUES #{rows.join(",\n")}"
|
|
129
|
+
else
|
|
130
|
+
# Arrays or block - current behavior
|
|
131
|
+
rows = coll.map do |item|
|
|
132
|
+
vals = if block
|
|
133
|
+
block.call(item)
|
|
134
|
+
elsif item.is_a?(Array)
|
|
135
|
+
item.map { |v| collect_bind(v) }
|
|
136
|
+
else
|
|
137
|
+
[collect_bind(item)]
|
|
138
|
+
end
|
|
139
|
+
"(#{vals.join(", ")})"
|
|
140
|
+
end
|
|
141
|
+
"VALUES #{rows.join(",\n")}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Generates an ORDER BY clause from a hash of column directions.
|
|
146
|
+
#
|
|
147
|
+
# Converts a hash of column names and sort directions into a valid
|
|
148
|
+
# SQL ORDER BY clause.
|
|
149
|
+
#
|
|
150
|
+
# @param hash [Hash{Symbol, String => Symbol, String, nil}] column names mapped to
|
|
151
|
+
# sort directions (+:asc+, +:desc+, +"ASC"+, +"DESC"+) or nil for default
|
|
152
|
+
# @return [String] the complete ORDER BY clause
|
|
153
|
+
#
|
|
154
|
+
# @example Basic ordering
|
|
155
|
+
# order_by(year: :desc, month: :desc)
|
|
156
|
+
# #=> "ORDER BY year DESC, month DESC"
|
|
157
|
+
#
|
|
158
|
+
# @example Mixed directions
|
|
159
|
+
# order_by(published_on: :desc, title: :asc)
|
|
160
|
+
# #=> "ORDER BY published_on DESC, title ASC"
|
|
161
|
+
#
|
|
162
|
+
# @example Column without direction (uses database default)
|
|
163
|
+
# order_by(id: nil)
|
|
164
|
+
# #=> "ORDER BY id"
|
|
165
|
+
#
|
|
166
|
+
# @example In an ERB template with a variable
|
|
167
|
+
# SELECT * FROM articles
|
|
168
|
+
# <%= order_by(ordering) %>
|
|
169
|
+
#
|
|
170
|
+
# @example Making it optional (when ordering may not be provided)
|
|
171
|
+
# <%= @ordering.presence && order_by(ordering) %>
|
|
172
|
+
#
|
|
173
|
+
# @example With default fallback
|
|
174
|
+
# <%= order_by(@order || {id: :desc}) %>
|
|
175
|
+
#
|
|
176
|
+
# @raise [ArgumentError] if hash is blank (nil, empty, or not present)
|
|
177
|
+
#
|
|
178
|
+
# @note The hash must not be blank. Use conditional ERB for optional ordering.
|
|
179
|
+
def order_by(hash)
|
|
180
|
+
raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
|
|
181
|
+
"ORDER BY " + hash.map do |k, v|
|
|
182
|
+
v.nil? ? k : [k, v.upcase].join(" ")
|
|
183
|
+
end.join(", ")
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
private
|
|
187
|
+
|
|
188
|
+
# Collects a value as a bind parameter and returns the placeholder name.
|
|
189
|
+
#
|
|
190
|
+
# This is the internal mechanism used by {#bind} and {#values} to
|
|
191
|
+
# accumulate bind values during template rendering. Each call generates
|
|
192
|
+
# a unique placeholder name (b1, b2, b3, ...).
|
|
193
|
+
#
|
|
194
|
+
# @api private
|
|
195
|
+
# @param value [Object] the value to collect
|
|
196
|
+
# @return [String] the placeholder string with colon prefix (e.g., ":b1")
|
|
197
|
+
def collect_bind(value)
|
|
198
|
+
@placeholder_counter += 1
|
|
199
|
+
key = :"b#{@placeholder_counter}"
|
|
200
|
+
@collected_binds[key] = value
|
|
201
|
+
":#{key}"
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|