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.
- checksums.yaml +4 -4
- data/.irbrc +9 -0
- data/CHANGELOG.md +123 -0
- data/README.md +84 -92
- data/Rakefile +10 -0
- data/lib/app_query/base_query.rb +182 -0
- data/lib/app_query/mappable.rb +86 -0
- data/lib/app_query/paginatable.rb +152 -0
- data/lib/app_query/render_helpers.rb +49 -11
- data/lib/app_query/rspec/helpers.rb +9 -1
- data/lib/app_query/tokenizer.rb +2 -1
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +317 -138
- data/mise.toml +1 -1
- data/rakelib/gem.rake +22 -22
- metadata +6 -3
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(
|
|
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
|
|
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
|
-
|
|
89
|
-
|
|
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, :
|
|
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 [
|
|
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:
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
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
|
|
322
|
-
# .select_all(cast: {
|
|
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(
|
|
399
|
+
# AppQuery("SELECT * FROM users").select_all("COUNT(*)")
|
|
326
400
|
#
|
|
327
401
|
# @raise [UnrenderedQueryError] if the query contains unrendered ERB
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 [
|
|
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 =
|
|
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(
|
|
380
|
-
select_all(binds:,
|
|
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 [
|
|
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(
|
|
396
|
-
select_one(binds:,
|
|
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 [
|
|
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:
|
|
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
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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(
|
|
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 [
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
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.
|
|
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 [
|
|
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
|
|
501
|
-
|
|
502
|
-
|
|
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.
|
|
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 [
|
|
551
|
-
# @return [Q] a new query object with the
|
|
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
|
|
555
|
-
# query.with_binds(
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
|
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
|
|
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
|
-
#
|
|
589
|
-
#
|
|
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
|
|
596
|
-
# # => "WITH _ AS (\n SELECT
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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