appquery 0.4.0 β 0.6.0.alpha
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/.irbrc +9 -0
- data/CHANGELOG.md +123 -0
- data/README.md +84 -92
- data/Rakefile +10 -0
- data/lib/app_query/base_query.rb +182 -0
- data/lib/app_query/mappable.rb +86 -0
- data/lib/app_query/paginatable.rb +152 -0
- data/lib/app_query/render_helpers.rb +49 -11
- data/lib/app_query/rspec/helpers.rb +9 -1
- data/lib/app_query/tokenizer.rb +2 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +317 -138
- data/mise.toml +1 -1
- data/rakelib/gem.rake +22 -22
- metadata +6 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6f00b1819f60c555c6400c5843dd4682e27be590a967593eeaaabb3cca267789
|
|
4
|
+
data.tar.gz: 9539d6caab140091640ffc7174745921a296005d58bc545c99c7492cdb1356be
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc7edf38ce15320b3bb58acc67460f7431e44f521be1b2a7dec6bd57cadbe8ba3c7c3570f8125bed6429e44a2e7c5f48ffbc64402f8bb5c00a2d6654d9350796
|
|
7
|
+
data.tar.gz: 01b227941d0aa8065c7d787ee7af22a1f6251860473a5fb11130965fd073f1dfdffb4f0cb3ce1569fa4be80728982f6f122efd9d85766c9ca245f19b1bbb5925
|
data/.irbrc
CHANGED
|
@@ -17,4 +17,13 @@ IRB.conf[:PROMPT][:APPQUERY] = {
|
|
|
17
17
|
PROMPT_C: "#{db_indicator}appquery* ",
|
|
18
18
|
RETURN: "=> %s\n"
|
|
19
19
|
}
|
|
20
|
+
|
|
21
|
+
# copy from history easier as prompt is not repeated across lines
|
|
22
|
+
IRB.conf[:PROMPT][:CLEAN] = {
|
|
23
|
+
PROMPT_I: ">> ",
|
|
24
|
+
PROMPT_S: " ", # same width as ">> "
|
|
25
|
+
PROMPT_C: " ",
|
|
26
|
+
PROMPT_N: " ",
|
|
27
|
+
RETURN: "=> %s\n"
|
|
28
|
+
}
|
|
20
29
|
IRB.conf[:PROMPT_MODE] = :APPQUERY
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,128 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
### β¨ Features
|
|
4
|
+
|
|
5
|
+
- ποΈ **`AppQuery::BaseQuery`** β structured query objects with explicit parameter declaration
|
|
6
|
+
```ruby
|
|
7
|
+
class ArticlesQuery < AppQuery::BaseQuery
|
|
8
|
+
bind :author_id
|
|
9
|
+
bind :status, default: nil
|
|
10
|
+
var :order_by, default: "created_at DESC"
|
|
11
|
+
cast published_at: :datetime
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
ArticlesQuery.new(author_id: 1).entries
|
|
15
|
+
ArticlesQuery.new(author_id: 1, status: "draft").first
|
|
16
|
+
```
|
|
17
|
+
Benefits over `AppQuery[:my_query]`:
|
|
18
|
+
- Explicit `bind` and `var` declarations with defaults
|
|
19
|
+
- Unknown parameter validation (catches typos)
|
|
20
|
+
- Self-documenting: `ArticlesQuery.binds`, `ArticlesQuery.vars`
|
|
21
|
+
- Middleware support via concerns
|
|
22
|
+
|
|
23
|
+
- π **`AppQuery::Paginatable`** β pagination middleware (Kaminari-compatible)
|
|
24
|
+
```ruby
|
|
25
|
+
class ApplicationQuery < AppQuery::BaseQuery
|
|
26
|
+
include AppQuery::Paginatable
|
|
27
|
+
per_page 25
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# With count (full pagination)
|
|
31
|
+
articles = ArticlesQuery.new.paginate(page: 1).entries
|
|
32
|
+
articles.total_pages # => 5
|
|
33
|
+
|
|
34
|
+
# Without count (large datasets, uses limit+1 trick)
|
|
35
|
+
articles = ArticlesQuery.new.paginate(page: 1, without_count: true).entries
|
|
36
|
+
articles.next_page # => 2 or nil
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
- πΊοΈ **`AppQuery::Mappable`** β map results to Ruby objects
|
|
40
|
+
```ruby
|
|
41
|
+
class ArticlesQuery < ApplicationQuery
|
|
42
|
+
include AppQuery::Mappable
|
|
43
|
+
|
|
44
|
+
class Item < Data.define(:title, :url, :published_on)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
articles = ArticlesQuery.new.entries
|
|
49
|
+
articles.first.title # => "Hello World"
|
|
50
|
+
articles.first.class # => ArticlesQuery::Item
|
|
51
|
+
|
|
52
|
+
# Skip mapping
|
|
53
|
+
ArticlesQuery.new.raw.entries.first # => {"title" => "Hello", ...}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
- π **`Result#transform!`** β transform result records in-place
|
|
57
|
+
```ruby
|
|
58
|
+
result = AppQuery[:users].select_all
|
|
59
|
+
result.transform! { |row| row.merge("full_name" => "#{row['first']} #{row['last']}") }
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- Add `any?`, `none?` - efficient ways to see if there's any results for a query.
|
|
63
|
+
- π― **Cast type shorthands** β use symbols instead of explicit type classes
|
|
64
|
+
```ruby
|
|
65
|
+
query.select_all(cast: {"published_on" => :date})
|
|
66
|
+
# instead of
|
|
67
|
+
query.select_all(cast: {"published_on" => ActiveRecord::Type::Date.new})
|
|
68
|
+
```
|
|
69
|
+
Supports all ActiveRecord types including adapter-specific ones (`:uuid`, `:jsonb`, etc.).
|
|
70
|
+
- π **Indifferent access** β for rows and cast keys
|
|
71
|
+
```ruby
|
|
72
|
+
row = query.select_one
|
|
73
|
+
row["name"] # works
|
|
74
|
+
row[:name] # also works
|
|
75
|
+
|
|
76
|
+
# cast keys can be symbols too
|
|
77
|
+
query.select_all(cast: {published_on: :date})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## [0.5.0] - 2025-12-21
|
|
81
|
+
|
|
82
|
+
### π₯ Breaking Changes
|
|
83
|
+
|
|
84
|
+
- π **`select:` keyword argument removed** β use positional argument instead
|
|
85
|
+
```ruby
|
|
86
|
+
# before
|
|
87
|
+
query.select_all(select: "SELECT * FROM :_")
|
|
88
|
+
# after
|
|
89
|
+
query.select_all("SELECT * FROM :_")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### β¨ Features
|
|
93
|
+
|
|
94
|
+
- πΎ **Add paginate ERB-helper**
|
|
95
|
+
```ruby
|
|
96
|
+
SELECT * FROM articles
|
|
97
|
+
<%= paginate(page: 1, per_page: 15) %>
|
|
98
|
+
# SELECT * FROM articles LIMIT 15 OFFSET 0
|
|
99
|
+
```
|
|
100
|
+
- π§° **Resolve query without extension**
|
|
101
|
+
`AppQuery[:weekly_sales]` loads `weekly_sales.sql` or `weekly_sales.sql.erb`.
|
|
102
|
+
- π **Nested result queries** via `with_select` β chain transformations using `:_` placeholder to reference the previous result
|
|
103
|
+
```ruby
|
|
104
|
+
active_users = AppQuery("SELECT * FROM users").with_select("SELECT * FROM :_ WHERE active")
|
|
105
|
+
active_users.count("SELECT * FROM :_ WHERE admin")
|
|
106
|
+
```
|
|
107
|
+
- π **New methods**: `#column`, `#ids`, `#count`, `#entries` β efficient shortcuts that only fetch what you need
|
|
108
|
+
```ruby
|
|
109
|
+
query.column(:email) # SELECT email only
|
|
110
|
+
query.ids # SELECT id only
|
|
111
|
+
query.count # SELECT COUNT(*) only
|
|
112
|
+
query.entries # shorthand for select_all.entries
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### π Fixes
|
|
116
|
+
|
|
117
|
+
- π§ Fix leading whitespace in `prepend_cte` causing parse errors
|
|
118
|
+
- π§ Fix binds being reset when no placeholders found
|
|
119
|
+
- β‘ `select_one` now uses `LIMIT 1` for better performance
|
|
120
|
+
|
|
121
|
+
### π Documentation
|
|
122
|
+
|
|
123
|
+
- π Revised README with cleaner intro and examples
|
|
124
|
+
- π Added example Rails app in `examples/demo`
|
|
125
|
+
|
|
3
126
|
## [0.4.0] - 2025-12-15
|
|
4
127
|
|
|
5
128
|
### features
|
data/README.md
CHANGED
|
@@ -3,63 +3,40 @@
|
|
|
3
3
|
[](https://badge.fury.io/rb/appquery)
|
|
4
4
|
[](https://eval.github.io/appquery/)
|
|
5
5
|
|
|
6
|
-
A
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
```
|
|
38
|
-
- **...helpers to rewrite a query for introspection during development and testing**
|
|
39
|
-
See what a CTE yields: `query.select_all(select: "SELECT * FROM some_cte")`.
|
|
40
|
-
Query the end result: `query.select_one(select: "SELECT COUNT(*) FROM _ WHERE ...")`.
|
|
41
|
-
Append/prepend CTEs:
|
|
42
|
-
```ruby
|
|
43
|
-
query.prepend_cte(<<~CTE)
|
|
44
|
-
articles(id, title) AS (
|
|
45
|
-
VALUES(1, 'Some title'),
|
|
46
|
-
(2, 'Another article'))
|
|
47
|
-
CTE
|
|
48
|
-
```
|
|
49
|
-
- **...rspec generators and helpers**
|
|
50
|
-
```ruby
|
|
51
|
-
RSpec.describe "AppQuery reports/weekly", type: :query do
|
|
52
|
-
describe "CTE some_cte" do
|
|
53
|
-
# see what this CTE yields
|
|
54
|
-
expect(described_query.select_all(select: "select * from some_cte")).to \
|
|
55
|
-
include(a_hash_including("id" => 1))
|
|
56
|
-
|
|
57
|
-
# shorter: the query and CTE are derived from the describe-descriptions so this suffices:
|
|
58
|
-
expect(select_all).to include ...
|
|
59
|
-
```
|
|
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.
|
|
7
|
+
|
|
8
|
+
```ruby
|
|
9
|
+
# Load and execute
|
|
10
|
+
week = AppQuery[:weekly_sales].with_binds(week: 1, year: 2025)
|
|
11
|
+
week.entries
|
|
12
|
+
#=> [{"week" => 2025-01-13, "category" => "Electronics", "revenue" => 12500, "target_met" => true}, ...]
|
|
13
|
+
|
|
14
|
+
# Filter results (query wraps in CTE, :_ references it)
|
|
15
|
+
week.count("SELECT * FROM :_ WHERE NOT target_met")
|
|
16
|
+
#=> 3
|
|
17
|
+
|
|
18
|
+
# Extract a column efficiently (only fetches that column)
|
|
19
|
+
week.column(:category)
|
|
20
|
+
#=> ["Electronics", "Clothing", "Home & Garden"]
|
|
21
|
+
|
|
22
|
+
# Named binds with defaults
|
|
23
|
+
AppQuery[:weekly_sales].select_all(binds: {min_revenue: 5000})
|
|
24
|
+
|
|
25
|
+
# ERB templating
|
|
26
|
+
AppQuery("SELECT * FROM contracts <%= order_by(ordering) %>")
|
|
27
|
+
.render(ordering: {year: :desc}).select_all
|
|
28
|
+
|
|
29
|
+
# Custom type casting
|
|
30
|
+
AppQuery("SELECT metadata FROM products").select_all(cast: {metadata: :json})
|
|
31
|
+
|
|
32
|
+
# Inspect/mock CTEs for testing
|
|
33
|
+
query.prepend_cte("sales AS (SELECT * FROM mock_data)")
|
|
34
|
+
```
|
|
35
|
+
|
|
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
|
|
60
37
|
|
|
61
38
|
> [!IMPORTANT]
|
|
62
|
-
> **Status**: alpha. API might change. See the CHANGELOG for breaking changes when upgrading.
|
|
39
|
+
> **Status**: alpha. API might change. See [the CHANGELOG](./CHANGELOG.md) for breaking changes when upgrading.
|
|
63
40
|
>
|
|
64
41
|
|
|
65
42
|
## Rationale
|
|
@@ -96,7 +73,7 @@ The prompt indicates what adapter the example uses:
|
|
|
96
73
|
|
|
97
74
|
```ruby
|
|
98
75
|
# showing select_(all|one|value)
|
|
99
|
-
[postgresql]> AppQuery(%{select date('now') as today}).select_all.
|
|
76
|
+
[postgresql]> AppQuery(%{select date('now') as today}).select_all.entries
|
|
100
77
|
=> [{"today" => "2025-05-10"}]
|
|
101
78
|
[postgresql]> AppQuery(%{select date('now') as today}).select_one
|
|
102
79
|
=> {"today" => "2025-05-10"}
|
|
@@ -104,21 +81,32 @@ The prompt indicates what adapter the example uses:
|
|
|
104
81
|
=> "2025-05-10"
|
|
105
82
|
|
|
106
83
|
# binds
|
|
107
|
-
|
|
108
|
-
[postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
|
|
109
|
-
# named binds
|
|
84
|
+
## named binds
|
|
110
85
|
[postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
|
|
111
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
|
+
|
|
112
96
|
# casting
|
|
113
|
-
|
|
114
|
-
|
|
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"}
|
|
115
103
|
|
|
116
104
|
## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
|
|
117
105
|
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
|
|
118
106
|
=> {"today" => "2025-05-12"}
|
|
119
107
|
## Providing per-column-casts fixes this:
|
|
120
|
-
|
|
121
|
-
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:
|
|
108
|
+
cast = {today: :date}
|
|
109
|
+
[sqlite]> AppQuery(%{select date('now') as today}).select_one(cast:)
|
|
122
110
|
=> {"today" => Mon, 12 May 2025}
|
|
123
111
|
|
|
124
112
|
|
|
@@ -128,16 +116,18 @@ casts = {"today" => ActiveRecord::Type::Date.new}
|
|
|
128
116
|
[2, "Let's learn SQL", 1.month.ago.to_date],
|
|
129
117
|
[3, "Another article", 2.weeks.ago.to_date]
|
|
130
118
|
]
|
|
131
|
-
[postgresql]> q = AppQuery(<<~SQL, cast: {
|
|
119
|
+
[postgresql]> q = AppQuery(<<~SQL, cast: {published_on: :date}).render(articles:)
|
|
132
120
|
WITH articles(id,title,published_on) AS (<%= values(articles) %>)
|
|
133
121
|
select * from articles order by id DESC
|
|
134
122
|
SQL
|
|
135
123
|
|
|
136
124
|
## query the articles-CTE
|
|
137
|
-
[postgresql]> q.select_all(
|
|
125
|
+
[postgresql]> q.select_all(%{select * from articles where id::integer < 2}).entries
|
|
138
126
|
|
|
139
|
-
## query the end-result (available
|
|
140
|
-
[postgresql]> q.select_one(
|
|
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
|
|
141
131
|
|
|
142
132
|
## ERB templating
|
|
143
133
|
# Extract a query from q that can be sorted dynamically:
|
|
@@ -164,7 +154,7 @@ SQL
|
|
|
164
154
|
### ...in a Rails project
|
|
165
155
|
|
|
166
156
|
> [!NOTE]
|
|
167
|
-
> The included [example Rails app](./examples/
|
|
157
|
+
> The included [example Rails app](./examples/demo) contains all data and queries described below.
|
|
168
158
|
|
|
169
159
|
Create a query:
|
|
170
160
|
```bash
|
|
@@ -174,15 +164,15 @@ rails g query recent_articles
|
|
|
174
164
|
Have some SQL (for SQLite, in this example):
|
|
175
165
|
```sql
|
|
176
166
|
-- app/queries/recent_articles.sql
|
|
177
|
-
WITH settings(
|
|
178
|
-
values(datetime('now', '-6 months'))
|
|
167
|
+
WITH settings(min_published_on) as (
|
|
168
|
+
values(COALESCE(:since, datetime('now', '-6 months')))
|
|
179
169
|
),
|
|
180
170
|
|
|
181
171
|
recent_articles(article_id, article_title, article_published_on, article_url) AS (
|
|
182
172
|
SELECT id, title, published_on, url
|
|
183
173
|
FROM articles
|
|
184
174
|
RIGHT JOIN settings
|
|
185
|
-
WHERE published_on >
|
|
175
|
+
WHERE published_on > settings.min_published_on
|
|
186
176
|
),
|
|
187
177
|
|
|
188
178
|
tags_by_article(article_id, tags) AS (
|
|
@@ -201,7 +191,7 @@ JOIN tags_by_article USING(article_id),
|
|
|
201
191
|
WHERE EXISTS (
|
|
202
192
|
SELECT 1
|
|
203
193
|
FROM json_each(tags)
|
|
204
|
-
WHERE json_each.value LIKE
|
|
194
|
+
WHERE json_each.value LIKE :tag OR :tag IS NULL
|
|
205
195
|
)
|
|
206
196
|
GROUP BY recent_articles.article_id
|
|
207
197
|
ORDER BY recent_articles.article_published_on
|
|
@@ -248,28 +238,31 @@ AppQuery[:recent_articles].select_all.entries
|
|
|
248
238
|
...
|
|
249
239
|
]
|
|
250
240
|
|
|
251
|
-
# we can provide a different cut off date via binds
|
|
252
|
-
AppQuery[:recent_articles].select_all(binds:
|
|
241
|
+
# we can provide a different cut off date via binds:
|
|
242
|
+
AppQuery[:recent_articles].select_all(binds: {since: 1.month.ago}).entries
|
|
253
243
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
|
|
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).
|
|
257
246
|
```
|
|
258
247
|
|
|
259
|
-
We can also dig deeper by query-ing the result, i.e. the CTE
|
|
248
|
+
We can also dig deeper by query-ing the result, i.e. the CTE `:_`:
|
|
260
249
|
|
|
261
250
|
```ruby
|
|
262
|
-
AppQuery[:recent_articles].select_one(
|
|
251
|
+
AppQuery[:recent_articles].select_one("select count(*) as cnt from :_")
|
|
263
252
|
# => {"cnt" => 13}
|
|
264
253
|
|
|
265
254
|
# For these kind of aggregate queries, we're only interested in the value:
|
|
266
|
-
AppQuery[:recent_articles].select_value(
|
|
255
|
+
AppQuery[:recent_articles].select_value("select count(*) from :_")
|
|
267
256
|
# => 13
|
|
257
|
+
|
|
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
|
|
268
261
|
```
|
|
269
262
|
|
|
270
263
|
Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
|
|
271
264
|
```ruby
|
|
272
|
-
puts AppQuery[:recent_articles].with_select("select
|
|
265
|
+
puts AppQuery[:recent_articles].with_select("select id from :_")
|
|
273
266
|
```
|
|
274
267
|
|
|
275
268
|
|
|
@@ -277,15 +270,15 @@ puts AppQuery[:recent_articles].with_select("select * from _")
|
|
|
277
270
|
|
|
278
271
|
You can select from a CTE similarly:
|
|
279
272
|
```ruby
|
|
280
|
-
AppQuery[:recent_articles].select_all(
|
|
273
|
+
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
|
|
281
274
|
# => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
|
|
282
275
|
...]
|
|
283
276
|
|
|
284
277
|
# NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
|
|
285
|
-
|
|
286
|
-
AppQuery[:recent_articles].select_all(
|
|
278
|
+
cast = {tags: :json}
|
|
279
|
+
AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast:)
|
|
287
280
|
|
|
288
|
-
1)
|
|
281
|
+
1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
|
|
289
282
|
AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
|
|
290
283
|
# => {"json_build_object"=>{"a"=>1, "b"=>true}}
|
|
291
284
|
```
|
|
@@ -294,7 +287,7 @@ Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyon
|
|
|
294
287
|
|
|
295
288
|
```ruby
|
|
296
289
|
AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
|
|
297
|
-
settings(
|
|
290
|
+
settings(min_published_on) as (
|
|
298
291
|
values(datetime('now', '-12 months'))
|
|
299
292
|
)
|
|
300
293
|
SQL
|
|
@@ -306,10 +299,10 @@ You could even mock existing tables (using PostgreSQL):
|
|
|
306
299
|
sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
|
|
307
300
|
{id: 2, title: "Another title", published_on: 1.months.ago}]
|
|
308
301
|
# show the provided cutoff date works
|
|
309
|
-
AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds:
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
)
|
|
313
306
|
CTE
|
|
314
307
|
```
|
|
315
308
|
|
|
@@ -329,7 +322,7 @@ require "rails_helper"
|
|
|
329
322
|
RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
|
|
330
323
|
describe "CTE articles" do
|
|
331
324
|
specify do
|
|
332
|
-
expect(described_query.select_all(
|
|
325
|
+
expect(described_query.select_all("select * from :cte")).to \
|
|
333
326
|
include(a_hash_including("article_id" => 1))
|
|
334
327
|
|
|
335
328
|
# short version: query, cte and select are all implied from descriptions
|
|
@@ -347,7 +340,6 @@ There's some sugar:
|
|
|
347
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").
|
|
348
341
|
- default_binds
|
|
349
342
|
The `binds`-value used when not explicitly provided.
|
|
350
|
-
E.g. given a query with a where-clause `WHERE published_at > COALESCE($1::timestamp, NOW() - '3 month'::interval)`, when setting `defaults_binds: [nil]` then `select_all` works like `select_all(binds: [nil])`.
|
|
351
343
|
|
|
352
344
|
## API Documentation
|
|
353
345
|
|
data/Rakefile
CHANGED
|
@@ -8,3 +8,13 @@ RSpec::Core::RakeTask.new(:spec)
|
|
|
8
8
|
require "standard/rake"
|
|
9
9
|
|
|
10
10
|
task default: %i[spec standard]
|
|
11
|
+
|
|
12
|
+
# version.rb is written at CI which prevents guard_clean from passing.
|
|
13
|
+
# Redefine guard_clean to make it a noop.
|
|
14
|
+
if ENV["CI"]
|
|
15
|
+
Rake::Task["release:guard_clean"].clear
|
|
16
|
+
task "release:guard_clean"
|
|
17
|
+
|
|
18
|
+
Rake::Task["release:source_control_push"].clear
|
|
19
|
+
task "release:source_control_push"
|
|
20
|
+
end
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/class/attribute" # class_attribute
|
|
4
|
+
require "active_support/core_ext/module/delegation" # delegate
|
|
5
|
+
|
|
6
|
+
module AppQuery
|
|
7
|
+
# Base class for query objects that wrap SQL files.
|
|
8
|
+
#
|
|
9
|
+
# BaseQuery provides a structured way to work with SQL queries compared to
|
|
10
|
+
# using `AppQuery[:my_query]` directly.
|
|
11
|
+
#
|
|
12
|
+
# ## Benefits over AppQuery[:my_query]
|
|
13
|
+
#
|
|
14
|
+
# ### 1. Explicit parameter declaration
|
|
15
|
+
# Declare required binds and vars upfront with defaults:
|
|
16
|
+
#
|
|
17
|
+
# class ArticlesQuery < AppQuery::BaseQuery
|
|
18
|
+
# bind :author_id # required
|
|
19
|
+
# bind :status, default: nil # optional
|
|
20
|
+
# var :order_by, default: "created_at DESC"
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# ### 2. Unknown parameter validation
|
|
24
|
+
# Raises ArgumentError for typos or unknown parameters:
|
|
25
|
+
#
|
|
26
|
+
# ArticlesQuery.new(athor_id: 1) # => ArgumentError: Unknown param(s): athor_id
|
|
27
|
+
#
|
|
28
|
+
# ### 3. Self-documenting queries
|
|
29
|
+
# Query classes show exactly what parameters are available:
|
|
30
|
+
#
|
|
31
|
+
# ArticlesQuery.binds # => {author_id: {default: nil}, status: {default: nil}}
|
|
32
|
+
# ArticlesQuery.vars # => {order_by: {default: "created_at DESC"}}
|
|
33
|
+
#
|
|
34
|
+
# ### 4. Middleware support
|
|
35
|
+
# Include concerns to add functionality:
|
|
36
|
+
#
|
|
37
|
+
# class ApplicationQuery < AppQuery::BaseQuery
|
|
38
|
+
# include AppQuery::Paginatable
|
|
39
|
+
# include AppQuery::Mappable
|
|
40
|
+
# end
|
|
41
|
+
#
|
|
42
|
+
# ### 5. Casts
|
|
43
|
+
# Define casts for columns:
|
|
44
|
+
#
|
|
45
|
+
# class ApplicationQuery < AppQuery::BaseQuery
|
|
46
|
+
# cast metadata: :json
|
|
47
|
+
# end
|
|
48
|
+
#
|
|
49
|
+
# ## Parameter types
|
|
50
|
+
#
|
|
51
|
+
# - **bind**: SQL bind parameters (safe from injection, used in WHERE clauses)
|
|
52
|
+
# - **var**: ERB template variables (for dynamic SQL generation like ORDER BY)
|
|
53
|
+
#
|
|
54
|
+
# ## Naming convention
|
|
55
|
+
#
|
|
56
|
+
# Query class name maps to SQL file:
|
|
57
|
+
# - `ArticlesQuery` -> `articles.sql.erb`
|
|
58
|
+
# - `Reports::MonthlyQuery` -> `reports/monthly.sql.erb`
|
|
59
|
+
#
|
|
60
|
+
# ## Example
|
|
61
|
+
#
|
|
62
|
+
# # app/queries/articles.sql.erb
|
|
63
|
+
# SELECT * FROM articles
|
|
64
|
+
# WHERE author_id = :author_id
|
|
65
|
+
# <% if @status %>AND status = :status<% end %>
|
|
66
|
+
# ORDER BY <%= @order_by %>
|
|
67
|
+
#
|
|
68
|
+
# # app/queries/articles_query.rb
|
|
69
|
+
# class ArticlesQuery < AppQuery::BaseQuery
|
|
70
|
+
# bind :author_id
|
|
71
|
+
# bind :status, default: nil
|
|
72
|
+
# var :order_by, default: "created_at DESC"
|
|
73
|
+
# cast published_at: :datetime
|
|
74
|
+
# end
|
|
75
|
+
#
|
|
76
|
+
# # Usage
|
|
77
|
+
# ArticlesQuery.new(author_id: 1).entries
|
|
78
|
+
# ArticlesQuery.new(author_id: 1, status: "draft", order_by: "title").first
|
|
79
|
+
#
|
|
80
|
+
class BaseQuery
|
|
81
|
+
class_attribute :_binds, default: {}
|
|
82
|
+
class_attribute :_vars, default: {}
|
|
83
|
+
class_attribute :_casts, default: {}
|
|
84
|
+
|
|
85
|
+
class << self
|
|
86
|
+
# Declares a bind parameter for the query.
|
|
87
|
+
#
|
|
88
|
+
# Bind parameters are passed to the database driver and are safe from
|
|
89
|
+
# SQL injection. Use for values in WHERE, HAVING, etc.
|
|
90
|
+
#
|
|
91
|
+
# @param name [Symbol] parameter name (used as :name in SQL)
|
|
92
|
+
# @param default [Object, Proc] default value (Proc is evaluated at instantiation)
|
|
93
|
+
#
|
|
94
|
+
# @example
|
|
95
|
+
# bind :user_id
|
|
96
|
+
# bind :status, default: "active"
|
|
97
|
+
# bind :since, default: -> { 1.week.ago }
|
|
98
|
+
def bind(name, default: nil)
|
|
99
|
+
self._binds = _binds.merge(name => {default:})
|
|
100
|
+
attr_reader name
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Declares a template variable for the query.
|
|
104
|
+
#
|
|
105
|
+
# Vars are available in ERB as both local variables and instance variables
|
|
106
|
+
# (@var). Use for dynamic SQL generation (ORDER BY, column selection, etc.)
|
|
107
|
+
#
|
|
108
|
+
# @param name [Symbol] variable name
|
|
109
|
+
# @param default [Object, Proc] default value (Proc is evaluated at instantiation)
|
|
110
|
+
#
|
|
111
|
+
# @example
|
|
112
|
+
# var :order_by, default: "created_at DESC"
|
|
113
|
+
# var :columns, default: "*"
|
|
114
|
+
def var(name, default: nil)
|
|
115
|
+
self._vars = _vars.merge(name => {default:})
|
|
116
|
+
attr_reader name
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Sets type casting for result columns.
|
|
120
|
+
#
|
|
121
|
+
# @param casts [Hash{Symbol => Symbol}] column name to type mapping
|
|
122
|
+
# @return [Hash] current cast configuration when called without arguments
|
|
123
|
+
#
|
|
124
|
+
# @example
|
|
125
|
+
# cast published_at: :datetime, metadata: :json
|
|
126
|
+
def cast(casts = nil)
|
|
127
|
+
return _casts if casts.nil?
|
|
128
|
+
self._casts = casts
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Hash] declared bind parameters with their options
|
|
132
|
+
def binds = _binds
|
|
133
|
+
|
|
134
|
+
# @return [Hash] declared template variables with their options
|
|
135
|
+
def vars = _vars
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def initialize(**params)
|
|
139
|
+
all_known = self.class.binds.keys + self.class.vars.keys
|
|
140
|
+
unknown = params.keys - all_known
|
|
141
|
+
raise ArgumentError, "Unknown param(s): #{unknown.join(", ")}" if unknown.any?
|
|
142
|
+
|
|
143
|
+
self.class.binds.merge(self.class.vars).each do |name, options|
|
|
144
|
+
value = params.fetch(name) {
|
|
145
|
+
default = options[:default]
|
|
146
|
+
default.is_a?(Proc) ? instance_exec(&default) : default
|
|
147
|
+
}
|
|
148
|
+
instance_variable_set(:"@#{name}", value)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
delegate :select_all, :select_one, :count, :to_s, :column, :first, :ids, to: :query
|
|
153
|
+
|
|
154
|
+
def entries
|
|
155
|
+
select_all
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def query
|
|
159
|
+
@query ||= base_query
|
|
160
|
+
.render(**render_vars)
|
|
161
|
+
.with_binds(**bind_vars)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def base_query
|
|
165
|
+
AppQuery[query_name, cast: self.class.cast]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
private
|
|
169
|
+
|
|
170
|
+
def query_name
|
|
171
|
+
self.class.name.underscore.sub(/_query$/, "")
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def render_vars
|
|
175
|
+
self.class.vars.keys.to_h { [_1, send(_1)] }
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def bind_vars
|
|
179
|
+
self.class.binds.keys.to_h { [_1, send(_1)] }
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|