appquery 0.4.0 → 0.5.0

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: 0ca8349f2df1ddf7e2248314238dd72ff5623d2924f1265f385cb1dfde8b274d
4
+ data.tar.gz: 12440ed32a10ca28acd01df89175906e2ed2f77105905ed44f905bc54def4c5d
5
5
  SHA512:
6
- metadata.gz: 14e0146910530087f2c56478e5c507a74ad9b6ccfea81a5c52e46fe24c07f4ef232db86056e1b97c7b7ed9ac976543b8e9c0ac4fcfcc90805aa9d668b426e061
7
- data.tar.gz: 21e8afa59badf98018ba334960b3a6f301511a84305ea4941627f9d9dce3a1b24a23e52201c2a14f0a8912efbdaf9da1742b010acc364ceb72560a7910cab8e2
6
+ metadata.gz: 8154cfbf83c7327cc6bdfbe501430007fb4ed9a5ecfb65f6d751358e3721ca853b9d4655968955147fa2e3aa4ea898db9f71bd3da19647e4e550d2887121daab
7
+ data.tar.gz: a51fc511fa7b5da2bf03c4d63910abb1bb980b95fc300bf188a8749e726e965d781d14d28ffa3240dbca515c7d2bb0f5e83041eae61bb6ea7e815a1f7a13d19f
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,51 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.0] - 2025-12-21
4
+
5
+ ### 💥 Breaking Changes
6
+
7
+ - 🔄 **`select:` keyword argument removed** — use positional argument instead
8
+ ```ruby
9
+ # before
10
+ query.select_all(select: "SELECT * FROM :_")
11
+ # after
12
+ query.select_all("SELECT * FROM :_")
13
+ ```
14
+
15
+ ### ✨ Features
16
+
17
+ - 🍾 **Add paginate ERB-helper**
18
+ ```ruby
19
+ SELECT * FROM articles
20
+ <%= paginate(page: 1, per_page: 15) %>
21
+ # SELECT * FROM articles LIMIT 15 OFFSET 0
22
+ ```
23
+ - 🧰 **Resolve query without extension**
24
+ `AppQuery[:weekly_sales]` loads `weekly_sales.sql` or `weekly_sales.sql.erb`.
25
+ - 🔗 **Nested result queries** via `with_select` — chain transformations using `:_` placeholder to reference the previous result
26
+ ```ruby
27
+ active_users = AppQuery("SELECT * FROM users").with_select("SELECT * FROM :_ WHERE active")
28
+ active_users.count("SELECT * FROM :_ WHERE admin")
29
+ ```
30
+ - 🚀 **New methods**: `#column`, `#ids`, `#count`, `#entries` — efficient shortcuts that only fetch what you need
31
+ ```ruby
32
+ query.column(:email) # SELECT email only
33
+ query.ids # SELECT id only
34
+ query.count # SELECT COUNT(*) only
35
+ query.entries # shorthand for select_all.entries
36
+ ```
37
+
38
+ ### 🐛 Fixes
39
+
40
+ - 🔧 Fix leading whitespace in `prepend_cte` causing parse errors
41
+ - 🔧 Fix binds being reset when no placeholders found
42
+ - ⚡ `select_one` now uses `LIMIT 1` for better performance
43
+
44
+ ### 📚 Documentation
45
+
46
+ - 📖 Revised README with cleaner intro and examples
47
+ - 🏠 Added example Rails app in `examples/demo`
48
+
3
49
  ## [0.4.0] - 2025-12-15
4
50
 
5
51
  ### 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" => ActiveRecord::Type::Json.new})
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
@@ -104,14 +81,23 @@ 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.day.ago, ts2: Time.now}).column("series")
89
+ SELECT
90
+ generate_series(:ts1::timestamp, COALESCE(:ts2, :ts2::timestamp, COALESCE(:interval, '5 minutes')::interval)
91
+ AS series
92
+ SQL
93
+
112
94
  # casting
113
- [postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
114
- => [{"today" => Sat, 10 May 2025}]
95
+ ## Cast values are used by default:
96
+ [postgresql]> AppQuery(%{select date('now')}).select_first
97
+ => {"today" => Sat, 10 May 2025}
98
+ ## compare ActiveRecord
99
+ [postgresql]> ActiveRecord::Base.connection.select_first(%{select date('now') as today})
100
+ => {"today" => "2025-12-20"}
115
101
 
116
102
  ## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
117
103
  [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
@@ -134,10 +120,12 @@ casts = {"today" => ActiveRecord::Type::Date.new}
134
120
  SQL
135
121
 
136
122
  ## query the articles-CTE
137
- [postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
123
+ [postgresql]> q.select_all(%{select * from articles where id < 2}).to_a
138
124
 
139
- ## query the end-result (available as the CTE named '_')
140
- [postgresql]> q.select_one(select: %{select * from _ limit 1})
125
+ ## query the end-result (available via the placeholder ':_')
126
+ [postgresql]> q.select_one(%{select * from :_ limit 1})
127
+ ### shorthand for that
128
+ [postgresql]> q.first
141
129
 
142
130
  ## ERB templating
143
131
  # Extract a query from q that can be sorted dynamically:
@@ -164,7 +152,7 @@ SQL
164
152
  ### ...in a Rails project
165
153
 
166
154
  > [!NOTE]
167
- > The included [example Rails app](./examples/ror) contains all data and queries described below.
155
+ > The included [example Rails app](./examples/demo) contains all data and queries described below.
168
156
 
169
157
  Create a query:
170
158
  ```bash
@@ -174,15 +162,15 @@ rails g query recent_articles
174
162
  Have some SQL (for SQLite, in this example):
175
163
  ```sql
176
164
  -- app/queries/recent_articles.sql
177
- WITH settings(default_min_published_on) as (
178
- values(datetime('now', '-6 months'))
165
+ WITH settings(min_published_on) as (
166
+ values(COALESCE(:since, datetime('now', '-6 months')))
179
167
  ),
180
168
 
181
169
  recent_articles(article_id, article_title, article_published_on, article_url) AS (
182
170
  SELECT id, title, published_on, url
183
171
  FROM articles
184
172
  RIGHT JOIN settings
185
- WHERE published_on > COALESCE(?1, settings.default_min_published_on)
173
+ WHERE published_on > settings.min_published_on
186
174
  ),
187
175
 
188
176
  tags_by_article(article_id, tags) AS (
@@ -201,7 +189,7 @@ JOIN tags_by_article USING(article_id),
201
189
  WHERE EXISTS (
202
190
  SELECT 1
203
191
  FROM json_each(tags)
204
- WHERE json_each.value LIKE ?2 OR ?2 IS NULL
192
+ WHERE json_each.value LIKE :tag OR :tag IS NULL
205
193
  )
206
194
  GROUP BY recent_articles.article_id
207
195
  ORDER BY recent_articles.article_published_on
@@ -248,28 +236,31 @@ AppQuery[:recent_articles].select_all.entries
248
236
  ...
249
237
  ]
250
238
 
251
- # we can provide a different cut off date via binds^1:
252
- AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
239
+ # we can provide a different cut off date via binds:
240
+ AppQuery[:recent_articles].select_all(binds: {since: 1.month.ago}).entries
253
241
 
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]`.
242
+ # NOTE: by default the binds get initialized with nil, e.g. for this example {since: nil, tag: nil}
243
+ # This prevents you from having to provide all binds every time. Default values are put in the SQL (via COALESCE).
257
244
  ```
258
245
 
259
- We can also dig deeper by query-ing the result, i.e. the CTE `_`:
246
+ We can also dig deeper by query-ing the result, i.e. the CTE `:_`:
260
247
 
261
248
  ```ruby
262
- AppQuery[:recent_articles].select_one(select: "select count(*) as cnt from _")
249
+ AppQuery[:recent_articles].select_one("select count(*) as cnt from :_")
263
250
  # => {"cnt" => 13}
264
251
 
265
252
  # For these kind of aggregate queries, we're only interested in the value:
266
- AppQuery[:recent_articles].select_value(select: "select count(*) from _")
253
+ AppQuery[:recent_articles].select_value("select count(*) from :_")
267
254
  # => 13
255
+
256
+ # but there's also the shorthand #count (which takes a sub-select):
257
+ AppQuery[:recent_articles].count #=> 13
258
+ AppQuery[:recent_articles].count(binds: {since: 0}) #=> 275
268
259
  ```
269
260
 
270
261
  Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
271
262
  ```ruby
272
- puts AppQuery[:recent_articles].with_select("select * from _")
263
+ puts AppQuery[:recent_articles].with_select("select id from :_")
273
264
  ```
274
265
 
275
266
 
@@ -277,15 +268,15 @@ puts AppQuery[:recent_articles].with_select("select * from _")
277
268
 
278
269
  You can select from a CTE similarly:
279
270
  ```ruby
280
- AppQuery[:recent_articles].select_all(select: "SELECT * FROM tags_by_article")
271
+ AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article")
281
272
  # => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
282
273
  ...]
283
274
 
284
275
  # NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
285
276
  types = {"tags" => ActiveRecord::Type::Json.new}
286
- AppQuery[:recent_articles].select_all(select: "SELECT * FROM tags_by_article", cast: types)
277
+ AppQuery[:recent_articles].select_all("SELECT * FROM tags_by_article", cast: types)
287
278
 
288
- 1) PostgreSQL, unlike SQLite, has json and array types. Just casting suffices:
279
+ 1) unlike SQLite, PostgreSQL has json and array types. Just casting suffices:
289
280
  AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
290
281
  # => {"json_build_object"=>{"a"=>1, "b"=>true}}
291
282
  ```
@@ -294,7 +285,7 @@ Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyon
294
285
 
295
286
  ```ruby
296
287
  AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
297
- settings(default_min_published_on) as (
288
+ settings(min_published_on) as (
298
289
  values(datetime('now', '-12 months'))
299
290
  )
300
291
  SQL
@@ -306,10 +297,10 @@ You could even mock existing tables (using PostgreSQL):
306
297
  sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
307
298
  {id: 2, title: "Another title", published_on: 1.months.ago}]
308
299
  # 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
- )
300
+ AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds: {since: 6.weeks.ago, articles: JSON[sample_articles]}).entries
301
+ articles AS (
302
+ SELECT * from json_to_recordset(:articles) AS x(id int, title text, published_on timestamp)
303
+ )
313
304
  CTE
314
305
  ```
315
306
 
@@ -329,7 +320,7 @@ require "rails_helper"
329
320
  RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
330
321
  describe "CTE articles" do
331
322
  specify do
332
- expect(described_query.select_all(select: "select * from :cte")).to \
323
+ expect(described_query.select_all("select * from :cte")).to \
333
324
  include(a_hash_including("article_id" => 1))
334
325
 
335
326
  # short version: query, cte and select are all implied from descriptions
@@ -347,7 +338,6 @@ There's some sugar:
347
338
  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
339
  - default_binds
349
340
  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
341
 
352
342
  ## API Documentation
353
343
 
@@ -155,32 +155,70 @@ module AppQuery
155
155
  # order_by(year: :desc, month: :desc)
156
156
  # #=> "ORDER BY year DESC, month DESC"
157
157
  #
158
- # @example Mixed directions
159
- # order_by(published_on: :desc, title: :asc)
160
- # #=> "ORDER BY published_on DESC, title ASC"
161
- #
162
158
  # @example Column without direction (uses database default)
163
159
  # order_by(id: nil)
164
160
  # #=> "ORDER BY id"
165
161
  #
162
+ # @example SQL literal
163
+ # order_by("RANDOM()")
164
+ # #=> "ORDER BY RANDOM()"
165
+ #
166
166
  # @example In an ERB template with a variable
167
167
  # SELECT * FROM articles
168
168
  # <%= order_by(ordering) %>
169
169
  #
170
170
  # @example Making it optional (when ordering may not be provided)
171
- # <%= @ordering.presence && order_by(ordering) %>
171
+ # <%= @order.presence && order_by(ordering) %>
172
172
  #
173
173
  # @example With default fallback
174
- # <%= order_by(@order || {id: :desc}) %>
174
+ # <%= order_by(@order.presence || {id: :desc}) %>
175
175
  #
176
176
  # @raise [ArgumentError] if hash is blank (nil, empty, or not present)
177
177
  #
178
178
  # @note The hash must not be blank. Use conditional ERB for optional ordering.
179
- def order_by(hash)
180
- raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
181
- "ORDER BY " + hash.map do |k, v|
182
- v.nil? ? k : [k, v.upcase].join(" ")
183
- end.join(", ")
179
+ def order_by(order)
180
+ usage = <<~USAGE
181
+ Provide columns to sort by, e.g. order_by(id: :asc), or SQL-literal, e.g. order_by("RANDOM()") (got #{order.inspect}).
182
+ USAGE
183
+ raise ArgumentError, usage unless order.present?
184
+
185
+ case order
186
+ when String then "ORDER BY #{order}"
187
+ when Hash
188
+ "ORDER BY " + order.map do |k, v|
189
+ v.nil? ? k : [k, v.upcase].join(" ")
190
+ end.join(", ")
191
+ else
192
+ raise ArgumentError, usage
193
+ end
194
+ end
195
+
196
+ # Generates a LIMIT/OFFSET clause for pagination.
197
+ #
198
+ # @param page [Integer] the page number (1-indexed)
199
+ # @param per_page [Integer] the number of items per page
200
+ # @return [String] the LIMIT/OFFSET clause
201
+ #
202
+ # @example Basic pagination
203
+ # paginate(page: 1, per_page: 25)
204
+ # #=> "LIMIT 25 OFFSET 0"
205
+ #
206
+ # @example Second page
207
+ # paginate(page: 2, per_page: 25)
208
+ # #=> "LIMIT 25 OFFSET 25"
209
+ #
210
+ # @example In an ERB template
211
+ # SELECT * FROM articles
212
+ # ORDER BY created_at DESC
213
+ # <%= paginate(page: page, per_page: per_page) %>
214
+ #
215
+ # @raise [ArgumentError] if page or per_page is not a positive integer
216
+ def paginate(page:, per_page:)
217
+ raise ArgumentError, "page must be a positive integer (got #{page.inspect})" unless page.is_a?(Integer) && page > 0
218
+ raise ArgumentError, "per_page must be a positive integer (got #{per_page.inspect})" unless per_page.is_a?(Integer) && per_page > 0
219
+
220
+ offset = (page - 1) * per_page
221
+ "LIMIT #{per_page} OFFSET #{offset}"
184
222
  end
185
223
 
186
224
  private
@@ -5,6 +5,10 @@ module AppQuery
5
5
  self.class.default_binds
6
6
  end
7
7
 
8
+ def default_vars
9
+ self.class.default_vars
10
+ end
11
+
8
12
  def expand_select(s)
9
13
  s.gsub(":cte", cte_name)
10
14
  end
@@ -24,7 +28,7 @@ module AppQuery
24
28
  def described_query(select: nil)
25
29
  select ||= "SELECT * FROM :cte" if cte_name
26
30
  select &&= expand_select(select) if cte_name
27
- self.class.described_query.with_select(select)
31
+ self.class.described_query.render(default_vars).with_select(select)
28
32
  end
29
33
 
30
34
  def cte_name
@@ -72,6 +76,10 @@ module AppQuery
72
76
  metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
73
77
  end
74
78
 
79
+ def default_vars
80
+ metadatas.find { _1[:default_vars] }&.[](:default_vars) || {}
81
+ end
82
+
75
83
  def included(klass)
76
84
  super
77
85
  # Inject classmethods into the group.
@@ -95,8 +95,9 @@ module AppQuery
95
95
  if eos?
96
96
  emit_token "COMMA", v: ","
97
97
  emit_token "WHITESPACE", v: "\n"
98
+ elsif match?(/\s/)
99
+ push_return :lex_prepend_cte, :lex_whitespace
98
100
  else
99
- # emit_token "WHITESPACE", v: " "
100
101
  push_return :lex_prepend_cte, :lex_recursive_cte
101
102
  end
102
103
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end
data/lib/app_query.rb CHANGED
@@ -72,21 +72,44 @@ module AppQuery
72
72
 
73
73
  # Loads a query from a file in the configured query path.
74
74
  #
75
+ # When no extension is provided, tries `.sql` first, then `.sql.erb`.
76
+ # Raises an error if both files exist (ambiguous).
77
+ #
75
78
  # @param query_name [String, Symbol] the query name or path (without extension)
76
79
  # @param opts [Hash] additional options passed to {Q#initialize}
77
80
  # @return [Q] a new query object loaded from the file
78
81
  #
79
- # @example Load a simple query
82
+ # @example Load a .sql file
80
83
  # AppQuery[:invoices] # loads app/queries/invoices.sql
81
84
  #
85
+ # @example Load a .sql.erb file (when .sql doesn't exist)
86
+ # AppQuery[:dynamic_report] # loads app/queries/dynamic_report.sql.erb
87
+ #
82
88
  # @example Load from a subdirectory
83
89
  # AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
84
90
  #
85
91
  # @example Load with explicit extension
86
92
  # AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
93
+ #
94
+ # @raise [Error] if both `.sql` and `.sql.erb` files exist for the same name
87
95
  def self.[](query_name, **opts)
88
- filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
89
- full_path = (Pathname.new(configuration.query_path) / filename).expand_path
96
+ base = Pathname.new(configuration.query_path) / query_name.to_s
97
+
98
+ full_path = if File.extname(query_name.to_s).empty?
99
+ sql_path = base.sub_ext(".sql").expand_path
100
+ erb_path = base.sub_ext(".sql.erb").expand_path
101
+ sql_exists = sql_path.exist?
102
+ erb_exists = erb_path.exist?
103
+
104
+ if sql_exists && erb_exists
105
+ raise Error, "Ambiguous query name #{query_name.inspect}: both #{sql_path} and #{erb_path} exist"
106
+ end
107
+
108
+ sql_exists ? sql_path : erb_path
109
+ else
110
+ base.expand_path
111
+ end
112
+
90
113
  Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
91
114
  end
92
115
 
@@ -186,14 +209,14 @@ module AppQuery
186
209
  # @return [String] the SQL string
187
210
  # @return [Array, Hash] bind parameters
188
211
  # @return [Boolean, Hash, Array] casting configuration
189
- attr_reader :name, :sql, :binds, :cast
212
+ attr_reader :sql, :name, :filename, :binds, :cast
190
213
 
191
214
  # Creates a new query object.
192
215
  #
193
216
  # @param sql [String] the SQL query string (may contain ERB)
194
217
  # @param name [String, nil] optional name for logging
195
218
  # @param filename [String, nil] optional filename for ERB error reporting
196
- # @param binds [Array, Hash] bind parameters for the query
219
+ # @param binds [Hash, nil] bind parameters for the query
197
220
  # @param cast [Boolean, Hash, Array] type casting configuration
198
221
  #
199
222
  # @example Simple query
@@ -201,28 +224,38 @@ module AppQuery
201
224
  #
202
225
  # @example With ERB and binds
203
226
  # Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
204
- def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
227
+ def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
205
228
  @sql = sql
206
229
  @name = name
207
230
  @filename = filename
208
231
  @binds = binds
209
232
  @cast = cast
233
+ @cte_depth = cte_depth
234
+ @binds = binds_with_defaults(sql, binds)
210
235
  end
211
236
 
212
- # @private
213
- def deep_dup
214
- super.send(:reset!)
237
+ attr_reader :cte_depth
238
+
239
+ def to_arel
240
+ if binds.presence
241
+ Arel::Nodes::BoundSqlLiteral.new sql, [], binds
242
+ else
243
+ # TODO: add retryable? available from >=7.1
244
+ Arel::Nodes::SqlLiteral.new(sql)
245
+ end
215
246
  end
216
- private :deep_dup
217
247
 
218
- # @private
219
- def reset!
220
- (instance_variables - %i[@sql @filename @name @binds @cast]).each do
221
- instance_variable_set(_1, nil)
248
+ private def binds_with_defaults(sql, binds)
249
+ if (named_binds = sql.scan(/:(?<!::)([a-zA-Z]\w*)/).flatten.map(&:to_sym).uniq.presence)
250
+ named_binds.zip(Array.new(named_binds.count)).to_h.merge(binds.to_h)
251
+ else
252
+ binds.to_h
222
253
  end
223
- self
224
254
  end
225
- private :reset!
255
+
256
+ def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
257
+ self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
258
+ end
226
259
 
227
260
  # @!group Rendering
228
261
 
@@ -267,12 +300,7 @@ module AppQuery
267
300
  sql = to_erb.result(helper.get_binding)
268
301
  collected = helper.collected_binds
269
302
 
270
- with_sql(sql).tap do |q|
271
- # Merge collected binds with existing binds (convert array to hash if needed)
272
- existing = @binds.is_a?(Hash) ? @binds : {}
273
- new_binds = existing.merge(collected)
274
- q.instance_variable_set(:@binds, new_binds) if new_binds.any?
275
- end
303
+ with_sql(sql).add_binds(**collected)
276
304
  end
277
305
 
278
306
  def to_erb
@@ -306,15 +334,12 @@ module AppQuery
306
334
 
307
335
  # Executes the query and returns all matching rows.
308
336
  #
309
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
310
337
  # @param select [String, nil] override the SELECT clause
338
+ # @param binds [Hash, nil] bind parameters to add
311
339
  # @param cast [Boolean, Hash, Array] type casting configuration
312
340
  # @return [Result] the query results with optional type casting
313
341
  #
314
- # @example Simple query with positional binds
315
- # AppQuery("SELECT * FROM users WHERE id = $1").select_all(binds: [1])
316
- #
317
- # @example Named binds
342
+ # @example (Named) binds
318
343
  # AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
319
344
  #
320
345
  # @example With type casting
@@ -325,37 +350,17 @@ module AppQuery
325
350
  # AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
326
351
  #
327
352
  # @raise [UnrenderedQueryError] if the query contains unrendered ERB
328
- # @raise [ArgumentError] if mixing positional binds with collected named binds
329
353
  #
330
354
  # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
331
- def select_all(binds: nil, select: nil, cast: self.cast)
332
- with_select(select).render({}).then do |aq|
333
- # Support both positional (array) and named (hash) binds
334
- if binds.is_a?(Array)
335
- if @binds.is_a?(Hash) && @binds.any?
336
- raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
337
- end
338
- # Positional binds using $1, $2, etc.
339
- ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
340
- Result.from_ar_result(result, cast)
341
- end
355
+ def select_all(s = nil, binds: {}, cast: self.cast)
356
+ add_binds(**binds).with_select(s).render({}).then do |aq|
357
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
358
+ aq.to_arel
342
359
  else
343
- # Named binds - merge collected binds with explicitly passed binds
344
- merged_binds = (@binds.is_a?(Hash) ? @binds : {}).merge(binds || {})
345
- if merged_binds.any?
346
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
347
- Arel.sql(aq.to_s, **merged_binds)
348
- else
349
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
350
- end
351
- ActiveRecord::Base.connection.select_all(sql, name).then do |result|
352
- Result.from_ar_result(result, cast)
353
- end
354
- else
355
- ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
356
- Result.from_ar_result(result, cast)
357
- end
358
- end
360
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
361
+ end
362
+ ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
363
+ Result.from_ar_result(result, cast)
359
364
  end
360
365
  end
361
366
  rescue NameError => e
@@ -366,23 +371,24 @@ module AppQuery
366
371
 
367
372
  # Executes the query and returns the first row.
368
373
  #
369
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
374
+ # @param binds [Hash, nil] bind parameters to add
370
375
  # @param select [String, nil] override the SELECT clause
371
376
  # @param cast [Boolean, Hash, Array] type casting configuration
372
377
  # @return [Hash, nil] the first row as a hash, or nil if no results
373
378
  #
374
379
  # @example
375
- # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
380
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
376
381
  # # => {"id" => 1, "name" => "Alice"}
377
382
  #
378
383
  # @see #select_all
379
- def select_one(binds: nil, select: nil, cast: self.cast)
380
- select_all(binds:, select:, cast:).first
384
+ def select_one(s = nil, binds: {}, cast: self.cast)
385
+ with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
381
386
  end
387
+ alias_method :first, :select_one
382
388
 
383
389
  # Executes the query and returns the first value of the first row.
384
390
  #
385
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
391
+ # @param binds [Hash, nil] named bind parameters
386
392
  # @param select [String, nil] override the SELECT clause
387
393
  # @param cast [Boolean, Hash, Array] type casting configuration
388
394
  # @return [Object, nil] the first value, or nil if no results
@@ -392,13 +398,94 @@ module AppQuery
392
398
  # # => 42
393
399
  #
394
400
  # @see #select_one
395
- def select_value(binds: nil, select: nil, cast: self.cast)
396
- select_one(binds:, select:, cast:)&.values&.first
401
+ def select_value(s = nil, binds: {}, cast: self.cast)
402
+ select_one(s, binds:, cast:)&.values&.first
403
+ end
404
+
405
+ # Returns the count of rows from the query.
406
+ #
407
+ # Wraps the query in a CTE and selects only the count, which is more
408
+ # efficient than fetching all rows via `select_all.count`.
409
+ #
410
+ # @param s [String, nil] optional SELECT to apply before counting
411
+ # @param binds [Hash, nil] bind parameters to add
412
+ # @return [Integer] the count of rows
413
+ #
414
+ # @example Simple count
415
+ # AppQuery("SELECT * FROM users").count
416
+ # # => 42
417
+ #
418
+ # @example Count with filtering
419
+ # AppQuery("SELECT * FROM users")
420
+ # .with_select("SELECT * FROM :_ WHERE active")
421
+ # .count
422
+ # # => 10
423
+ def count(s = nil, binds: {})
424
+ with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first
425
+ end
426
+
427
+ # Returns an array of values for a single column.
428
+ #
429
+ # Wraps the query in a CTE and selects only the specified column, which is
430
+ # more efficient than fetching all columns via `select_all.column(name)`.
431
+ # The column name is safely quoted, making this method safe for user input.
432
+ #
433
+ # @param c [String, Symbol] the column name to extract
434
+ # @param s [String, nil] optional SELECT to apply before extracting
435
+ # @param binds [Hash, nil] bind parameters to add
436
+ # @return [Array] the column values
437
+ #
438
+ # @example Extract a single column
439
+ # AppQuery("SELECT id, name FROM users").column(:name)
440
+ # # => ["Alice", "Bob", "Charlie"]
441
+ #
442
+ # @example With additional filtering
443
+ # AppQuery("SELECT * FROM users").column(:email, "SELECT * FROM :_ WHERE active")
444
+ # # => ["alice@example.com", "bob@example.com"]
445
+ def column(c, s = nil, binds: {})
446
+ quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
447
+ with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column")
448
+ end
449
+
450
+ # Returns an array of id values from the query.
451
+ #
452
+ # Convenience method equivalent to `column(:id)`. More efficient than
453
+ # fetching all columns via `select_all.column("id")`.
454
+ #
455
+ # @param s [String, nil] optional SELECT to apply before extracting
456
+ # @param binds [Hash, nil] bind parameters to add
457
+ # @return [Array] the id values
458
+ #
459
+ # @example Get all user IDs
460
+ # AppQuery("SELECT * FROM users").ids
461
+ # # => [1, 2, 3]
462
+ #
463
+ # @example With filtering
464
+ # AppQuery("SELECT * FROM users").ids("SELECT * FROM :_ WHERE active")
465
+ # # => [1, 3]
466
+ def ids(s = nil, binds: {})
467
+ column(:id, s, binds:)
468
+ end
469
+
470
+ # Executes the query and returns results as an Array of Hashes.
471
+ #
472
+ # Shorthand for `select_all(...).entries`. Accepts the same arguments as
473
+ # {#select_all}.
474
+ #
475
+ # @return [Array<Hash>] the query results as an array
476
+ #
477
+ # @example
478
+ # AppQuery("SELECT * FROM users").entries
479
+ # # => [{"id" => 1, "name" => "Alice"}, {"id" => 2, "name" => "Bob"}]
480
+ #
481
+ # @see #select_all
482
+ def entries(...)
483
+ select_all(...).entries
397
484
  end
398
485
 
399
486
  # Executes an INSERT query.
400
487
  #
401
- # @param binds [Array, Hash] bind parameters for the query
488
+ # @param binds [Hash, nil] bind parameters for the query
402
489
  # @param returning [String, nil] columns to return (Rails 7.1+ only)
403
490
  # @return [Integer, Object] the inserted ID or returning value
404
491
  #
@@ -419,30 +506,22 @@ module AppQuery
419
506
  #
420
507
  # @raise [UnrenderedQueryError] if the query contains unrendered ERB
421
508
  # @raise [ArgumentError] if returning is used with Rails < 7.1
422
- def insert(binds: [], returning: nil)
509
+ def insert(binds: {}, returning: nil)
423
510
  # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
424
511
  if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
425
512
  raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
426
513
  end
427
514
 
428
- binds = binds.presence || @binds
429
- render({}).then do |aq|
430
- if binds.is_a?(Hash)
431
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
432
- Arel.sql(aq.to_s, **binds)
433
- else
434
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
435
- end
436
- if ActiveRecord::VERSION::STRING.to_f >= 7.1
437
- ActiveRecord::Base.connection.insert(sql, name, returning:)
438
- else
439
- ActiveRecord::Base.connection.insert(sql, name)
440
- end
441
- elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
442
- # pk is the less flexible returning
443
- ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
515
+ with_binds(**binds).render({}).then do |aq|
516
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
517
+ aq.to_arel
518
+ else
519
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
520
+ end
521
+ if ActiveRecord::VERSION::STRING.to_f >= 7.1
522
+ ActiveRecord::Base.connection.insert(sql, name, returning:)
444
523
  else
445
- ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
524
+ ActiveRecord::Base.connection.insert(sql, name)
446
525
  end
447
526
  end
448
527
  rescue NameError => e
@@ -453,7 +532,7 @@ module AppQuery
453
532
 
454
533
  # Executes an UPDATE query.
455
534
  #
456
- # @param binds [Array, Hash] bind parameters for the query
535
+ # @param binds [Hash, nil] bind parameters for the query
457
536
  # @return [Integer] the number of affected rows
458
537
  #
459
538
  # @example With named binds
@@ -465,19 +544,14 @@ module AppQuery
465
544
  # .update(binds: ["New Title", 1])
466
545
  #
467
546
  # @raise [UnrenderedQueryError] if the query contains unrendered ERB
468
- def update(binds: [])
469
- binds = binds.presence || @binds
470
- render({}).then do |aq|
471
- if binds.is_a?(Hash)
472
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
473
- Arel.sql(aq.to_s, **binds)
474
- else
475
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
476
- end
477
- ActiveRecord::Base.connection.update(sql, name)
547
+ def update(binds: {})
548
+ with_binds(**binds).render({}).then do |aq|
549
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
550
+ aq.to_arel
478
551
  else
479
- ActiveRecord::Base.connection.update(aq.to_s, name, binds)
552
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
480
553
  end
554
+ ActiveRecord::Base.connection.update(sql, name)
481
555
  end
482
556
  rescue NameError => e
483
557
  raise e unless e.instance_of?(NameError)
@@ -486,7 +560,7 @@ module AppQuery
486
560
 
487
561
  # Executes a DELETE query.
488
562
  #
489
- # @param binds [Array, Hash] bind parameters for the query
563
+ # @param binds [Hash, nil] bind parameters for the query
490
564
  # @return [Integer] the number of deleted rows
491
565
  #
492
566
  # @example With named binds
@@ -496,19 +570,14 @@ module AppQuery
496
570
  # AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
497
571
  #
498
572
  # @raise [UnrenderedQueryError] if the query contains unrendered ERB
499
- def delete(binds: [])
500
- binds = binds.presence || @binds
501
- render({}).then do |aq|
502
- if binds.is_a?(Hash)
503
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
504
- Arel.sql(aq.to_s, **binds)
505
- else
506
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
507
- end
508
- ActiveRecord::Base.connection.delete(sql, name)
573
+ def delete(binds: {})
574
+ with_binds(**binds).render({}).then do |aq|
575
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
576
+ aq.to_arel
509
577
  else
510
- ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
578
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
511
579
  end
580
+ ActiveRecord::Base.connection.delete(sql, name)
512
581
  end
513
582
  rescue NameError => e
514
583
  raise e unless e.instance_of?(NameError)
@@ -547,16 +616,29 @@ module AppQuery
547
616
 
548
617
  # Returns a new query with different bind parameters.
549
618
  #
550
- # @param binds [Array, Hash] the new bind parameters
551
- # @return [Q] a new query object with the specified binds
619
+ # @param binds [Hash, nil] the bind parameters
620
+ # @return [Q] a new query object with the binds replaced
552
621
  #
553
622
  # @example
554
- # query = AppQuery("SELECT * FROM users WHERE id = :id")
555
- # query.with_binds(id: 1).select_one
556
- def with_binds(binds)
557
- deep_dup.tap do
558
- _1.instance_variable_set(:@binds, binds)
559
- end
623
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
624
+ # query.with_binds(bar: 2).binds
625
+ # # => {foo: nil, bar: 2}
626
+ def with_binds(**binds)
627
+ deep_dup(binds:)
628
+ end
629
+ alias_method :replace_binds, :with_binds
630
+
631
+ # Returns a new query with binds added.
632
+ #
633
+ # @param binds [Hash, nil] the bind parameters to add
634
+ # @return [Q] a new query object with the added binds
635
+ #
636
+ # @example
637
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
638
+ # query.add_binds(bar: 2).binds
639
+ # # => {foo: 1, bar: 2}
640
+ def add_binds(**binds)
641
+ deep_dup(binds: self.binds.merge(binds))
560
642
  end
561
643
 
562
644
  # Returns a new query with different cast settings.
@@ -568,9 +650,7 @@ module AppQuery
568
650
  # query = AppQuery("SELECT created_at FROM users")
569
651
  # query.with_cast(false).select_all # disable casting
570
652
  def with_cast(cast)
571
- deep_dup.tap do
572
- _1.instance_variable_set(:@cast, cast)
573
- end
653
+ deep_dup(cast:)
574
654
  end
575
655
 
576
656
  # Returns a new query with different SQL.
@@ -578,31 +658,48 @@ module AppQuery
578
658
  # @param sql [String] the new SQL string
579
659
  # @return [Q] a new query object with the specified SQL
580
660
  def with_sql(sql)
581
- deep_dup.tap do
582
- _1.instance_variable_set(:@sql, sql)
583
- end
661
+ deep_dup(sql:)
584
662
  end
585
663
 
586
664
  # Returns a new query with a modified SELECT statement.
587
665
  #
588
- # If the query has a CTE named `"_"`, replaces the SELECT statement.
589
- # Otherwise, wraps the original query in a `"_"` CTE and uses the new SELECT.
666
+ # Wraps the current SELECT in a numbered CTE and applies the new SELECT.
667
+ # CTEs are named `_`, `_1`, `_2`, etc. Use `:_` in the new SELECT to
668
+ # reference the previous result.
590
669
  #
591
670
  # @param sql [String, nil] the new SELECT statement (nil returns self)
592
671
  # @return [Q] a new query object with the modified SELECT
593
672
  #
594
- # @example
595
- # AppQuery("SELECT id, name FROM users").with_select("SELECT COUNT(*) FROM _")
596
- # # => "WITH _ AS (\n SELECT id, name FROM users\n)\nSELECT COUNT(*) FROM _"
673
+ # @example Single transformation
674
+ # AppQuery("SELECT * FROM users").with_select("SELECT COUNT(*) FROM :_")
675
+ # # => "WITH _ AS (\n SELECT * FROM users\n)\nSELECT COUNT(*) FROM _"
676
+ #
677
+ # @example Chained transformations
678
+ # AppQuery("SELECT * FROM users")
679
+ # .with_select("SELECT * FROM :_ WHERE active")
680
+ # .with_select("SELECT COUNT(*) FROM :_")
681
+ # # => WITH _ AS (SELECT * FROM users),
682
+ # # _1 AS (SELECT * FROM _ WHERE active)
683
+ # # SELECT COUNT(*) FROM _1
597
684
  def with_select(sql)
598
685
  return self if sql.nil?
599
- if cte_names.include?("_")
600
- with_sql(tokens.each_with_object([]) do |token, acc|
601
- v = (token[:t] == "SELECT") ? sql : token[:v]
686
+
687
+ # First CTE is "_", then "_1", "_2", etc.
688
+ current_cte = (cte_depth == 0) ? "_" : "_#{cte_depth}"
689
+
690
+ # Replace :_ with the current CTE name
691
+ processed_sql = sql.gsub(/:_\b/, current_cte)
692
+
693
+ # Wrap current SELECT in numbered CTE
694
+ new_cte = "#{current_cte} AS (\n #{select}\n)"
695
+
696
+ append_cte(new_cte).then do |q|
697
+ # Replace the SELECT token with processed_sql and increment depth
698
+ new_sql = q.tokens.each_with_object([]) do |token, acc|
699
+ v = (token[:t] == "SELECT") ? processed_sql : token[:v]
602
700
  acc << v
603
- end.join)
604
- else
605
- append_cte("_ as (\n #{select}\n)").with_select(sql)
701
+ end.join
702
+ q.deep_dup(sql: new_sql, cte_depth: cte_depth + 1)
606
703
  end
607
704
  end
608
705
 
data/mise.toml CHANGED
@@ -3,4 +3,4 @@ ruby = "3.4"
3
3
 
4
4
  [env]
5
5
  _.path = ["bin"]
6
- PROJECT_ROOT = "{{config_root}}"
6
+ PROJECT_ROOT = "{{config_root}}"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: appquery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -73,7 +73,7 @@ licenses:
73
73
  metadata:
74
74
  homepage_uri: https://github.com/eval/appquery
75
75
  source_code_uri: https://github.com/eval/appquery
76
- changelog_uri: https://github.com/eval/gem-try/blob/main/CHANGELOG.md
76
+ changelog_uri: https://github.com/eval/appquery/blob/main/CHANGELOG.md
77
77
  rdoc_options: []
78
78
  require_paths:
79
79
  - lib