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.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.3.0"
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,9 +224,55 @@ 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
- with_sql(to_erb.result(render_helper(vars).get_binding))
266
+ helper = render_helper(vars)
267
+ sql = to_erb.result(helper.get_binding)
268
+ collected = helper.collected_binds
269
+
270
+ with_sql(sql).tap do |q|
271
+ # Merge collected binds with existing binds (convert array to hash if needed)
272
+ existing = @binds.is_a?(Hash) ? @binds : {}
273
+ new_binds = existing.merge(collected)
274
+ q.instance_variable_set(:@binds, new_binds) if new_binds.any?
275
+ end
126
276
  end
127
277
 
128
278
  def to_erb
@@ -133,29 +283,17 @@ module AppQuery
133
283
  def render_helper(vars)
134
284
  Module.new do
135
285
  extend self
286
+ include AppQuery::RenderHelpers
287
+
288
+ @collected_binds = {}
289
+ @placeholder_counter = 0
136
290
 
137
291
  vars.each do |k, v|
138
292
  define_method(k) { v }
139
293
  instance_variable_set(:"@#{k}", v)
140
294
  end
141
295
 
142
- # Examples
143
- # <%= order_by({year: :desc, month: :desc}) %>
144
- # #=> ORDER BY year DESC, month DESC
145
- #
146
- # Using variable:
147
- # <%= order_by(ordering) %>
148
- # NOTE Raises when ordering not provided or when blank.
149
- #
150
- # Make it optional:
151
- # <%= @ordering.presence && order_by(ordering) %>
152
- #
153
- def order_by(hash)
154
- raise ArgumentError, "Provide columns to sort by, e.g. order_by(id: :asc) (got #{hash.inspect})." unless hash.present?
155
- "ORDER BY " + hash.map do |k, v|
156
- v.nil? ? k : [k, v.upcase].join(" ")
157
- end.join(", ")
158
- end
296
+ attr_reader :collected_binds
159
297
 
160
298
  def get_binding
161
299
  binding
@@ -164,22 +302,147 @@ module AppQuery
164
302
  end
165
303
  private :render_helper
166
304
 
167
- def select_all(binds: [], select: nil, cast: self.cast)
168
- binds = binds.presence || @binds
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
+ #
330
+ # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
331
+ def select_all(binds: nil, select: nil, cast: self.cast)
169
332
  with_select(select).render({}).then do |aq|
333
+ # Support both positional (array) and named (hash) binds
334
+ if binds.is_a?(Array)
335
+ if @binds.is_a?(Hash) && @binds.any?
336
+ raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
337
+ end
338
+ # Positional binds using $1, $2, etc.
339
+ ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
340
+ Result.from_ar_result(result, cast)
341
+ end
342
+ else
343
+ # Named binds - merge collected binds with explicitly passed binds
344
+ merged_binds = (@binds.is_a?(Hash) ? @binds : {}).merge(binds || {})
345
+ if merged_binds.any?
346
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
347
+ Arel.sql(aq.to_s, **merged_binds)
348
+ else
349
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
350
+ end
351
+ ActiveRecord::Base.connection.select_all(sql, name).then do |result|
352
+ Result.from_ar_result(result, cast)
353
+ end
354
+ else
355
+ ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
356
+ Result.from_ar_result(result, cast)
357
+ end
358
+ end
359
+ end
360
+ end
361
+ rescue NameError => e
362
+ # Prevent any subclasses, e.g. NoMethodError
363
+ raise e unless e.instance_of?(NameError)
364
+ raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
365
+ end
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
379
+ def select_one(binds: nil, select: nil, cast: self.cast)
380
+ select_all(binds:, select:, cast:).first
381
+ end
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
395
+ def select_value(binds: nil, select: nil, cast: self.cast)
396
+ select_one(binds:, select:, cast:)&.values&.first
397
+ end
398
+
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
406
+ # AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
407
+ # INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
408
+ # SQL
409
+ #
410
+ # @example With values helper
411
+ # articles = [{title: "First", created_at: Time.current}]
412
+ # AppQuery(<<~SQL).render(articles:).insert
413
+ # INSERT INTO articles(title, created_at) <%= values(articles) %>
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
422
+ def insert(binds: [], returning: nil)
423
+ # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
424
+ if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
425
+ raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
426
+ end
427
+
428
+ binds = binds.presence || @binds
429
+ render({}).then do |aq|
170
430
  if binds.is_a?(Hash)
171
431
  sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
172
432
  Arel.sql(aq.to_s, **binds)
173
433
  else
174
434
  ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
175
435
  end
176
- ActiveRecord::Base.connection.select_all(sql, name).then do |result|
177
- Result.from_ar_result(result, cast)
436
+ if ActiveRecord::VERSION::STRING.to_f >= 7.1
437
+ ActiveRecord::Base.connection.insert(sql, name, returning:)
438
+ else
439
+ ActiveRecord::Base.connection.insert(sql, name)
178
440
  end
441
+ elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
442
+ # pk is the less flexible returning
443
+ ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
179
444
  else
180
- ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
181
- Result.from_ar_result(result, cast)
182
- end
445
+ ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
183
446
  end
184
447
  end
185
448
  rescue NameError => e
@@ -188,44 +451,149 @@ module AppQuery
188
451
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
189
452
  end
190
453
 
191
- def select_one(binds: [], select: nil, cast: self.cast)
192
- select_all(binds:, select:, cast:).first
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
468
+ def update(binds: [])
469
+ binds = binds.presence || @binds
470
+ render({}).then do |aq|
471
+ if binds.is_a?(Hash)
472
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
473
+ Arel.sql(aq.to_s, **binds)
474
+ else
475
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
476
+ end
477
+ ActiveRecord::Base.connection.update(sql, name)
478
+ else
479
+ ActiveRecord::Base.connection.update(aq.to_s, name, binds)
480
+ end
481
+ end
482
+ rescue NameError => e
483
+ raise e unless e.instance_of?(NameError)
484
+ raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
193
485
  end
194
486
 
195
- def select_value(binds: [], select: nil, cast: self.cast)
196
- select_one(binds:, select:, cast:)&.values&.first
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
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
499
+ def delete(binds: [])
500
+ binds = binds.presence || @binds
501
+ render({}).then do |aq|
502
+ if binds.is_a?(Hash)
503
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
504
+ Arel.sql(aq.to_s, **binds)
505
+ else
506
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
507
+ end
508
+ ActiveRecord::Base.connection.delete(sql, name)
509
+ else
510
+ ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
511
+ end
512
+ end
513
+ rescue NameError => e
514
+ raise e unless e.instance_of?(NameError)
515
+ raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
197
516
  end
198
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
199
524
  def tokens
200
525
  @tokens ||= tokenizer.run
201
526
  end
202
527
 
528
+ # Returns the tokenizer instance for this query.
529
+ #
530
+ # @return [Tokenizer] the tokenizer
203
531
  def tokenizer
204
532
  @tokenizer ||= Tokenizer.new(to_s)
205
533
  end
206
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"]
207
542
  def cte_names
208
543
  tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
209
544
  end
210
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
211
556
  def with_binds(binds)
212
557
  deep_dup.tap do
213
558
  _1.instance_variable_set(:@binds, binds)
214
559
  end
215
560
  end
216
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
217
570
  def with_cast(cast)
218
571
  deep_dup.tap do
219
572
  _1.instance_variable_set(:@cast, cast)
220
573
  end
221
574
  end
222
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
223
580
  def with_sql(sql)
224
581
  deep_dup.tap do
225
582
  _1.instance_variable_set(:@sql, sql)
226
583
  end
227
584
  end
228
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 _"
229
597
  def with_select(sql)
230
598
  return self if sql.nil?
231
599
  if cte_names.include?("_")
@@ -238,16 +606,48 @@ module AppQuery
238
606
  end
239
607
  end
240
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"
241
618
  def select
242
619
  tokens.find { _1[:t] == "SELECT" }&.[](:v)
243
620
  end
244
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
245
629
  def recursive?
246
630
  !!tokens.find { _1[:t] == "RECURSIVE" }
247
631
  end
248
632
 
249
- # example:
250
- # 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"
251
651
  def prepend_cte(cte)
252
652
  # early raise when cte is not valid sql
253
653
  to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
@@ -268,8 +668,22 @@ module AppQuery
268
668
  end
269
669
  end
270
670
 
271
- # example:
272
- # 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"
273
687
  def append_cte(cte)
274
688
  # early raise when cte is not valid sql
275
689
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
@@ -297,8 +711,17 @@ module AppQuery
297
711
  end
298
712
  end
299
713
 
300
- # Replaces an existing cte.
301
- # 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
302
725
  def replace_cte(cte)
303
726
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
304
727
  [!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
@@ -330,12 +753,27 @@ module AppQuery
330
753
  end.join)
331
754
  end
332
755
 
756
+ # @!endgroup
757
+
758
+ # Returns the SQL string.
759
+ #
760
+ # @return [String] the SQL query string
333
761
  def to_s
334
762
  @sql
335
763
  end
336
764
  end
337
765
  end
338
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
339
777
  def AppQuery(...)
340
778
  AppQuery::Q.new(...)
341
779
  end