appquery 0.4.0.rc1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,13 +70,46 @@ 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
+ # When no extension is provided, tries `.sql` first, then `.sql.erb`.
76
+ # Raises an error if both files exist (ambiguous).
77
+ #
78
+ # @param query_name [String, Symbol] the query name or path (without extension)
79
+ # @param opts [Hash] additional options passed to {Q#initialize}
80
+ # @return [Q] a new query object loaded from the file
81
+ #
82
+ # @example Load a .sql file
83
+ # AppQuery[:invoices] # loads app/queries/invoices.sql
84
+ #
85
+ # @example Load a .sql.erb file (when .sql doesn't exist)
86
+ # AppQuery[:dynamic_report] # loads app/queries/dynamic_report.sql.erb
87
+ #
88
+ # @example Load from a subdirectory
89
+ # AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
90
+ #
91
+ # @example Load with explicit extension
92
+ # AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
93
+ #
94
+ # @raise [Error] if both `.sql` and `.sql.erb` files exist for the same name
33
95
  def self.[](query_name, **opts)
34
- filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
35
- full_path = (Pathname.new(configuration.query_path) / filename).expand_path
96
+ base = Pathname.new(configuration.query_path) / query_name.to_s
97
+
98
+ full_path = if File.extname(query_name.to_s).empty?
99
+ sql_path = base.sub_ext(".sql").expand_path
100
+ erb_path = base.sub_ext(".sql.erb").expand_path
101
+ sql_exists = sql_path.exist?
102
+ erb_exists = erb_path.exist?
103
+
104
+ if sql_exists && erb_exists
105
+ raise Error, "Ambiguous query name #{query_name.inspect}: both #{sql_path} and #{erb_path} exist"
106
+ end
107
+
108
+ sql_exists ? sql_path : erb_path
109
+ else
110
+ base.expand_path
111
+ end
112
+
36
113
  Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
37
114
  end
38
115
 
@@ -97,41 +174,133 @@ module AppQuery
97
174
  private_constant :EMPTY
98
175
  end
99
176
 
177
+ # Query object for building, rendering, and executing SQL queries.
178
+ #
179
+ # Q wraps a SQL string (optionally with ERB templating) and provides methods
180
+ # for query execution, CTE manipulation, and result handling.
181
+ #
182
+ # ## Method Groups
183
+ #
184
+ # - **Rendering** — Process ERB templates to produce executable SQL.
185
+ # - **Query Execution** — Execute queries against the database. These methods
186
+ # wrap the equivalent `ActiveRecord::Base.connection` methods (`select_all`,
187
+ # `insert`, `update`, `delete`).
188
+ # - **Query Introspection** — Inspect and analyze the structure of the query.
189
+ # - **Query Transformation** — Create modified copies of the query. All
190
+ # transformation methods are immutable—they return a new {Q} instance and
191
+ # leave the original unchanged.
192
+ # - **CTE Manipulation** — Add, replace, or reorder Common Table Expressions
193
+ # (CTEs). Like transformation methods, these return a new {Q} instance.
194
+ #
195
+ # @example Basic query
196
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
197
+ #
198
+ # @example ERB templating
199
+ # AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
200
+ # .render(name: "Alice")
201
+ # .select_all
202
+ #
203
+ # @example CTE manipulation
204
+ # AppQuery("WITH base AS (SELECT 1) SELECT * FROM base")
205
+ # .append_cte("extra AS (SELECT 2)")
206
+ # .select_all
100
207
  class Q
101
- attr_reader :name, :sql, :binds, :cast
208
+ # @return [String, nil] optional name for the query (used in logs)
209
+ # @return [String] the SQL string
210
+ # @return [Array, Hash] bind parameters
211
+ # @return [Boolean, Hash, Array] casting configuration
212
+ attr_reader :sql, :name, :filename, :binds, :cast
102
213
 
103
- def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
214
+ # Creates a new query object.
215
+ #
216
+ # @param sql [String] the SQL query string (may contain ERB)
217
+ # @param name [String, nil] optional name for logging
218
+ # @param filename [String, nil] optional filename for ERB error reporting
219
+ # @param binds [Hash, nil] bind parameters for the query
220
+ # @param cast [Boolean, Hash, Array] type casting configuration
221
+ #
222
+ # @example Simple query
223
+ # Q.new("SELECT * FROM users")
224
+ #
225
+ # @example With ERB and binds
226
+ # Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
227
+ def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
104
228
  @sql = sql
105
229
  @name = name
106
230
  @filename = filename
107
231
  @binds = binds
108
232
  @cast = cast
233
+ @cte_depth = cte_depth
234
+ @binds = binds_with_defaults(sql, binds)
109
235
  end
110
236
 
111
- def deep_dup
112
- super.send(:reset!)
237
+ attr_reader :cte_depth
238
+
239
+ def to_arel
240
+ if binds.presence
241
+ Arel::Nodes::BoundSqlLiteral.new sql, [], binds
242
+ else
243
+ # TODO: add retryable? available from >=7.1
244
+ Arel::Nodes::SqlLiteral.new(sql)
245
+ end
113
246
  end
114
247
 
115
- def reset!
116
- (instance_variables - %i[@sql @filename @name @binds @cast]).each do
117
- instance_variable_set(_1, nil)
248
+ private def binds_with_defaults(sql, binds)
249
+ if (named_binds = sql.scan(/:(?<!::)([a-zA-Z]\w*)/).flatten.map(&:to_sym).uniq.presence)
250
+ named_binds.zip(Array.new(named_binds.count)).to_h.merge(binds.to_h)
251
+ else
252
+ binds.to_h
118
253
  end
119
- self
120
254
  end
121
- private :reset!
122
255
 
123
- def render(vars)
256
+ def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
257
+ self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
258
+ end
259
+
260
+ # @!group Rendering
261
+
262
+ # Renders the ERB template with the given variables.
263
+ #
264
+ # Processes ERB tags in the SQL and collects any bind parameters created
265
+ # by helpers like {RenderHelpers#bind} and {RenderHelpers#values}.
266
+ #
267
+ # @param vars [Hash] variables to make available in the ERB template
268
+ # @return [Q] a new query object with rendered SQL and collected binds
269
+ #
270
+ # @example Rendering with variables
271
+ # AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
272
+ # .render(name: "Alice")
273
+ # # => Q with SQL: "SELECT * FROM users WHERE name = :b1"
274
+ # # and binds: {b1: "Alice"}
275
+ #
276
+ # @example Using instance variables
277
+ # AppQuery("SELECT * FROM users WHERE active = <%= @active %>")
278
+ # .render(active: true)
279
+ #
280
+ # @example vars are available as local and instance variable.
281
+ # # This fails as `ordering` is not provided:
282
+ # AppQuery(<<~SQL).render
283
+ # SELECT * FROM articles
284
+ # <%= order_by(ordering) %>
285
+ # SQL
286
+ #
287
+ # # ...but this query works without `ordering` being passed to render:
288
+ # AppQuery(<<~SQL).render
289
+ # SELECT * FROM articles
290
+ # <%= @ordering.presence && order_by(ordering) %>
291
+ # SQL
292
+ # # NOTE that `@ordering.present? && ...` would render as `false`.
293
+ # # Use `@ordering.presence` instead.
294
+ #
295
+ #
296
+ # @see RenderHelpers for available helper methods in templates
297
+ def render(vars = {})
124
298
  vars ||= {}
125
299
  helper = render_helper(vars)
126
300
  sql = to_erb.result(helper.get_binding)
127
301
  collected = helper.collected_binds
128
302
 
129
- with_sql(sql).tap do |q|
130
- # Merge collected binds with existing binds (convert array to hash if needed)
131
- existing = @binds.is_a?(Hash) ? @binds : {}
132
- new_binds = existing.merge(collected)
133
- q.instance_variable_set(:@binds, new_binds) if new_binds.any?
134
- end
303
+ with_sql(sql).add_binds(**collected)
135
304
  end
136
305
 
137
306
  def to_erb
@@ -142,6 +311,7 @@ module AppQuery
142
311
  def render_helper(vars)
143
312
  Module.new do
144
313
  extend self
314
+ include AppQuery::RenderHelpers
145
315
 
146
316
  @collected_binds = {}
147
317
  @placeholder_counter = 0
@@ -151,94 +321,8 @@ module AppQuery
151
321
  instance_variable_set(:"@#{k}", v)
152
322
  end
153
323
 
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
324
  attr_reader :collected_binds
162
325
 
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
326
  def get_binding
243
327
  binding
244
328
  end
@@ -246,35 +330,37 @@ module AppQuery
246
330
  end
247
331
  private :render_helper
248
332
 
333
+ # @!group Query Execution
334
+
335
+ # Executes the query and returns all matching rows.
336
+ #
337
+ # @param select [String, nil] override the SELECT clause
338
+ # @param binds [Hash, nil] bind parameters to add
339
+ # @param cast [Boolean, Hash, Array] type casting configuration
340
+ # @return [Result] the query results with optional type casting
341
+ #
342
+ # @example (Named) binds
343
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
344
+ #
345
+ # @example With type casting
346
+ # AppQuery("SELECT created_at FROM users")
347
+ # .select_all(cast: {created_at: ActiveRecord::Type::DateTime.new})
348
+ #
349
+ # @example Override SELECT clause
350
+ # AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
351
+ #
352
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
353
+ #
249
354
  # TODO: have aliases for common casts: select_all(cast: {"today" => :date})
250
- def select_all(binds: nil, select: nil, cast: self.cast)
251
- with_select(select).render({}).then do |aq|
252
- # Support both positional (array) and named (hash) binds
253
- if binds.is_a?(Array)
254
- if @binds.is_a?(Hash) && @binds.any?
255
- raise ArgumentError, "Cannot use positional binds (Array) when query has collected named binds from values()/bind() helpers. Use named binds (Hash) instead."
256
- end
257
- # Positional binds using $1, $2, etc.
258
- ActiveRecord::Base.connection.select_all(aq.to_s, name, binds).then do |result|
259
- Result.from_ar_result(result, cast)
260
- end
355
+ def select_all(s = nil, binds: {}, cast: self.cast)
356
+ add_binds(**binds).with_select(s).render({}).then do |aq|
357
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
358
+ aq.to_arel
261
359
  else
262
- # Named binds - merge collected binds with explicitly passed binds
263
- merged_binds = (@binds.is_a?(Hash) ? @binds : {}).merge(binds || {})
264
- if merged_binds.any?
265
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
266
- Arel.sql(aq.to_s, **merged_binds)
267
- else
268
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **merged_binds])
269
- end
270
- ActiveRecord::Base.connection.select_all(sql, name).then do |result|
271
- Result.from_ar_result(result, cast)
272
- end
273
- else
274
- ActiveRecord::Base.connection.select_all(aq.to_s, name).then do |result|
275
- Result.from_ar_result(result, cast)
276
- end
277
- end
360
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
361
+ end
362
+ ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
363
+ Result.from_ar_result(result, cast)
278
364
  end
279
365
  end
280
366
  rescue NameError => e
@@ -283,49 +369,159 @@ module AppQuery
283
369
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
284
370
  end
285
371
 
286
- def select_one(binds: nil, select: nil, cast: self.cast)
287
- select_all(binds:, select:, cast:).first
372
+ # Executes the query and returns the first row.
373
+ #
374
+ # @param binds [Hash, nil] bind parameters to add
375
+ # @param select [String, nil] override the SELECT clause
376
+ # @param cast [Boolean, Hash, Array] type casting configuration
377
+ # @return [Hash, nil] the first row as a hash, or nil if no results
378
+ #
379
+ # @example
380
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
381
+ # # => {"id" => 1, "name" => "Alice"}
382
+ #
383
+ # @see #select_all
384
+ def select_one(s = nil, binds: {}, cast: self.cast)
385
+ with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
386
+ end
387
+ alias_method :first, :select_one
388
+
389
+ # Executes the query and returns the first value of the first row.
390
+ #
391
+ # @param binds [Hash, nil] named bind parameters
392
+ # @param select [String, nil] override the SELECT clause
393
+ # @param cast [Boolean, Hash, Array] type casting configuration
394
+ # @return [Object, nil] the first value, or nil if no results
395
+ #
396
+ # @example
397
+ # AppQuery("SELECT COUNT(*) FROM users").select_value
398
+ # # => 42
399
+ #
400
+ # @see #select_one
401
+ def select_value(s = nil, binds: {}, cast: self.cast)
402
+ select_one(s, binds:, cast:)&.values&.first
403
+ end
404
+
405
+ # Returns the count of rows from the query.
406
+ #
407
+ # Wraps the query in a CTE and selects only the count, which is more
408
+ # efficient than fetching all rows via `select_all.count`.
409
+ #
410
+ # @param s [String, nil] optional SELECT to apply before counting
411
+ # @param binds [Hash, nil] bind parameters to add
412
+ # @return [Integer] the count of rows
413
+ #
414
+ # @example Simple count
415
+ # AppQuery("SELECT * FROM users").count
416
+ # # => 42
417
+ #
418
+ # @example Count with filtering
419
+ # AppQuery("SELECT * FROM users")
420
+ # .with_select("SELECT * FROM :_ WHERE active")
421
+ # .count
422
+ # # => 10
423
+ def count(s = nil, binds: {})
424
+ with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first
425
+ end
426
+
427
+ # Returns an array of values for a single column.
428
+ #
429
+ # Wraps the query in a CTE and selects only the specified column, which is
430
+ # more efficient than fetching all columns via `select_all.column(name)`.
431
+ # The column name is safely quoted, making this method safe for user input.
432
+ #
433
+ # @param c [String, Symbol] the column name to extract
434
+ # @param s [String, nil] optional SELECT to apply before extracting
435
+ # @param binds [Hash, nil] bind parameters to add
436
+ # @return [Array] the column values
437
+ #
438
+ # @example Extract a single column
439
+ # AppQuery("SELECT id, name FROM users").column(:name)
440
+ # # => ["Alice", "Bob", "Charlie"]
441
+ #
442
+ # @example With additional filtering
443
+ # AppQuery("SELECT * FROM users").column(:email, "SELECT * FROM :_ WHERE active")
444
+ # # => ["alice@example.com", "bob@example.com"]
445
+ def column(c, s = nil, binds: {})
446
+ quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
447
+ with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column")
448
+ end
449
+
450
+ # Returns an array of id values from the query.
451
+ #
452
+ # Convenience method equivalent to `column(:id)`. More efficient than
453
+ # fetching all columns via `select_all.column("id")`.
454
+ #
455
+ # @param s [String, nil] optional SELECT to apply before extracting
456
+ # @param binds [Hash, nil] bind parameters to add
457
+ # @return [Array] the id values
458
+ #
459
+ # @example Get all user IDs
460
+ # AppQuery("SELECT * FROM users").ids
461
+ # # => [1, 2, 3]
462
+ #
463
+ # @example With filtering
464
+ # AppQuery("SELECT * FROM users").ids("SELECT * FROM :_ WHERE active")
465
+ # # => [1, 3]
466
+ def ids(s = nil, binds: {})
467
+ column(:id, s, binds:)
288
468
  end
289
469
 
290
- def select_value(binds: nil, select: nil, cast: self.cast)
291
- select_one(binds:, select:, cast:)&.values&.first
470
+ # Executes the query and returns results as an Array of Hashes.
471
+ #
472
+ # Shorthand for `select_all(...).entries`. Accepts the same arguments as
473
+ # {#select_all}.
474
+ #
475
+ # @return [Array<Hash>] the query results as an array
476
+ #
477
+ # @example
478
+ # AppQuery("SELECT * FROM users").entries
479
+ # # => [{"id" => 1, "name" => "Alice"}, {"id" => 2, "name" => "Bob"}]
480
+ #
481
+ # @see #select_all
482
+ def entries(...)
483
+ select_all(...).entries
292
484
  end
293
485
 
294
- # Examples
486
+ # Executes an INSERT query.
487
+ #
488
+ # @param binds [Hash, nil] bind parameters for the query
489
+ # @param returning [String, nil] columns to return (Rails 7.1+ only)
490
+ # @return [Integer, Object] the inserted ID or returning value
491
+ #
492
+ # @example With positional binds
295
493
  # AppQuery(<<~SQL).insert(binds: ["Let's learn SQL!"])
296
- # INSERT INTO videos(title, created_at, updated_at) values($1, now(), now())
494
+ # INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
297
495
  # SQL
298
496
  #
299
- # articles = [
300
- # {title: "First article"}
301
- # ].map { it.merge(created_at: Time.current)}
302
- # AppQuery(<<~SQL).render(articles:)
497
+ # @example With values helper
498
+ # articles = [{title: "First", created_at: Time.current}]
499
+ # AppQuery(<<~SQL).render(articles:).insert
303
500
  # INSERT INTO articles(title, created_at) <%= values(articles) %>
304
501
  # SQL
305
- def insert(binds: [], returning: nil)
502
+ #
503
+ # @example With returning (Rails 7.1+)
504
+ # AppQuery("INSERT INTO users(name) VALUES($1)")
505
+ # .insert(binds: ["Alice"], returning: "id, created_at")
506
+ #
507
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
508
+ # @raise [ArgumentError] if returning is used with Rails < 7.1
509
+ def insert(binds: {}, returning: nil)
306
510
  # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
307
511
  if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
308
512
  raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
309
513
  end
310
514
 
311
- binds = binds.presence || @binds
312
- render({}).then do |aq|
313
- if binds.is_a?(Hash)
314
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
315
- Arel.sql(aq.to_s, **binds)
316
- else
317
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
318
- end
319
- if ActiveRecord::VERSION::STRING.to_f >= 7.1
320
- ActiveRecord::Base.connection.insert(sql, name, returning:)
321
- else
322
- ActiveRecord::Base.connection.insert(sql, name)
323
- end
324
- elsif ActiveRecord::VERSION::STRING.to_f >= 7.1
325
- # pk is the less flexible returning
326
- ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning:)
515
+ with_binds(**binds).render({}).then do |aq|
516
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
517
+ aq.to_arel
327
518
  else
328
- ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
519
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
520
+ end
521
+ if ActiveRecord::VERSION::STRING.to_f >= 7.1
522
+ ActiveRecord::Base.connection.insert(sql, name, returning:)
523
+ else
524
+ ActiveRecord::Base.connection.insert(sql, name)
329
525
  end
330
526
  end
331
527
  rescue NameError => e
@@ -334,100 +530,221 @@ module AppQuery
334
530
  raise UnrenderedQueryError, "Query is ERB. Use #render before select-ing."
335
531
  end
336
532
 
337
- # Examples:
338
- # AppQuery("UPDATE videos SET title = 'New' WHERE id = :id").update(binds: {id: 1})
339
- def update(binds: [])
340
- binds = binds.presence || @binds
341
- render({}).then do |aq|
342
- if binds.is_a?(Hash)
343
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
344
- Arel.sql(aq.to_s, **binds)
345
- else
346
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
347
- end
348
- ActiveRecord::Base.connection.update(sql, name)
533
+ # Executes an UPDATE query.
534
+ #
535
+ # @param binds [Hash, nil] bind parameters for the query
536
+ # @return [Integer] the number of affected rows
537
+ #
538
+ # @example With named binds
539
+ # AppQuery("UPDATE videos SET title = 'New' WHERE id = :id")
540
+ # .update(binds: {id: 1})
541
+ #
542
+ # @example With positional binds
543
+ # AppQuery("UPDATE videos SET title = $1 WHERE id = $2")
544
+ # .update(binds: ["New Title", 1])
545
+ #
546
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
547
+ def update(binds: {})
548
+ with_binds(**binds).render({}).then do |aq|
549
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
550
+ aq.to_arel
349
551
  else
350
- ActiveRecord::Base.connection.update(aq.to_s, name, binds)
552
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
351
553
  end
554
+ ActiveRecord::Base.connection.update(sql, name)
352
555
  end
353
556
  rescue NameError => e
354
557
  raise e unless e.instance_of?(NameError)
355
558
  raise UnrenderedQueryError, "Query is ERB. Use #render before updating."
356
559
  end
357
560
 
358
- # Examples:
561
+ # Executes a DELETE query.
562
+ #
563
+ # @param binds [Hash, nil] bind parameters for the query
564
+ # @return [Integer] the number of deleted rows
565
+ #
566
+ # @example With named binds
359
567
  # AppQuery("DELETE FROM videos WHERE id = :id").delete(binds: {id: 1})
360
- def delete(binds: [])
361
- binds = binds.presence || @binds
362
- render({}).then do |aq|
363
- if binds.is_a?(Hash)
364
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
365
- Arel.sql(aq.to_s, **binds)
366
- else
367
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
368
- end
369
- ActiveRecord::Base.connection.delete(sql, name)
568
+ #
569
+ # @example With positional binds
570
+ # AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
571
+ #
572
+ # @raise [UnrenderedQueryError] if the query contains unrendered ERB
573
+ def delete(binds: {})
574
+ with_binds(**binds).render({}).then do |aq|
575
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
576
+ aq.to_arel
370
577
  else
371
- ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
578
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
372
579
  end
580
+ ActiveRecord::Base.connection.delete(sql, name)
373
581
  end
374
582
  rescue NameError => e
375
583
  raise e unless e.instance_of?(NameError)
376
584
  raise UnrenderedQueryError, "Query is ERB. Use #render before deleting."
377
585
  end
378
586
 
587
+ # @!group Query Introspection
588
+
589
+ # Returns the tokenized representation of the SQL.
590
+ #
591
+ # @return [Array<Hash>] array of token hashes with :t (type) and :v (value) keys
592
+ # @see Tokenizer
379
593
  def tokens
380
594
  @tokens ||= tokenizer.run
381
595
  end
382
596
 
597
+ # Returns the tokenizer instance for this query.
598
+ #
599
+ # @return [Tokenizer] the tokenizer
383
600
  def tokenizer
384
601
  @tokenizer ||= Tokenizer.new(to_s)
385
602
  end
386
603
 
604
+ # Returns the names of all CTEs (Common Table Expressions) in the query.
605
+ #
606
+ # @return [Array<String>] the CTE names in order of appearance
607
+ #
608
+ # @example
609
+ # AppQuery("WITH a AS (SELECT 1), b AS (SELECT 2) SELECT * FROM a, b").cte_names
610
+ # # => ["a", "b"]
387
611
  def cte_names
388
612
  tokens.filter { _1[:t] == "CTE_IDENTIFIER" }.map { _1[:v] }
389
613
  end
390
614
 
391
- def with_binds(binds)
392
- deep_dup.tap do
393
- _1.instance_variable_set(:@binds, binds)
394
- end
615
+ # @!group Query Transformation
616
+
617
+ # Returns a new query with different bind parameters.
618
+ #
619
+ # @param binds [Hash, nil] the bind parameters
620
+ # @return [Q] a new query object with the binds replaced
621
+ #
622
+ # @example
623
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
624
+ # query.with_binds(bar: 2).binds
625
+ # # => {foo: nil, bar: 2}
626
+ def with_binds(**binds)
627
+ deep_dup(binds:)
395
628
  end
629
+ alias_method :replace_binds, :with_binds
396
630
 
631
+ # Returns a new query with binds added.
632
+ #
633
+ # @param binds [Hash, nil] the bind parameters to add
634
+ # @return [Q] a new query object with the added binds
635
+ #
636
+ # @example
637
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
638
+ # query.add_binds(bar: 2).binds
639
+ # # => {foo: 1, bar: 2}
640
+ def add_binds(**binds)
641
+ deep_dup(binds: self.binds.merge(binds))
642
+ end
643
+
644
+ # Returns a new query with different cast settings.
645
+ #
646
+ # @param cast [Boolean, Hash, Array] the new cast configuration
647
+ # @return [Q] a new query object with the specified cast settings
648
+ #
649
+ # @example
650
+ # query = AppQuery("SELECT created_at FROM users")
651
+ # query.with_cast(false).select_all # disable casting
397
652
  def with_cast(cast)
398
- deep_dup.tap do
399
- _1.instance_variable_set(:@cast, cast)
400
- end
653
+ deep_dup(cast:)
401
654
  end
402
655
 
656
+ # Returns a new query with different SQL.
657
+ #
658
+ # @param sql [String] the new SQL string
659
+ # @return [Q] a new query object with the specified SQL
403
660
  def with_sql(sql)
404
- deep_dup.tap do
405
- _1.instance_variable_set(:@sql, sql)
406
- end
661
+ deep_dup(sql:)
407
662
  end
408
663
 
664
+ # Returns a new query with a modified SELECT statement.
665
+ #
666
+ # Wraps the current SELECT in a numbered CTE and applies the new SELECT.
667
+ # CTEs are named `_`, `_1`, `_2`, etc. Use `:_` in the new SELECT to
668
+ # reference the previous result.
669
+ #
670
+ # @param sql [String, nil] the new SELECT statement (nil returns self)
671
+ # @return [Q] a new query object with the modified SELECT
672
+ #
673
+ # @example Single transformation
674
+ # AppQuery("SELECT * FROM users").with_select("SELECT COUNT(*) FROM :_")
675
+ # # => "WITH _ AS (\n SELECT * FROM users\n)\nSELECT COUNT(*) FROM _"
676
+ #
677
+ # @example Chained transformations
678
+ # AppQuery("SELECT * FROM users")
679
+ # .with_select("SELECT * FROM :_ WHERE active")
680
+ # .with_select("SELECT COUNT(*) FROM :_")
681
+ # # => WITH _ AS (SELECT * FROM users),
682
+ # # _1 AS (SELECT * FROM _ WHERE active)
683
+ # # SELECT COUNT(*) FROM _1
409
684
  def with_select(sql)
410
685
  return self if sql.nil?
411
- if cte_names.include?("_")
412
- with_sql(tokens.each_with_object([]) do |token, acc|
413
- v = (token[:t] == "SELECT") ? sql : token[:v]
686
+
687
+ # First CTE is "_", then "_1", "_2", etc.
688
+ current_cte = (cte_depth == 0) ? "_" : "_#{cte_depth}"
689
+
690
+ # Replace :_ with the current CTE name
691
+ processed_sql = sql.gsub(/:_\b/, current_cte)
692
+
693
+ # Wrap current SELECT in numbered CTE
694
+ new_cte = "#{current_cte} AS (\n #{select}\n)"
695
+
696
+ append_cte(new_cte).then do |q|
697
+ # Replace the SELECT token with processed_sql and increment depth
698
+ new_sql = q.tokens.each_with_object([]) do |token, acc|
699
+ v = (token[:t] == "SELECT") ? processed_sql : token[:v]
414
700
  acc << v
415
- end.join)
416
- else
417
- append_cte("_ as (\n #{select}\n)").with_select(sql)
701
+ end.join
702
+ q.deep_dup(sql: new_sql, cte_depth: cte_depth + 1)
418
703
  end
419
704
  end
420
705
 
706
+ # @!group Query Introspection
707
+
708
+ # Returns the SELECT clause of the query.
709
+ #
710
+ # @return [String, nil] the SELECT clause, or nil if not found
711
+ #
712
+ # @example
713
+ # AppQuery("SELECT id, name FROM users").select
714
+ # # => "SELECT id, name FROM users"
421
715
  def select
422
716
  tokens.find { _1[:t] == "SELECT" }&.[](:v)
423
717
  end
424
718
 
719
+ # Checks if the query uses RECURSIVE CTEs.
720
+ #
721
+ # @return [Boolean] true if the query contains WITH RECURSIVE
722
+ #
723
+ # @example
724
+ # AppQuery("WITH RECURSIVE t AS (...) SELECT * FROM t").recursive?
725
+ # # => true
425
726
  def recursive?
426
727
  !!tokens.find { _1[:t] == "RECURSIVE" }
427
728
  end
428
729
 
429
- # example:
430
- # AppQuery("select 1").prepend_cte("foo as(select 1)")
730
+ # @!group CTE Manipulation
731
+
732
+ # Prepends a CTE to the beginning of the WITH clause.
733
+ #
734
+ # If the query has no CTEs, wraps it with WITH. If the query already has
735
+ # CTEs, adds the new CTE at the beginning.
736
+ #
737
+ # @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
738
+ # @return [Q] a new query object with the prepended CTE
739
+ #
740
+ # @example Adding a CTE to a simple query
741
+ # AppQuery("SELECT 1").prepend_cte("foo AS (SELECT 2)")
742
+ # # => "WITH foo AS (SELECT 2) SELECT 1"
743
+ #
744
+ # @example Prepending to existing CTEs
745
+ # AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
746
+ # .prepend_cte("foo AS (SELECT 1)")
747
+ # # => "WITH foo AS (SELECT 1), bar AS (SELECT 2) SELECT * FROM bar"
431
748
  def prepend_cte(cte)
432
749
  # early raise when cte is not valid sql
433
750
  to_append = Tokenizer.tokenize(cte, state: :lex_prepend_cte).then do |tokens|
@@ -448,8 +765,22 @@ module AppQuery
448
765
  end
449
766
  end
450
767
 
451
- # example:
452
- # AppQuery("select 1").append_cte("foo as(select 1)")
768
+ # Appends a CTE to the end of the WITH clause.
769
+ #
770
+ # If the query has no CTEs, wraps it with WITH. If the query already has
771
+ # CTEs, adds the new CTE at the end.
772
+ #
773
+ # @param cte [String] the CTE definition (e.g., "foo AS (SELECT 1)")
774
+ # @return [Q] a new query object with the appended CTE
775
+ #
776
+ # @example Adding a CTE to a simple query
777
+ # AppQuery("SELECT 1").append_cte("foo AS (SELECT 2)")
778
+ # # => "WITH foo AS (SELECT 2) SELECT 1"
779
+ #
780
+ # @example Appending to existing CTEs
781
+ # AppQuery("WITH bar AS (SELECT 2) SELECT * FROM bar")
782
+ # .append_cte("foo AS (SELECT 1)")
783
+ # # => "WITH bar AS (SELECT 2), foo AS (SELECT 1) SELECT * FROM bar"
453
784
  def append_cte(cte)
454
785
  # early raise when cte is not valid sql
455
786
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_append_cte).then do |tokens|
@@ -477,8 +808,17 @@ module AppQuery
477
808
  end
478
809
  end
479
810
 
480
- # Replaces an existing cte.
481
- # Raises `ArgumentError` when cte does not exist.
811
+ # Replaces an existing CTE with a new definition.
812
+ #
813
+ # @param cte [String] the new CTE definition (must have same name as existing CTE)
814
+ # @return [Q] a new query object with the replaced CTE
815
+ #
816
+ # @example
817
+ # AppQuery("WITH foo AS (SELECT 1) SELECT * FROM foo")
818
+ # .replace_cte("foo AS (SELECT 2)")
819
+ # # => "WITH foo AS (SELECT 2) SELECT * FROM foo"
820
+ #
821
+ # @raise [ArgumentError] if the CTE name doesn't exist in the query
482
822
  def replace_cte(cte)
483
823
  add_recursive, to_append = Tokenizer.tokenize(cte, state: :lex_recursive_cte).then do |tokens|
484
824
  [!recursive? && tokens.find { _1[:t] == "RECURSIVE" },
@@ -510,12 +850,27 @@ module AppQuery
510
850
  end.join)
511
851
  end
512
852
 
853
+ # @!endgroup
854
+
855
+ # Returns the SQL string.
856
+ #
857
+ # @return [String] the SQL query string
513
858
  def to_s
514
859
  @sql
515
860
  end
516
861
  end
517
862
  end
518
863
 
864
+ # Convenience method to create a new {AppQuery::Q} instance.
865
+ #
866
+ # Accepts the same arguments as {AppQuery::Q#initialize}.
867
+ #
868
+ # @return [AppQuery::Q] a new query object
869
+ #
870
+ # @example
871
+ # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
872
+ #
873
+ # @see AppQuery::Q#initialize
519
874
  def AppQuery(...)
520
875
  AppQuery::Q.new(...)
521
876
  end