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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d2b91bbf7a82c936eaa700c842aadc4cf3275eafbf3dab96d1f57fe0d2c208cd
4
- data.tar.gz: 8b9180cf1818c8a7275075fc5d4fd3c3ac336146ea2ef9492205a0c0988945b7
3
+ metadata.gz: 7da66720e6fdbc08a2a016a1b5576f1914a1f2a1705c8263a98ac8f59ba9e61e
4
+ data.tar.gz: 037f03abfd85601c8f88c9d8caabf9094b245c458f8f0fcc363065871af1ff72
5
5
  SHA512:
6
- metadata.gz: '073497cc044bfa98614a5cdafb9b59931a9c30e635f7bd6e32c3ebb7342bab1139a7f6c651fad9fa5d804d715b61afacb412ed597f45a8225ee2a33cb50f1c19'
7
- data.tar.gz: 183aa21ef6e49c094974deccf0edf6b6a46b9eba0bca3a04a70cce3236e720817ba5ec76d729924dd4dce3d54797372d0faaeb920e2208eb7a149ae7f65c55e0
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 providing ergonomic raw SQL queries for ActiveRecord. Inline or stored queries in `app/queries/`, execute them with proper type casting, filter/transform results using CTEs and have parameterization via ERB.
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
- **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) |
39
74
 
40
- > [!IMPORTANT]
41
- > **Status**: using it in production for multiple projects, but API might change pre v1.0. See [the CHANGELOG](./CHANGELOG.md) for breaking changes when upgrading.
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, would rather use raw SQL instead of Arel and hash-maps are fine instead of full-fledge ActiveRecord instances.
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
- 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.
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
- ## Installation
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
- Install the gem and add to the application's Gemfile by executing:
90
+ ## Installation
54
91
 
55
92
  ```bash
56
93
  bundle add appquery
57
94
  ```
58
95
 
59
- ## Usage
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
- ### ...from console
98
+ Generate a query:
65
99
 
66
- 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.
67
- <details>
68
- <summary>Database setup (the `bin/console`-script does this for your)</summary>
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
- The prompt indicates what adapter the example uses:
104
+ Write your SQL in `app/queries/weekly_sales.sql`:
77
105
 
78
- ```ruby
79
- # showing select_(all|one|value)
80
- [postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
81
- => [{"today" => Fri, 02 Jan 2026}]
82
- [postgresql]> AppQuery(%{select date('now') as today}).select_one
83
- => {"today" => Fri, 02 Jan 2026}
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
- # casting
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
- ## query the articles-CTE
133
- [postgresql]> q.select_all(%{select * from articles where id::integer < 2}).entries
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 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.
166
124
 
167
- Create a query:
168
- ```bash
169
- rails g query recent_articles
170
- ```
125
+ ### Console Exploration
171
126
 
172
- Have some SQL (for SQLite, in this example):
173
- ```sql
174
- -- app/queries/recent_articles.sql
175
- WITH settings(min_published_on) as (
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
- The result would look like this:
132
+ [postgresql]> AppQuery(%{select date('now') as today}).select_one
133
+ => {"today" => Fri, 02 Jan 2026}
209
134
 
210
- ```ruby
211
- [{"article_id"=>292,
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
- Even for this fairly trivial query, there's already quite some things 'encoded' that we might want to verify or capture in tests:
221
- - only certain columns
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
- 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>
230
147
 
231
- ### Verify query results
148
+ ### Type Casting
232
149
 
233
- > [!NOTE]
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[:recent_articles].select_all.entries
240
- # =>
241
- [{"article_id"=>292,
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
- # we can provide a different cut off date via binds:
250
- 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!
251
160
 
252
- # NOTE: by default the binds get initialized with nil, e.g. for this example {since: nil, tag: nil}
253
- # 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})
254
163
  ```
255
164
 
256
- We can also dig deeper by query-ing the result, i.e. the CTE `:_`:
165
+ ### Named Binds
257
166
 
258
167
  ```ruby
259
- AppQuery[:recent_articles].select_one("select count(*) as cnt from :_")
260
- # => {"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
+ ```
261
181
 
262
- # For these kind of aggregate queries, we're only interested in the value:
263
- AppQuery[:recent_articles].select_value("select count(*) from :_")
264
- # => 13
182
+ ### CTE Manipulation
265
183
 
266
- # but there's also the shorthand #count (which takes a sub-select):
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
- puts AppQuery[:recent_articles].with_select("select id from :_")
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
- ### 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
278
196
 
279
- You can select from a CTE similarly:
280
- ```ruby
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
- # NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
286
- cast = {tags: :json}
287
- 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
288
203
 
289
- 1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
290
- AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
291
- # => {"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)")
292
208
  ```
293
209
 
294
- Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyond just the select:
210
+ ### ERB Templating
295
211
 
296
212
  ```ruby
297
- AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
298
- settings(min_published_on) as (
299
- values(datetime('now', '-12 months'))
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
- You could even mock existing tables (using PostgreSQL):
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
- 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
320
244
  ```
321
245
 
322
- ### Spec
246
+ ### RSpec Integration
323
247
 
324
- When generating a query `reports/weekly`, a spec-file like below is generated:
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("select * from :cte")).to \
255
+ expect(described_query.select_all("SELECT * FROM :cte")).to \
334
256
  include(a_hash_including("article_id" => 1))
335
257
 
336
- # short version: query, cte and select are all implied from descriptions
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
- - 💾 tested with **SQLite** and **PostgreSQL**
359
- - 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
360
- - 💎 requires Ruby **>=v3.2**
361
- 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/)) |
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
- $ bin/console sqlite3::memory:
374
- $ bin/console postgres://localhost:5432/some_db
375
-
376
- # more details
377
- $ bin/console -h
280
+ # Setup
281
+ bin/setup # Make sure it exits with code 0
378
282
 
379
- # when needing an appraisal, use bin/run (this ensures signals are handled correctly):
380
- $ bin/run rails_head console
381
- ```
283
+ # Console (connects to database)
284
+ bin/console sqlite3::memory:
285
+ bin/console postgres://localhost:5432/some_db
382
286
 
383
- ### various
287
+ # With specific Rails version
288
+ bin/run rails_head console
384
289
 
385
- Run `rake spec` to run the tests.
290
+ # Run tests
291
+ rake spec
292
+ ```
386
293
 
387
- To install this gem onto your local machine, run `bundle exec rake install`.
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
- CI will build the gem, sign it (Sigstore attestation), push to RubyGems, and create a GitHub release (see [release.yml](https://github.com/eval/appquery/blob/3ed2adfacf952acc191a21a44b7c43a375b8975b/.github/workflows/release.yml#L34)).
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.
@@ -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"
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
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