appquery 0.4.0 → 0.6.0.alpha

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
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "app_query/version"
4
+ require_relative "app_query/base_query"
5
+ require_relative "app_query/paginatable"
6
+ require_relative "app_query/mappable"
4
7
  require_relative "app_query/tokenizer"
5
8
  require_relative "app_query/render_helpers"
6
9
  require "active_record"
@@ -22,7 +25,7 @@ require "active_record"
22
25
  # end
23
26
  #
24
27
  # @example CTE manipulation
25
- # AppQuery(<<~SQL).select_all(select: "select * from articles where id = 1")
28
+ # AppQuery(<<~SQL).select_all("select * from articles where id = 1")
26
29
  # WITH articles AS(...)
27
30
  # SELECT * FROM articles
28
31
  # ORDER BY id
@@ -72,21 +75,44 @@ module AppQuery
72
75
 
73
76
  # Loads a query from a file in the configured query path.
74
77
  #
78
+ # When no extension is provided, tries `.sql` first, then `.sql.erb`.
79
+ # Raises an error if both files exist (ambiguous).
80
+ #
75
81
  # @param query_name [String, Symbol] the query name or path (without extension)
76
82
  # @param opts [Hash] additional options passed to {Q#initialize}
77
83
  # @return [Q] a new query object loaded from the file
78
84
  #
79
- # @example Load a simple query
85
+ # @example Load a .sql file
80
86
  # AppQuery[:invoices] # loads app/queries/invoices.sql
81
87
  #
88
+ # @example Load a .sql.erb file (when .sql doesn't exist)
89
+ # AppQuery[:dynamic_report] # loads app/queries/dynamic_report.sql.erb
90
+ #
82
91
  # @example Load from a subdirectory
83
92
  # AppQuery["reports/weekly"] # loads app/queries/reports/weekly.sql
84
93
  #
85
94
  # @example Load with explicit extension
86
95
  # AppQuery["invoices.sql.erb"] # loads app/queries/invoices.sql.erb
96
+ #
97
+ # @raise [Error] if both `.sql` and `.sql.erb` files exist for the same name
87
98
  def self.[](query_name, **opts)
88
- filename = File.extname(query_name.to_s).empty? ? "#{query_name}.sql" : query_name.to_s
89
- full_path = (Pathname.new(configuration.query_path) / filename).expand_path
99
+ base = Pathname.new(configuration.query_path) / query_name.to_s
100
+
101
+ full_path = if File.extname(query_name.to_s).empty?
102
+ sql_path = base.sub_ext(".sql").expand_path
103
+ erb_path = base.sub_ext(".sql.erb").expand_path
104
+ sql_exists = sql_path.exist?
105
+ erb_exists = erb_path.exist?
106
+
107
+ if sql_exists && erb_exists
108
+ raise Error, "Ambiguous query name #{query_name.inspect}: both #{sql_path} and #{erb_path} exist"
109
+ end
110
+
111
+ sql_exists ? sql_path : erb_path
112
+ else
113
+ base.expand_path
114
+ end
115
+
90
116
  Q.new(full_path.read, name: "AppQuery #{query_name}", filename: full_path.to_s, **opts)
91
117
  end
92
118
 
@@ -114,6 +140,48 @@ module AppQuery
114
140
  count
115
141
  end
116
142
 
143
+ private
144
+
145
+ # Override to provide indifferent access (string or symbol keys).
146
+ def hash_rows
147
+ @hash_rows ||= rows.map do |row|
148
+ columns.zip(row).to_h.with_indifferent_access
149
+ end
150
+ end
151
+
152
+ public
153
+
154
+ # Transforms each record in-place using the provided block.
155
+ #
156
+ # @yield [Hash] each record as a hash with indifferent access
157
+ # @yieldreturn [Hash] the transformed record
158
+ # @return [self] the result object for chaining
159
+ #
160
+ # @example Add a computed field
161
+ # result = AppQuery[:users].select_all
162
+ # result.transform! { |r| r.merge("full_name" => "#{r['first']} #{r['last']}") }
163
+ def transform!
164
+ @hash_rows = hash_rows.map { |r| yield(r) } unless empty?
165
+ self
166
+ end
167
+
168
+ # Resolves a cast type value, converting symbols to ActiveRecord types.
169
+ #
170
+ # @param value [Symbol, Object] the cast type (symbol shorthand or type instance)
171
+ # @return [Object] the resolved type instance
172
+ #
173
+ # @example
174
+ # resolve_cast_type(:date) #=> ActiveRecord::Type::Date instance
175
+ # resolve_cast_type(ActiveRecord::Type::Json.new) #=> returns as-is
176
+ def self.resolve_cast_type(value)
177
+ case value
178
+ when Symbol
179
+ ActiveRecord::Type.lookup(value)
180
+ else
181
+ value
182
+ end
183
+ end
184
+
117
185
  def self.from_ar_result(r, cast = nil)
118
186
  if r.empty?
119
187
  EMPTY
@@ -122,7 +190,7 @@ module AppQuery
122
190
  when Array
123
191
  r.columns.zip(cast).to_h
124
192
  when Hash
125
- cast
193
+ cast.transform_keys(&:to_s).transform_values { |v| resolve_cast_type(v) }
126
194
  else
127
195
  {}
128
196
  end
@@ -186,14 +254,14 @@ module AppQuery
186
254
  # @return [String] the SQL string
187
255
  # @return [Array, Hash] bind parameters
188
256
  # @return [Boolean, Hash, Array] casting configuration
189
- attr_reader :name, :sql, :binds, :cast
257
+ attr_reader :sql, :name, :filename, :binds, :cast
190
258
 
191
259
  # Creates a new query object.
192
260
  #
193
261
  # @param sql [String] the SQL query string (may contain ERB)
194
262
  # @param name [String, nil] optional name for logging
195
263
  # @param filename [String, nil] optional filename for ERB error reporting
196
- # @param binds [Array, Hash] bind parameters for the query
264
+ # @param binds [Hash, nil] bind parameters for the query
197
265
  # @param cast [Boolean, Hash, Array] type casting configuration
198
266
  #
199
267
  # @example Simple query
@@ -201,28 +269,38 @@ module AppQuery
201
269
  #
202
270
  # @example With ERB and binds
203
271
  # Q.new("SELECT * FROM users WHERE id = :id", binds: {id: 1})
204
- def initialize(sql, name: nil, filename: nil, binds: [], cast: true)
272
+ def initialize(sql, name: nil, filename: nil, binds: {}, cast: true, cte_depth: 0)
205
273
  @sql = sql
206
274
  @name = name
207
275
  @filename = filename
208
276
  @binds = binds
209
277
  @cast = cast
278
+ @cte_depth = cte_depth
279
+ @binds = binds_with_defaults(sql, binds)
210
280
  end
211
281
 
212
- # @private
213
- def deep_dup
214
- super.send(:reset!)
282
+ attr_reader :cte_depth
283
+
284
+ def to_arel
285
+ if binds.presence
286
+ Arel::Nodes::BoundSqlLiteral.new sql, [], binds
287
+ else
288
+ # TODO: add retryable? available from >=7.1
289
+ Arel::Nodes::SqlLiteral.new(sql)
290
+ end
215
291
  end
216
- private :deep_dup
217
292
 
218
- # @private
219
- def reset!
220
- (instance_variables - %i[@sql @filename @name @binds @cast]).each do
221
- instance_variable_set(_1, nil)
293
+ private def binds_with_defaults(sql, binds)
294
+ if (named_binds = sql.scan(/:(?<!::)([a-zA-Z]\w*)/).flatten.map(&:to_sym).uniq.presence)
295
+ named_binds.zip(Array.new(named_binds.count)).to_h.merge(binds.to_h)
296
+ else
297
+ binds.to_h
222
298
  end
223
- self
224
299
  end
225
- private :reset!
300
+
301
+ def deep_dup(sql: self.sql, name: self.name, filename: self.filename, binds: self.binds.dup, cast: self.cast, cte_depth: self.cte_depth)
302
+ self.class.new(sql, name:, filename:, binds:, cast:, cte_depth:)
303
+ end
226
304
 
227
305
  # @!group Rendering
228
306
 
@@ -267,12 +345,7 @@ module AppQuery
267
345
  sql = to_erb.result(helper.get_binding)
268
346
  collected = helper.collected_binds
269
347
 
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
348
+ with_sql(sql).add_binds(**collected)
276
349
  end
277
350
 
278
351
  def to_erb
@@ -306,56 +379,35 @@ module AppQuery
306
379
 
307
380
  # Executes the query and returns all matching rows.
308
381
  #
309
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
310
382
  # @param select [String, nil] override the SELECT clause
383
+ # @param binds [Hash, nil] bind parameters to add
311
384
  # @param cast [Boolean, Hash, Array] type casting configuration
312
385
  # @return [Result] the query results with optional type casting
313
386
  #
314
- # @example Simple query with positional binds
315
- # AppQuery("SELECT * FROM users WHERE id = $1").select_all(binds: [1])
316
- #
317
- # @example Named binds
387
+ # @example (Named) binds
318
388
  # AppQuery("SELECT * FROM users WHERE id = :id").select_all(binds: {id: 1})
319
389
  #
320
- # @example With type casting
321
- # AppQuery("SELECT created_at FROM users")
322
- # .select_all(cast: {created_at: ActiveRecord::Type::DateTime.new})
390
+ # @example With type casting (shorthand)
391
+ # AppQuery("SELECT published_on FROM articles")
392
+ # .select_all(cast: {"published_on" => :date})
393
+ #
394
+ # @example With type casting (explicit)
395
+ # AppQuery("SELECT metadata FROM products")
396
+ # .select_all(cast: {"metadata" => ActiveRecord::Type::Json.new})
323
397
  #
324
398
  # @example Override SELECT clause
325
- # AppQuery("SELECT * FROM users").select_all(select: "COUNT(*)")
399
+ # AppQuery("SELECT * FROM users").select_all("COUNT(*)")
326
400
  #
327
401
  # @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)
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
402
+ def select_all(s = nil, binds: {}, cast: self.cast)
403
+ add_binds(**binds).with_select(s).render({}).then do |aq|
404
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
405
+ aq.to_arel
342
406
  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
407
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, aq.binds])
408
+ end
409
+ ActiveRecord::Base.connection.select_all(sql, aq.name).then do |result|
410
+ Result.from_ar_result(result, cast)
359
411
  end
360
412
  end
361
413
  rescue NameError => e
@@ -366,23 +418,24 @@ module AppQuery
366
418
 
367
419
  # Executes the query and returns the first row.
368
420
  #
369
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
421
+ # @param binds [Hash, nil] bind parameters to add
370
422
  # @param select [String, nil] override the SELECT clause
371
423
  # @param cast [Boolean, Hash, Array] type casting configuration
372
424
  # @return [Hash, nil] the first row as a hash, or nil if no results
373
425
  #
374
426
  # @example
375
- # AppQuery("SELECT * FROM users WHERE id = $1").select_one(binds: [1])
427
+ # AppQuery("SELECT * FROM users WHERE id = :id").select_one(binds: {id: 1})
376
428
  # # => {"id" => 1, "name" => "Alice"}
377
429
  #
378
430
  # @see #select_all
379
- def select_one(binds: nil, select: nil, cast: self.cast)
380
- select_all(binds:, select:, cast:).first
431
+ def select_one(s = nil, binds: {}, cast: self.cast)
432
+ with_select(s).select_all("SELECT * FROM :_ LIMIT 1", binds:, cast:).first
381
433
  end
434
+ alias_method :first, :select_one
382
435
 
383
436
  # Executes the query and returns the first value of the first row.
384
437
  #
385
- # @param binds [Array, Hash, nil] bind parameters (positional or named)
438
+ # @param binds [Hash, nil] named bind parameters
386
439
  # @param select [String, nil] override the SELECT clause
387
440
  # @param cast [Boolean, Hash, Array] type casting configuration
388
441
  # @return [Object, nil] the first value, or nil if no results
@@ -392,13 +445,129 @@ module AppQuery
392
445
  # # => 42
393
446
  #
394
447
  # @see #select_one
395
- def select_value(binds: nil, select: nil, cast: self.cast)
396
- select_one(binds:, select:, cast:)&.values&.first
448
+ def select_value(s = nil, binds: {}, cast: self.cast)
449
+ select_one(s, binds:, cast:)&.values&.first
450
+ end
451
+
452
+ # Returns the count of rows from the query.
453
+ #
454
+ # Wraps the query in a CTE and selects only the count, which is more
455
+ # efficient than fetching all rows via `select_all.count`.
456
+ #
457
+ # @param s [String, nil] optional SELECT to apply before counting
458
+ # @param binds [Hash, nil] bind parameters to add
459
+ # @return [Integer] the count of rows
460
+ #
461
+ # @example Simple count
462
+ # AppQuery("SELECT * FROM users").count
463
+ # # => 42
464
+ #
465
+ # @example Count with filtering
466
+ # AppQuery("SELECT * FROM users")
467
+ # .with_select("SELECT * FROM :_ WHERE active")
468
+ # .count
469
+ # # => 10
470
+ def count(s = nil, binds: {})
471
+ with_select(s).select_all("SELECT COUNT(*) c FROM :_", binds:).column("c").first
472
+ end
473
+
474
+ # Returns whether any rows exist in the query result.
475
+ #
476
+ # Uses `EXISTS` which stops at the first matching row, making it more
477
+ # efficient than `count > 0` for large result sets.
478
+ #
479
+ # @param s [String, nil] optional SELECT to apply before checking
480
+ # @param binds [Hash, nil] bind parameters to add
481
+ # @return [Boolean] true if at least one row exists
482
+ #
483
+ # @example Check if query has results
484
+ # AppQuery("SELECT * FROM users").any?
485
+ # # => true
486
+ #
487
+ # @example Check with filtering
488
+ # AppQuery("SELECT * FROM users").any?("SELECT * FROM :_ WHERE admin")
489
+ # # => false
490
+ def any?(s = nil, binds: {})
491
+ with_select(s).select_all("SELECT EXISTS(SELECT 1 FROM :_) e", binds:).column("e").first
492
+ end
493
+
494
+ # Returns whether no rows exist in the query result.
495
+ #
496
+ # Inverse of {#any?}. Uses `EXISTS` for efficiency.
497
+ #
498
+ # @param s [String, nil] optional SELECT to apply before checking
499
+ # @param binds [Hash, nil] bind parameters to add
500
+ # @return [Boolean] true if no rows exist
501
+ #
502
+ # @example Check if query is empty
503
+ # AppQuery("SELECT * FROM users WHERE admin").none?
504
+ # # => true
505
+ def none?(s = nil, binds: {})
506
+ !any?(s, binds:)
507
+ end
508
+
509
+ # Returns an array of values for a single column.
510
+ #
511
+ # Wraps the query in a CTE and selects only the specified column, which is
512
+ # more efficient than fetching all columns via `select_all.column(name)`.
513
+ # The column name is safely quoted, making this method safe for user input.
514
+ #
515
+ # @param c [String, Symbol] the column name to extract
516
+ # @param s [String, nil] optional SELECT to apply before extracting
517
+ # @param binds [Hash, nil] bind parameters to add
518
+ # @return [Array] the column values
519
+ #
520
+ # @example Extract a single column
521
+ # AppQuery("SELECT id, name FROM users").column(:name)
522
+ # # => ["Alice", "Bob", "Charlie"]
523
+ #
524
+ # @example With additional filtering
525
+ # AppQuery("SELECT * FROM users").column(:email, "SELECT * FROM :_ WHERE active")
526
+ # # => ["alice@example.com", "bob@example.com"]
527
+ def column(c, s = nil, binds: {})
528
+ quoted_column = ActiveRecord::Base.connection.quote_column_name(c)
529
+ with_select(s).select_all("SELECT #{quoted_column} AS column FROM :_", binds:).column("column")
530
+ end
531
+
532
+ # Returns an array of id values from the query.
533
+ #
534
+ # Convenience method equivalent to `column(:id)`. More efficient than
535
+ # fetching all columns via `select_all.column("id")`.
536
+ #
537
+ # @param s [String, nil] optional SELECT to apply before extracting
538
+ # @param binds [Hash, nil] bind parameters to add
539
+ # @return [Array] the id values
540
+ #
541
+ # @example Get all user IDs
542
+ # AppQuery("SELECT * FROM users").ids
543
+ # # => [1, 2, 3]
544
+ #
545
+ # @example With filtering
546
+ # AppQuery("SELECT * FROM users").ids("SELECT * FROM :_ WHERE active")
547
+ # # => [1, 3]
548
+ def ids(s = nil, binds: {})
549
+ column(:id, s, binds:)
550
+ end
551
+
552
+ # Executes the query and returns results as an Array of Hashes.
553
+ #
554
+ # Shorthand for `select_all(...).entries`. Accepts the same arguments as
555
+ # {#select_all}.
556
+ #
557
+ # @return [Array<Hash>] the query results as an array
558
+ #
559
+ # @example
560
+ # AppQuery("SELECT * FROM users").entries
561
+ # # => [{"id" => 1, "name" => "Alice"}, {"id" => 2, "name" => "Bob"}]
562
+ #
563
+ # @see #select_all
564
+ def entries(...)
565
+ select_all(...).entries
397
566
  end
398
567
 
399
568
  # Executes an INSERT query.
400
569
  #
401
- # @param binds [Array, Hash] bind parameters for the query
570
+ # @param binds [Hash, nil] bind parameters for the query
402
571
  # @param returning [String, nil] columns to return (Rails 7.1+ only)
403
572
  # @return [Integer, Object] the inserted ID or returning value
404
573
  #
@@ -419,30 +588,22 @@ module AppQuery
419
588
  #
420
589
  # @raise [UnrenderedQueryError] if the query contains unrendered ERB
421
590
  # @raise [ArgumentError] if returning is used with Rails < 7.1
422
- def insert(binds: [], returning: nil)
591
+ def insert(binds: {}, returning: nil)
423
592
  # ActiveRecord::Base.connection.insert(sql, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds, returning: nil)
424
593
  if returning && ActiveRecord::VERSION::STRING.to_f < 7.1
425
594
  raise ArgumentError, "The 'returning' option requires Rails 7.1+. Current version: #{ActiveRecord::VERSION::STRING}"
426
595
  end
427
596
 
428
- binds = binds.presence || @binds
429
- render({}).then do |aq|
430
- if binds.is_a?(Hash)
431
- sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
432
- Arel.sql(aq.to_s, **binds)
433
- else
434
- ActiveRecord::Base.sanitize_sql_array([aq.to_s, **binds])
435
- end
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)
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:)
597
+ with_binds(**binds).render({}).then do |aq|
598
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
599
+ aq.to_arel
600
+ else
601
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
602
+ end
603
+ if ActiveRecord::VERSION::STRING.to_f >= 7.1
604
+ ActiveRecord::Base.connection.insert(sql, name, returning:)
444
605
  else
445
- ActiveRecord::Base.connection.insert(aq.to_s, name, _pk = nil, _id_value = nil, _sequence_name = nil, binds)
606
+ ActiveRecord::Base.connection.insert(sql, name)
446
607
  end
447
608
  end
448
609
  rescue NameError => e
@@ -453,7 +614,7 @@ module AppQuery
453
614
 
454
615
  # Executes an UPDATE query.
455
616
  #
456
- # @param binds [Array, Hash] bind parameters for the query
617
+ # @param binds [Hash, nil] bind parameters for the query
457
618
  # @return [Integer] the number of affected rows
458
619
  #
459
620
  # @example With named binds
@@ -465,19 +626,14 @@ module AppQuery
465
626
  # .update(binds: ["New Title", 1])
466
627
  #
467
628
  # @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)
629
+ def update(binds: {})
630
+ with_binds(**binds).render({}).then do |aq|
631
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
632
+ aq.to_arel
478
633
  else
479
- ActiveRecord::Base.connection.update(aq.to_s, name, binds)
634
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
480
635
  end
636
+ ActiveRecord::Base.connection.update(sql, name)
481
637
  end
482
638
  rescue NameError => e
483
639
  raise e unless e.instance_of?(NameError)
@@ -486,7 +642,7 @@ module AppQuery
486
642
 
487
643
  # Executes a DELETE query.
488
644
  #
489
- # @param binds [Array, Hash] bind parameters for the query
645
+ # @param binds [Hash, nil] bind parameters for the query
490
646
  # @return [Integer] the number of deleted rows
491
647
  #
492
648
  # @example With named binds
@@ -496,19 +652,14 @@ module AppQuery
496
652
  # AppQuery("DELETE FROM videos WHERE id = $1").delete(binds: [1])
497
653
  #
498
654
  # @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)
655
+ def delete(binds: {})
656
+ with_binds(**binds).render({}).then do |aq|
657
+ sql = if ActiveRecord::VERSION::STRING.to_f >= 7.1
658
+ aq.to_arel
509
659
  else
510
- ActiveRecord::Base.connection.delete(aq.to_s, name, binds)
660
+ ActiveRecord::Base.sanitize_sql_array([aq.to_s, **aq.binds])
511
661
  end
662
+ ActiveRecord::Base.connection.delete(sql, name)
512
663
  end
513
664
  rescue NameError => e
514
665
  raise e unless e.instance_of?(NameError)
@@ -547,16 +698,29 @@ module AppQuery
547
698
 
548
699
  # Returns a new query with different bind parameters.
549
700
  #
550
- # @param binds [Array, Hash] the new bind parameters
551
- # @return [Q] a new query object with the specified binds
701
+ # @param binds [Hash, nil] the bind parameters
702
+ # @return [Q] a new query object with the binds replaced
552
703
  #
553
704
  # @example
554
- # query = AppQuery("SELECT * FROM users WHERE id = :id")
555
- # query.with_binds(id: 1).select_one
556
- def with_binds(binds)
557
- deep_dup.tap do
558
- _1.instance_variable_set(:@binds, binds)
559
- end
705
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
706
+ # query.with_binds(bar: 2).binds
707
+ # # => {foo: nil, bar: 2}
708
+ def with_binds(**binds)
709
+ deep_dup(binds:)
710
+ end
711
+ alias_method :replace_binds, :with_binds
712
+
713
+ # Returns a new query with binds added.
714
+ #
715
+ # @param binds [Hash, nil] the bind parameters to add
716
+ # @return [Q] a new query object with the added binds
717
+ #
718
+ # @example
719
+ # query = AppQuery("SELECT :foo, :bar", binds: {foo: 1})
720
+ # query.add_binds(bar: 2).binds
721
+ # # => {foo: 1, bar: 2}
722
+ def add_binds(**binds)
723
+ deep_dup(binds: self.binds.merge(binds))
560
724
  end
561
725
 
562
726
  # Returns a new query with different cast settings.
@@ -568,9 +732,7 @@ module AppQuery
568
732
  # query = AppQuery("SELECT created_at FROM users")
569
733
  # query.with_cast(false).select_all # disable casting
570
734
  def with_cast(cast)
571
- deep_dup.tap do
572
- _1.instance_variable_set(:@cast, cast)
573
- end
735
+ deep_dup(cast:)
574
736
  end
575
737
 
576
738
  # Returns a new query with different SQL.
@@ -578,31 +740,48 @@ module AppQuery
578
740
  # @param sql [String] the new SQL string
579
741
  # @return [Q] a new query object with the specified SQL
580
742
  def with_sql(sql)
581
- deep_dup.tap do
582
- _1.instance_variable_set(:@sql, sql)
583
- end
743
+ deep_dup(sql:)
584
744
  end
585
745
 
586
746
  # Returns a new query with a modified SELECT statement.
587
747
  #
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.
748
+ # Wraps the current SELECT in a numbered CTE and applies the new SELECT.
749
+ # CTEs are named `_`, `_1`, `_2`, etc. Use `:_` in the new SELECT to
750
+ # reference the previous result.
590
751
  #
591
752
  # @param sql [String, nil] the new SELECT statement (nil returns self)
592
753
  # @return [Q] a new query object with the modified SELECT
593
754
  #
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 _"
755
+ # @example Single transformation
756
+ # AppQuery("SELECT * FROM users").with_select("SELECT COUNT(*) FROM :_")
757
+ # # => "WITH _ AS (\n SELECT * FROM users\n)\nSELECT COUNT(*) FROM _"
758
+ #
759
+ # @example Chained transformations
760
+ # AppQuery("SELECT * FROM users")
761
+ # .with_select("SELECT * FROM :_ WHERE active")
762
+ # .with_select("SELECT COUNT(*) FROM :_")
763
+ # # => WITH _ AS (SELECT * FROM users),
764
+ # # _1 AS (SELECT * FROM _ WHERE active)
765
+ # # SELECT COUNT(*) FROM _1
597
766
  def with_select(sql)
598
767
  return self if sql.nil?
599
- if cte_names.include?("_")
600
- with_sql(tokens.each_with_object([]) do |token, acc|
601
- v = (token[:t] == "SELECT") ? sql : token[:v]
768
+
769
+ # First CTE is "_", then "_1", "_2", etc.
770
+ current_cte = (cte_depth == 0) ? "_" : "_#{cte_depth}"
771
+
772
+ # Replace :_ with the current CTE name
773
+ processed_sql = sql.gsub(/:_\b/, current_cte)
774
+
775
+ # Wrap current SELECT in numbered CTE
776
+ new_cte = "#{current_cte} AS (\n #{select}\n)"
777
+
778
+ append_cte(new_cte).then do |q|
779
+ # Replace the SELECT token with processed_sql and increment depth
780
+ new_sql = q.tokens.each_with_object([]) do |token, acc|
781
+ v = (token[:t] == "SELECT") ? processed_sql : token[:v]
602
782
  acc << v
603
- end.join)
604
- else
605
- append_cte("_ as (\n #{select}\n)").with_select(sql)
783
+ end.join
784
+ q.deep_dup(sql: new_sql, cte_depth: cte_depth + 1)
606
785
  end
607
786
  end
608
787
 
data/mise.toml CHANGED
@@ -3,4 +3,4 @@ ruby = "3.4"
3
3
 
4
4
  [env]
5
5
  _.path = ["bin"]
6
- PROJECT_ROOT = "{{config_root}}"
6
+ PROJECT_ROOT = "{{config_root}}"