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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e96dbd6cf521caaee6d919ee3dd7fc796bb92f48ef3e135271b8b82408fb6bcc
4
- data.tar.gz: 0c2250d2a62773ae26ff79469ea6b7e0a63ac246f2824518495a0f4f2ef2b13c
3
+ metadata.gz: 6f00b1819f60c555c6400c5843dd4682e27be590a967593eeaaabb3cca267789
4
+ data.tar.gz: 9539d6caab140091640ffc7174745921a296005d58bc545c99c7492cdb1356be
5
5
  SHA512:
6
- metadata.gz: 14e0146910530087f2c56478e5c507a74ad9b6ccfea81a5c52e46fe24c07f4ef232db86056e1b97c7b7ed9ac976543b8e9c0ac4fcfcc90805aa9d668b426e061
7
- data.tar.gz: 21e8afa59badf98018ba334960b3a6f301511a84305ea4941627f9d9dce3a1b24a23e52201c2a14f0a8912efbdaf9da1742b010acc364ceb72560a7910cab8e2
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
  [![Gem Version](https://badge.fury.io/rb/appquery.svg)](https://badge.fury.io/rb/appquery)
4
4
  [![API Docs](https://img.shields.io/badge/API_Docs-YARD-blue.svg)](https://eval.github.io/appquery/)
5
5
 
6
- A Rubygem :gem: that makes working with raw SQL queries in Rails projects convenient.
7
- Specifically it provides:
8
- - **...a dedicated folder for queries**
9
- e.g. `app/queries/reports/weekly.sql` is instantiated via `AppQuery["reports/weekly"]`.
10
-
11
- - **...ERB templating**
12
- Simple ERB templating with helper-functions:
13
- ```sql
14
- -- app/queries/contracts.sql.erb
15
- SELECT * FROM contracts
16
- <%= order_by(order) %>
17
- ```
18
- ```ruby
19
- AppQuery["contracts.sql.erb"].render(order: {year: :desc, month: :desc}).select_all
20
- ```
21
- - **...positional and named binds**
22
- Intuitive binds:
23
- ```ruby
24
- AppQuery(<<~SQL).select_value(binds: {interval: '1 day'})
25
- select now() - (:interval)::interval as some_date
26
- SQL
27
- AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
28
- select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
29
- SQL
30
- ```
31
- - **...casting**
32
- Automatic and custom casting:
33
- ```ruby
34
- AppQuery(%{select array[1,2]}).select_value #=> [1,2]
35
- cast = {"data" => ActiveRecord::Type::Json.new}
36
- AppQuery(%{select '{"a": 1}' as data}).select_value(cast:)
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.to_a
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
- # positional binds
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
- [postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
114
- => [{"today" => Sat, 10 May 2025}]
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
- casts = {"today" => ActiveRecord::Type::Date.new}
121
- [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
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: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
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(select: %{select * from articles where id < 2}).to_a
125
+ [postgresql]> q.select_all(%{select * from articles where id::integer < 2}).entries
138
126
 
139
- ## query the end-result (available as the CTE named '_')
140
- [postgresql]> q.select_one(select: %{select * from _ limit 1})
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/ror) contains all data and queries described below.
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(default_min_published_on) as (
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 > COALESCE(?1, settings.default_min_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 ?2 OR ?2 IS NULL
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^1:
252
- AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
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
- 1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for
255
- $1 and $2 (which our query can deal with).
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(select: "select count(*) as cnt from _")
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(select: "select count(*) from _")
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 * from _")
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(select: "SELECT * FROM tags_by_article")
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
- types = {"tags" => ActiveRecord::Type::Json.new}
286
- AppQuery[:recent_articles].select_all(select: "SELECT * FROM tags_by_article", cast: types)
278
+ cast = {tags: :json}
279
+ AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast:)
287
280
 
288
- 1) PostgreSQL, unlike SQLite, has json and array types. Just casting suffices:
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(default_min_published_on) as (
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: [6.weeks.ago, nil, JSON[sample_articles]).entries
310
- articles AS (
311
- SELECT * from json_to_recordset($3) AS x(id int, title text, published_on timestamp)
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(select: "select * from :cte")).to \
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