appquery 0.6.0.rc9 → 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 +195 -261
- 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
|
|
@@ -12,6 +35,8 @@ week.entries
|
|
|
12
35
|
#=> [{"week" => 2025-01-13, "category" => "Electronics", "revenue" => 12500, "target_met" => true}, ...]
|
|
13
36
|
|
|
14
37
|
# Filter results (query wraps in CTE, :_ references it)
|
|
38
|
+
week.count
|
|
39
|
+
#=> 5
|
|
15
40
|
week.count("SELECT * FROM :_ WHERE NOT target_met")
|
|
16
41
|
#=> 3
|
|
17
42
|
|
|
@@ -33,350 +58,259 @@ AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
|
33
58
|
query.prepend_cte("sales AS (SELECT * FROM mock_data)")
|
|
34
59
|
```
|
|
35
60
|
|
|
36
|
-
|
|
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) |
|
|
37
74
|
|
|
38
|
-
> [!IMPORTANT]
|
|
39
|
-
> **Status**:
|
|
40
|
-
>
|
|
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.
|
|
41
78
|
|
|
42
79
|
## Rationale
|
|
43
80
|
|
|
44
|
-
Sometimes ActiveRecord doesn't cut it
|
|
45
|
-
This library aims to alleviate all of these issues by providing a consistent interface across select_* methods and ActiveRecord versions. It should make inspecting and testing queries easier—especially when they're built from CTEs.
|
|
81
|
+
Sometimes ActiveRecord doesn't cut it: you need performance, prefer raw SQL over Arel, and hash-maps suffice instead of full ActiveRecord instances.
|
|
46
82
|
|
|
47
|
-
|
|
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.
|
|
48
84
|
|
|
49
|
-
|
|
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
|
|
89
|
+
|
|
90
|
+
## Installation
|
|
50
91
|
|
|
51
92
|
```bash
|
|
52
93
|
bundle add appquery
|
|
53
94
|
```
|
|
54
95
|
|
|
55
|
-
##
|
|
96
|
+
## Quick Start
|
|
56
97
|
|
|
57
|
-
|
|
58
|
-
> 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.
|
|
98
|
+
Generate a query:
|
|
59
99
|
|
|
60
|
-
|
|
100
|
+
```bash
|
|
101
|
+
rails g query weekly_sales
|
|
102
|
+
```
|
|
61
103
|
|
|
62
|
-
|
|
63
|
-
<details>
|
|
64
|
-
<summary>Database setup (the `bin/console`-script does this for your)</summary>
|
|
65
|
-
|
|
66
|
-
```ruby
|
|
67
|
-
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
68
|
-
ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
|
|
69
|
-
```
|
|
70
|
-
</details>
|
|
104
|
+
Write your SQL in `app/queries/weekly_sales.sql`:
|
|
71
105
|
|
|
72
|
-
|
|
106
|
+
```sql
|
|
107
|
+
SELECT week, category, revenue
|
|
108
|
+
FROM sales
|
|
109
|
+
WHERE week = :week AND year = :year
|
|
110
|
+
ORDER BY revenue DESC
|
|
111
|
+
```
|
|
73
112
|
|
|
74
|
-
|
|
75
|
-
# showing select_(all|one|value)
|
|
76
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
|
|
77
|
-
=> [{"today" => "2025-05-10"}]
|
|
78
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
79
|
-
=> {"today" => "2025-05-10"}
|
|
80
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
81
|
-
=> "2025-05-10"
|
|
82
|
-
|
|
83
|
-
# binds
|
|
84
|
-
## named binds
|
|
85
|
-
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
86
|
-
|
|
87
|
-
## not all binds need to be provided (ie they are nil by default) - so defaults can be added in SQL:
|
|
88
|
-
[postgresql]> AppQuery(<<~SQL).select_all(binds: {ts1: 2.days.ago, ts2: Time.now, interval: '1 hour'}).column("series")
|
|
89
|
-
SELECT generate_series(
|
|
90
|
-
:ts1::timestamp,
|
|
91
|
-
:ts2::timestamp,
|
|
92
|
-
COALESCE(:interval, '5 minutes')::interval
|
|
93
|
-
) AS series
|
|
94
|
-
SQL
|
|
95
|
-
|
|
96
|
-
# casting
|
|
97
|
-
## Cast values are used by default:
|
|
98
|
-
[postgresql]> AppQuery(%{select date('now')}).select_one
|
|
99
|
-
=> {"today" => Sat, 10 May 2025}
|
|
100
|
-
## compare ActiveRecord
|
|
101
|
-
[postgresql]> ActiveRecord::Base.connection.select_one(%{select date('now') as today})
|
|
102
|
-
=> {"today" => "2025-12-20"}
|
|
103
|
-
|
|
104
|
-
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
105
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
106
|
-
=> {"today" => "2025-05-12"}
|
|
107
|
-
## Providing per-column-casts fixes this:
|
|
108
|
-
cast = {today: :date}
|
|
109
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:)
|
|
110
|
-
=> {"today" => Mon, 12 May 2025}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
# rewriting queries (using CTEs)
|
|
114
|
-
[postgresql]> articles = [
|
|
115
|
-
[1, "Using my new static site generator", 2.months.ago.to_date],
|
|
116
|
-
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
117
|
-
[3, "Another article", 2.weeks.ago.to_date]
|
|
118
|
-
]
|
|
119
|
-
[postgresql]> q = AppQuery(<<~SQL, cast: {published_on: :date}).render(articles:)
|
|
120
|
-
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
121
|
-
select * from articles order by id DESC
|
|
122
|
-
SQL
|
|
113
|
+
Execute it:
|
|
123
114
|
|
|
124
|
-
|
|
125
|
-
[
|
|
126
|
-
|
|
127
|
-
## query the end-result (available via the placeholder ':_')
|
|
128
|
-
[postgresql]> q.select_one(%{select * from :_ limit 1})
|
|
129
|
-
### shorthand for that
|
|
130
|
-
[postgresql]> q.first
|
|
131
|
-
|
|
132
|
-
## ERB templating
|
|
133
|
-
# Extract a query from q that can be sorted dynamically:
|
|
134
|
-
[postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
|
|
135
|
-
[postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
|
|
136
|
-
|
|
137
|
-
# shows latest articles first, and titles sorted alphabetically
|
|
138
|
-
# for articles published on the same date.
|
|
139
|
-
# order_by raises when it's passed something that would result in just `ORDER BY`:
|
|
140
|
-
[postgresql]> q2.render(order: {})
|
|
141
|
-
|
|
142
|
-
# doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
|
|
143
|
-
[postgresql]> q2.select_all.entries
|
|
144
|
-
|
|
145
|
-
# NOTE you can use both `order` and `@order`: local variables like `order` are required,
|
|
146
|
-
# while instance variables like `@order` are optional.
|
|
147
|
-
# To skip the order-part when provided:
|
|
148
|
-
<%= @order.presence && order_by(order) %>
|
|
149
|
-
# or use a default when order-part is always wanted but not always provided:
|
|
150
|
-
<%= 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}, ...]
|
|
151
118
|
```
|
|
152
119
|
|
|
153
|
-
|
|
154
|
-
### ...in a Rails project
|
|
120
|
+
## Usage
|
|
155
121
|
|
|
156
122
|
> [!NOTE]
|
|
157
|
-
> 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.
|
|
158
124
|
|
|
159
|
-
|
|
160
|
-
```bash
|
|
161
|
-
rails g query recent_articles
|
|
162
|
-
```
|
|
125
|
+
### Console Exploration
|
|
163
126
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
values(COALESCE(:since, datetime('now', '-6 months')))
|
|
169
|
-
),
|
|
170
|
-
|
|
171
|
-
recent_articles(article_id, article_title, article_published_on, article_url) AS (
|
|
172
|
-
SELECT id, title, published_on, url
|
|
173
|
-
FROM articles
|
|
174
|
-
RIGHT JOIN settings
|
|
175
|
-
WHERE published_on > settings.min_published_on
|
|
176
|
-
),
|
|
177
|
-
|
|
178
|
-
tags_by_article(article_id, tags) AS (
|
|
179
|
-
SELECT articles_tags.article_id,
|
|
180
|
-
json_group_array(tags.name) AS tags
|
|
181
|
-
FROM articles_tags
|
|
182
|
-
JOIN tags ON articles_tags.tag_id = tags.id
|
|
183
|
-
GROUP BY articles_tags.article_id
|
|
184
|
-
)
|
|
185
|
-
|
|
186
|
-
SELECT recent_articles.*,
|
|
187
|
-
group_concat(json_each.value, ',' ORDER BY value ASC) tags_str
|
|
188
|
-
FROM recent_articles
|
|
189
|
-
JOIN tags_by_article USING(article_id),
|
|
190
|
-
json_each(tags)
|
|
191
|
-
WHERE EXISTS (
|
|
192
|
-
SELECT 1
|
|
193
|
-
FROM json_each(tags)
|
|
194
|
-
WHERE json_each.value LIKE :tag OR :tag IS NULL
|
|
195
|
-
)
|
|
196
|
-
GROUP BY recent_articles.article_id
|
|
197
|
-
ORDER BY recent_articles.article_published_on
|
|
198
|
-
```
|
|
127
|
+
```ruby
|
|
128
|
+
# Testdrive from console
|
|
129
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
|
|
130
|
+
=> [{"today" => Fri, 02 Jan 2026}]
|
|
199
131
|
|
|
200
|
-
|
|
132
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
133
|
+
=> {"today" => Fri, 02 Jan 2026}
|
|
201
134
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
"article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
|
|
205
|
-
"article_published_on"=>"2024-05-17",
|
|
206
|
-
"article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
|
|
207
|
-
"tags_str"=>"release:7x,release:revision"},
|
|
208
|
-
...
|
|
209
|
-
]
|
|
135
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_value
|
|
136
|
+
=> Fri, 02 Jan 2026
|
|
210
137
|
```
|
|
211
138
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
- only published articles
|
|
215
|
-
- only articles _with_ tags
|
|
216
|
-
- only articles published after some date
|
|
217
|
-
- either provided or using the default
|
|
218
|
-
- articles are sorted in a certain order
|
|
219
|
-
- 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>
|
|
220
141
|
|
|
221
|
-
|
|
142
|
+
```ruby
|
|
143
|
+
ActiveRecord::Base.logger = Logger.new(STDOUT)
|
|
144
|
+
ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
|
|
145
|
+
```
|
|
146
|
+
</details>
|
|
222
147
|
|
|
223
|
-
###
|
|
148
|
+
### Type Casting
|
|
224
149
|
|
|
225
|
-
|
|
226
|
-
> 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`.
|
|
227
|
-
> 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):
|
|
228
151
|
|
|
229
|
-
Given the query above, you can get the result like so:
|
|
230
152
|
```ruby
|
|
231
|
-
AppQuery
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
|
|
235
|
-
"article_published_on"=>"2024-05-17",
|
|
236
|
-
"article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
|
|
237
|
-
"tags_str"=>"release:7x,release:revision"},
|
|
238
|
-
...
|
|
239
|
-
]
|
|
153
|
+
# AppQuery
|
|
154
|
+
AppQuery(%{select date('now') as today}).select_one
|
|
155
|
+
=> {"today" => Fri, 02 Jan 2026}
|
|
240
156
|
|
|
241
|
-
#
|
|
242
|
-
|
|
157
|
+
# Compare with raw ActiveRecord
|
|
158
|
+
ActiveRecord::Base.connection.select_one(%{select date('now') as today})
|
|
159
|
+
=> {"today" => "2025-12-20"} # String, not Date!
|
|
243
160
|
|
|
244
|
-
#
|
|
245
|
-
|
|
161
|
+
# Custom casting
|
|
162
|
+
AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
246
163
|
```
|
|
247
164
|
|
|
248
|
-
|
|
165
|
+
### Named Binds
|
|
249
166
|
|
|
250
167
|
```ruby
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
```
|
|
253
181
|
|
|
254
|
-
|
|
255
|
-
AppQuery[:recent_articles].select_value("select count(*) from :_")
|
|
256
|
-
# => 13
|
|
182
|
+
### CTE Manipulation
|
|
257
183
|
|
|
258
|
-
|
|
259
|
-
AppQuery[:recent_articles].count #=> 13
|
|
260
|
-
AppQuery[:recent_articles].count(binds: {since: 0}) #=> 275
|
|
261
|
-
```
|
|
184
|
+
Rewrite queries using CTEs:
|
|
262
185
|
|
|
263
|
-
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
|
|
264
186
|
```ruby
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
+
]
|
|
268
191
|
|
|
269
|
-
|
|
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
|
|
270
196
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
|
|
274
|
-
# => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
|
|
275
|
-
...]
|
|
197
|
+
# Query the CTE directly
|
|
198
|
+
q.select_all("SELECT * FROM articles WHERE id < 2")
|
|
276
199
|
|
|
277
|
-
#
|
|
278
|
-
|
|
279
|
-
|
|
200
|
+
# Query the result (via :_ placeholder)
|
|
201
|
+
q.select_one("SELECT * FROM :_ LIMIT 1")
|
|
202
|
+
q.first # shorthand
|
|
280
203
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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)")
|
|
284
208
|
```
|
|
285
209
|
|
|
286
|
-
|
|
210
|
+
### ERB Templating
|
|
287
211
|
|
|
288
212
|
```ruby
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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) %>
|
|
293
225
|
SQL
|
|
294
226
|
```
|
|
295
227
|
|
|
296
|
-
|
|
297
|
-
```ruby
|
|
298
|
-
# using Ruby data:
|
|
299
|
-
sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
|
|
300
|
-
{id: 2, title: "Another title", published_on: 1.months.ago}]
|
|
301
|
-
# show the provided cutoff date works
|
|
302
|
-
AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds: {since: 6.weeks.ago, articles: JSON[sample_articles]}).entries
|
|
303
|
-
articles AS (
|
|
304
|
-
SELECT * from json_to_recordset(:articles) AS x(id int, title text, published_on timestamp)
|
|
305
|
-
)
|
|
306
|
-
CTE
|
|
307
|
-
```
|
|
228
|
+
### Data Export (PostgreSQL)
|
|
308
229
|
|
|
309
|
-
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten sql:
|
|
310
230
|
```ruby
|
|
311
|
-
|
|
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
|
|
312
244
|
```
|
|
313
245
|
|
|
314
|
-
###
|
|
246
|
+
### RSpec Integration
|
|
315
247
|
|
|
316
|
-
|
|
248
|
+
Generated spec files include helpers:
|
|
317
249
|
|
|
318
250
|
```ruby
|
|
319
251
|
# spec/queries/reports/weekly_query_spec.rb
|
|
320
|
-
require "rails_helper"
|
|
321
|
-
|
|
322
252
|
RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
|
|
323
253
|
describe "CTE articles" do
|
|
324
254
|
specify do
|
|
325
|
-
expect(described_query.select_all("
|
|
255
|
+
expect(described_query.select_all("SELECT * FROM :cte")).to \
|
|
326
256
|
include(a_hash_including("article_id" => 1))
|
|
327
257
|
|
|
328
|
-
#
|
|
258
|
+
# Short version: query, cte and select are implied from descriptions
|
|
329
259
|
expect(select_all).to include(a_hash_including("article_id" => 1))
|
|
330
260
|
end
|
|
331
261
|
end
|
|
332
262
|
end
|
|
333
263
|
```
|
|
334
264
|
|
|
335
|
-
There's some sugar:
|
|
336
|
-
- `described_query`
|
|
337
|
-
...just like `described_class` in regular class specs.
|
|
338
|
-
It's an instance of `AppQuery` based on the last word of the top-description (i.e. "reports/weekly" from "AppQuery reports/weekly").
|
|
339
|
-
- `:cte` placeholder
|
|
340
|
-
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").
|
|
341
|
-
- default_binds
|
|
342
|
-
The `binds`-value used when not explicitly provided.
|
|
343
|
-
|
|
344
265
|
## API Documentation
|
|
345
266
|
|
|
346
267
|
See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
|
|
347
268
|
|
|
348
269
|
## Compatibility
|
|
349
270
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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/)) |
|
|
354
276
|
|
|
355
277
|
## Development
|
|
356
278
|
|
|
357
|
-
|
|
279
|
+
```bash
|
|
280
|
+
# Setup
|
|
281
|
+
bin/setup # Make sure it exits with code 0
|
|
282
|
+
|
|
283
|
+
# Console (connects to database)
|
|
284
|
+
bin/console sqlite3::memory:
|
|
285
|
+
bin/console postgres://localhost:5432/some_db
|
|
358
286
|
|
|
359
|
-
|
|
287
|
+
# With specific Rails version
|
|
288
|
+
bin/run rails_head console
|
|
360
289
|
|
|
361
|
-
|
|
290
|
+
# Run tests
|
|
291
|
+
rake spec
|
|
292
|
+
```
|
|
362
293
|
|
|
363
|
-
|
|
364
|
-
```bash
|
|
365
|
-
$ bin/console sqlite3::memory:
|
|
366
|
-
$ bin/console postgres://localhost:5432/some_db
|
|
294
|
+
Using [mise](https://mise.jdx.dev/) for env-vars is recommended.
|
|
367
295
|
|
|
368
|
-
|
|
369
|
-
$ bin/console -h
|
|
296
|
+
### Releasing
|
|
370
297
|
|
|
371
|
-
|
|
372
|
-
$ bin/run rails_head console
|
|
373
|
-
```
|
|
298
|
+
Create a signed git tag and push:
|
|
374
299
|
|
|
375
|
-
|
|
300
|
+
```bash
|
|
301
|
+
# Regular release
|
|
302
|
+
git tag -s 1.2.3 -m "Release 1.2.3"
|
|
303
|
+
|
|
304
|
+
# Prerelease
|
|
305
|
+
git tag -s 1.2.3.rc1 -m "Release 1.2.3.rc1"
|
|
376
306
|
|
|
377
|
-
|
|
307
|
+
git push origin --tags
|
|
308
|
+
|
|
309
|
+
# then change version.rb for the next dev-cycle
|
|
310
|
+
VERSION = "1.2.4.dev"
|
|
311
|
+
```
|
|
378
312
|
|
|
379
|
-
|
|
313
|
+
CI will build, sign (Sigstore attestation), push to RubyGems, and create a GitHub release.
|
|
380
314
|
|
|
381
315
|
## Contributing
|
|
382
316
|
|
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
|