appquery 0.4.0.rc1 → 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/.yardopts +11 -0
- data/CHANGELOG.md +7 -2
- data/README.md +10 -227
- data/lib/app_query/render_helpers.rb +204 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +364 -106
- data/rakelib/yard.rake +17 -0
- metadata +4 -1
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/.yardopts
ADDED
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
|
|
@@ -352,223 +349,9 @@ There's some sugar:
|
|
|
352
349
|
The `binds`-value used when not explicitly provided.
|
|
353
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])`.
|
|
354
351
|
|
|
355
|
-
##
|
|
356
|
-
|
|
357
|
-
### generic
|
|
358
|
-
|
|
359
|
-
<details>
|
|
360
|
-
<summary><code>AppQuery(sql) ⇒ AppQuery::Q</code></summary>
|
|
361
|
-
|
|
362
|
-
### Examples
|
|
363
|
-
|
|
364
|
-
```ruby
|
|
365
|
-
AppQuery("some sql")
|
|
366
|
-
```
|
|
367
|
-
</details>
|
|
368
|
-
|
|
369
|
-
### module AppQuery
|
|
370
|
-
|
|
371
|
-
<details>
|
|
372
|
-
<summary><code>AppQuery[query_name] ⇒ AppQuery::Q</code></summary>
|
|
373
|
-
|
|
374
|
-
### Examples
|
|
375
|
-
|
|
376
|
-
```ruby
|
|
377
|
-
AppQuery[:recent_articles]
|
|
378
|
-
AppQuery["export/articles"]
|
|
379
|
-
```
|
|
380
|
-
|
|
381
|
-
</details>
|
|
382
|
-
|
|
383
|
-
<details>
|
|
384
|
-
<summary><code>AppQuery.configure {|Configuration| ... } ⇒ void </code></summary>
|
|
385
|
-
|
|
386
|
-
Configure AppQuery.
|
|
387
|
-
|
|
388
|
-
### Examples
|
|
389
|
-
|
|
390
|
-
```ruby
|
|
391
|
-
AppQuery.configure do |cfg|
|
|
392
|
-
cfg.query_path = "db/queries" # default: "app/queries"
|
|
393
|
-
end
|
|
394
|
-
```
|
|
395
|
-
|
|
396
|
-
</details>
|
|
397
|
-
|
|
398
|
-
<details>
|
|
399
|
-
<summary><code>AppQuery.configuration ⇒ AppQuery::Configuration </code></summary>
|
|
400
|
-
|
|
401
|
-
Get configuration
|
|
402
|
-
|
|
403
|
-
### Examples
|
|
404
|
-
|
|
405
|
-
```ruby
|
|
406
|
-
AppQuery.configure do |cfg|
|
|
407
|
-
cfg.query_path = "db/queries" # default: "app/queries"
|
|
408
|
-
end
|
|
409
|
-
AppQuery.configuration
|
|
410
|
-
```
|
|
411
|
-
|
|
412
|
-
</details>
|
|
413
|
-
|
|
414
|
-
### class AppQuery::Q
|
|
415
|
-
|
|
416
|
-
Instantiate via `AppQuery(sql)` or `AppQuery[:query_file]`.
|
|
417
|
-
|
|
418
|
-
<details>
|
|
419
|
-
<summary><code>AppQuery::Q#cte_names ⇒ [Array< String >] </code></summary>
|
|
420
|
-
|
|
421
|
-
Returns names of CTEs in query.
|
|
422
|
-
|
|
423
|
-
### Examples
|
|
424
|
-
|
|
425
|
-
```ruby
|
|
426
|
-
AppQuery("select * from articles").cte_names # => []
|
|
427
|
-
AppQuery("with foo as(select 1) select * from foo").cte_names # => ["foo"]
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
</details>
|
|
431
|
-
|
|
432
|
-
<details>
|
|
433
|
-
<summary><code>AppQuery::Q#recursive? ⇒ Boolean </code></summary>
|
|
434
|
-
|
|
435
|
-
Returns whether or not the WITH-clause is recursive or not.
|
|
436
|
-
|
|
437
|
-
### Examples
|
|
438
|
-
|
|
439
|
-
```ruby
|
|
440
|
-
AppQuery("select * from articles").recursive? # => false
|
|
441
|
-
AppQuery("with recursive foo as(select 1) select * from foo") # => true
|
|
442
|
-
```
|
|
443
|
-
|
|
444
|
-
</details>
|
|
445
|
-
|
|
446
|
-
<details>
|
|
447
|
-
<summary><code>AppQuery::Q#select ⇒ String </code></summary>
|
|
448
|
-
|
|
449
|
-
Returns select-part of the query. When using CTEs, this will be `<select>` in a query like `with foo as (select 1) <select>`.
|
|
450
|
-
|
|
451
|
-
### Examples
|
|
452
|
-
|
|
453
|
-
```ruby
|
|
454
|
-
AppQuery("select * from articles") # => "select * from articles"
|
|
455
|
-
AppQuery("with foo as(select 1) select * from foo") # => "select * from foo"
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
</details>
|
|
459
|
-
|
|
460
|
-
#### query execution
|
|
461
|
-
|
|
462
|
-
<details>
|
|
463
|
-
<summary><code>AppQuery::Q#select_all(select: nil, binds: [], cast: false) ⇒ AppQuery::Result</code></summary>
|
|
464
|
-
|
|
465
|
-
`select` replaces the existing select. The existing select is wrapped in a CTE named `_`.
|
|
466
|
-
`binds` array with values for any (positional) placeholder in the query.
|
|
467
|
-
`cast` boolean or `Hash` indicating whether or not (and how) to cast. E.g. `{"some_column" => ActiveRecord::Type::Date.new}`.
|
|
468
|
-
|
|
469
|
-
### Examples
|
|
470
|
-
|
|
471
|
-
```ruby
|
|
472
|
-
# SQLite
|
|
473
|
-
aq = AppQuery(<<~SQL)
|
|
474
|
-
with data(id, title) as (
|
|
475
|
-
values('1', 'Some title'),
|
|
476
|
-
('2', 'Another title')
|
|
477
|
-
)
|
|
478
|
-
select * from data
|
|
479
|
-
where id=?1 or ?1 is null
|
|
480
|
-
SQL
|
|
481
|
-
|
|
482
|
-
# selecting from the select
|
|
483
|
-
aq.select_all(select: "select * from _ where id > 1").entries #=> [{...}]
|
|
484
|
-
|
|
485
|
-
# selecting from a CTE
|
|
486
|
-
aq.select_all(select: "select id from data").entries
|
|
487
|
-
|
|
488
|
-
# casting
|
|
489
|
-
aq.select_all(select: "select id from data", cast: {"id" => ActiveRecord::Type::Integer.new})
|
|
490
|
-
|
|
491
|
-
# binds
|
|
492
|
-
aq.select_all(binds: ['2'])
|
|
493
|
-
```
|
|
494
|
-
|
|
495
|
-
</details>
|
|
496
|
-
|
|
497
|
-
<details>
|
|
498
|
-
<summary><code>AppQuery::Q#select_one(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
|
|
499
|
-
|
|
500
|
-
First result from `AppQuery::Q#select_all`.
|
|
501
|
-
|
|
502
|
-
See examples from `AppQuery::Q#select_all`.
|
|
503
|
-
|
|
504
|
-
</details>
|
|
505
|
-
|
|
506
|
-
<details>
|
|
507
|
-
<summary><code>AppQuery::Q#select_value(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
|
|
508
|
-
|
|
509
|
-
First value from `AppQuery::Q#select_one`. Typically for selects like `select count(*) ...`, `select min(article_published_on) ...`.
|
|
510
|
-
|
|
511
|
-
See examples from `AppQuery::Q#select_all`.
|
|
512
|
-
|
|
513
|
-
</details>
|
|
514
|
-
|
|
515
|
-
#### query rewriting
|
|
516
|
-
|
|
517
|
-
<details>
|
|
518
|
-
<summary><code>AppQuery::Q#with_select(sql) ⇒ AppQuery::Q</code></summary>
|
|
352
|
+
## API Documentation
|
|
519
353
|
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
### Examples
|
|
523
|
-
|
|
524
|
-
```ruby
|
|
525
|
-
puts AppQuery("select 1").with_select("select 2")
|
|
526
|
-
WITH _ as (
|
|
527
|
-
select 1
|
|
528
|
-
)
|
|
529
|
-
select 2
|
|
530
|
-
```
|
|
531
|
-
|
|
532
|
-
</details>
|
|
533
|
-
|
|
534
|
-
<details>
|
|
535
|
-
<summary><code>AppQuery::Q#prepend_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
536
|
-
|
|
537
|
-
Returns new instance with provided CTE.
|
|
538
|
-
|
|
539
|
-
### Examples
|
|
540
|
-
|
|
541
|
-
```ruby
|
|
542
|
-
query.prepend_cte("foo as (values(1, 'Some article'))").cte_names # => ["foo", "existing_cte"]
|
|
543
|
-
```
|
|
544
|
-
|
|
545
|
-
</details>
|
|
546
|
-
|
|
547
|
-
<details>
|
|
548
|
-
<summary><code>AppQuery::Q#append_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
549
|
-
|
|
550
|
-
Returns new instance with provided CTE.
|
|
551
|
-
|
|
552
|
-
### Examples
|
|
553
|
-
|
|
554
|
-
```ruby
|
|
555
|
-
query.append_cte("foo as (values(1, 'Some article'))").cte_names # => ["existing_cte", "foo"]
|
|
556
|
-
```
|
|
557
|
-
|
|
558
|
-
</details>
|
|
559
|
-
|
|
560
|
-
<details>
|
|
561
|
-
<summary><code>AppQuery::Q#replace_cte(sql) ⇒ AppQuery::Q</code></summary>
|
|
562
|
-
|
|
563
|
-
Returns new instance with replaced CTE. Raises `ArgumentError` when CTE does not already exist.
|
|
564
|
-
|
|
565
|
-
### Examples
|
|
566
|
-
|
|
567
|
-
```ruby
|
|
568
|
-
query.replace_cte("recent_articles as (select values(1, 'Some article'))")
|
|
569
|
-
```
|
|
570
|
-
|
|
571
|
-
</details>
|
|
354
|
+
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
572
355
|
|
|
573
356
|
## Compatibility
|
|
574
357
|
|
|
@@ -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
|
data/lib/app_query/version.rb
CHANGED
data/lib/app_query.rb
CHANGED
|
@@ -2,23 +2,67 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "app_query/version"
|
|
4
4
|
require_relative "app_query/tokenizer"
|
|
5
|
+
require_relative "app_query/render_helpers"
|
|
5
6
|
require "active_record"
|
|
6
7
|
|
|
8
|
+
# AppQuery provides a way to work with raw SQL queries using ERB templating,
|
|
9
|
+
# parameter binding, and CTE manipulation.
|
|
10
|
+
#
|
|
11
|
+
# @example Using the global function
|
|
12
|
+
# AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
|
|
13
|
+
# AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
|
|
14
|
+
#
|
|
15
|
+
# @example Loading queries from files
|
|
16
|
+
# # Loads from app/queries/invoices.sql
|
|
17
|
+
# AppQuery[:invoices].select_all
|
|
18
|
+
#
|
|
19
|
+
# @example Configuration
|
|
20
|
+
# AppQuery.configure do |config|
|
|
21
|
+
# config.query_path = "db/queries"
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# @example CTE manipulation
|
|
25
|
+
# AppQuery(<<~SQL).select_all(select: "select * from articles where id = 1")
|
|
26
|
+
# WITH articles AS(...)
|
|
27
|
+
# SELECT * FROM articles
|
|
28
|
+
# ORDER BY id
|
|
29
|
+
# SQL
|
|
7
30
|
module AppQuery
|
|
31
|
+
# Generic error class for AppQuery errors.
|
|
8
32
|
class Error < StandardError; end
|
|
9
33
|
|
|
34
|
+
# Raised when attempting to execute a query that contains unrendered ERB.
|
|
10
35
|
class UnrenderedQueryError < StandardError; end
|
|
11
36
|
|
|
37
|
+
# Configuration options for AppQuery.
|
|
38
|
+
#
|
|
39
|
+
# @!attribute query_path
|
|
40
|
+
# @return [String] the directory path where query files are located
|
|
41
|
+
# (default: "app/queries")
|
|
12
42
|
Configuration = Struct.new(:query_path)
|
|
13
43
|
|
|
44
|
+
# Returns the current configuration.
|
|
45
|
+
#
|
|
46
|
+
# @return [Configuration] the configuration instance
|
|
14
47
|
def self.configuration
|
|
15
48
|
@configuration ||= AppQuery::Configuration.new
|
|
16
49
|
end
|
|
17
50
|
|
|
51
|
+
# Yields the configuration for modification.
|
|
52
|
+
#
|
|
53
|
+
# @yield [Configuration] the configuration instance
|
|
54
|
+
#
|
|
55
|
+
# @example
|
|
56
|
+
# AppQuery.configure do |config|
|
|
57
|
+
# config.query_path = "db/queries"
|
|
58
|
+
# end
|
|
18
59
|
def self.configure
|
|
19
60
|
yield configuration if block_given?
|
|
20
61
|
end
|
|
21
62
|
|
|
63
|
+
# Resets configuration to default values.
|
|
64
|
+
#
|
|
65
|
+
# @return [void]
|
|
22
66
|
def self.reset_configuration!
|
|
23
67
|
configure do |config|
|
|
24
68
|
config.query_path = "app/queries"
|
|
@@ -26,10 +70,20 @@ module AppQuery
|
|
|
26
70
|
end
|
|
27
71
|
reset_configuration!
|
|
28
72
|
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
73
|
+
# Loads a query from a file in the configured query path.
|
|
74
|
+
#
|
|
75
|
+
# @param query_name [String, Symbol] the query name or path (without extension)
|
|
76
|
+
# @param opts [Hash] additional options passed to {Q#initialize}
|
|
77
|
+
# @return [Q] a new query object loaded from the file
|
|
78
|
+
#
|
|
79
|
+
# @example Load a simple query
|
|
80
|
+
# AppQuery[:invoices] # loads app/queries/invoices.sql
|
|
81
|
+
#
|
|
82
|
+
# @example Load from a subdirectory
|
|
83
|
+
# AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
|
|
84
|
+
#
|
|
85
|
+
# @example Load with explicit extension
|
|
86
|
+
# AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
|
|
33
87
|
def self.[](query_name, **opts)
|
|
34
88
|
filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
|
|
35
89
|
full_path = (Pathname.new(configuration.query_path) / filename).expand_path
|
|
@@ -97,9 +151,56 @@ module AppQuery
|
|
|
97
151
|
private_constant :EMPTY
|
|
98
152
|
end
|
|
99
153
|
|
|
154
|
+
# Query object for building, rendering, and executing SQL queries.
|
|
155
|
+
#
|
|
156
|
+
# Q wraps a SQL string (optionally with ERB templating) and provides methods
|
|
157
|
+
# for query execution, CTE manipulation, and result handling.
|
|
158
|
+
#
|
|
159
|
+
# ## Method Groups
|
|
160
|
+
#
|
|
161
|
+
# - **Rendering** — Process ERB templates to produce executable SQL.
|
|
162
|
+
# - **Query Execution** — Execute queries against the database. These methods
|
|
163
|
+
# wrap the equivalent `ActiveRecord::Base.connection` methods (`select_all`,
|
|
164
|
+
# `insert`, `update`, `delete`).
|
|
165
|
+
# - **Query Introspection** — Inspect and analyze the structure of the query.
|
|
166
|
+
# - **Query Transformation** — Create modified copies of the query. All
|
|
167
|
+
# transformation methods are immutable—they return a new {Q} instance and
|
|
168
|
+
# leave the original unchanged.
|
|
169
|
+
# - **CTE Manipulation** — Add, replace, or reorder Common Table Expressions
|
|
170
|
+
# (CTEs). Like transformation methods, these return a new {Q} instance.
|
|
171
|
+
#
|
|
172
|
+
# @example Basic query
|
|
173
|
+
# AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
|
|
174
|
+
#
|
|
175
|
+
# @example ERB templating
|
|
176
|
+
# AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
|
|
177
|
+
# .render(name: "Alice")
|
|
178
|
+
# .select_all
|
|
179
|
+
#
|
|
180
|
+
# @example CTE manipulation
|
|
181
|
+
# AppQuery("WITH base AS (SELECT 1) SELECT * FROM base")
|
|
182
|
+
# .append_cte("extra AS (SELECT 2)")
|
|
183
|
+
# .select_all
|
|
100
184
|
class Q
|
|
185
|
+
# @return [String, nil] optional name for the query (used in logs)
|
|
186
|
+
# @return [String] the SQL string
|
|
187
|
+
# @return [Array, Hash] bind parameters
|
|
188
|
+
# @return [Boolean, Hash, Array] casting configuration
|
|
101
189
|
attr_reader :name, :sql, :binds, :cast
|
|
102
190
|
|
|
191
|
+
# Creates a new query object.
|
|
192
|
+
#
|
|
193
|
+
# @param sql [String] the SQL query string (may contain ERB)
|
|
194
|
+
# @param name [String, nil] optional name for logging
|
|
195
|
+
# @param filename [String, nil] optional filename for ERB error reporting
|
|
196
|
+
# @param binds [Array, Hash] bind parameters for the query
|
|
197
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
198
|
+
#
|
|
199
|
+
# @example Simple query
|
|
200
|
+
# Q.new("SELECT * FROM users")
|
|
201
|
+
#
|
|
202
|
+
# @example With ERB and binds
|
|
203
|
+
# Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
|
|
103
204
|
def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
|
|
104
205
|
@sql = sql
|
|
105
206
|
@name = name
|
|
@@ -108,10 +209,13 @@ module AppQuery
|
|
|
108
209
|
@cast = cast
|
|
109
210
|
end
|
|
110
211
|
|
|
212
|
+
# @private
|
|
111
213
|
def deep_dup
|
|
112
214
|
super.send(:reset!)
|
|
113
215
|
end
|
|
216
|
+
private :deep_dup
|
|
114
217
|
|
|
218
|
+
# @private
|
|
115
219
|
def reset!
|
|
116
220
|
(instance_variables - %i[@sql @filename @name @binds @cast]).each do
|
|
117
221
|
instance_variable_set(_1, nil)
|
|
@@ -120,7 +224,44 @@ module AppQuery
|
|
|
120
224
|
end
|
|
121
225
|
private :reset!
|
|
122
226
|
|
|
123
|
-
|
|
227
|
+
# @!group Rendering
|
|
228
|
+
|
|
229
|
+
# Renders the ERB template with the given variables.
|
|
230
|
+
#
|
|
231
|
+
# Processes ERB tags in the SQL and collects any bind parameters created
|
|
232
|
+
# by helpers like {RenderHelpers#bind} and {RenderHelpers#values}.
|
|
233
|
+
#
|
|
234
|
+
# @param vars [Hash] variables to make available in the ERB template
|
|
235
|
+
# @return [Q] a new query object with rendered SQL and collected binds
|
|
236
|
+
#
|
|
237
|
+
# @example Rendering with variables
|
|
238
|
+
# AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
|
|
239
|
+
# .render(name: "Alice")
|
|
240
|
+
# # => Q with SQL: "SELECT * FROM users WHERE name = :b1"
|
|
241
|
+
# # and binds: {b1: "Alice"}
|
|
242
|
+
#
|
|
243
|
+
# @example Using instance variables
|
|
244
|
+
# AppQuery("SELECT * FROM users WHERE active = <%= @active %>")
|
|
245
|
+
# .render(active: true)
|
|
246
|
+
#
|
|
247
|
+
# @example vars are available as local and instance variable.
|
|
248
|
+
# # This fails as `ordering` is not provided:
|
|
249
|
+
# AppQuery(<<~SQL).render
|
|
250
|
+
# SELECT * FROM articles
|
|
251
|
+
# <%= order_by(ordering) %>
|
|
252
|
+
# SQL
|
|
253
|
+
#
|
|
254
|
+
# # ...but this query works without `ordering` being passed to render:
|
|
255
|
+
# AppQuery(<<~SQL).render
|
|
256
|
+
# SELECT * FROM articles
|
|
257
|
+
# <%= @ordering.presence && order_by(ordering) %>
|
|
258
|
+
# SQL
|
|
259
|
+
# # NOTE that `@ordering.present? && ...` would render as `false`.
|
|
260
|
+
# # Use `@ordering.presence` instead.
|
|
261
|
+
#
|
|
262
|
+
#
|
|
263
|
+
# @see RenderHelpers for available helper methods in templates
|
|
264
|
+
def render(vars = {})
|
|
124
265
|
vars ||= {}
|
|
125
266
|
helper = render_helper(vars)
|
|
126
267
|
sql = to_erb.result(helper.get_binding)
|
|
@@ -142,6 +283,7 @@ module AppQuery
|
|
|
142
283
|
def render_helper(vars)
|
|
143
284
|
Module.new do
|
|
144
285
|
extend self
|
|
286
|
+
include AppQuery::RenderHelpers
|
|
145
287
|
|
|
146
288
|
@collected_binds = {}
|
|
147
289
|
@placeholder_counter = 0
|
|
@@ -151,94 +293,8 @@ module AppQuery
|
|
|
151
293
|
instance_variable_set(:"@#{k}", v)
|
|
152
294
|
end
|
|
153
295
|
|
|
154
|
-
def collect_bind(value)
|
|
155
|
-
@placeholder_counter += 1
|
|
156
|
-
key = :"b#{@placeholder_counter}"
|
|
157
|
-
@collected_binds[key] = value
|
|
158
|
-
":#{key}"
|
|
159
|
-
end
|
|
160
|
-
|
|
161
296
|
attr_reader :collected_binds
|
|
162
297
|
|
|
163
|
-
# Examples
|
|
164
|
-
# quote("Let's learn Ruby") #=> 'Let''s learn Ruby'
|
|
165
|
-
def quote(...)
|
|
166
|
-
ActiveRecord::Base.connection.quote(...)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
# Examples
|
|
170
|
-
# <%= bind(title) %> #=> :b1 (with title added to binds)
|
|
171
|
-
def bind(value)
|
|
172
|
-
collect_bind(value)
|
|
173
|
-
end
|
|
174
|
-
|
|
175
|
-
# Examples
|
|
176
|
-
# <%= values([[1, "Some video"], [2, "Another video"]]) %>
|
|
177
|
-
# #=> VALUES (:b1, :b2), (:b3, :b4) with binds {b1: 1, b2: "Some video", ...}
|
|
178
|
-
#
|
|
179
|
-
# <%= values([{id: 1, title: "Some video"}]) %>
|
|
180
|
-
# #=> (id, title) VALUES (:b1, :b2) with binds {b1: 1, b2: "Some video"}
|
|
181
|
-
#
|
|
182
|
-
# <%= values([{title: "A"}, {title: "B", published_on: "2024-01-01"}]) %>
|
|
183
|
-
# #=> (title, published_on) VALUES (:b1, NULL), (:b2, :b3)
|
|
184
|
-
#
|
|
185
|
-
# Skip column names (e.g. for UNION ALL or CTEs):
|
|
186
|
-
# with articles as(
|
|
187
|
-
# <%= values([[1, "title"]], skip_columns: true) %>
|
|
188
|
-
# )
|
|
189
|
-
# #=> with articles as (VALUES (:b1, :b2))
|
|
190
|
-
#
|
|
191
|
-
# With block (mix bind() and quote()):
|
|
192
|
-
# <%= values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] } %>
|
|
193
|
-
# #=> VALUES (:b1, 'Some title', now()), (:b2, 'Other title', now())
|
|
194
|
-
def values(coll, skip_columns: false, &block)
|
|
195
|
-
first = coll.first
|
|
196
|
-
|
|
197
|
-
# For hash collections, collect all unique keys
|
|
198
|
-
if first.is_a?(Hash) && !block
|
|
199
|
-
all_keys = coll.flat_map(&:keys).uniq
|
|
200
|
-
|
|
201
|
-
rows = coll.map do |row|
|
|
202
|
-
vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
|
|
203
|
-
"(#{vals.join(", ")})"
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
|
|
207
|
-
"#{columns}VALUES #{rows.join(",\n")}"
|
|
208
|
-
else
|
|
209
|
-
# Arrays or block - current behavior
|
|
210
|
-
rows = coll.map do |item|
|
|
211
|
-
vals = if block
|
|
212
|
-
block.call(item)
|
|
213
|
-
elsif item.is_a?(Array)
|
|
214
|
-
item.map { |v| collect_bind(v) }
|
|
215
|
-
else
|
|
216
|
-
[collect_bind(item)]
|
|
217
|
-
end
|
|
218
|
-
"(#{vals.join(", ")})"
|
|
219
|
-
end
|
|
220
|
-
"VALUES #{rows.join(",\n")}"
|
|
221
|
-
end
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Examples
|
|
225
|
-
# <%= order_by({year: :desc, month: :desc}) %>
|
|
226
|
-
# #=> ORDER BY year DESC, month DESC
|
|
227
|
-
#
|
|
228
|
-
# Using variable:
|
|
229
|
-
# <%= order_by(ordering) %>
|
|
230
|
-
# NOTE Raises when ordering not provided or when blank.
|
|
231
|
-
#
|
|
232
|
-
# Make it optional:
|
|
233
|
-
# <%= @ordering.presence && order_by(ordering) %>
|
|
234
|
-
#
|
|
235
|
-
def order_by(hash)
|
|
236
|
-
raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
|
|
237
|
-
"ORDER BY " + hash.map do |k, v|
|
|
238
|
-
v.nil? ? k : [k, v.upcase].join(" ")
|
|
239
|
-
end.join(", ")
|
|
240
|
-
end
|
|
241
|
-
|
|
242
298
|
def get_binding
|
|
243
299
|
binding
|
|
244
300
|
end
|
|
@@ -246,6 +302,31 @@ module AppQuery
|
|
|
246
302
|
end
|
|
247
303
|
private :render_helper
|
|
248
304
|
|
|
305
|
+
# @!group Query Execution
|
|
306
|
+
|
|
307
|
+
# Executes the query and returns all matching rows.
|
|
308
|
+
#
|
|
309
|
+
# @param binds [Array, Hash, nil] bind parameters (positional or named)
|
|
310
|
+
# @param select [String, nil] override the SELECT clause
|
|
311
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
312
|
+
# @return [Result] the query results with optional type casting
|
|
313
|
+
#
|
|
314
|
+
# @example Simple query with positional binds
|
|
315
|
+
# AppQuery("SELECT * FROM users WHERE id = $1").select_all(binds: [1])
|
|
316
|
+
#
|
|
317
|
+
# @example Named binds
|
|
318
|
+
# AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
|
|
319
|
+
#
|
|
320
|
+
# @example With type casting
|
|
321
|
+
# AppQuery("SELECT created_at FROM users")
|
|
322
|
+
# .select_all(cast: {created_at: ActiveRecord::Type::DateTime.new})
|
|
323
|
+
#
|
|
324
|
+
# @example Override SELECT clause
|
|
325
|
+
# AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
|
|
326
|
+
#
|
|
327
|
+
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
328
|
+
# @raise [ArgumentError] if mixing positional binds with collected named binds
|
|
329
|
+
#
|
|
249
330
|
# TODO: have aliases for common casts: select_all(cast: {"today" => :date})
|
|
250
331
|
def select_all(binds: nil, select: nil, cast: self.cast)
|
|
251
332
|
with_select(select).render({}).then do |aq|
|
|
@@ -283,25 +364,61 @@ module AppQuery
|
|
|
283
364
|
raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
|
|
284
365
|
end
|
|
285
366
|
|
|
367
|
+
# Executes the query and returns the first row.
|
|
368
|
+
#
|
|
369
|
+
# @param binds [Array, Hash, nil] bind parameters (positional or named)
|
|
370
|
+
# @param select [String, nil] override the SELECT clause
|
|
371
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
372
|
+
# @return [Hash, nil] the first row as a hash, or nil if no results
|
|
373
|
+
#
|
|
374
|
+
# @example
|
|
375
|
+
# AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
|
|
376
|
+
# # => {"id" => 1, "name" => "Alice"}
|
|
377
|
+
#
|
|
378
|
+
# @see #select_all
|
|
286
379
|
def select_one(binds: nil, select: nil, cast: self.cast)
|
|
287
380
|
select_all(binds:, select:, cast:).first
|
|
288
381
|
end
|
|
289
382
|
|
|
383
|
+
# Executes the query and returns the first value of the first row.
|
|
384
|
+
#
|
|
385
|
+
# @param binds [Array, Hash, nil] bind parameters (positional or named)
|
|
386
|
+
# @param select [String, nil] override the SELECT clause
|
|
387
|
+
# @param cast [Boolean, Hash, Array] type casting configuration
|
|
388
|
+
# @return [Object, nil] the first value, or nil if no results
|
|
389
|
+
#
|
|
390
|
+
# @example
|
|
391
|
+
# AppQuery("SELECT COUNT(*) FROM users").select_value
|
|
392
|
+
# # => 42
|
|
393
|
+
#
|
|
394
|
+
# @see #select_one
|
|
290
395
|
def select_value(binds: nil, select: nil, cast: self.cast)
|
|
291
396
|
select_one(binds:, select:, cast:)&.values&.first
|
|
292
397
|
end
|
|
293
398
|
|
|
294
|
-
#
|
|
399
|
+
# Executes an INSERT query.
|
|
400
|
+
#
|
|
401
|
+
# @param binds [Array, Hash] bind parameters for the query
|
|
402
|
+
# @param returning [String, nil] columns to return (Rails 7.1+ only)
|
|
403
|
+
# @return [Integer, Object] the inserted ID or returning value
|
|
404
|
+
#
|
|
405
|
+
# @example With positional binds
|
|
295
406
|
# AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
|
|
296
|
-
# INSERT INTO videos(title, created_at, updated_at)
|
|
407
|
+
# INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
|
|
297
408
|
# SQL
|
|
298
409
|
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
302
|
-
# AppQuery(<<~SQL).render(articles:)
|
|
410
|
+
# @example With values helper
|
|
411
|
+
# articles = [{title: "First", created_at: Time.current}]
|
|
412
|
+
# AppQuery(<<~SQL).render(articles:).insert
|
|
303
413
|
# INSERT INTO articles(title, created_at) <%= values(articles) %>
|
|
304
414
|
# SQL
|
|
415
|
+
#
|
|
416
|
+
# @example With returning (Rails 7.1+)
|
|
417
|
+
# AppQuery("INSERT INTO users(name) VALUES($1)")
|
|
418
|
+
# .insert(binds: ["Alice"], returning: "id, created_at")
|
|
419
|
+
#
|
|
420
|
+
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
421
|
+
# @raise [ArgumentError] if returning is used with Rails < 7.1
|
|
305
422
|
def insert(binds: [], returning: nil)
|
|
306
423
|
# ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
|
|
307
424
|
if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
|
|
@@ -334,8 +451,20 @@ module AppQuery
|
|
|
334
451
|
raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
|
|
335
452
|
end
|
|
336
453
|
|
|
337
|
-
#
|
|
338
|
-
#
|
|
454
|
+
# Executes an UPDATE query.
|
|
455
|
+
#
|
|
456
|
+
# @param binds [Array, Hash] bind parameters for the query
|
|
457
|
+
# @return [Integer] the number of affected rows
|
|
458
|
+
#
|
|
459
|
+
# @example With named binds
|
|
460
|
+
# AppQuery("UPDATE videos SET title = 'New' WHERE id = :id")
|
|
461
|
+
# .update(binds: {id: 1})
|
|
462
|
+
#
|
|
463
|
+
# @example With positional binds
|
|
464
|
+
# AppQuery("UPDATE videos SET title = $1 WHERE id = $2")
|
|
465
|
+
# .update(binds: ["New Title", 1])
|
|
466
|
+
#
|
|
467
|
+
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
339
468
|
def update(binds: [])
|
|
340
469
|
binds = binds.presence || @binds
|
|
341
470
|
render({}).then do |aq|
|
|
@@ -355,8 +484,18 @@ module AppQuery
|
|
|
355
484
|
raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
|
|
356
485
|
end
|
|
357
486
|
|
|
358
|
-
#
|
|
487
|
+
# Executes a DELETE query.
|
|
488
|
+
#
|
|
489
|
+
# @param binds [Array, Hash] bind parameters for the query
|
|
490
|
+
# @return [Integer] the number of deleted rows
|
|
491
|
+
#
|
|
492
|
+
# @example With named binds
|
|
359
493
|
# AppQuery("DELETE FROM videos WHERE id = :id").delete(binds: {id: 1})
|
|
494
|
+
#
|
|
495
|
+
# @example With positional binds
|
|
496
|
+
# AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
|
|
497
|
+
#
|
|
498
|
+
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
360
499
|
def delete(binds: [])
|
|
361
500
|
binds = binds.presence || @binds
|
|
362
501
|
render({}).then do |aq|
|
|
@@ -376,36 +515,85 @@ module AppQuery
|
|
|
376
515
|
raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
|
|
377
516
|
end
|
|
378
517
|
|
|
518
|
+
# @!group Query Introspection
|
|
519
|
+
|
|
520
|
+
# Returns the tokenized representation of the SQL.
|
|
521
|
+
#
|
|
522
|
+
# @return [Array<Hash>] array of token hashes with :t (type) and :v (value) keys
|
|
523
|
+
# @see Tokenizer
|
|
379
524
|
def tokens
|
|
380
525
|
@tokens ||= tokenizer.run
|
|
381
526
|
end
|
|
382
527
|
|
|
528
|
+
# Returns the tokenizer instance for this query.
|
|
529
|
+
#
|
|
530
|
+
# @return [Tokenizer] the tokenizer
|
|
383
531
|
def tokenizer
|
|
384
532
|
@tokenizer ||= Tokenizer.new(to_s)
|
|
385
533
|
end
|
|
386
534
|
|
|
535
|
+
# Returns the names of all CTEs (Common Table Expressions) in the query.
|
|
536
|
+
#
|
|
537
|
+
# @return [Array<String>] the CTE names in order of appearance
|
|
538
|
+
#
|
|
539
|
+
# @example
|
|
540
|
+
# AppQuery("WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b").cte_names
|
|
541
|
+
# # => ["a", "b"]
|
|
387
542
|
def cte_names
|
|
388
543
|
tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
|
|
389
544
|
end
|
|
390
545
|
|
|
546
|
+
# @!group Query Transformation
|
|
547
|
+
|
|
548
|
+
# Returns a new query with different bind parameters.
|
|
549
|
+
#
|
|
550
|
+
# @param binds [Array, Hash] the new bind parameters
|
|
551
|
+
# @return [Q] a new query object with the specified binds
|
|
552
|
+
#
|
|
553
|
+
# @example
|
|
554
|
+
# query = AppQuery("SELECT * FROM users WHERE id = :id")
|
|
555
|
+
# query.with_binds(id: 1).select_one
|
|
391
556
|
def with_binds(binds)
|
|
392
557
|
deep_dup.tap do
|
|
393
558
|
_1.instance_variable_set(:@binds, binds)
|
|
394
559
|
end
|
|
395
560
|
end
|
|
396
561
|
|
|
562
|
+
# Returns a new query with different cast settings.
|
|
563
|
+
#
|
|
564
|
+
# @param cast [Boolean, Hash, Array] the new cast configuration
|
|
565
|
+
# @return [Q] a new query object with the specified cast settings
|
|
566
|
+
#
|
|
567
|
+
# @example
|
|
568
|
+
# query = AppQuery("SELECT created_at FROM users")
|
|
569
|
+
# query.with_cast(false).select_all # disable casting
|
|
397
570
|
def with_cast(cast)
|
|
398
571
|
deep_dup.tap do
|
|
399
572
|
_1.instance_variable_set(:@cast, cast)
|
|
400
573
|
end
|
|
401
574
|
end
|
|
402
575
|
|
|
576
|
+
# Returns a new query with different SQL.
|
|
577
|
+
#
|
|
578
|
+
# @param sql [String] the new SQL string
|
|
579
|
+
# @return [Q] a new query object with the specified SQL
|
|
403
580
|
def with_sql(sql)
|
|
404
581
|
deep_dup.tap do
|
|
405
582
|
_1.instance_variable_set(:@sql, sql)
|
|
406
583
|
end
|
|
407
584
|
end
|
|
408
585
|
|
|
586
|
+
# Returns a new query with a modified SELECT statement.
|
|
587
|
+
#
|
|
588
|
+
# If the query has a CTE named `"_"`, replaces the SELECT statement.
|
|
589
|
+
# Otherwise, wraps the original query in a `"_"` CTE and uses the new SELECT.
|
|
590
|
+
#
|
|
591
|
+
# @param sql [String, nil] the new SELECT statement (nil returns self)
|
|
592
|
+
# @return [Q] a new query object with the modified SELECT
|
|
593
|
+
#
|
|
594
|
+
# @example
|
|
595
|
+
# AppQuery("SELECT id, name FROM users").with_select("SELECT COUNT(*) FROM _")
|
|
596
|
+
# # => "WITH _ AS (\n SELECT id, name FROM users\n)\nSELECT COUNT(*) FROM _"
|
|
409
597
|
def with_select(sql)
|
|
410
598
|
return self if sql.nil?
|
|
411
599
|
if cte_names.include?("_")
|
|
@@ -418,16 +606,48 @@ module AppQuery
|
|
|
418
606
|
end
|
|
419
607
|
end
|
|
420
608
|
|
|
609
|
+
# @!group Query Introspection
|
|
610
|
+
|
|
611
|
+
# Returns the SELECT clause of the query.
|
|
612
|
+
#
|
|
613
|
+
# @return [String, nil] the SELECT clause, or nil if not found
|
|
614
|
+
#
|
|
615
|
+
# @example
|
|
616
|
+
# AppQuery("SELECT id, name FROM users").select
|
|
617
|
+
# # => "SELECT id, name FROM users"
|
|
421
618
|
def select
|
|
422
619
|
tokens.find { _1[:t] == "SELECT" }&.[](:v)
|
|
423
620
|
end
|
|
424
621
|
|
|
622
|
+
# Checks if the query uses RECURSIVE CTEs.
|
|
623
|
+
#
|
|
624
|
+
# @return [Boolean] true if the query contains WITH RECURSIVE
|
|
625
|
+
#
|
|
626
|
+
# @example
|
|
627
|
+
# AppQuery("WITH RECURSIVE t AS (...) SELECT * FROM t").recursive?
|
|
628
|
+
# # => true
|
|
425
629
|
def recursive?
|
|
426
630
|
!!tokens.find { _1[:t] == "RECURSIVE" }
|
|
427
631
|
end
|
|
428
632
|
|
|
429
|
-
#
|
|
430
|
-
|
|
633
|
+
# @!group CTE Manipulation
|
|
634
|
+
|
|
635
|
+
# Prepends a CTE to the beginning of the WITH clause.
|
|
636
|
+
#
|
|
637
|
+
# If the query has no CTEs, wraps it with WITH. If the query already has
|
|
638
|
+
# CTEs, adds the new CTE at the beginning.
|
|
639
|
+
#
|
|
640
|
+
# @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
|
|
641
|
+
# @return [Q] a new query object with the prepended CTE
|
|
642
|
+
#
|
|
643
|
+
# @example Adding a CTE to a simple query
|
|
644
|
+
# AppQuery("SELECT 1").prepend_cte("foo AS (SELECT 2)")
|
|
645
|
+
# # => "WITH foo AS (SELECT 2) SELECT 1"
|
|
646
|
+
#
|
|
647
|
+
# @example Prepending to existing CTEs
|
|
648
|
+
# AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
|
|
649
|
+
# .prepend_cte("foo AS (SELECT 1)")
|
|
650
|
+
# # => "WITH foo AS (SELECT 1), bar AS (SELECT 2) SELECT * FROM bar"
|
|
431
651
|
def prepend_cte(cte)
|
|
432
652
|
# early raise when cte is not valid sql
|
|
433
653
|
to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
|
|
@@ -448,8 +668,22 @@ module AppQuery
|
|
|
448
668
|
end
|
|
449
669
|
end
|
|
450
670
|
|
|
451
|
-
#
|
|
452
|
-
#
|
|
671
|
+
# Appends a CTE to the end of the WITH clause.
|
|
672
|
+
#
|
|
673
|
+
# If the query has no CTEs, wraps it with WITH. If the query already has
|
|
674
|
+
# CTEs, adds the new CTE at the end.
|
|
675
|
+
#
|
|
676
|
+
# @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
|
|
677
|
+
# @return [Q] a new query object with the appended CTE
|
|
678
|
+
#
|
|
679
|
+
# @example Adding a CTE to a simple query
|
|
680
|
+
# AppQuery("SELECT 1").append_cte("foo AS (SELECT 2)")
|
|
681
|
+
# # => "WITH foo AS (SELECT 2) SELECT 1"
|
|
682
|
+
#
|
|
683
|
+
# @example Appending to existing CTEs
|
|
684
|
+
# AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
|
|
685
|
+
# .append_cte("foo AS (SELECT 1)")
|
|
686
|
+
# # => "WITH bar AS (SELECT 2), foo AS (SELECT 1) SELECT * FROM bar"
|
|
453
687
|
def append_cte(cte)
|
|
454
688
|
# early raise when cte is not valid sql
|
|
455
689
|
add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
|
|
@@ -477,8 +711,17 @@ module AppQuery
|
|
|
477
711
|
end
|
|
478
712
|
end
|
|
479
713
|
|
|
480
|
-
# Replaces an existing
|
|
481
|
-
#
|
|
714
|
+
# Replaces an existing CTE with a new definition.
|
|
715
|
+
#
|
|
716
|
+
# @param cte [String] the new CTE definition (must have same name as existing CTE)
|
|
717
|
+
# @return [Q] a new query object with the replaced CTE
|
|
718
|
+
#
|
|
719
|
+
# @example
|
|
720
|
+
# AppQuery("WITH foo AS (SELECT 1) SELECT * FROM foo")
|
|
721
|
+
# .replace_cte("foo AS (SELECT 2)")
|
|
722
|
+
# # => "WITH foo AS (SELECT 2) SELECT * FROM foo"
|
|
723
|
+
#
|
|
724
|
+
# @raise [ArgumentError] if the CTE name doesn't exist in the query
|
|
482
725
|
def replace_cte(cte)
|
|
483
726
|
add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
|
|
484
727
|
[!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
|
|
@@ -510,12 +753,27 @@ module AppQuery
|
|
|
510
753
|
end.join)
|
|
511
754
|
end
|
|
512
755
|
|
|
756
|
+
# @!endgroup
|
|
757
|
+
|
|
758
|
+
# Returns the SQL string.
|
|
759
|
+
#
|
|
760
|
+
# @return [String] the SQL query string
|
|
513
761
|
def to_s
|
|
514
762
|
@sql
|
|
515
763
|
end
|
|
516
764
|
end
|
|
517
765
|
end
|
|
518
766
|
|
|
767
|
+
# Convenience method to create a new {AppQuery::Q} instance.
|
|
768
|
+
#
|
|
769
|
+
# Accepts the same arguments as {AppQuery::Q#initialize}.
|
|
770
|
+
#
|
|
771
|
+
# @return [AppQuery::Q] a new query object
|
|
772
|
+
#
|
|
773
|
+
# @example
|
|
774
|
+
# AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
|
|
775
|
+
#
|
|
776
|
+
# @see AppQuery::Q#initialize
|
|
519
777
|
def AppQuery(...)
|
|
520
778
|
AppQuery::Q.new(...)
|
|
521
779
|
end
|
data/rakelib/yard.rake
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
require "yard"
|
|
2
|
+
|
|
3
|
+
YARD::Rake::YardocTask.new(:docs) do |t|
|
|
4
|
+
# Options defined in `.yardopts` are read first, then merged with
|
|
5
|
+
# options defined here.
|
|
6
|
+
#
|
|
7
|
+
# It's recommended to define options in `.yardopts` instead of here,
|
|
8
|
+
# as `.yardopts` can be read by external YARD tools, like the
|
|
9
|
+
# hot-reload YARD server `yard server --reload`.
|
|
10
|
+
|
|
11
|
+
# Use APPQUERY_VERSION env var (set from git tag in CI), or fall back to git describe
|
|
12
|
+
version = ENV["APPQUERY_VERSION"] || `git describe --tags --abbrev=0 2>/dev/null`.strip
|
|
13
|
+
version = nil if version.empty?
|
|
14
|
+
|
|
15
|
+
title = ["AppQuery", version, "API Documentation"].compact.join(" ")
|
|
16
|
+
t.options += ["--title", title]
|
|
17
|
+
end
|
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.0
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -43,6 +43,7 @@ files:
|
|
|
43
43
|
- ".irbrc"
|
|
44
44
|
- ".rspec"
|
|
45
45
|
- ".standard.yml"
|
|
46
|
+
- ".yardopts"
|
|
46
47
|
- Appraisals
|
|
47
48
|
- CHANGELOG.md
|
|
48
49
|
- LICENSE.txt
|
|
@@ -50,6 +51,7 @@ files:
|
|
|
50
51
|
- Rakefile
|
|
51
52
|
- lib/app_query.rb
|
|
52
53
|
- lib/app_query/base.rb
|
|
54
|
+
- lib/app_query/render_helpers.rb
|
|
53
55
|
- lib/app_query/rspec.rb
|
|
54
56
|
- lib/app_query/rspec/helpers.rb
|
|
55
57
|
- lib/app_query/tokenizer.rb
|
|
@@ -63,6 +65,7 @@ files:
|
|
|
63
65
|
- mise.local.toml.example
|
|
64
66
|
- mise.toml
|
|
65
67
|
- rakelib/gem.rake
|
|
68
|
+
- rakelib/yard.rake
|
|
66
69
|
- sig/appquery.rbs
|
|
67
70
|
homepage: https://github.com/eval/appquery
|
|
68
71
|
licenses:
|