appquery 0.1.0 → 0.3.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: 10872e5424d3f5853f5317384cd44927241c74ac6f1df8ba41f925796384816b
4
- data.tar.gz: eaa746cb1b2c7b2a48058ae1ceb1374245b6a197fab801411d0d602f4f58e7e2
3
+ metadata.gz: 64e74167bbafa7217db5f9c0bab6efc8b55655abbbcb7e2fccefca3dfe1afae8
4
+ data.tar.gz: 16f6870106206b547ed307fb6cbcd8d9250610239ccf9dc046e6e6c9719d76dd
5
5
  SHA512:
6
- metadata.gz: 8a11faf4af63d87413ccfd28e2224ad230e2e451cdf3518302c4faaf2df0594118f7d2ac99fa57d65a75feb52163780088038e9748ffeb2212b41cc4505ae0a3
7
- data.tar.gz: 7a6e4c0337a2fff23f845b04878994f162f7c49b5e9d981f1713cd5d0c490d8b0ea226871a92d754b3a4a5cc0e0b873217d650ec9d9f52ba79a0cfc257d24acc
6
+ metadata.gz: 74ce3c5a8d22b2b41bc477069e9720c7b91cad52909ee3e4db8533c0a6f357227ec7e9485f513b8306a0d4994b53556971ae09284c72c2c5370bee45a0c244e3
7
+ data.tar.gz: 61b01b05753f3a6f27194e47b40bb91aa822c5c263dbe78d0328f1b09aad02d95acbfc7ba191d4fb773b536c1b1ad03465e05c87c4ecf1dc279ed3e461ba97b2
data/.standard.yml CHANGED
@@ -1,3 +1,5 @@
1
1
  # For available configuration options, see:
2
2
  # https://github.com/standardrb/standard
3
- ruby_version: 3.0
3
+ ruby_version: 3.1
4
+ ignore:
5
+ - 'examples/**/*'
data/Appraisals ADDED
@@ -0,0 +1,15 @@
1
+ appraise "rails-70" do
2
+ gem "rails", "~> 7.0"
3
+ end
4
+
5
+ appraise "rails-71" do
6
+ gem "rails", "~> 7.1"
7
+ end
8
+
9
+ appraise "rails-72" do
10
+ gem "rails", "~> 7.2"
11
+ end
12
+
13
+ appraise "rails-80" do
14
+ gem "rails", "~> 8.0"
15
+ end
data/LICENSE.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2024 Gert Goet
3
+ Copyright (c) 2025 Gert Goet
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -1,32 +1,596 @@
1
- # Appquery
1
+ # AppQuery - raw SQL 🥦, cooked :stew:
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
3
+ [![Gem Version](https://badge.fury.io/rb/appquery.svg)](https://badge.fury.io/rb/appquery)
4
4
 
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/appquery`. To experiment with that code, run `bin/console` for an interactive prompt.
5
+ A Rubygem :gem: that makes working with raw SQL (READ) queries in Rails projects more convenient.
6
+ Specifically it provides:
7
+ - **...a dedicated folder for queries**
8
+ e.g. `app/queries/reports/weekly.sql` is instantiated via `AppQuery["reports/weekly"]`.
9
+ - **...Rails/rspec generators**
10
+ ```
11
+ $ rails generate query reports/weekly
12
+ create app/queries/reports/weekly.sql
13
+ invoke rspec
14
+ create spec/queries/reports/weekly_query_spec.rb
15
+ ```
16
+ - **...ERB templating**
17
+ Simple ERB templating with helper-functions:
18
+ ```sql
19
+ -- app/queries/contracts.sql.erb
20
+ SELECT * FROM contracts
21
+ <%= order_by(order) %>
22
+ ```
23
+ ```ruby
24
+ AppQuery["contracts.sql.erb"].render(order: {year: :desc, month: :desc}).select_all
25
+ ```
26
+ - **...positional and named binds**
27
+ Intuitive binds:
28
+ ```ruby
29
+ AppQuery(%{select now() - (:interval)::interval as some_date}).select_value(binds: {interval: '1 day'})
30
+ AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
31
+ select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
32
+ SQL
33
+ ```
34
+ - **...casting**
35
+ Automatic and custom casting:
36
+ ```ruby
37
+ AppQuery(%{select array[1,2]}).select_value #=> [1,2]
38
+ cast = {"data" => ActiveRecord::Type::Json.new}
39
+ AppQuery(%{select '{"a": 1}' as data}).select_value(cast:)
40
+ ```
41
+ - **...helpers to rewrite a query for introspection during development and testing**
42
+ See what a CTE yields: `query.select_all(select: "SELECT * FROM some_cte")`.
43
+ Query the end result: `query.select_one(select: "SELECT COUNT(*) FROM _ WHERE ...")`.
44
+ Append/prepend CTEs:
45
+ ```ruby
46
+ query.prepend_cte(<<~CTE)
47
+ articles(id, title) AS (
48
+ VALUES(1, 'Some title'),
49
+ (2, 'Another article'))
50
+ CTE
51
+ ```
52
+ - **...rspec-helpers**
53
+ ```ruby
54
+ RSpec.describe "AppQuery reports/weekly", type: :query do
55
+ describe "CTE some_cte" do
56
+ # see what this CTE yields
57
+ expect(described_query.select_all(select: "select * from some_cte")).to \
58
+ include(a_hash_including("id" => 1))
59
+
60
+ # shorter: the query and CTE are derived from the describe-descriptions so this suffices:
61
+ expect(select_all).to include ...
62
+ ```
6
63
 
7
- ## Installation
64
+ > [!IMPORTANT]
65
+ > **Status**: alpha. API might change. See the CHANGELOG for breaking changes when upgrading.
66
+ >
67
+
68
+ ## Rationale
8
69
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
70
+ Sometimes ActiveRecord doesn't cut it, and you'd rather use raw SQL to get the right data out. That, however, introduces some new problems. First of all, you'll run into the not-so-intuitive use of [select_(all|one|value)](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all) — for example, how they differ with respect to type casting, and how their behavior can vary between ActiveRecord versions. Then there's the testability, introspection, and maintainability of the resulting SQL queries.
71
+ This library aims to alleviate all of these issues by providing a consistent interface across select_* methods and ActiveRecord versions. It should make inspecting and testing queries easier—especially when they're built from CTEs.
72
+
73
+ ## Installation
10
74
 
11
75
  Install the gem and add to the application's Gemfile by executing:
12
76
 
13
77
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
78
+ bundle add appquery
79
+ ```
80
+
81
+ ## Usage
82
+
83
+ > [!NOTE]
84
+ > The following (trivial) examples are not meant to convince you to ditch your ORM, but just to show how this gem handles raw SQL queries.
85
+
86
+ ### ...from console
87
+
88
+ Testdriving can be easily done from the console. Either by cloning this repository (recommended, see `Development`-section) or installing the gem in an existing Rails project.
89
+ <details>
90
+ <summary>Database setup (the `bin/console`-script does this for your)</summary>
91
+
92
+ ```ruby
93
+ ActiveRecord::Base.logger = Logger.new(STDOUT)
94
+ ActiveRecord::Base.establish_connection(url: 'postgres://localhost:5432/some_db')
95
+ ```
96
+ </details>
97
+
98
+ The following examples assume PostgreSQL (SQLite where stated):
99
+
100
+ ```ruby
101
+ # showing select_(all|one|value)
102
+ > AppQuery(%{select date('now') as today}).select_all.to_a
103
+ => [{"today" => "2025-05-10"}]
104
+ > AppQuery(%{select date('now') as today}).select_one
105
+ => {"today" => "2025-05-10"}
106
+ > AppQuery(%{select date('now') as today}).select_value
107
+ => "2025-05-10"
108
+
109
+ # binds
110
+ # positional binds
111
+ > AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
112
+ # named binds
113
+ > AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
114
+
115
+ # casting
116
+ > AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
117
+ => [{"today" => Sat, 10 May 2025}]
118
+
119
+ ## SQLite doesn't have a notion of dates or timestamp's so casting won't do anything:
120
+ sqlite> AppQuery(%{select date('now') as today}).select_one(cast: true)
121
+ => {"today" => "2025-05-12"}
122
+ ## Providing per-column-casts fixes this:
123
+ casts = {"today" => ActiveRecord::Type::Date.new}
124
+ sqlite> AppQuery(%{select date('now') as today}).select_one(cast: casts)
125
+ => {"today" => Mon, 12 May 2025}
126
+
127
+ # rewriting queries (using CTEs)
128
+ q = AppQuery(<<~SQL)
129
+ WITH articles(id,title,published_on) AS (
130
+ values(1, 'Some title', '2024-3-31'),
131
+ (2, 'Other title', '2024-10-31'),
132
+ (3, 'Same title?', '2024-3-31'))
133
+ select * from articles order by id DESC
134
+ SQL
135
+
136
+ ## query the articles-CTE
137
+ q.select_all(select: %{select * from articles where id < 2}).to_a
138
+
139
+ ## query the end-result (available as the CTE named '_')
140
+ q.select_one(select: %{select * from _ limit 1})
141
+
142
+ ## ERB templating
143
+ # Extract a query from q that can be sorted dynamically:
144
+ q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
145
+ q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
146
+ # shows latest articles first, and titles sorted alphabetically
147
+ # for articles published on the same date.
148
+ # order_by raises when it's passed something that would result in just `ORDER BY`:
149
+ q2.render(order: {})
150
+ # doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
151
+ q2.select_all.entries
152
+
153
+ # NOTE you can use both `order` and `@order`: local variables like `order` are required,
154
+ # while instance variables like `@order` are optional.
155
+ # To skip the order-part when provided:
156
+ <%= @order.presence && order_by(order) %>
157
+ # or use a default when order-part is always wanted but not always provided:
158
+ <%= order_by(@order || {id: :desc}) %>
15
159
  ```
16
160
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
18
161
 
162
+ ### ...in a Rails project
163
+
164
+ > [!NOTE]
165
+ > The included [example Rails app](./examples/ror) contains all data and queries described below.
166
+
167
+ Create a query:
19
168
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
169
+ rails g query recent_articles
21
170
  ```
22
171
 
23
- ## Usage
172
+ Have some SQL (for SQLite, in this example):
173
+ ```sql
174
+ -- app/queries/recent_articles.sql
175
+ WITH settings(default_min_published_on) as (
176
+ values(datetime('now', '-6 months'))
177
+ ),
178
+
179
+ recent_articles(article_id, article_title, article_published_on, article_url) AS (
180
+ SELECT id, title, published_on, url
181
+ FROM articles
182
+ RIGHT JOIN settings
183
+ WHERE published_on > COALESCE(?1, settings.default_min_published_on)
184
+ ),
185
+
186
+ tags_by_article(article_id, tags) AS (
187
+ SELECT articles_tags.article_id,
188
+ json_group_array(tags.name) AS tags
189
+ FROM articles_tags
190
+ JOIN tags ON articles_tags.tag_id = tags.id
191
+ GROUP BY articles_tags.article_id
192
+ )
193
+
194
+ SELECT recent_articles.*,
195
+ group_concat(json_each.value, ',' ORDER BY value ASC) tags_str
196
+ FROM recent_articles
197
+ JOIN tags_by_article USING(article_id),
198
+ json_each(tags)
199
+ WHERE EXISTS (
200
+ SELECT 1
201
+ FROM json_each(tags)
202
+ WHERE json_each.value LIKE ?2 OR ?2 IS NULL
203
+ )
204
+ GROUP BY recent_articles.article_id
205
+ ORDER BY recent_articles.article_published_on
206
+ ```
207
+
208
+ The result would look like this:
209
+
210
+ ```ruby
211
+ [{"article_id"=>292,
212
+ "article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
213
+ "article_published_on"=>"2024-05-17",
214
+ "article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
215
+ "tags_str"=>"release:7x,release:revision"},
216
+ ...
217
+ ]
218
+ ```
219
+
220
+ Even for this fairly trivial query, there's already quite some things 'encoded' that we might want to verify or capture in tests:
221
+ - only certain columns
222
+ - only published articles
223
+ - only articles _with_ tags
224
+ - only articles published after some date
225
+ - either provided or using the default
226
+ - articles are sorted in a certain order
227
+ - tags appear in a certain order and are formatted a certain way
228
+
229
+ Using the SQL-rewriting capabilities shown below, this library allows you to express these assertions in tests or verify them during development.
230
+
231
+ ### Verify query results
232
+
233
+ > [!NOTE]
234
+ > There's `AppQuery#select_all`, `AppQuery#select_one` and `AppQuery#select_value` to execute a query. `select_(all|one)` are tiny wrappers around the equivalent methods from `ActiveRecord::Base.connection`.
235
+ > Instead of [positional arguments](https://api.rubyonrails.org/classes/ActiveRecord/ConnectionAdapters/DatabaseStatements.html#method-i-select_all), these methods accept keywords `select`, `binds` and `cast`. See below for examples.
236
+
237
+ Given the query above, you can get the result like so:
238
+ ```ruby
239
+ AppQuery[:recent_articles].select_all.entries
240
+ # =>
241
+ [{"article_id"=>292,
242
+ "article_title"=>"Rails Versions 7.0.8.2, and 7.1.3.3 have been released!",
243
+ "article_published_on"=>"2024-05-17",
244
+ "article_url"=>"https://rubyonrails.org/2024/5/17/Rails-Versions-7-0-8-2-and-7-1-3-3-have-been-released",
245
+ "tags_str"=>"release:7x,release:revision"},
246
+ ...
247
+ ]
248
+
249
+ # we can provide a different cut off date via binds^1:
250
+ AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
251
+
252
+ 1) note that SQLite can deal with unbound parameters, i.e. when no binds are provided it assumes null for $1 and $2 (which our query can deal with).
253
+ For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
254
+ ```
255
+
256
+ We can also dig deeper by query-ing the result, i.e. the CTE `_`:
257
+
258
+ ```ruby
259
+ AppQuery[:recent_articles].select_one(select: "select count(*) as cnt from _")
260
+ # => {"cnt" => 13}
261
+
262
+ # For these kind of aggregate queries, we're only interested in the value:
263
+ AppQuery[:recent_articles].select_value(select: "select count(*) from _")
264
+ # => 13
265
+ ```
266
+
267
+ Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten SQL:
268
+ ```ruby
269
+ puts AppQuery[:recent_articles].with_select("select * from _")
270
+ ```
271
+
272
+
273
+ ### Verify CTE results
274
+
275
+ You can select from a CTE similarly:
276
+ ```ruby
277
+ AppQuery[:recent_articles].select_all(select: "SELECT * FROM tags_by_article")
278
+ # => [{"article_id"=>1, "tags"=>"[\"release:pre\",\"release:patch\",\"release:1x\"]"},
279
+ ...]
280
+
281
+ # NOTE how the tags are json strings. Casting allows us to turn these into proper arrays^1:
282
+ types = {"tags" => ActiveRecord::Type::Json.new}
283
+ AppQuery[:recent_articles].select_all(select: "SELECT * FROM tags_by_article", cast: types)
284
+
285
+ 1) PostgreSQL, unlike SQLite, has json and array types. Just casting suffices:
286
+ AppQuery("select json_build_object('a', 1, 'b', true)").select_one(cast: true)
287
+ # => {"json_build_object"=>{"a"=>1, "b"=>true}}
288
+ ```
289
+
290
+ Using the methods `(prepend|append|replace)_cte`, we can rewrite the query beyond just the select:
291
+
292
+ ```ruby
293
+ AppQuery[:recent_articles].replace_cte(<<~SQL).select_all.entries
294
+ settings(default_min_published_on) as (
295
+ values(datetime('now', '-12 months'))
296
+ )
297
+ SQL
298
+ ```
299
+
300
+ You could even mock existing tables (using PostgreSQL):
301
+ ```ruby
302
+ # using Ruby data:
303
+ sample_articles = [{id: 1, title: "Some title", published_on: 3.months.ago},
304
+ {id: 2, title: "Another title", published_on: 1.months.ago}]
305
+ # show the provided cutoff date works
306
+ AppQuery[:recent_articles].prepend_cte(<<-CTE).select_all(binds: [6.weeks.ago, nil, JSON[sample_articles]).entries
307
+ articles AS (
308
+ SELECT * from json_to_recordset($3) AS x(id int, title text, published_on timestamp)
309
+ )
310
+ CTE
311
+ ```
312
+
313
+ Use `AppQuery#with_select` to get a new AppQuery-instance with the rewritten sql:
314
+ ```ruby
315
+ puts AppQuery[:recent_articles].with_select("select * from some_cte")
316
+ ```
317
+
318
+ ### Spec
319
+
320
+ When generating a query `reports/weekly`, a spec-file like below is generated:
321
+
322
+ ```ruby
323
+ # spec/queries/reports/weekly_query_spec.rb
324
+ require "rails_helper"
325
+
326
+ RSpec.describe "AppQuery reports/weekly", type: :query, default_binds: [] do
327
+ describe "CTE articles" do
328
+ specify do
329
+ expect(described_query.select_all(select: "select * from :cte")).to \
330
+ include(a_hash_including("article_id" => 1))
331
+
332
+ # short version: query, cte and select are all implied from descriptions
333
+ expect(select_all).to include(a_hash_including("article_id" => 1))
334
+ end
335
+ end
336
+ end
337
+ ```
338
+
339
+ There's some sugar:
340
+ - `described_query`
341
+ ...just like `described_class` in regular class specs.
342
+ It's an instance of `AppQuery` based on the last word of the top-description (i.e. "reports/weekly" from "AppQuery reports/weekly").
343
+ - `:cte` placeholder
344
+ 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").
345
+ - default_binds
346
+ The `binds`-value used when not explicitly provided.
347
+ 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])`.
348
+
349
+ ## 💎 API Doc 💎
350
+
351
+ ### generic
352
+
353
+ <details>
354
+ <summary><code>AppQuery(sql) ⇒ AppQuery::Q</code></summary>
355
+
356
+ ### Examples
357
+
358
+ ```ruby
359
+ AppQuery("some sql")
360
+ ```
361
+ </details>
362
+
363
+ ### module AppQuery
364
+
365
+ <details>
366
+ <summary><code>AppQuery[query_name] ⇒ AppQuery::Q</code></summary>
367
+
368
+ ### Examples
369
+
370
+ ```ruby
371
+ AppQuery[:recent_articles]
372
+ AppQuery["export/articles"]
373
+ ```
374
+
375
+ </details>
376
+
377
+ <details>
378
+ <summary><code>AppQuery.configure {|Configuration| ... } ⇒ void </code></summary>
379
+
380
+ Configure AppQuery.
381
+
382
+ ### Examples
383
+
384
+ ```ruby
385
+ AppQuery.configure do |cfg|
386
+ cfg.query_path = "db/queries" # default: "app/queries"
387
+ end
388
+ ```
389
+
390
+ </details>
24
391
 
25
- TODO: Write usage instructions here
392
+ <details>
393
+ <summary><code>AppQuery.configuration ⇒ AppQuery::Configuration </code></summary>
394
+
395
+ Get configuration
396
+
397
+ ### Examples
398
+
399
+ ```ruby
400
+ AppQuery.configure do |cfg|
401
+ cfg.query_path = "db/queries" # default: "app/queries"
402
+ end
403
+ AppQuery.configuration
404
+ ```
405
+
406
+ </details>
407
+
408
+ ### class AppQuery::Q
409
+
410
+ Instantiate via `AppQuery(sql)` or `AppQuery[:query_file]`.
411
+
412
+ <details>
413
+ <summary><code>AppQuery::Q#cte_names ⇒ [Array< String >] </code></summary>
414
+
415
+ Returns names of CTEs in query.
416
+
417
+ ### Examples
418
+
419
+ ```ruby
420
+ AppQuery("select * from articles").cte_names # => []
421
+ AppQuery("with foo as(select 1) select * from foo").cte_names # => ["foo"]
422
+ ```
423
+
424
+ </details>
425
+
426
+ <details>
427
+ <summary><code>AppQuery::Q#recursive? ⇒ Boolean </code></summary>
428
+
429
+ Returns whether or not the WITH-clause is recursive or not.
430
+
431
+ ### Examples
432
+
433
+ ```ruby
434
+ AppQuery("select * from articles").recursive? # => false
435
+ AppQuery("with recursive foo as(select 1) select * from foo") # => true
436
+ ```
437
+
438
+ </details>
439
+
440
+ <details>
441
+ <summary><code>AppQuery::Q#select ⇒ String </code></summary>
442
+
443
+ Returns select-part of the query. When using CTEs, this will be `<select>` in a query like `with foo as (select 1) <select>`.
444
+
445
+ ### Examples
446
+
447
+ ```ruby
448
+ AppQuery("select * from articles") # => "select * from articles"
449
+ AppQuery("with foo as(select 1) select * from foo") # => "select * from foo"
450
+ ```
451
+
452
+ </details>
453
+
454
+ #### query execution
455
+
456
+ <details>
457
+ <summary><code>AppQuery::Q#select_all(select: nil, binds: [], cast: false) ⇒ AppQuery::Result</code></summary>
458
+
459
+ `select` replaces the existing select. The existing select is wrapped in a CTE named `_`.
460
+ `binds` array with values for any (positional) placeholder in the query.
461
+ `cast` boolean or `Hash` indicating whether or not (and how) to cast. E.g. `{"some_column" => ActiveRecord::Type::Date.new}`.
462
+
463
+ ### Examples
464
+
465
+ ```ruby
466
+ # SQLite
467
+ aq = AppQuery(<<~SQL)
468
+ with data(id, title) as (
469
+ values('1', 'Some title'),
470
+ ('2', 'Another title')
471
+ )
472
+ select * from data
473
+ where id=?1 or ?1 is null
474
+ SQL
475
+
476
+ # selecting from the select
477
+ aq.select_all(select: "select * from _ where id > 1").entries #=> [{...}]
478
+
479
+ # selecting from a CTE
480
+ aq.select_all(select: "select id from data").entries
481
+
482
+ # casting
483
+ aq.select_all(select: "select id from data", cast: {"id" => ActiveRecord::Type::Integer.new})
484
+
485
+ # binds
486
+ aq.select_all(binds: ['2'])
487
+ ```
488
+
489
+ </details>
490
+
491
+ <details>
492
+ <summary><code>AppQuery::Q#select_one(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
493
+
494
+ First result from `AppQuery::Q#select_all`.
495
+
496
+ See examples from `AppQuery::Q#select_all`.
497
+
498
+ </details>
499
+
500
+ <details>
501
+ <summary><code>AppQuery::Q#select_value(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
502
+
503
+ First value from `AppQuery::Q#select_one`. Typically for selects like `select count(*) ...`, `select min(article_published_on) ...`.
504
+
505
+ See examples from `AppQuery::Q#select_all`.
506
+
507
+ </details>
508
+
509
+ #### query rewriting
510
+
511
+ <details>
512
+ <summary><code>AppQuery::Q#with_select(sql) ⇒ AppQuery::Q</code></summary>
513
+
514
+ Returns new instance with provided select. The existing select is available via CTE `_`.
515
+
516
+ ### Examples
517
+
518
+ ```ruby
519
+ puts AppQuery("select 1").with_select("select 2")
520
+ WITH _ as (
521
+ select 1
522
+ )
523
+ select 2
524
+ ```
525
+
526
+ </details>
527
+
528
+ <details>
529
+ <summary><code>AppQuery::Q#prepend_cte(sql) ⇒ AppQuery::Q</code></summary>
530
+
531
+ Returns new instance with provided CTE.
532
+
533
+ ### Examples
534
+
535
+ ```ruby
536
+ query.prepend_cte("foo as (values(1, 'Some article'))").cte_names # => ["foo", "existing_cte"]
537
+ ```
538
+
539
+ </details>
540
+
541
+ <details>
542
+ <summary><code>AppQuery::Q#append_cte(sql) ⇒ AppQuery::Q</code></summary>
543
+
544
+ Returns new instance with provided CTE.
545
+
546
+ ### Examples
547
+
548
+ ```ruby
549
+ query.append_cte("foo as (values(1, 'Some article'))").cte_names # => ["existing_cte", "foo"]
550
+ ```
551
+
552
+ </details>
553
+
554
+ <details>
555
+ <summary><code>AppQuery::Q#replace_cte(sql) ⇒ AppQuery::Q</code></summary>
556
+
557
+ Returns new instance with replaced CTE. Raises `ArgumentError` when CTE does not already exist.
558
+
559
+ ### Examples
560
+
561
+ ```ruby
562
+ query.replace_cte("recent_articles as (select values(1, 'Some article'))")
563
+ ```
564
+
565
+ </details>
566
+
567
+ ## Compatibility
568
+
569
+ - 💾 tested with **SQLite** and **PostgreSQL**
570
+ - 🚆 tested with Rails **v6.1**, **v7** and **v8.0**
571
+ - 💎 requires Ruby **>v3.2**
572
+ Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
26
573
 
27
574
  ## Development
28
575
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
576
+ After checking out the repo, run `bin/setup` to install dependencies. **Make sure to check it exits with status code 0.**
577
+
578
+ Using [mise](https://mise.jdx.dev/) for env-vars recommended.
579
+
580
+ ### console
581
+
582
+ The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
583
+ ```bash
584
+ $ ./bin/console sqlite3::memory:
585
+ $ ./bin/console postgres://localhost:5432/some_db
586
+
587
+ # more details
588
+ $ ./bin/console -h
589
+ ```
590
+
591
+ ### various
592
+
593
+ Run `rake spec` to run the tests.
30
594
 
31
595
  To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
32
596
 
@@ -0,0 +1,45 @@
1
+ module AppQuery
2
+ class Base
3
+ class_attribute :_cast, default: true, instance_predicate: false
4
+ class_attribute :_default_binds, default: {}, instance_predicate: false
5
+
6
+ class << self
7
+ def run(build_only: false, binds: {}, vars: {}, cast: self.cast, select: nil, **)
8
+ _build(binds:, vars:, cast:, select:).then do
9
+ build_only ? _1 : _1.select_all
10
+ end
11
+ end
12
+
13
+ def build(**opts)
14
+ run(build_only: true, **opts)
15
+ end
16
+
17
+ def default_binds(v = nil)
18
+ return _default_binds if v.nil?
19
+ self._default_binds = v
20
+ end
21
+
22
+ def cast(v = nil)
23
+ return _cast if v.nil?
24
+ self._cast = v
25
+ end
26
+
27
+ def query_name
28
+ derive_query_name unless defined?(@query_name)
29
+ @query_name
30
+ end
31
+
32
+ attr_writer :query_name
33
+
34
+ private
35
+
36
+ def _build(cast:, binds: {}, select: nil, vars: {})
37
+ AppQuery[query_name, binds:, cast:].render(vars).with_select(select)
38
+ end
39
+
40
+ def derive_query_name
41
+ self.query_name = name.underscore.sub(/_query$/, "")
42
+ end
43
+ end
44
+ end
45
+ end