appquery 0.4.0.rc1 → 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: 585ecafd973b1dd9fc8c944b93c64998a8b983438250cbd1ec81317d9e2362d4
4
- data.tar.gz: 8197dc68853e7299266a0f8c2dd7fa102bbbf7e743680786bce4c7c2eedcd719
3
+ metadata.gz: e96dbd6cf521caaee6d919ee3dd7fc796bb92f48ef3e135271b8b82408fb6bcc
4
+ data.tar.gz: 0c2250d2a62773ae26ff79469ea6b7e0a63ac246f2824518495a0f4f2ef2b13c
5
5
  SHA512:
6
- metadata.gz: 37b5baf0f42dbad9ee1f356e0541b5677cb93a934d38ad851931142f79a041337f77cbbd4f56c1e880b4db9140460237afa8627eb0f6f9c73461fc1f6b3843cb
7
- data.tar.gz: 3edc3e28e09a648a3d14634beab10cdde1be8d1b31c6c8163fd2f19844fab31a5ecf762ddcef9311f18d1356fe6e2c765f99dd2d08706284c687e94d21c36261
6
+ metadata.gz: 14e0146910530087f2c56478e5c507a74ad9b6ccfea81a5c52e46fe24c07f4ef232db86056e1b97c7b7ed9ac976543b8e9c0ac4fcfcc90805aa9d668b426e061
7
+ data.tar.gz: 21e8afa59badf98018ba334960b3a6f301511a84305ea4941627f9d9dce3a1b24a23e52201c2a14f0a8912efbdaf9da1742b010acc364ceb72560a7910cab8e2
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/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
@@ -352,223 +349,9 @@ There's some sugar:
352
349
  The `binds`-value used when not explicitly provided.
353
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])`.
354
351
 
355
- ## 💎 API Doc 💎
356
-
357
- ### generic
358
-
359
- <details>
360
- <summary><code>AppQuery(sql) ⇒ AppQuery::Q</code></summary>
361
-
362
- ### Examples
363
-
364
- ```ruby
365
- AppQuery("some sql")
366
- ```
367
- </details>
368
-
369
- ### module AppQuery
370
-
371
- <details>
372
- <summary><code>AppQuery[query_name] ⇒ AppQuery::Q</code></summary>
373
-
374
- ### Examples
375
-
376
- ```ruby
377
- AppQuery[:recent_articles]
378
- AppQuery["export/articles"]
379
- ```
380
-
381
- </details>
382
-
383
- <details>
384
- <summary><code>AppQuery.configure {|Configuration| ... } ⇒ void </code></summary>
385
-
386
- Configure AppQuery.
387
-
388
- ### Examples
389
-
390
- ```ruby
391
- AppQuery.configure do |cfg|
392
- cfg.query_path = "db/queries" # default: "app/queries"
393
- end
394
- ```
395
-
396
- </details>
397
-
398
- <details>
399
- <summary><code>AppQuery.configuration ⇒ AppQuery::Configuration </code></summary>
400
-
401
- Get configuration
402
-
403
- ### Examples
404
-
405
- ```ruby
406
- AppQuery.configure do |cfg|
407
- cfg.query_path = "db/queries" # default: "app/queries"
408
- end
409
- AppQuery.configuration
410
- ```
411
-
412
- </details>
413
-
414
- ### class AppQuery::Q
415
-
416
- Instantiate via `AppQuery(sql)` or `AppQuery[:query_file]`.
417
-
418
- <details>
419
- <summary><code>AppQuery::Q#cte_names ⇒ [Array< String >] </code></summary>
420
-
421
- Returns names of CTEs in query.
422
-
423
- ### Examples
424
-
425
- ```ruby
426
- AppQuery("select * from articles").cte_names # => []
427
- AppQuery("with foo as(select 1) select * from foo").cte_names # => ["foo"]
428
- ```
429
-
430
- </details>
431
-
432
- <details>
433
- <summary><code>AppQuery::Q#recursive? ⇒ Boolean </code></summary>
434
-
435
- Returns whether or not the WITH-clause is recursive or not.
436
-
437
- ### Examples
438
-
439
- ```ruby
440
- AppQuery("select * from articles").recursive? # => false
441
- AppQuery("with recursive foo as(select 1) select * from foo") # => true
442
- ```
443
-
444
- </details>
445
-
446
- <details>
447
- <summary><code>AppQuery::Q#select ⇒ String </code></summary>
448
-
449
- Returns select-part of the query. When using CTEs, this will be `<select>` in a query like `with foo as (select 1) <select>`.
450
-
451
- ### Examples
452
-
453
- ```ruby
454
- AppQuery("select * from articles") # => "select * from articles"
455
- AppQuery("with foo as(select 1) select * from foo") # => "select * from foo"
456
- ```
457
-
458
- </details>
459
-
460
- #### query execution
461
-
462
- <details>
463
- <summary><code>AppQuery::Q#select_all(select: nil, binds: [], cast: false) ⇒ AppQuery::Result</code></summary>
464
-
465
- `select` replaces the existing select. The existing select is wrapped in a CTE named `_`.
466
- `binds` array with values for any (positional) placeholder in the query.
467
- `cast` boolean or `Hash` indicating whether or not (and how) to cast. E.g. `{"some_column" => ActiveRecord::Type::Date.new}`.
468
-
469
- ### Examples
470
-
471
- ```ruby
472
- # SQLite
473
- aq = AppQuery(<<~SQL)
474
- with data(id, title) as (
475
- values('1', 'Some title'),
476
- ('2', 'Another title')
477
- )
478
- select * from data
479
- where id=?1 or ?1 is null
480
- SQL
481
-
482
- # selecting from the select
483
- aq.select_all(select: "select * from _ where id > 1").entries #=> [{...}]
484
-
485
- # selecting from a CTE
486
- aq.select_all(select: "select id from data").entries
487
-
488
- # casting
489
- aq.select_all(select: "select id from data", cast: {"id" => ActiveRecord::Type::Integer.new})
490
-
491
- # binds
492
- aq.select_all(binds: ['2'])
493
- ```
494
-
495
- </details>
496
-
497
- <details>
498
- <summary><code>AppQuery::Q#select_one(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
499
-
500
- First result from `AppQuery::Q#select_all`.
501
-
502
- See examples from `AppQuery::Q#select_all`.
503
-
504
- </details>
505
-
506
- <details>
507
- <summary><code>AppQuery::Q#select_value(select: nil, binds: [], cast: false) ⇒ AppQuery::Result </code></summary>
508
-
509
- First value from `AppQuery::Q#select_one`. Typically for selects like `select count(*) ...`, `select min(article_published_on) ...`.
510
-
511
- See examples from `AppQuery::Q#select_all`.
512
-
513
- </details>
514
-
515
- #### query rewriting
516
-
517
- <details>
518
- <summary><code>AppQuery::Q#with_select(sql) ⇒ AppQuery::Q</code></summary>
352
+ ## API Documentation
519
353
 
520
- Returns new instance with provided select. The existing select is available via CTE `_`.
521
-
522
- ### Examples
523
-
524
- ```ruby
525
- puts AppQuery("select 1").with_select("select 2")
526
- WITH _ as (
527
- select 1
528
- )
529
- select 2
530
- ```
531
-
532
- </details>
533
-
534
- <details>
535
- <summary><code>AppQuery::Q#prepend_cte(sql) ⇒ AppQuery::Q</code></summary>
536
-
537
- Returns new instance with provided CTE.
538
-
539
- ### Examples
540
-
541
- ```ruby
542
- query.prepend_cte("foo as (values(1, 'Some article'))").cte_names # => ["foo", "existing_cte"]
543
- ```
544
-
545
- </details>
546
-
547
- <details>
548
- <summary><code>AppQuery::Q#append_cte(sql) ⇒ AppQuery::Q</code></summary>
549
-
550
- Returns new instance with provided CTE.
551
-
552
- ### Examples
553
-
554
- ```ruby
555
- query.append_cte("foo as (values(1, 'Some article'))").cte_names # => ["existing_cte", "foo"]
556
- ```
557
-
558
- </details>
559
-
560
- <details>
561
- <summary><code>AppQuery::Q#replace_cte(sql) ⇒ AppQuery::Q</code></summary>
562
-
563
- Returns new instance with replaced CTE. Raises `ArgumentError` when CTE does not already exist.
564
-
565
- ### Examples
566
-
567
- ```ruby
568
- query.replace_cte("recent_articles as (select values(1, 'Some article'))")
569
- ```
570
-
571
- </details>
354
+ See the [YARD documentation](https://eval.github.io/appquery/) for the full API reference.
572
355
 
573
356
  ## Compatibility
574
357
 
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.4.0.rc1"
4
+ VERSION = "0.4.0"
5
5
  end
data/lib/app_query.rb CHANGED
@@ -2,23 +2,67 @@
2
2
 
3
3
  require_relative "app_query/version"
4
4
  require_relative "app_query/tokenizer"
5
+ require_relative "app_query/render_helpers"
5
6
  require "active_record"
6
7
 
8
+ # AppQuery provides a way to work with raw SQL queries using ERB templating,
9
+ # parameter binding, and CTE manipulation.
10
+ #
11
+ # @example Using the global function
12
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
13
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
14
+ #
15
+ # @example Loading queries from files
16
+ # # Loads from app/queries/invoices.sql
17
+ # AppQuery[:invoices].select_all
18
+ #
19
+ # @example Configuration
20
+ # AppQuery.configure do |config|
21
+ # config.query_path = "db/queries"
22
+ # end
23
+ #
24
+ # @example CTE manipulation
25
+ # AppQuery(<<~SQL).select_all(select: "select * from articles where id = 1")
26
+ # WITH articles AS(...)
27
+ # SELECT * FROM articles
28
+ # ORDER BY id
29
+ # SQL
7
30
  module AppQuery
31
+ # Generic error class for AppQuery errors.
8
32
  class Error < StandardError; end
9
33
 
34
+ # Raised when attempting to execute a query that contains unrendered ERB.
10
35
  class UnrenderedQueryError < StandardError; end
11
36
 
37
+ # Configuration options for AppQuery.
38
+ #
39
+ # @!attribute query_path
40
+ # @return [String] the directory path where query files are located
41
+ # (default: "app/queries")
12
42
  Configuration = Struct.new(:query_path)
13
43
 
44
+ # Returns the current configuration.
45
+ #
46
+ # @return [Configuration] the configuration instance
14
47
  def self.configuration
15
48
  @configuration ||= AppQuery::Configuration.new
16
49
  end
17
50
 
51
+ # Yields the configuration for modification.
52
+ #
53
+ # @yield [Configuration] the configuration instance
54
+ #
55
+ # @example
56
+ # AppQuery.configure do |config|
57
+ # config.query_path = "db/queries"
58
+ # end
18
59
  def self.configure
19
60
  yield configuration if block_given?
20
61
  end
21
62
 
63
+ # Resets configuration to default values.
64
+ #
65
+ # @return [void]
22
66
  def self.reset_configuration!
23
67
  configure do |config|
24
68
  config.query_path = "app/queries"
@@ -26,10 +70,20 @@ module AppQuery
26
70
  end
27
71
  reset_configuration!
28
72
 
29
- # Examples:
30
- # AppQuery[:invoices] # looks for invoices.sql
31
- # AppQuery["reports/weekly"]
32
- # AppQuery["invoices.sql.erb"]
73
+ # Loads a query from a file in the configured query path.
74
+ #
75
+ # @param query_name [String, Symbol] the query name or path (without extension)
76
+ # @param opts [Hash] additional options passed to {Q#initialize}
77
+ # @return [Q] a new query object loaded from the file
78
+ #
79
+ # @example Load a simple query
80
+ # AppQuery[:invoices] # loads app/queries/invoices.sql
81
+ #
82
+ # @example Load from a subdirectory
83
+ # AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
84
+ #
85
+ # @example Load with explicit extension
86
+ # AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
33
87
  def self.[](query_name, **opts)
34
88
  filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
35
89
  full_path = (Pathname.new(configuration.query_path) / filename).expand_path
@@ -97,9 +151,56 @@ module AppQuery
97
151
  private_constant :EMPTY
98
152
  end
99
153
 
154
+ # Query object for building, rendering, and executing SQL queries.
155
+ #
156
+ # Q wraps a SQL string (optionally with ERB templating) and provides methods
157
+ # for query execution, CTE manipulation, and result handling.
158
+ #
159
+ # ## Method Groups
160
+ #
161
+ # - **Rendering** — Process ERB templates to produce executable SQL.
162
+ # - **Query Execution** — Execute queries against the database. These methods
163
+ # wrap the equivalent `ActiveRecord::Base.connection` methods (`select_all`,
164
+ # `insert`, `update`, `delete`).
165
+ # - **Query Introspection** — Inspect and analyze the structure of the query.
166
+ # - **Query Transformation** — Create modified copies of the query. All
167
+ # transformation methods are immutable—they return a new {Q} instance and
168
+ # leave the original unchanged.
169
+ # - **CTE Manipulation** — Add, replace, or reorder Common Table Expressions
170
+ # (CTEs). Like transformation methods, these return a new {Q} instance.
171
+ #
172
+ # @example Basic query
173
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
174
+ #
175
+ # @example ERB templating
176
+ # AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
177
+ # .render(name: "Alice")
178
+ # .select_all
179
+ #
180
+ # @example CTE manipulation
181
+ # AppQuery("WITH base AS (SELECT 1) SELECT * FROM base")
182
+ # .append_cte("extra AS (SELECT 2)")
183
+ # .select_all
100
184
  class Q
185
+ # @return [String, nil] optional name for the query (used in logs)
186
+ # @return [String] the SQL string
187
+ # @return [Array, Hash] bind parameters
188
+ # @return [Boolean, Hash, Array] casting configuration
101
189
  attr_reader :name, :sql, :binds, :cast
102
190
 
191
+ # Creates a new query object.
192
+ #
193
+ # @param sql [String] the SQL query string (may contain ERB)
194
+ # @param name [String, nil] optional name for logging
195
+ # @param filename [String, nil] optional filename for ERB error reporting
196
+ # @param binds [Array, Hash] bind parameters for the query
197
+ # @param cast [Boolean, Hash, Array] type casting configuration
198
+ #
199
+ # @example Simple query
200
+ # Q.new("SELECT * FROM users")
201
+ #
202
+ # @example With ERB and binds
203
+ # Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
103
204
  def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
104
205
  @sql = sql
105
206
  @name = name
@@ -108,10 +209,13 @@ module AppQuery
108
209
  @cast = cast
109
210
  end
110
211
 
212
+ # @private
111
213
  def deep_dup
112
214
  super.send(:reset!)
113
215
  end
216
+ private :deep_dup
114
217
 
218
+ # @private
115
219
  def reset!
116
220
  (instance_variables - %i[@sql @filename @name @binds @cast]).each do
117
221
  instance_variable_set(_1, nil)
@@ -120,7 +224,44 @@ module AppQuery
120
224
  end
121
225
  private :reset!
122
226
 
123
- def render(vars)
227
+ # @!group Rendering
228
+
229
+ # Renders the ERB template with the given variables.
230
+ #
231
+ # Processes ERB tags in the SQL and collects any bind parameters created
232
+ # by helpers like {RenderHelpers#bind} and {RenderHelpers#values}.
233
+ #
234
+ # @param vars [Hash] variables to make available in the ERB template
235
+ # @return [Q] a new query object with rendered SQL and collected binds
236
+ #
237
+ # @example Rendering with variables
238
+ # AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
239
+ # .render(name: "Alice")
240
+ # # => Q with SQL: "SELECT * FROM users WHERE name = :b1"
241
+ # # and binds: {b1: "Alice"}
242
+ #
243
+ # @example Using instance variables
244
+ # AppQuery("SELECT * FROM users WHERE active = <%= @active %>")
245
+ # .render(active: true)
246
+ #
247
+ # @example vars are available as local and instance variable.
248
+ # # This fails as `ordering` is not provided:
249
+ # AppQuery(<<~SQL).render
250
+ # SELECT * FROM articles
251
+ # <%= order_by(ordering) %>
252
+ # SQL
253
+ #
254
+ # # ...but this query works without `ordering` being passed to render:
255
+ # AppQuery(<<~SQL).render
256
+ # SELECT * FROM articles
257
+ # <%= @ordering.presence && order_by(ordering) %>
258
+ # SQL
259
+ # # NOTE that `@ordering.present? && ...` would render as `false`.
260
+ # # Use `@ordering.presence` instead.
261
+ #
262
+ #
263
+ # @see RenderHelpers for available helper methods in templates
264
+ def render(vars = {})
124
265
  vars ||= {}
125
266
  helper = render_helper(vars)
126
267
  sql = to_erb.result(helper.get_binding)
@@ -142,6 +283,7 @@ module AppQuery
142
283
  def render_helper(vars)
143
284
  Module.new do
144
285
  extend self
286
+ include AppQuery::RenderHelpers
145
287
 
146
288
  @collected_binds = {}
147
289
  @placeholder_counter = 0
@@ -151,94 +293,8 @@ module AppQuery
151
293
  instance_variable_set(:"@#{k}", v)
152
294
  end
153
295
 
154
- def collect_bind(value)
155
- @placeholder_counter += 1
156
- key = :"b#{@placeholder_counter}"
157
- @collected_binds[key] = value
158
- ":#{key}"
159
- end
160
-
161
296
  attr_reader :collected_binds
162
297
 
163
- # Examples
164
- # quote("Let's learn Ruby") #=> 'Let''s learn Ruby'
165
- def quote(...)
166
- ActiveRecord::Base.connection.quote(...)
167
- end
168
-
169
- # Examples
170
- # <%= bind(title) %> #=> :b1 (with title added to binds)
171
- def bind(value)
172
- collect_bind(value)
173
- end
174
-
175
- # Examples
176
- # <%= values([[1, "Some video"], [2, "Another video"]]) %>
177
- # #=> VALUES (:b1, :b2), (:b3, :b4) with binds {b1: 1, b2: "Some video", ...}
178
- #
179
- # <%= values([{id: 1, title: "Some video"}]) %>
180
- # #=> (id, title) VALUES (:b1, :b2) with binds {b1: 1, b2: "Some video"}
181
- #
182
- # <%= values([{title: "A"}, {title: "B", published_on: "2024-01-01"}]) %>
183
- # #=> (title, published_on) VALUES (:b1, NULL), (:b2, :b3)
184
- #
185
- # Skip column names (e.g. for UNION ALL or CTEs):
186
- # with articles as(
187
- # <%= values([[1, "title"]], skip_columns: true) %>
188
- # )
189
- # #=> with articles as (VALUES (:b1, :b2))
190
- #
191
- # With block (mix bind() and quote()):
192
- # <%= values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] } %>
193
- # #=> VALUES (:b1, 'Some title', now()), (:b2, 'Other title', now())
194
- def values(coll, skip_columns: false, &block)
195
- first = coll.first
196
-
197
- # For hash collections, collect all unique keys
198
- if first.is_a?(Hash) && !block
199
- all_keys = coll.flat_map(&:keys).uniq
200
-
201
- rows = coll.map do |row|
202
- vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
203
- "(#{vals.join(", ")})"
204
- end
205
-
206
- columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
207
- "#{columns}VALUES #{rows.join(",\n")}"
208
- else
209
- # Arrays or block - current behavior
210
- rows = coll.map do |item|
211
- vals = if block
212
- block.call(item)
213
- elsif item.is_a?(Array)
214
- item.map { |v| collect_bind(v) }
215
- else
216
- [collect_bind(item)]
217
- end
218
- "(#{vals.join(", ")})"
219
- end
220
- "VALUES #{rows.join(",\n")}"
221
- end
222
- end
223
-
224
- # Examples
225
- # <%= order_by({year: :desc, month: :desc}) %>
226
- # #=> ORDER BY year DESC, month DESC
227
- #
228
- # Using variable:
229
- # <%= order_by(ordering) %>
230
- # NOTE Raises when ordering not provided or when blank.
231
- #
232
- # Make it optional:
233
- # <%= @ordering.presence && order_by(ordering) %>
234
- #
235
- def order_by(hash)
236
- raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
237
- "ORDER BY " + hash.map do |k, v|
238
- v.nil? ? k : [k, v.upcase].join(" ")
239
- end.join(", ")
240
- end
241
-
242
298
  def get_binding
243
299
  binding
244
300
  end
@@ -246,6 +302,31 @@ module AppQuery
246
302
  end
247
303
  private :render_helper
248
304
 
305
+ # @!group Query Execution
306
+
307
+ # Executes the query and returns all matching rows.
308
+ #
309
+ # @param binds [Array, Hash, nil] bind parameters (positional or named)
310
+ # @param select [String, nil] override the SELECT clause
311
+ # @param cast [Boolean, Hash, Array] type casting configuration
312
+ # @return [Result] the query results with optional type casting
313
+ #
314
+ # @example Simple query with positional binds
315
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_all(binds: [1])
316
+ #
317
+ # @example Named binds
318
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
319
+ #
320
+ # @example With type casting
321
+ # AppQuery("SELECT created_at FROM users")
322
+ # .select_all(cast: {created_at: ActiveRecord::Type::DateTime.new})
323
+ #
324
+ # @example Override SELECT clause
325
+ # AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
326
+ #
327
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
328
+ # @raise [ArgumentError] if mixing positional binds with collected named binds
329
+ #
249
330
  # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
250
331
  def select_all(binds: nil, select: nil, cast: self.cast)
251
332
  with_select(select).render({}).then do |aq|
@@ -283,25 +364,61 @@ module AppQuery
283
364
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
284
365
  end
285
366
 
367
+ # Executes the query and returns the first row.
368
+ #
369
+ # @param binds [Array, Hash, nil] bind parameters (positional or named)
370
+ # @param select [String, nil] override the SELECT clause
371
+ # @param cast [Boolean, Hash, Array] type casting configuration
372
+ # @return [Hash, nil] the first row as a hash, or nil if no results
373
+ #
374
+ # @example
375
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
376
+ # # => {"id" => 1, "name" => "Alice"}
377
+ #
378
+ # @see #select_all
286
379
  def select_one(binds: nil, select: nil, cast: self.cast)
287
380
  select_all(binds:, select:, cast:).first
288
381
  end
289
382
 
383
+ # Executes the query and returns the first value of the first row.
384
+ #
385
+ # @param binds [Array, Hash, nil] bind parameters (positional or named)
386
+ # @param select [String, nil] override the SELECT clause
387
+ # @param cast [Boolean, Hash, Array] type casting configuration
388
+ # @return [Object, nil] the first value, or nil if no results
389
+ #
390
+ # @example
391
+ # AppQuery("SELECT COUNT(*) FROM users").select_value
392
+ # # => 42
393
+ #
394
+ # @see #select_one
290
395
  def select_value(binds: nil, select: nil, cast: self.cast)
291
396
  select_one(binds:, select:, cast:)&.values&.first
292
397
  end
293
398
 
294
- # Examples
399
+ # Executes an INSERT query.
400
+ #
401
+ # @param binds [Array, Hash] bind parameters for the query
402
+ # @param returning [String, nil] columns to return (Rails 7.1+ only)
403
+ # @return [Integer, Object] the inserted ID or returning value
404
+ #
405
+ # @example With positional binds
295
406
  # AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
296
- # INSERT INTO videos(title, created_at, updated_at) values($1, now(), now())
407
+ # INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
297
408
  # SQL
298
409
  #
299
- # articles = [
300
- # {title: "First article"}
301
- # ].map { it.merge(created_at: Time.current)}
302
- # AppQuery(<<~SQL).render(articles:)
410
+ # @example With values helper
411
+ # articles = [{title: "First", created_at: Time.current}]
412
+ # AppQuery(<<~SQL).render(articles:).insert
303
413
  # INSERT INTO articles(title, created_at) <%= values(articles) %>
304
414
  # SQL
415
+ #
416
+ # @example With returning (Rails 7.1+)
417
+ # AppQuery("INSERT INTO users(name) VALUES($1)")
418
+ # .insert(binds: ["Alice"], returning: "id, created_at")
419
+ #
420
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
421
+ # @raise [ArgumentError] if returning is used with Rails < 7.1
305
422
  def insert(binds: [], returning: nil)
306
423
  # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
307
424
  if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
@@ -334,8 +451,20 @@ module AppQuery
334
451
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
335
452
  end
336
453
 
337
- # Examples:
338
- # AppQuery("UPDATE videos SET title = 'New' WHERE id = :id").update(binds: {id: 1})
454
+ # Executes an UPDATE query.
455
+ #
456
+ # @param binds [Array, Hash] bind parameters for the query
457
+ # @return [Integer] the number of affected rows
458
+ #
459
+ # @example With named binds
460
+ # AppQuery("UPDATE videos SET title = 'New' WHERE id = :id")
461
+ # .update(binds: {id: 1})
462
+ #
463
+ # @example With positional binds
464
+ # AppQuery("UPDATE videos SET title = $1 WHERE id = $2")
465
+ # .update(binds: ["New Title", 1])
466
+ #
467
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
339
468
  def update(binds: [])
340
469
  binds = binds.presence || @binds
341
470
  render({}).then do |aq|
@@ -355,8 +484,18 @@ module AppQuery
355
484
  raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
356
485
  end
357
486
 
358
- # Examples:
487
+ # Executes a DELETE query.
488
+ #
489
+ # @param binds [Array, Hash] bind parameters for the query
490
+ # @return [Integer] the number of deleted rows
491
+ #
492
+ # @example With named binds
359
493
  # AppQuery("DELETE FROM videos WHERE id = :id").delete(binds: {id: 1})
494
+ #
495
+ # @example With positional binds
496
+ # AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
497
+ #
498
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
360
499
  def delete(binds: [])
361
500
  binds = binds.presence || @binds
362
501
  render({}).then do |aq|
@@ -376,36 +515,85 @@ module AppQuery
376
515
  raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
377
516
  end
378
517
 
518
+ # @!group Query Introspection
519
+
520
+ # Returns the tokenized representation of the SQL.
521
+ #
522
+ # @return [Array<Hash>] array of token hashes with :t (type) and :v (value) keys
523
+ # @see Tokenizer
379
524
  def tokens
380
525
  @tokens ||= tokenizer.run
381
526
  end
382
527
 
528
+ # Returns the tokenizer instance for this query.
529
+ #
530
+ # @return [Tokenizer] the tokenizer
383
531
  def tokenizer
384
532
  @tokenizer ||= Tokenizer.new(to_s)
385
533
  end
386
534
 
535
+ # Returns the names of all CTEs (Common Table Expressions) in the query.
536
+ #
537
+ # @return [Array<String>] the CTE names in order of appearance
538
+ #
539
+ # @example
540
+ # AppQuery("WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b").cte_names
541
+ # # => ["a", "b"]
387
542
  def cte_names
388
543
  tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
389
544
  end
390
545
 
546
+ # @!group Query Transformation
547
+
548
+ # Returns a new query with different bind parameters.
549
+ #
550
+ # @param binds [Array, Hash] the new bind parameters
551
+ # @return [Q] a new query object with the specified binds
552
+ #
553
+ # @example
554
+ # query = AppQuery("SELECT * FROM users WHERE id = :id")
555
+ # query.with_binds(id: 1).select_one
391
556
  def with_binds(binds)
392
557
  deep_dup.tap do
393
558
  _1.instance_variable_set(:@binds, binds)
394
559
  end
395
560
  end
396
561
 
562
+ # Returns a new query with different cast settings.
563
+ #
564
+ # @param cast [Boolean, Hash, Array] the new cast configuration
565
+ # @return [Q] a new query object with the specified cast settings
566
+ #
567
+ # @example
568
+ # query = AppQuery("SELECT created_at FROM users")
569
+ # query.with_cast(false).select_all # disable casting
397
570
  def with_cast(cast)
398
571
  deep_dup.tap do
399
572
  _1.instance_variable_set(:@cast, cast)
400
573
  end
401
574
  end
402
575
 
576
+ # Returns a new query with different SQL.
577
+ #
578
+ # @param sql [String] the new SQL string
579
+ # @return [Q] a new query object with the specified SQL
403
580
  def with_sql(sql)
404
581
  deep_dup.tap do
405
582
  _1.instance_variable_set(:@sql, sql)
406
583
  end
407
584
  end
408
585
 
586
+ # Returns a new query with a modified SELECT statement.
587
+ #
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.
590
+ #
591
+ # @param sql [String, nil] the new SELECT statement (nil returns self)
592
+ # @return [Q] a new query object with the modified SELECT
593
+ #
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 _"
409
597
  def with_select(sql)
410
598
  return self if sql.nil?
411
599
  if cte_names.include?("_")
@@ -418,16 +606,48 @@ module AppQuery
418
606
  end
419
607
  end
420
608
 
609
+ # @!group Query Introspection
610
+
611
+ # Returns the SELECT clause of the query.
612
+ #
613
+ # @return [String, nil] the SELECT clause, or nil if not found
614
+ #
615
+ # @example
616
+ # AppQuery("SELECT id, name FROM users").select
617
+ # # => "SELECT id, name FROM users"
421
618
  def select
422
619
  tokens.find { _1[:t] == "SELECT" }&.[](:v)
423
620
  end
424
621
 
622
+ # Checks if the query uses RECURSIVE CTEs.
623
+ #
624
+ # @return [Boolean] true if the query contains WITH RECURSIVE
625
+ #
626
+ # @example
627
+ # AppQuery("WITH RECURSIVE t AS (...) SELECT * FROM t").recursive?
628
+ # # => true
425
629
  def recursive?
426
630
  !!tokens.find { _1[:t] == "RECURSIVE" }
427
631
  end
428
632
 
429
- # example:
430
- # AppQuery("select 1").prepend_cte("foo as(select 1)")
633
+ # @!group CTE Manipulation
634
+
635
+ # Prepends a CTE to the beginning of the WITH clause.
636
+ #
637
+ # If the query has no CTEs, wraps it with WITH. If the query already has
638
+ # CTEs, adds the new CTE at the beginning.
639
+ #
640
+ # @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
641
+ # @return [Q] a new query object with the prepended CTE
642
+ #
643
+ # @example Adding a CTE to a simple query
644
+ # AppQuery("SELECT 1").prepend_cte("foo AS (SELECT 2)")
645
+ # # => "WITH foo AS (SELECT 2) SELECT 1"
646
+ #
647
+ # @example Prepending to existing CTEs
648
+ # AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
649
+ # .prepend_cte("foo AS (SELECT 1)")
650
+ # # => "WITH foo AS (SELECT 1), bar AS (SELECT 2) SELECT * FROM bar"
431
651
  def prepend_cte(cte)
432
652
  # early raise when cte is not valid sql
433
653
  to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
@@ -448,8 +668,22 @@ module AppQuery
448
668
  end
449
669
  end
450
670
 
451
- # example:
452
- # AppQuery("select 1").append_cte("foo as(select 1)")
671
+ # Appends a CTE to the end of the WITH clause.
672
+ #
673
+ # If the query has no CTEs, wraps it with WITH. If the query already has
674
+ # CTEs, adds the new CTE at the end.
675
+ #
676
+ # @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
677
+ # @return [Q] a new query object with the appended CTE
678
+ #
679
+ # @example Adding a CTE to a simple query
680
+ # AppQuery("SELECT 1").append_cte("foo AS (SELECT 2)")
681
+ # # => "WITH foo AS (SELECT 2) SELECT 1"
682
+ #
683
+ # @example Appending to existing CTEs
684
+ # AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
685
+ # .append_cte("foo AS (SELECT 1)")
686
+ # # => "WITH bar AS (SELECT 2), foo AS (SELECT 1) SELECT * FROM bar"
453
687
  def append_cte(cte)
454
688
  # early raise when cte is not valid sql
455
689
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
@@ -477,8 +711,17 @@ module AppQuery
477
711
  end
478
712
  end
479
713
 
480
- # Replaces an existing cte.
481
- # Raises `ArgumentError` when cte does not exist.
714
+ # Replaces an existing CTE with a new definition.
715
+ #
716
+ # @param cte [String] the new CTE definition (must have same name as existing CTE)
717
+ # @return [Q] a new query object with the replaced CTE
718
+ #
719
+ # @example
720
+ # AppQuery("WITH foo AS (SELECT 1) SELECT * FROM foo")
721
+ # .replace_cte("foo AS (SELECT 2)")
722
+ # # => "WITH foo AS (SELECT 2) SELECT * FROM foo"
723
+ #
724
+ # @raise [ArgumentError] if the CTE name doesn't exist in the query
482
725
  def replace_cte(cte)
483
726
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
484
727
  [!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
@@ -510,12 +753,27 @@ module AppQuery
510
753
  end.join)
511
754
  end
512
755
 
756
+ # @!endgroup
757
+
758
+ # Returns the SQL string.
759
+ #
760
+ # @return [String] the SQL query string
513
761
  def to_s
514
762
  @sql
515
763
  end
516
764
  end
517
765
  end
518
766
 
767
+ # Convenience method to create a new {AppQuery::Q} instance.
768
+ #
769
+ # Accepts the same arguments as {AppQuery::Q#initialize}.
770
+ #
771
+ # @return [AppQuery::Q] a new query object
772
+ #
773
+ # @example
774
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
775
+ #
776
+ # @see AppQuery::Q#initialize
519
777
  def AppQuery(...)
520
778
  AppQuery::Q.new(...)
521
779
  end
data/rakelib/yard.rake ADDED
@@ -0,0 +1,17 @@
1
+ require "yard"
2
+
3
+ YARD::Rake::YardocTask.new(:docs) do |t|
4
+ # Options defined in `.yardopts` are read first, then merged with
5
+ # options defined here.
6
+ #
7
+ # It's recommended to define options in `.yardopts` instead of here,
8
+ # as `.yardopts` can be read by external YARD tools, like the
9
+ # hot-reload YARD server `yard server --reload`.
10
+
11
+ # Use APPQUERY_VERSION env var (set from git tag in CI), or fall back to git describe
12
+ version = ENV["APPQUERY_VERSION"] || `git describe --tags --abbrev=0 2>/dev/null`.strip
13
+ version = nil if version.empty?
14
+
15
+ title = ["AppQuery", version, "API Documentation"].compact.join(" ")
16
+ t.options += ["--title", title]
17
+ end
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.rc1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gert Goet
@@ -43,6 +43,7 @@ files:
43
43
  - ".irbrc"
44
44
  - ".rspec"
45
45
  - ".standard.yml"
46
+ - ".yardopts"
46
47
  - Appraisals
47
48
  - CHANGELOG.md
48
49
  - LICENSE.txt
@@ -50,6 +51,7 @@ files:
50
51
  - Rakefile
51
52
  - lib/app_query.rb
52
53
  - lib/app_query/base.rb
54
+ - lib/app_query/render_helpers.rb
53
55
  - lib/app_query/rspec.rb
54
56
  - lib/app_query/rspec/helpers.rb
55
57
  - lib/app_query/tokenizer.rb
@@ -63,6 +65,7 @@ files:
63
65
  - mise.local.toml.example
64
66
  - mise.toml
65
67
  - rakelib/gem.rake
68
+ - rakelib/yard.rake
66
69
  - sig/appquery.rbs
67
70
  homepage: https://github.com/eval/appquery
68
71
  licenses: