appquery 0.6.0 → 0.7.0.rc1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.yard/templates/default/fulldoc/html/css/dark.css +234 -0
- data/.yard/templates/default/fulldoc/html/setup.rb +5 -0
- data/.yard/templates/default/layout/html/setup.rb +5 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +1 -1
- data/README.md +179 -276
- data/lib/app_query/tokenizer.rb +12 -2
- data/lib/app_query/version.rb +1 -1
- 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: 7da66720e6fdbc08a2a016a1b5576f1914a1f2a1705c8263a98ac8f59ba9e61e
|
|
4
|
+
data.tar.gz: 037f03abfd85601c8f88c9d8caabf9094b245c458f8f0fcc363065871af1ff72
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 13f53f2ce470e3a004768a9e2549028e302cf84a0a184df54e8b9aebb8f14ad93bc678d29e85f1261d4b79737a331f051a8aefe88880b36487451a2755de641c
|
|
7
|
+
data.tar.gz: df40f45249d57c5d287249c46ca3b6a7cb0f94f8bf398ef498acf1e82d8e02c2c9ad8e5e5f93b46a7bdf26bcaf187d926ce670059a264491be97028a2b8d44b1
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/* Dark mode styles for YARD documentation */
|
|
2
|
+
@media (prefers-color-scheme: dark) {
|
|
3
|
+
:root {
|
|
4
|
+
color-scheme: dark;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
body {
|
|
8
|
+
background: #0d1117;
|
|
9
|
+
color: #c9d1d9;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/* Main content area */
|
|
13
|
+
#main {
|
|
14
|
+
background: #0d1117;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/* Navigation */
|
|
18
|
+
#nav {
|
|
19
|
+
border-right-color: #30363d;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@media (max-width: 920px) {
|
|
23
|
+
#nav {
|
|
24
|
+
background: #161b22;
|
|
25
|
+
border-color: #30363d;
|
|
26
|
+
box-shadow: -7px 5px 25px #010409;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Links */
|
|
31
|
+
#content a, #content a:visited {
|
|
32
|
+
color: #58a6ff;
|
|
33
|
+
}
|
|
34
|
+
#content a:hover {
|
|
35
|
+
background: #1f2428;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/* Headers */
|
|
39
|
+
h1 {
|
|
40
|
+
border-top-color: #30363d;
|
|
41
|
+
}
|
|
42
|
+
h2 {
|
|
43
|
+
border-bottom-color: #30363d;
|
|
44
|
+
}
|
|
45
|
+
h2 small a {
|
|
46
|
+
border-color: #30363d;
|
|
47
|
+
background: #161b22;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* Code blocks */
|
|
51
|
+
#filecontents pre.code, .docstring pre.code, .tags pre.example {
|
|
52
|
+
background: #161b22;
|
|
53
|
+
border-color: #30363d;
|
|
54
|
+
}
|
|
55
|
+
pre.code { color: #c9d1d9; }
|
|
56
|
+
pre.code .comment { color: #8b949e; }
|
|
57
|
+
pre.code .const, pre.code .constant { color: #d2a8ff; }
|
|
58
|
+
pre.code .kw { color: #ff7b72; }
|
|
59
|
+
pre.code .tstring_content, pre.code .tstring, pre.code .dstring,
|
|
60
|
+
pre.code .heredoc_beg, pre.code .heredoc_end,
|
|
61
|
+
pre.code .val { color: #a5d6ff; }
|
|
62
|
+
pre.code .symbol, pre.code .label { color: #7ee787; }
|
|
63
|
+
pre.code .ivar { color: #ffa657; }
|
|
64
|
+
pre.code .fid, pre.code .rubyid_new { color: #d2a8ff; }
|
|
65
|
+
|
|
66
|
+
/* Inline code */
|
|
67
|
+
.docstring p > code, .docstring p > tt,
|
|
68
|
+
.tags p > code, .tags p > tt {
|
|
69
|
+
color: #f97583;
|
|
70
|
+
background: #161b22;
|
|
71
|
+
}
|
|
72
|
+
*:not(pre) > code {
|
|
73
|
+
background: #161b22;
|
|
74
|
+
border-color: #30363d;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* Object links in docstrings */
|
|
78
|
+
.summary_desc .object_link a, .docstring .object_link a {
|
|
79
|
+
color: #58a6ff;
|
|
80
|
+
background: #1f2428;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* Signatures */
|
|
84
|
+
p.signature, h3.signature {
|
|
85
|
+
background: #161b22;
|
|
86
|
+
border-color: #30363d;
|
|
87
|
+
}
|
|
88
|
+
p.signature .extras, h3.signature .extras { color: #8b949e; }
|
|
89
|
+
|
|
90
|
+
/* Summary boxes */
|
|
91
|
+
.summary_signature {
|
|
92
|
+
background: #161b22;
|
|
93
|
+
border-color: #30363d;
|
|
94
|
+
}
|
|
95
|
+
.summary_signature:hover {
|
|
96
|
+
background: #1f2937;
|
|
97
|
+
border-color: #3b82f6;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* Tables */
|
|
101
|
+
#filecontents table th, #filecontents table td,
|
|
102
|
+
.docstring table th, .docstring table td {
|
|
103
|
+
border-color: #30363d;
|
|
104
|
+
}
|
|
105
|
+
#filecontents table tr:nth-child(odd),
|
|
106
|
+
.docstring table tr:nth-child(odd) { background: #161b22; }
|
|
107
|
+
#filecontents table tr:nth-child(even),
|
|
108
|
+
.docstring table tr:nth-child(even) { background: #0d1117; }
|
|
109
|
+
#filecontents table th, .docstring table th { background: #21262d; }
|
|
110
|
+
|
|
111
|
+
/* Box info */
|
|
112
|
+
.box_info dl dt {
|
|
113
|
+
border-color: #30363d;
|
|
114
|
+
}
|
|
115
|
+
.box_info dl dd {
|
|
116
|
+
border-color: #30363d;
|
|
117
|
+
}
|
|
118
|
+
.box_info dl:nth-child(odd) > * { background: #161b22; }
|
|
119
|
+
.box_info dl:nth-child(even) > * { background: #0d1117; }
|
|
120
|
+
|
|
121
|
+
/* Definition lists */
|
|
122
|
+
#filecontents dl, .docstring dl { border-color: #30363d; }
|
|
123
|
+
#filecontents dt, .docstring dt { background: #21262d; }
|
|
124
|
+
|
|
125
|
+
/* Notes */
|
|
126
|
+
.note {
|
|
127
|
+
border-color: #30363d;
|
|
128
|
+
color: #c9d1d9;
|
|
129
|
+
}
|
|
130
|
+
.note.todo { background: #3d3200; border-color: #5c4a00; }
|
|
131
|
+
.note.deprecated { background: #3d1f1f; border-color: #5c2d2d; }
|
|
132
|
+
.note.returns_void { background: #21262d; }
|
|
133
|
+
.note.title { background: #21262d; }
|
|
134
|
+
.note.title.constructor { background: #1f3a5f; border-color: #2d4a6f; }
|
|
135
|
+
.note.title.writeonly { background: #1a4d1a; border-color: #2a5d2a; }
|
|
136
|
+
.note.title.readonly { background: #1f3a5f; border-color: #2d4a6f; }
|
|
137
|
+
.note.title.private { background: #30363d; border-color: #484f58; }
|
|
138
|
+
|
|
139
|
+
/* Search */
|
|
140
|
+
#search a {
|
|
141
|
+
background: #161b22;
|
|
142
|
+
border-color: #30363d;
|
|
143
|
+
color: #58a6ff;
|
|
144
|
+
fill: #58a6ff;
|
|
145
|
+
box-shadow: -1px 1px 3px #010409;
|
|
146
|
+
}
|
|
147
|
+
#search a:hover { background: #1f2428; }
|
|
148
|
+
#search a.active {
|
|
149
|
+
background: #1f6feb;
|
|
150
|
+
border-color: #1f6feb;
|
|
151
|
+
}
|
|
152
|
+
#search a.inactive { color: #484f58; fill: #484f58; }
|
|
153
|
+
|
|
154
|
+
/* Menu */
|
|
155
|
+
#menu { color: #484f58; }
|
|
156
|
+
#menu .title { color: #c9d1d9; }
|
|
157
|
+
#menu a, #menu a:visited { color: #c9d1d9; border-bottom-color: #30363d; }
|
|
158
|
+
#menu a:hover { color: #58a6ff; }
|
|
159
|
+
|
|
160
|
+
/* Footer */
|
|
161
|
+
#footer { border-top-color: #30363d; color: #8b949e; }
|
|
162
|
+
#footer a, #footer a:visited { color: #c9d1d9; border-bottom-color: #30363d; }
|
|
163
|
+
#footer a:hover { color: #58a6ff; }
|
|
164
|
+
|
|
165
|
+
/* TOC */
|
|
166
|
+
#toc {
|
|
167
|
+
background: #161b22;
|
|
168
|
+
border-color: #30363d;
|
|
169
|
+
box-shadow: -2px 2px 6px #010409;
|
|
170
|
+
}
|
|
171
|
+
#toc.hidden { background: #161b22; }
|
|
172
|
+
#toc.hidden:hover { background: #1f2428; }
|
|
173
|
+
|
|
174
|
+
/* Method details */
|
|
175
|
+
.method_details { border-top-color: #30363d; }
|
|
176
|
+
|
|
177
|
+
/* Inheritance tree */
|
|
178
|
+
.inheritanceTree, .toggleDefines {
|
|
179
|
+
background: #161b22;
|
|
180
|
+
border-left-color: #30363d;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/* Source code */
|
|
184
|
+
.source_code { border-left-color: #30363d; }
|
|
185
|
+
.source_code .lines { color: #8b949e; }
|
|
186
|
+
|
|
187
|
+
/* List items */
|
|
188
|
+
li.r1 { background: #161b22; }
|
|
189
|
+
li.r2 { background: #0d1117; }
|
|
190
|
+
|
|
191
|
+
/* Constants */
|
|
192
|
+
dl.constants dd { color: #c9d1d9; }
|
|
193
|
+
|
|
194
|
+
/* Full list pages (Class List, Method List, File List) */
|
|
195
|
+
.fixed_header {
|
|
196
|
+
background: #0d1117;
|
|
197
|
+
}
|
|
198
|
+
#noresults {
|
|
199
|
+
background: #161b22;
|
|
200
|
+
}
|
|
201
|
+
li.odd {
|
|
202
|
+
background: #161b22;
|
|
203
|
+
}
|
|
204
|
+
li.even {
|
|
205
|
+
background: #0d1117;
|
|
206
|
+
}
|
|
207
|
+
.item:hover {
|
|
208
|
+
background: #21262d;
|
|
209
|
+
}
|
|
210
|
+
a, a:visited {
|
|
211
|
+
color: #58a6ff;
|
|
212
|
+
}
|
|
213
|
+
li {
|
|
214
|
+
color: #8b949e;
|
|
215
|
+
}
|
|
216
|
+
li.clicked > .item {
|
|
217
|
+
background: #1f6feb;
|
|
218
|
+
color: #c9d1d9;
|
|
219
|
+
}
|
|
220
|
+
li.clicked > .item a, li.clicked > .item a:visited {
|
|
221
|
+
color: #fff;
|
|
222
|
+
}
|
|
223
|
+
#search input {
|
|
224
|
+
background: #0d1117;
|
|
225
|
+
border-color: #30363d;
|
|
226
|
+
color: #c9d1d9;
|
|
227
|
+
}
|
|
228
|
+
#full_list_nav {
|
|
229
|
+
color: #484f58;
|
|
230
|
+
}
|
|
231
|
+
#full_list_nav a, #nav a:visited {
|
|
232
|
+
color: #58a6ff;
|
|
233
|
+
}
|
|
234
|
+
}
|
data/.yardopts
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### 🐛 Fixes
|
|
4
|
+
|
|
5
|
+
- 🔧 Fix literal strings containing parentheses breaking CTE-parsing.
|
|
6
|
+
|
|
7
|
+
## 0.6.0
|
|
8
|
+
|
|
9
|
+
**Releasedate**: 2-1-2026
|
|
10
|
+
**Rubygems**: https://rubygems.org/gems/appquery/versions/0.6.0
|
|
11
|
+
|
|
3
12
|
### ✨ Features
|
|
4
13
|
|
|
5
14
|
- 🏗️ **`AppQuery::BaseQuery`** — structured query objects with explicit parameter declaration
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -1,9 +1,32 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
<picture>
|
|
2
|
+
<source media="(prefers-color-scheme: dark)" srcset=".github/banner-dark.svg">
|
|
3
|
+
<source media="(prefers-color-scheme: light)" srcset=".github/banner-light.svg">
|
|
4
|
+
<img alt="AppQuery - Raw SQL, ergonomically" src=".github/banner-light.svg" width="100%">
|
|
5
|
+
</picture>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Ergonomic raw SQL queries for ActiveRecord</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<p align="center">
|
|
12
|
+
<a href="https://rubygems.org/gems/appquery"><img src="https://img.shields.io/gem/v/appquery.svg?style=flat-square&color=blue" alt="Gem Version"></a>
|
|
13
|
+
<a href="https://github.com/eval/appquery/actions/workflows/main.yml"><img src="https://img.shields.io/github/actions/workflow/status/eval/appquery/main.yml?branch=main&style=flat-square&label=CI" alt="CI Status"></a>
|
|
14
|
+
<a href="https://eval.github.io/appquery/"><img src="https://img.shields.io/badge/docs-YARD-blue.svg?style=flat-square" alt="API Docs"></a>
|
|
15
|
+
<a href="https://rubygems.org/gems/appquery"><img src="https://img.shields.io/gem/dt/appquery.svg?style=flat-square&color=orange" alt="Downloads"></a>
|
|
16
|
+
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green.svg?style=flat-square" alt="License"></a>
|
|
17
|
+
</p>
|
|
18
|
+
|
|
19
|
+
<p align="center">
|
|
20
|
+
<a href="#installation">Installation</a> •
|
|
21
|
+
<a href="#quick-start">Quick Start</a> •
|
|
22
|
+
<a href="#usage">Usage</a> •
|
|
23
|
+
<a href="#api-documentation">API Docs</a> •
|
|
24
|
+
<a href="#compatibility">Compatibility</a>
|
|
25
|
+
</p>
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
A Ruby gem for working with raw SQL in Rails. Store queries in `app/queries/`, execute with proper type casting, filter/transform using CTEs, and parameterize via ERB.
|
|
7
30
|
|
|
8
31
|
```ruby
|
|
9
32
|
# Load and execute
|
|
@@ -35,356 +58,240 @@ AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
|
35
58
|
query.prepend_cte("sales AS (SELECT * FROM mock_data)")
|
|
36
59
|
```
|
|
37
60
|
|
|
38
|
-
|
|
61
|
+
## Highlights
|
|
62
|
+
|
|
63
|
+
| Feature | Description |
|
|
64
|
+
|---------|-------------|
|
|
65
|
+
| **Query Files** | Store SQL in `app/queries/` with Rails generator |
|
|
66
|
+
| **Execution** | `select_all` / `select_one` / `select_value` / `count` / `column` / `ids` |
|
|
67
|
+
| **CTE Manipulation** | Query transformation via `prepend_cte` / `append_cte` / `replace_cte` |
|
|
68
|
+
| **Immutable** | Derive new queries from existing ones |
|
|
69
|
+
| **Named Binds** | Safe parameterization with automatic defaults |
|
|
70
|
+
| **ERB Helpers** | `order_by`, `paginate`, `values`, `bind` |
|
|
71
|
+
| **Type Casting** | Automatic + custom type casting |
|
|
72
|
+
| **RSpec Integration** | Built-in matchers and helpers for testing |
|
|
73
|
+
| **Export** | Stream results via `copy_to` (PostgreSQL) |
|
|
39
74
|
|
|
40
|
-
> [!IMPORTANT]
|
|
41
|
-
> **Status**:
|
|
42
|
-
>
|
|
75
|
+
> [!IMPORTANT]
|
|
76
|
+
> **Status**: Using in production for multiple projects, but API might change pre v1.0.
|
|
77
|
+
> See [the CHANGELOG](./CHANGELOG.md) for breaking changes when upgrading.
|
|
43
78
|
|
|
44
79
|
## Rationale
|
|
45
80
|
|
|
46
|
-
Sometimes ActiveRecord doesn't cut it: you need performance,
|
|
47
|
-
That, however, introduces some new problems. First of all, you'll run into the not-so-intuitive use of [select_(all|one|value)](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all) — for example, how they differ with respect to type casting, and how their behavior can vary between ActiveRecord versions. Then there's the testability, introspection, and maintainability of the resulting SQL queries.
|
|
81
|
+
Sometimes ActiveRecord doesn't cut it: you need performance, prefer raw SQL over Arel, and hash-maps suffice instead of full ActiveRecord instances.
|
|
48
82
|
|
|
49
|
-
|
|
83
|
+
That introduces new problems: the not-so-intuitive `select_all`/`select_one`/`select_value` methods differ in type casting behavior across ActiveRecord versions. Then there's testability, introspection, and maintainability of SQL queries.
|
|
50
84
|
|
|
51
|
-
|
|
85
|
+
**AppQuery** provides:
|
|
86
|
+
- Consistent interface across `select_*` methods and ActiveRecord versions
|
|
87
|
+
- Easy inspection and testing—especially for CTE-based queries
|
|
88
|
+
- Clean parameterization via named binds and ERB
|
|
52
89
|
|
|
53
|
-
|
|
90
|
+
## Installation
|
|
54
91
|
|
|
55
92
|
```bash
|
|
56
93
|
bundle add appquery
|
|
57
94
|
```
|
|
58
95
|
|
|
59
|
-
##
|
|
60
|
-
|
|
61
|
-
> [!NOTE]
|
|
62
|
-
> The following (trivial) examples are not meant to convince you to ditch your ORM, but just to show how this gem handles raw SQL queries.
|
|
96
|
+
## Quick Start
|
|
63
97
|
|
|
64
|
-
|
|
98
|
+
Generate a query:
|
|
65
99
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
```ruby
|
|
71
|
-
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
72
|
-
ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
|
|
73
|
-
```
|
|
74
|
-
</details>
|
|
100
|
+
```bash
|
|
101
|
+
rails g query weekly_sales
|
|
102
|
+
```
|
|
75
103
|
|
|
76
|
-
|
|
104
|
+
Write your SQL in `app/queries/weekly_sales.sql`:
|
|
77
105
|
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
85
|
-
=> Fri, 02 Jan 2026
|
|
106
|
+
```sql
|
|
107
|
+
SELECT week, category, revenue
|
|
108
|
+
FROM sales
|
|
109
|
+
WHERE week = :week AND year = :year
|
|
110
|
+
ORDER BY revenue DESC
|
|
111
|
+
```
|
|
86
112
|
|
|
87
|
-
|
|
88
|
-
As can be seen from these examples, values are automatically casted.
|
|
89
|
-
|
|
90
|
-
## compare ActiveRecord
|
|
91
|
-
[postgresql]> ActiveRecord::Base.connection.select_one(%{select date('now') as today})
|
|
92
|
-
=> {"today" => "2025-12-20"}
|
|
93
|
-
|
|
94
|
-
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
95
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
96
|
-
=> {"today" => "2025-05-12"}
|
|
97
|
-
## Providing per-column-casts fixes this:
|
|
98
|
-
cast = {today: :date}
|
|
99
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:)
|
|
100
|
-
=> {"today" => Mon, 12 May 2025}
|
|
101
|
-
|
|
102
|
-
# binds
|
|
103
|
-
## named binds
|
|
104
|
-
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
105
|
-
=> 2025-12-31 12:57:27.41132 UTC
|
|
106
|
-
|
|
107
|
-
## not all binds need to be provided (ie they are nil by default) - so defaults can be added in SQL:
|
|
108
|
-
[postgresql]> AppQuery(<<~SQL).select_all(binds: {ts1: 2.days.ago, ts2: Time.now, interval: '1 hour'}).column("series")
|
|
109
|
-
SELECT generate_series(
|
|
110
|
-
:ts1::timestamp,
|
|
111
|
-
:ts2::timestamp,
|
|
112
|
-
COALESCE(:interval, '5 minutes')::interval
|
|
113
|
-
) AS series
|
|
114
|
-
SQL
|
|
115
|
-
=>
|
|
116
|
-
[2025-12-31 12:57:46.969709 UTC,
|
|
117
|
-
2025-12-31 13:57:46.969709 UTC,
|
|
118
|
-
2025-12-31 14:57:46.969709 UTC,
|
|
119
|
-
...]
|
|
120
|
-
|
|
121
|
-
# rewriting queries (using CTEs)
|
|
122
|
-
[postgresql]> articles = [
|
|
123
|
-
[1, "Using my new static site generator", 2.months.ago.to_date],
|
|
124
|
-
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
125
|
-
[3, "Another article", 2.weeks.ago.to_date]
|
|
126
|
-
]
|
|
127
|
-
[postgresql]> q = AppQuery(<<~SQL, cast: {published_on: :date}).render(articles:)
|
|
128
|
-
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
129
|
-
select * from articles order by id DESC
|
|
130
|
-
SQL
|
|
113
|
+
Execute it:
|
|
131
114
|
|
|
132
|
-
|
|
133
|
-
[
|
|
134
|
-
|
|
135
|
-
## query the end-result (available via the placeholder ':_')
|
|
136
|
-
[postgresql]> q.select_one(%{select * from :_ limit 1})
|
|
137
|
-
### shorthand for that
|
|
138
|
-
[postgresql]> q.first
|
|
139
|
-
|
|
140
|
-
## ERB templating
|
|
141
|
-
# Extract a query from q that can be sorted dynamically:
|
|
142
|
-
[postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
|
|
143
|
-
[postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
|
|
144
|
-
|
|
145
|
-
# shows latest articles first, and titles sorted alphabetically
|
|
146
|
-
# for articles published on the same date.
|
|
147
|
-
# order_by raises when it's passed something that would result in just `ORDER BY`:
|
|
148
|
-
[postgresql]> q2.render(order: {})
|
|
149
|
-
|
|
150
|
-
# doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
|
|
151
|
-
[postgresql]> q2.select_all.entries
|
|
152
|
-
|
|
153
|
-
# NOTE you can use both `order` and `@order`: local variables like `order` are required,
|
|
154
|
-
# while instance variables like `@order` are optional.
|
|
155
|
-
# To skip the order-part when provided:
|
|
156
|
-
<%= @order.presence && order_by(order) %>
|
|
157
|
-
# or use a default when order-part is always wanted but not always provided:
|
|
158
|
-
<%= order_by(@order || {id: :desc}) %>
|
|
115
|
+
```ruby
|
|
116
|
+
AppQuery[:weekly_sales].select_all(binds: {week: 1, year: 2025})
|
|
117
|
+
#=> [{"week" => 1, "category" => "Electronics", "revenue" => 12500}, ...]
|
|
159
118
|
```
|
|
160
119
|
|
|
161
|
-
|
|
162
|
-
### ...in a Rails project
|
|
120
|
+
## Usage
|
|
163
121
|
|
|
164
122
|
> [!NOTE]
|
|
165
|
-
> The included [example Rails app](./examples/demo) contains
|
|
123
|
+
> The following examples show how this gem handles raw SQL. The included [example Rails app](./examples/demo) contains runnable queries.
|
|
166
124
|
|
|
167
|
-
|
|
168
|
-
```bash
|
|
169
|
-
rails g query recent_articles
|
|
170
|
-
```
|
|
125
|
+
### Console Exploration
|
|
171
126
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
values(COALESCE(:since, datetime('now', '-6 months')))
|
|
177
|
-
),
|
|
178
|
-
|
|
179
|
-
recent_articles(article_id, article_title, article_published_on, article_url) AS (
|
|
180
|
-
SELECT id, title, published_on, url
|
|
181
|
-
FROM articles
|
|
182
|
-
RIGHT JOIN settings
|
|
183
|
-
WHERE published_on > settings.min_published_on
|
|
184
|
-
),
|
|
185
|
-
|
|
186
|
-
tags_by_article(article_id, tags) AS (
|
|
187
|
-
SELECT articles_tags.article_id,
|
|
188
|
-
json_group_array(tags.name) AS tags
|
|
189
|
-
FROM articles_tags
|
|
190
|
-
JOIN tags ON articles_tags.tag_id = tags.id
|
|
191
|
-
GROUP BY articles_tags.article_id
|
|
192
|
-
)
|
|
193
|
-
|
|
194
|
-
SELECT recent_articles.*,
|
|
195
|
-
group_concat(json_each.value, ',' ORDER BY value ASC) tags_str
|
|
196
|
-
FROM recent_articles
|
|
197
|
-
JOIN tags_by_article USING(article_id),
|
|
198
|
-
json_each(tags)
|
|
199
|
-
WHERE EXISTS (
|
|
200
|
-
SELECT 1
|
|
201
|
-
FROM json_each(tags)
|
|
202
|
-
WHERE json_each.value LIKE :tag OR :tag IS NULL
|
|
203
|
-
)
|
|
204
|
-
GROUP BY recent_articles.article_id
|
|
205
|
-
ORDER BY recent_articles.article_published_on
|
|
206
|
-
```
|
|
127
|
+
```ruby
|
|
128
|
+
# Testdrive from console
|
|
129
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
|
|
130
|
+
=> [{"today" => Fri, 02 Jan 2026}]
|
|
207
131
|
|
|
208
|
-
|
|
132
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
133
|
+
=> {"today" => Fri, 02 Jan 2026}
|
|
209
134
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
"article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
|
|
213
|
-
"article_published_on"=>"2024-05-17",
|
|
214
|
-
"article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
|
|
215
|
-
"tags_str"=>"release:7x,release:revision"},
|
|
216
|
-
...
|
|
217
|
-
]
|
|
135
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
136
|
+
=> Fri, 02 Jan 2026
|
|
218
137
|
```
|
|
219
138
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
- only published articles
|
|
223
|
-
- only articles _with_ tags
|
|
224
|
-
- only articles published after some date
|
|
225
|
-
- either provided or using the default
|
|
226
|
-
- articles are sorted in a certain order
|
|
227
|
-
- tags appear in a certain order and are formatted a certain way
|
|
139
|
+
<details>
|
|
140
|
+
<summary><strong>Database setup</strong> (the <code>bin/console</code> script does this for you)</summary>
|
|
228
141
|
|
|
229
|
-
|
|
142
|
+
```ruby
|
|
143
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
144
|
+
ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
|
|
145
|
+
```
|
|
146
|
+
</details>
|
|
230
147
|
|
|
231
|
-
###
|
|
148
|
+
### Type Casting
|
|
232
149
|
|
|
233
|
-
|
|
234
|
-
> There's `AppQuery#select_all`, `AppQuery#select_one` and `AppQuery#select_value` to execute a query. `select_(all|one)` are tiny wrappers around the equivalent methods from `ActiveRecord::Base.connection`.
|
|
235
|
-
> Instead of [positional arguments](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all), these methods accept keywords `select`, `binds` and `cast`. See below for examples.
|
|
150
|
+
Values are automatically cast (unlike raw ActiveRecord):
|
|
236
151
|
|
|
237
|
-
Given the query above, you can get the result like so:
|
|
238
152
|
```ruby
|
|
239
|
-
AppQuery
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
"article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
|
|
243
|
-
"article_published_on"=>"2024-05-17",
|
|
244
|
-
"article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
|
|
245
|
-
"tags_str"=>"release:7x,release:revision"},
|
|
246
|
-
...
|
|
247
|
-
]
|
|
153
|
+
# AppQuery
|
|
154
|
+
AppQuery(%{select date('now') as today}).select_one
|
|
155
|
+
=> {"today" => Fri, 02 Jan 2026}
|
|
248
156
|
|
|
249
|
-
#
|
|
250
|
-
|
|
157
|
+
# Compare with raw ActiveRecord
|
|
158
|
+
ActiveRecord::Base.connection.select_one(%{select date('now') as today})
|
|
159
|
+
=> {"today" => "2025-12-20"} # String, not Date!
|
|
251
160
|
|
|
252
|
-
#
|
|
253
|
-
|
|
161
|
+
# Custom casting
|
|
162
|
+
AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
254
163
|
```
|
|
255
164
|
|
|
256
|
-
|
|
165
|
+
### Named Binds
|
|
257
166
|
|
|
258
167
|
```ruby
|
|
259
|
-
|
|
260
|
-
|
|
168
|
+
# Named binds
|
|
169
|
+
AppQuery(%{select now() - (:interval)::interval as date})
|
|
170
|
+
.select_value(binds: {interval: '2 days'})
|
|
171
|
+
|
|
172
|
+
# Binds default to nil - add SQL defaults via COALESCE
|
|
173
|
+
AppQuery(<<~SQL).select_all(binds: {ts1: 2.days.ago, ts2: Time.now})
|
|
174
|
+
SELECT generate_series(
|
|
175
|
+
:ts1::timestamp,
|
|
176
|
+
:ts2::timestamp,
|
|
177
|
+
COALESCE(:interval, '5 minutes')::interval
|
|
178
|
+
) AS series
|
|
179
|
+
SQL
|
|
180
|
+
```
|
|
261
181
|
|
|
262
|
-
|
|
263
|
-
AppQuery[:recent_articles].select_value("select count(*) from :_")
|
|
264
|
-
# => 13
|
|
182
|
+
### CTE Manipulation
|
|
265
183
|
|
|
266
|
-
|
|
267
|
-
AppQuery[:recent_articles].count #=> 13
|
|
268
|
-
AppQuery[:recent_articles].count(binds: {since: 0}) #=> 275
|
|
269
|
-
```
|
|
184
|
+
Rewrite queries using CTEs:
|
|
270
185
|
|
|
271
|
-
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
|
|
272
186
|
```ruby
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
187
|
+
articles = [
|
|
188
|
+
[1, "Using my new static site generator", 2.months.ago.to_date],
|
|
189
|
+
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
190
|
+
]
|
|
276
191
|
|
|
277
|
-
|
|
192
|
+
q = AppQuery(<<~SQL, cast: {published_on: :date}).render(articles:)
|
|
193
|
+
WITH articles(id, title, published_on) AS (<%= values(articles) %>)
|
|
194
|
+
SELECT * FROM articles ORDER BY id DESC
|
|
195
|
+
SQL
|
|
278
196
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
|
|
282
|
-
# => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
|
|
283
|
-
...]
|
|
197
|
+
# Query the CTE directly
|
|
198
|
+
q.select_all("SELECT * FROM articles WHERE id < 2")
|
|
284
199
|
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
200
|
+
# Query the result (via :_ placeholder)
|
|
201
|
+
q.select_one("SELECT * FROM :_ LIMIT 1")
|
|
202
|
+
q.first # shorthand
|
|
288
203
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
204
|
+
# Rewrite CTEs
|
|
205
|
+
q.replace_cte("settings(cutoff) AS (VALUES(DATE '2024-01-01'))")
|
|
206
|
+
q.prepend_cte("mock_data AS (SELECT 1)")
|
|
207
|
+
q.append_cte("extra AS (SELECT 2)")
|
|
292
208
|
```
|
|
293
209
|
|
|
294
|
-
|
|
210
|
+
### ERB Templating
|
|
295
211
|
|
|
296
212
|
```ruby
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
213
|
+
# Dynamic ORDER BY
|
|
214
|
+
q = AppQuery("SELECT * FROM articles <%= order_by(ordering) %>")
|
|
215
|
+
q.render(ordering: {published_on: :desc, title: :asc}).select_all
|
|
216
|
+
|
|
217
|
+
# Pagination
|
|
218
|
+
AppQuery("SELECT * FROM users <%= paginate(page: page, per_page: per_page) %>")
|
|
219
|
+
.render(page: 2, per_page: 25).select_all
|
|
220
|
+
|
|
221
|
+
# Optional clauses using instance variables
|
|
222
|
+
AppQuery(<<~SQL).render(order: nil) # @order is nil, clause is skipped
|
|
223
|
+
SELECT * FROM articles
|
|
224
|
+
<%= @order.presence && order_by(order) %>
|
|
301
225
|
SQL
|
|
302
226
|
```
|
|
303
227
|
|
|
304
|
-
|
|
305
|
-
```ruby
|
|
306
|
-
# using Ruby data:
|
|
307
|
-
sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
|
|
308
|
-
{id: 2, title: "Another title", published_on: 1.months.ago}]
|
|
309
|
-
# show the provided cutoff date works
|
|
310
|
-
AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds: {since: 6.weeks.ago, articles: JSON[sample_articles]}).entries
|
|
311
|
-
articles AS (
|
|
312
|
-
SELECT * from json_to_recordset(:articles) AS x(id int, title text, published_on timestamp)
|
|
313
|
-
)
|
|
314
|
-
CTE
|
|
315
|
-
```
|
|
228
|
+
### Data Export (PostgreSQL)
|
|
316
229
|
|
|
317
|
-
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten sql:
|
|
318
230
|
```ruby
|
|
319
|
-
|
|
231
|
+
# Return as string
|
|
232
|
+
csv = AppQuery[:users].copy_to
|
|
233
|
+
#=> "id,name\n1,Alice\n2,Bob\n..."
|
|
234
|
+
|
|
235
|
+
# Write to file
|
|
236
|
+
AppQuery[:users].copy_to(to: "export.csv")
|
|
237
|
+
|
|
238
|
+
# Stream to IO
|
|
239
|
+
File.open("users.csv.gz", "wb") do |f|
|
|
240
|
+
gz = Zlib::GzipWriter.new(f)
|
|
241
|
+
AppQuery[:users].copy_to(to: gz)
|
|
242
|
+
gz.close
|
|
243
|
+
end
|
|
320
244
|
```
|
|
321
245
|
|
|
322
|
-
###
|
|
246
|
+
### RSpec Integration
|
|
323
247
|
|
|
324
|
-
|
|
248
|
+
Generated spec files include helpers:
|
|
325
249
|
|
|
326
250
|
```ruby
|
|
327
251
|
# spec/queries/reports/weekly_query_spec.rb
|
|
328
|
-
require "rails_helper"
|
|
329
|
-
|
|
330
252
|
RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
|
|
331
253
|
describe "CTE articles" do
|
|
332
254
|
specify do
|
|
333
|
-
expect(described_query.select_all("
|
|
255
|
+
expect(described_query.select_all("SELECT * FROM :cte")).to \
|
|
334
256
|
include(a_hash_including("article_id" => 1))
|
|
335
257
|
|
|
336
|
-
#
|
|
258
|
+
# Short version: query, cte and select are implied from descriptions
|
|
337
259
|
expect(select_all).to include(a_hash_including("article_id" => 1))
|
|
338
260
|
end
|
|
339
261
|
end
|
|
340
262
|
end
|
|
341
263
|
```
|
|
342
264
|
|
|
343
|
-
There's some sugar:
|
|
344
|
-
- `described_query`
|
|
345
|
-
...just like `described_class` in regular class specs.
|
|
346
|
-
It's an instance of `AppQuery` based on the last word of the top-description (i.e. "reports/weekly" from "AppQuery reports/weekly").
|
|
347
|
-
- `:cte` placeholder
|
|
348
|
-
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").
|
|
349
|
-
- default_binds
|
|
350
|
-
The `binds`-value used when not explicitly provided.
|
|
351
|
-
|
|
352
265
|
## API Documentation
|
|
353
266
|
|
|
354
267
|
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
355
268
|
|
|
356
269
|
## Compatibility
|
|
357
270
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
271
|
+
| Component | Supported |
|
|
272
|
+
|-----------|-----------|
|
|
273
|
+
| **Databases** | PostgreSQL, SQLite |
|
|
274
|
+
| **Rails** | 7.x, 8.x |
|
|
275
|
+
| **Ruby** | 3.3+ ([maintained versions](https://www.ruby-lang.org/en/downloads/branches/)) |
|
|
362
276
|
|
|
363
277
|
## Development
|
|
364
278
|
|
|
365
|
-
After checking out the repo, run `bin/setup` to install dependencies. **Make sure to check it exits with status code 0.**
|
|
366
|
-
|
|
367
|
-
Using [mise](https://mise.jdx.dev/) for env-vars recommended.
|
|
368
|
-
|
|
369
|
-
### console
|
|
370
|
-
|
|
371
|
-
The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
|
|
372
279
|
```bash
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
# more details
|
|
377
|
-
$ bin/console -h
|
|
280
|
+
# Setup
|
|
281
|
+
bin/setup # Make sure it exits with code 0
|
|
378
282
|
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
283
|
+
# Console (connects to database)
|
|
284
|
+
bin/console sqlite3::memory:
|
|
285
|
+
bin/console postgres://localhost:5432/some_db
|
|
382
286
|
|
|
383
|
-
|
|
287
|
+
# With specific Rails version
|
|
288
|
+
bin/run rails_head console
|
|
384
289
|
|
|
385
|
-
Run
|
|
290
|
+
# Run tests
|
|
291
|
+
rake spec
|
|
292
|
+
```
|
|
386
293
|
|
|
387
|
-
|
|
294
|
+
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
388
295
|
|
|
389
296
|
### Releasing
|
|
390
297
|
|
|
@@ -397,18 +304,14 @@ git tag -s 1.2.3 -m "Release 1.2.3"
|
|
|
397
304
|
# Prerelease
|
|
398
305
|
git tag -s 1.2.3.rc1 -m "Release 1.2.3.rc1"
|
|
399
306
|
|
|
400
|
-
# Push the tag
|
|
401
307
|
git push origin --tags
|
|
402
|
-
```
|
|
403
308
|
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
After the release, update version.rb to the next dev version:
|
|
407
|
-
|
|
408
|
-
```ruby
|
|
309
|
+
# then change version.rb for the next dev-cycle
|
|
409
310
|
VERSION = "1.2.4.dev"
|
|
410
311
|
```
|
|
411
312
|
|
|
313
|
+
CI will build, sign (Sigstore attestation), push to RubyGems, and create a GitHub release.
|
|
314
|
+
|
|
412
315
|
## Contributing
|
|
413
316
|
|
|
414
317
|
Bug reports and pull requests are welcome on GitHub at https://github.com/eval/appquery.
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -257,16 +257,26 @@ module AppQuery
|
|
|
257
257
|
|
|
258
258
|
level = 1
|
|
259
259
|
loop do
|
|
260
|
-
read_until(/\)|\(/)
|
|
260
|
+
read_until(/\)|\(|'/)
|
|
261
261
|
if eos?
|
|
262
262
|
err "CTE select ended prematurely"
|
|
263
|
+
elsif match?(/'/)
|
|
264
|
+
# Skip string literal (handle escaped quotes '')
|
|
265
|
+
read_char
|
|
266
|
+
loop do
|
|
267
|
+
read_until(/'/)
|
|
268
|
+
read_char
|
|
269
|
+
break unless match?(/'/) # '' is escaped quote, continue
|
|
270
|
+
read_char
|
|
271
|
+
end
|
|
263
272
|
elsif match?(/\(/)
|
|
264
273
|
level += 1
|
|
274
|
+
read_char
|
|
265
275
|
elsif match?(/\)/)
|
|
266
276
|
level -= 1
|
|
267
277
|
break if level.zero?
|
|
278
|
+
read_char
|
|
268
279
|
end
|
|
269
|
-
read_char
|
|
270
280
|
end
|
|
271
281
|
|
|
272
282
|
err "Expected non-empty CTE select, e.g. '(select 1)'" if chars_read.strip == "("
|
data/lib/app_query/version.rb
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.7.0.rc1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Gert Goet
|
|
@@ -43,6 +43,9 @@ files:
|
|
|
43
43
|
- ".irbrc"
|
|
44
44
|
- ".rspec"
|
|
45
45
|
- ".standard.yml"
|
|
46
|
+
- ".yard/templates/default/fulldoc/html/css/dark.css"
|
|
47
|
+
- ".yard/templates/default/fulldoc/html/setup.rb"
|
|
48
|
+
- ".yard/templates/default/layout/html/setup.rb"
|
|
46
49
|
- ".yardopts"
|
|
47
50
|
- Appraisals
|
|
48
51
|
- CHANGELOG.md
|