appquery 0.3.0 → 0.4.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: 64e74167bbafa7217db5f9c0bab6efc8b55655abbbcb7e2fccefca3dfe1afae8
4
- data.tar.gz: 16f6870106206b547ed307fb6cbcd8d9250610239ccf9dc046e6e6c9719d76dd
3
+ metadata.gz: e96dbd6cf521caaee6d919ee3dd7fc796bb92f48ef3e135271b8b82408fb6bcc
4
+ data.tar.gz: 0c2250d2a62773ae26ff79469ea6b7e0a63ac246f2824518495a0f4f2ef2b13c
5
5
  SHA512:
6
- metadata.gz: 74ce3c5a8d22b2b41bc477069e9720c7b91cad52909ee3e4db8533c0a6f357227ec7e9485f513b8306a0d4994b53556971ae09284c72c2c5370bee45a0c244e3
7
- data.tar.gz: 61b01b05753f3a6f27194e47b40bb91aa822c5c263dbe78d0328f1b09aad02d95acbfc7ba191d4fb773b536c1b1ad03465e05c87c4ecf1dc279ed3e461ba97b2
6
+ metadata.gz: 14e0146910530087f2c56478e5c507a74ad9b6ccfea81a5c52e46fe24c07f4ef232db86056e1b97c7b7ed9ac976543b8e9c0ac4fcfcc90805aa9d668b426e061
7
+ data.tar.gz: 21e8afa59badf98018ba334960b3a6f301511a84305ea4941627f9d9dce3a1b24a23e52201c2a14f0a8912efbdaf9da1742b010acc364ceb72560a7910cab8e2
data/.irbrc ADDED
@@ -0,0 +1,20 @@
1
+ puts "Loading #{__FILE__}"
2
+ # put overrides/additions in '_irbrc'
3
+
4
+ IRB.conf[:HISTORY_FILE] = "#{ENV["PROJECT_ROOT"]}/tmp/.irb_history"
5
+
6
+ # Custom IRB prompt showing database adapter
7
+ db_indicator = begin
8
+ adapter = ActiveRecord::Base.connection.adapter_name.downcase
9
+ "\e[33m[#{adapter}]\e[0m "
10
+ rescue ActiveRecord::ConnectionNotEstablished, NameError
11
+ "\e[34m[no-db]\e[0m "
12
+ end
13
+
14
+ IRB.conf[:PROMPT][:APPQUERY] = {
15
+ PROMPT_I: "#{db_indicator}appquery> ",
16
+ PROMPT_S: "#{db_indicator}appquery%l ",
17
+ PROMPT_C: "#{db_indicator}appquery* ",
18
+ RETURN: "=> %s\n"
19
+ }
20
+ IRB.conf[:PROMPT_MODE] = :APPQUERY
data/.yardopts ADDED
@@ -0,0 +1,11 @@
1
+ --readme README.md
2
+ --markup markdown
3
+ --no-private
4
+ --output-dir doc
5
+ --exclude tmp/
6
+ --exclude spec/
7
+ --exclude examples/
8
+ --exclude gemfiles/
9
+ lib/**/*.rb
10
+ - LICENSE.txt
11
+ - CHANGELOG.md
data/Appraisals CHANGED
@@ -1,15 +1,25 @@
1
1
  appraise "rails-70" do
2
- gem "rails", "~> 7.0"
2
+ gem "rails", "~> 7.0.0"
3
+ gem "sqlite3", "~> 1.4"
3
4
  end
4
5
 
5
6
  appraise "rails-71" do
6
- gem "rails", "~> 7.1"
7
+ gem "rails", "~> 7.1.0"
7
8
  end
8
9
 
9
10
  appraise "rails-72" do
10
- gem "rails", "~> 7.2"
11
+ gem "rails", "~> 7.2.0"
11
12
  end
12
13
 
13
14
  appraise "rails-80" do
14
- gem "rails", "~> 8.0"
15
+ gem "rails", "~> 8.0.0"
16
+ end
17
+
18
+ appraise "rails-81" do
19
+ gem "rails", "~> 8.1.0"
20
+ end
21
+
22
+ appraise "rails-head" do
23
+ gem "activerecord", github: "rails/rails"
24
+ gem "rails", github: "rails/rails"
15
25
  end
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
- ## [0.1.0] - 2024-10-13
3
+ ## [0.4.0] - 2025-12-15
4
4
 
5
- - Initial release
5
+ ### features
6
+
7
+ - add insert, update and delete
8
+ - API docs at [eval.github.io/appquery](https://eval.github.io/appquery)
9
+ - add ERB-helpers [values, bind and quote ](https://eval.github.io/appquery/AppQuery/RenderHelpers.html).
10
+ - enabled trusted publishing to rubygems.org
data/README.md CHANGED
@@ -1,18 +1,13 @@
1
1
  # AppQuery - raw SQL 🥦, cooked :stew:
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/appquery.svg)](https://badge.fury.io/rb/appquery)
4
+ [![API Docs](https://img.shields.io/badge/API_Docs-YARD-blue.svg)](https://eval.github.io/appquery/)
4
5
 
5
- A Rubygem :gem: that makes working with raw SQL (READ) queries in Rails projects more convenient.
6
+ A Rubygem :gem: that makes working with raw SQL queries in Rails projects convenient.
6
7
  Specifically it provides:
7
8
  - **...a dedicated folder for queries**
8
9
  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
- ```
10
+
16
11
  - **...ERB templating**
17
12
  Simple ERB templating with helper-functions:
18
13
  ```sql
@@ -26,7 +21,9 @@ Specifically it provides:
26
21
  - **...positional and named binds**
27
22
  Intuitive binds:
28
23
  ```ruby
29
- AppQuery(%{select now() - (:interval)::interval as some_date}).select_value(binds: {interval: '1 day'})
24
+ AppQuery(<<~SQL).select_value(binds: {interval: '1 day'})
25
+ select now() - (:interval)::interval as some_date
26
+ SQL
30
27
  AppQuery(<<~SQL).select_all(binds: [2.day.ago, Time.now, '5 minutes']).column("series")
31
28
  select generate_series($1::timestamp, $2::timestamp, $3::interval) as series
32
29
  SQL
@@ -48,8 +45,8 @@ Specifically it provides:
48
45
  VALUES(1, 'Some title'),
49
46
  (2, 'Another article'))
50
47
  CTE
51
- ```
52
- - **...rspec-helpers**
48
+ ```
49
+ - **...rspec generators and helpers**
53
50
  ```ruby
54
51
  RSpec.describe "AppQuery reports/weekly", type: :query do
55
52
  describe "CTE some_cte" do
@@ -95,60 +92,65 @@ Testdriving can be easily done from the console. Either by cloning this reposito
95
92
  ```
96
93
  </details>
97
94
 
98
- The following examples assume PostgreSQL (SQLite where stated):
95
+ The prompt indicates what adapter the example uses:
99
96
 
100
97
  ```ruby
101
98
  # showing select_(all|one|value)
102
- > AppQuery(%{select date('now') as today}).select_all.to_a
99
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all.to_a
103
100
  => [{"today" => "2025-05-10"}]
104
- > AppQuery(%{select date('now') as today}).select_one
101
+ [postgresql]> AppQuery(%{select date('now') as today}).select_one
105
102
  => {"today" => "2025-05-10"}
106
- > AppQuery(%{select date('now') as today}).select_value
103
+ [postgresql]> AppQuery(%{select date('now') as today}).select_value
107
104
  => "2025-05-10"
108
105
 
109
106
  # binds
110
107
  # positional binds
111
- > AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
108
+ [postgresql]> AppQuery(%{select now() - ($1)::interval as date}).select_value(binds: ['2 days'])
112
109
  # named binds
113
- > AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
110
+ [postgresql]> AppQuery(%{select now() - (:interval)::interval as date}).select_value(binds: {interval: '2 days'})
114
111
 
115
112
  # casting
116
- > AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
113
+ [postgresql]> AppQuery(%{select date('now') as today}).select_all(cast: true).to_a
117
114
  => [{"today" => Sat, 10 May 2025}]
118
115
 
119
116
  ## 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)
117
+ [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: true)
121
118
  => {"today" => "2025-05-12"}
122
119
  ## Providing per-column-casts fixes this:
123
120
  casts = {"today" => ActiveRecord::Type::Date.new}
124
- sqlite> AppQuery(%{select date('now') as today}).select_one(cast: casts)
121
+ [sqlite]> AppQuery(%{select date('now') as today}).select_one(cast: casts)
125
122
  => {"today" => Mon, 12 May 2025}
126
123
 
124
+
127
125
  # 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'))
126
+ [postgresql]> articles = [
127
+ [1, "Using my new static site generator", 2.months.ago.to_date],
128
+ [2, "Let's learn SQL", 1.month.ago.to_date],
129
+ [3, "Another article", 2.weeks.ago.to_date]
130
+ ]
131
+ [postgresql]> q = AppQuery(<<~SQL, cast: {"published_on" => ActiveRecord::Type::Date.new}).render(articles:)
132
+ WITH articles(id,title,published_on) AS (<%= values(articles) %>)
133
133
  select * from articles order by id DESC
134
134
  SQL
135
135
 
136
136
  ## query the articles-CTE
137
- q.select_all(select: %{select * from articles where id < 2}).to_a
137
+ [postgresql]> q.select_all(select: %{select * from articles where id < 2}).to_a
138
138
 
139
139
  ## query the end-result (available as the CTE named '_')
140
- q.select_one(select: %{select * from _ limit 1})
140
+ [postgresql]> q.select_one(select: %{select * from _ limit 1})
141
141
 
142
142
  ## ERB templating
143
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
144
+ [postgresql]> q2 = q.with_select("select id,title,published_on::date from articles <%= order_by(order) %>")
145
+ [postgresql]> q2.render(order: {"published_on::date": :desc, 'lower(title)': "asc"}).select_all.entries
146
+
146
147
  # shows latest articles first, and titles sorted alphabetically
147
148
  # for articles published on the same date.
148
149
  # order_by raises when it's passed something that would result in just `ORDER BY`:
149
- q2.render(order: {})
150
+ [postgresql]> q2.render(order: {})
151
+
150
152
  # doing a select using a query that should be rendered, a `AppQuery::UnrenderedQueryError` will be raised:
151
- q2.select_all.entries
153
+ [postgresql]> q2.select_all.entries
152
154
 
153
155
  # NOTE you can use both `order` and `@order`: local variables like `order` are required,
154
156
  # while instance variables like `@order` are optional.
@@ -249,7 +251,8 @@ AppQuery[:recent_articles].select_all.entries
249
251
  # we can provide a different cut off date via binds^1:
250
252
  AppQuery[:recent_articles].select_all(binds: [1.month.ago]).entries
251
253
 
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).
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).
253
256
  For Postgres you would always need to provide 2 values, e.g. `binds: [nil, nil]`.
254
257
  ```
255
258
 
@@ -346,229 +349,15 @@ There's some sugar:
346
349
  The `binds`-value used when not explicitly provided.
347
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])`.
348
351
 
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>
391
-
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>
352
+ ## API Documentation
442
353
 
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>
354
+ See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
566
355
 
567
356
  ## Compatibility
568
357
 
569
358
  - 💾 tested with **SQLite** and **PostgreSQL**
570
- - 🚆 tested with Rails **v6.1**, **v7** and **v8.0**
571
- - 💎 requires Ruby **>v3.2**
359
+ - 🚆 tested with Rails v7.x and v8.x (might still work with v6.1, but is no longer included in the test-matrix)
360
+ - 💎 requires Ruby **>=v3.2**
572
361
  Goal is to support [maintained Ruby versions](https://www.ruby-lang.org/en/downloads/branches/).
573
362
 
574
363
  ## Development
@@ -581,11 +370,14 @@ Using [mise](https://mise.jdx.dev/) for env-vars recommended.
581
370
 
582
371
  The [console-script](./bin/console) is setup such that it's easy to connect with a database and experiment with the library:
583
372
  ```bash
584
- $ ./bin/console sqlite3::memory:
585
- $ ./bin/console postgres://localhost:5432/some_db
373
+ $ bin/console sqlite3::memory:
374
+ $ bin/console postgres://localhost:5432/some_db
586
375
 
587
376
  # more details
588
- $ ./bin/console -h
377
+ $ bin/console -h
378
+
379
+ # when needing an appraisal, use bin/run (this ensures signals are handled correctly):
380
+ $ bin/run rails_head console
589
381
  ```
590
382
 
591
383
  ### various
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ module AppQuery
4
+ # Provides helper methods for rendering SQL templates in ERB.
5
+ #
6
+ # These helpers are available within ERB templates when using {Q#render}.
7
+ # They provide safe SQL construction with parameterized queries.
8
+ #
9
+ # @note These methods require +@collected_binds+ (Hash) and
10
+ # +@placeholder_counter+ (Integer) instance variables to be initialized
11
+ # in the including context.
12
+ #
13
+ # @example Basic usage in an ERB template
14
+ # SELECT * FROM users WHERE name = <%= bind(name) %>
15
+ # <%= order_by(sorting) %>
16
+ #
17
+ # @see Q#render
18
+ module RenderHelpers
19
+ # Quotes a value for safe inclusion in SQL using ActiveRecord's quoting.
20
+ #
21
+ # Use this helper when you need to embed a literal value directly in SQL
22
+ # rather than using a bind parameter. This is useful for values that need
23
+ # to be visible in the SQL string itself.
24
+ #
25
+ # @param value [Object] the value to quote (typically a String or Number)
26
+ # @return [String] the SQL-safe quoted value
27
+ #
28
+ # @example Quoting a string with special characters
29
+ # quote("Let's learn SQL!") #=> "'Let''s learn SQL!'"
30
+ #
31
+ # @example In an ERB template
32
+ # INSERT INTO articles (title) VALUES(<%= quote(title) %>)
33
+ #
34
+ # @note Prefer {#bind} for parameterized queries when possible, as it
35
+ # provides better security and query plan caching.
36
+ #
37
+ # @see #bind
38
+ def quote(value)
39
+ ActiveRecord::Base.connection.quote(value)
40
+ end
41
+
42
+ # Creates a named bind parameter placeholder and collects the value.
43
+ #
44
+ # This is the preferred way to include dynamic values in SQL queries.
45
+ # The value is collected internally and a placeholder (e.g., +:b1+) is
46
+ # returned for insertion into the SQL template.
47
+ #
48
+ # @param value [Object] the value to bind (any type supported by ActiveRecord)
49
+ # @return [String] the placeholder string (e.g., ":b1", ":b2", etc.)
50
+ #
51
+ # @example Basic bind usage
52
+ # bind("Some title") #=> ":b1" (with "Some title" added to collected binds)
53
+ #
54
+ # @example In an ERB template
55
+ # SELECT * FROM videos WHERE title = <%= bind(title) %>
56
+ # # Results in: SELECT * FROM videos WHERE title = :b1
57
+ # # With binds: {b1: <value of title>}
58
+ #
59
+ # @example Multiple binds
60
+ # SELECT * FROM t WHERE a = <%= bind(val1) %> AND b = <%= bind(val2) %>
61
+ # # Results in: SELECT * FROM t WHERE a = :b1 AND b = :b2
62
+ #
63
+ # @see #values for binding multiple values in a VALUES clause
64
+ # @see #quote for embedding quoted literals directly
65
+ def bind(value)
66
+ collect_bind(value)
67
+ end
68
+
69
+ # Generates a SQL VALUES clause from a collection with automatic bind parameters.
70
+ #
71
+ # Supports three input formats:
72
+ # 1. *Array of Arrays* - Simple row data without column names
73
+ # 2. *Array of Hashes* - Row data with automatic column name extraction
74
+ # 3. *Collection with block* - Custom value transformation per row
75
+ #
76
+ # @param coll [Array<Array>, Array<Hash>] the collection of row data
77
+ # @param skip_columns [Boolean] when true, omits the column name list
78
+ # (useful for UNION ALL or CTEs where column names are defined elsewhere)
79
+ # @yield [item] optional block to transform each item into an array of SQL expressions
80
+ # @yieldparam item [Object] each item from the collection
81
+ # @yieldreturn [Array<String>] array of SQL expressions for the row values
82
+ # @return [String] the complete VALUES clause SQL fragment
83
+ #
84
+ # @example Array of arrays (simplest form)
85
+ # values([[1, "Title A"], [2, "Title B"]])
86
+ # #=> "VALUES (:b1, :b2),\n(:b3, :b4)"
87
+ # # binds: {b1: 1, b2: "Title A", b3: 2, b4: "Title B"}
88
+ #
89
+ # @example Array of hashes (with automatic column names)
90
+ # values([{id: 1, title: "Video A"}, {id: 2, title: "Video B"}])
91
+ # #=> "(id, title) VALUES (:b1, :b2),\n(:b3, :b4)"
92
+ #
93
+ # @example Hashes with mixed keys (NULL for missing values)
94
+ # values([{title: "A"}, {title: "B", published_on: "2024-01-01"}])
95
+ # #=> "(title, published_on) VALUES (:b1, NULL),\n(:b2, :b3)"
96
+ #
97
+ # @example Skip columns for UNION ALL
98
+ # SELECT id FROM articles UNION ALL <%= values([{id: 1}], skip_columns: true) %>
99
+ # #=> "SELECT id FROM articles UNION ALL VALUES (:b1)"
100
+ #
101
+ # @example With block for custom expressions
102
+ # values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] }
103
+ # #=> "VALUES (:b1, 'Escaped Title', now()), (:b2, 'Other', now())"
104
+ #
105
+ # @example In a CTE
106
+ # WITH articles(id, title) AS (<%= values(data) %>)
107
+ # SELECT * FROM articles
108
+ #
109
+ # @see #bind for individual value binding
110
+ # @see #quote for quoting literal values
111
+ #
112
+ # TODO: Add types: parameter to cast bind placeholders (needed for UNION ALL
113
+ # where PG can't infer types). E.g. values([[1]], types: [:integer])
114
+ # would generate VALUES (:b1::integer)
115
+ def values(coll, skip_columns: false, &block)
116
+ first = coll.first
117
+
118
+ # For hash collections, collect all unique keys
119
+ if first.is_a?(Hash) && !block
120
+ all_keys = coll.flat_map(&:keys).uniq
121
+
122
+ rows = coll.map do |row|
123
+ vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
124
+ "(#{vals.join(", ")})"
125
+ end
126
+
127
+ columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
128
+ "#{columns}VALUES #{rows.join(",\n")}"
129
+ else
130
+ # Arrays or block - current behavior
131
+ rows = coll.map do |item|
132
+ vals = if block
133
+ block.call(item)
134
+ elsif item.is_a?(Array)
135
+ item.map { |v| collect_bind(v) }
136
+ else
137
+ [collect_bind(item)]
138
+ end
139
+ "(#{vals.join(", ")})"
140
+ end
141
+ "VALUES #{rows.join(",\n")}"
142
+ end
143
+ end
144
+
145
+ # Generates an ORDER BY clause from a hash of column directions.
146
+ #
147
+ # Converts a hash of column names and sort directions into a valid
148
+ # SQL ORDER BY clause.
149
+ #
150
+ # @param hash [Hash{Symbol, String => Symbol, String, nil}] column names mapped to
151
+ # sort directions (+:asc+, +:desc+, +"ASC"+, +"DESC"+) or nil for default
152
+ # @return [String] the complete ORDER BY clause
153
+ #
154
+ # @example Basic ordering
155
+ # order_by(year: :desc, month: :desc)
156
+ # #=> "ORDER BY year DESC, month DESC"
157
+ #
158
+ # @example Mixed directions
159
+ # order_by(published_on: :desc, title: :asc)
160
+ # #=> "ORDER BY published_on DESC, title ASC"
161
+ #
162
+ # @example Column without direction (uses database default)
163
+ # order_by(id: nil)
164
+ # #=> "ORDER BY id"
165
+ #
166
+ # @example In an ERB template with a variable
167
+ # SELECT * FROM articles
168
+ # <%= order_by(ordering) %>
169
+ #
170
+ # @example Making it optional (when ordering may not be provided)
171
+ # <%= @ordering.presence && order_by(ordering) %>
172
+ #
173
+ # @example With default fallback
174
+ # <%= order_by(@order || {id: :desc}) %>
175
+ #
176
+ # @raise [ArgumentError] if hash is blank (nil, empty, or not present)
177
+ #
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(", ")
184
+ end
185
+
186
+ private
187
+
188
+ # Collects a value as a bind parameter and returns the placeholder name.
189
+ #
190
+ # This is the internal mechanism used by {#bind} and {#values} to
191
+ # accumulate bind values during template rendering. Each call generates
192
+ # a unique placeholder name (b1, b2, b3, ...).
193
+ #
194
+ # @api private
195
+ # @param value [Object] the value to collect
196
+ # @return [String] the placeholder string with colon prefix (e.g., ":b1")
197
+ def collect_bind(value)
198
+ @placeholder_counter += 1
199
+ key = :"b#{@placeholder_counter}"
200
+ @collected_binds[key] = value
201
+ ":#{key}"
202
+ end
203
+ end
204
+ end