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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5e25e411862644c1f8c660265f3ad89dc6c42cd0d41e4ddec4bd58936f73224f
4
- data.tar.gz: 20ce2aab12e0970c6be53683d679f89efc22d3538589486b044af58e66a0aa5b
3
+ metadata.gz: 7da66720e6fdbc08a2a016a1b5576f1914a1f2a1705c8263a98ac8f59ba9e61e
4
+ data.tar.gz: 037f03abfd85601c8f88c9d8caabf9094b245c458f8f0fcc363065871af1ff72
5
5
  SHA512:
6
- metadata.gz: 18d0837262b9bf8bfaf2fc450c398cdab3f2269703169802a67820dfc65f5cfc22bbaf7d27fcf17c8643d4955e258e25a2def45f2c34a55229ef8f823ccf46c6
7
- data.tar.gz: 56d82917430c3b5e7ed140caed4df130f09cd18b4b436f8d2bb6e5d11562b438c104a961065c8e6f50c9a544bf3624fa8861424060489f76669abb85f28761b9
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
+ }
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ def stylesheets_full_list
4
+ super + %w[css/dark.css]
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ def stylesheets
4
+ super + %w[css/dark.css]
5
+ end
data/.yardopts CHANGED
@@ -1,5 +1,7 @@
1
1
  --readme README.md
2
2
  --markup markdown
3
+ --asset .github:.github
4
+ --template-path .yard/templates
3
5
  --no-private
4
6
  --output-dir doc
5
7
  --exclude tmp/
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2025 Gert Goet
3
+ Copyright (c) 2026 Gert Goet
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,9 +1,32 @@
1
- # AppQuery - raw SQL 🥦, cooked :stew:
2
-
3
- [![Gem Version](https://badge.fury.io/rb/appquery.svg)](https://badge.fury.io/rb/appquery)
4
- [![API Docs](https://img.shields.io/badge/API_Docs-YARD-blue.svg)](https://eval.github.io/appquery/)
5
-
6
- A Ruby gem for working with raw SQL in Rails. Store queries in `app/queries/`, execute them with proper type casting, and filter/transform results using CTEs.
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
- **Highlights**: query files with generator · `select_all`/`select_one`/`select_value`/`count`/`column`/`ids` · query transformation via CTEs · immutable (derive new queries from existing) · named binds · ERB helpers (`order_by`, `paginate`, `values`, `bind`) · automatic + custom type casting · RSpec integration
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**: alpha. API might change. See [the CHANGELOG](./CHANGELOG.md) for breaking changes when upgrading.
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, and you'd rather use raw SQL to get the right data out. 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.
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
- ## Installation
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
- Install the gem and add to the application's Gemfile by executing:
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
- ## Usage
96
+ ## Quick Start
56
97
 
57
- > [!NOTE]
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
- ### ...from console
100
+ ```bash
101
+ rails g query weekly_sales
102
+ ```
61
103
 
62
- Testdriving can be easily done from the console. Either by cloning this repository (recommended, see `Development`-section) or installing the gem in an existing Rails project.
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
- The prompt indicates what adapter the example uses:
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
- ```ruby
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
- ## query the articles-CTE
125
- [postgresql]> q.select_all(%{select * from articles where id::integer < 2}).entries
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 all data and queries described below.
123
+ > The following examples show how this gem handles raw SQL. The included [example Rails app](./examples/demo) contains runnable queries.
158
124
 
159
- Create a query:
160
- ```bash
161
- rails g query recent_articles
162
- ```
125
+ ### Console Exploration
163
126
 
164
- Have some SQL (for SQLite, in this example):
165
- ```sql
166
- -- app/queries/recent_articles.sql
167
- WITH settings(min_published_on) as (
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
- The result would look like this:
132
+ [postgresql]> AppQuery(%{select date('now') as today}).select_one
133
+ => {"today" => Fri, 02 Jan 2026}
201
134
 
202
- ```ruby
203
- [{"article_id"=>292,
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
- Even for this fairly trivial query, there's already quite some things 'encoded' that we might want to verify or capture in tests:
213
- - only certain columns
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
- Using the SQL-rewriting capabilities shown below, this library allows you to express these assertions in tests or verify them during development.
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
- ### Verify query results
148
+ ### Type Casting
224
149
 
225
- > [!NOTE]
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[:recent_articles].select_all.entries
232
- # =>
233
- [{"article_id"=>292,
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
- # we can provide a different cut off date via binds:
242
- AppQuery[:recent_articles].select_all(binds: {since: 1.month.ago}).entries
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
- # NOTE: by default the binds get initialized with nil, e.g. for this example {since: nil, tag: nil}
245
- # This prevents you from having to provide all binds every time. Default values are put in the SQL (via COALESCE).
161
+ # Custom casting
162
+ AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
246
163
  ```
247
164
 
248
- We can also dig deeper by query-ing the result, i.e. the CTE `:_`:
165
+ ### Named Binds
249
166
 
250
167
  ```ruby
251
- AppQuery[:recent_articles].select_one("select count(*) as cnt from :_")
252
- # => {"cnt" => 13}
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
- # For these kind of aggregate queries, we're only interested in the value:
255
- AppQuery[:recent_articles].select_value("select count(*) from :_")
256
- # => 13
182
+ ### CTE Manipulation
257
183
 
258
- # but there's also the shorthand #count (which takes a sub-select):
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
- puts AppQuery[:recent_articles].with_select("select id from :_")
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
- ### Verify CTE results
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
- You can select from a CTE similarly:
272
- ```ruby
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
- # NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
278
- cast = {tags: :json}
279
- AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast:)
200
+ # Query the result (via :_ placeholder)
201
+ q.select_one("SELECT * FROM :_ LIMIT 1")
202
+ q.first # shorthand
280
203
 
281
- 1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
282
- AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
283
- # => {"json_build_object"=>{"a"=>1, "b"=>true}}
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
- Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyond just the select:
210
+ ### ERB Templating
287
211
 
288
212
  ```ruby
289
- AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
290
- settings(min_published_on) as (
291
- values(datetime('now', '-12 months'))
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
- You could even mock existing tables (using PostgreSQL):
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
- puts AppQuery[:recent_articles].with_select("select * from some_cte")
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
- ### Spec
246
+ ### RSpec Integration
315
247
 
316
- When generating a query `reports/weekly`, a spec-file like below is generated:
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("select * from :cte")).to \
255
+ expect(described_query.select_all("SELECT * FROM :cte")).to \
326
256
  include(a_hash_including("article_id" => 1))
327
257
 
328
- # short version: query, cte and select are all implied from descriptions
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
- - 💾 tested with **SQLite** and **PostgreSQL**
351
- - 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
352
- - 💎 requires Ruby **>=v3.2**
353
- Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
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
- After checking out the repo, run `bin/setup` to install dependencies. **Make sure to check it exits with status code 0.**
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
- Using [mise](https://mise.jdx.dev/) for env-vars recommended.
287
+ # With specific Rails version
288
+ bin/run rails_head console
360
289
 
361
- ### console
290
+ # Run tests
291
+ rake spec
292
+ ```
362
293
 
363
- The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
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
- # more details
369
- $ bin/console -h
296
+ ### Releasing
370
297
 
371
- # when needing an appraisal, use bin/run (this ensures signals are handled correctly):
372
- $ bin/run rails_head console
373
- ```
298
+ Create a signed git tag and push:
374
299
 
375
- ### various
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
- Run `rake spec` to run the tests.
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
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
313
+ CI will build, sign (Sigstore attestation), push to RubyGems, and create a GitHub release.
380
314
 
381
315
  ## Contributing
382
316
 
@@ -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 == "("
@@ -3,5 +3,5 @@
3
3
  module AppQuery
4
4
  # This should just contain the .dev of the upcoming version.
5
5
  # When doing the actual release, CI will write the tag here before pushing the gem.
6
- VERSION = "0.6.0.rc9"
6
+ VERSION = "0.7.0.rc1"
7
7
  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.6.0.rc9
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