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.
- checksums.yaml +4 -4
- data/.irbrc +20 -0
- data/.yardopts +11 -0
- data/Appraisals +14 -4
- data/CHANGELOG.md +7 -2
- data/README.md +45 -253
- data/lib/app_query/render_helpers.rb +204 -0
- data/lib/app_query/version.rb +1 -1
- data/lib/app_query.rb +478 -40
- data/rakelib/gem.rake +25 -0
- data/rakelib/yard.rake +17 -0
- metadata +7 -2
data/lib/app_query/version.rb
CHANGED
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
-
|
|
227
|
+
# @!group Rendering
|
|
228
|
+
|
|
229
|
+
# Renders the ERB template with the given variables.
|
|
230
|
+
#
|
|
231
|
+
# Processes ERB tags in the SQL and collects any bind parameters created
|
|
232
|
+
# by helpers like {RenderHelpers#bind} and {RenderHelpers#values}.
|
|
233
|
+
#
|
|
234
|
+
# @param vars [Hash] variables to make available in the ERB template
|
|
235
|
+
# @return [Q] a new query object with rendered SQL and collected binds
|
|
236
|
+
#
|
|
237
|
+
# @example Rendering with variables
|
|
238
|
+
# AppQuery("SELECT * FROM users WHERE name = <%= bind(name) %>")
|
|
239
|
+
# .render(name: "Alice")
|
|
240
|
+
# # => Q with SQL: "SELECT * FROM users WHERE name = :b1"
|
|
241
|
+
# # and binds: {b1: "Alice"}
|
|
242
|
+
#
|
|
243
|
+
# @example Using instance variables
|
|
244
|
+
# AppQuery("SELECT * FROM users WHERE active = <%= @active %>")
|
|
245
|
+
# .render(active: true)
|
|
246
|
+
#
|
|
247
|
+
# @example vars are available as local and instance variable.
|
|
248
|
+
# # This fails as `ordering` is not provided:
|
|
249
|
+
# AppQuery(<<~SQL).render
|
|
250
|
+
# SELECT * FROM articles
|
|
251
|
+
# <%= order_by(ordering) %>
|
|
252
|
+
# SQL
|
|
253
|
+
#
|
|
254
|
+
# # ...but this query works without `ordering` being passed to render:
|
|
255
|
+
# AppQuery(<<~SQL).render
|
|
256
|
+
# SELECT * FROM articles
|
|
257
|
+
# <%= @ordering.presence && order_by(ordering) %>
|
|
258
|
+
# SQL
|
|
259
|
+
# # NOTE that `@ordering.present? && ...` would render as `false`.
|
|
260
|
+
# # Use `@ordering.presence` instead.
|
|
261
|
+
#
|
|
262
|
+
#
|
|
263
|
+
# @see RenderHelpers for available helper methods in templates
|
|
264
|
+
def render(vars = {})
|
|
124
265
|
vars ||= {}
|
|
125
|
-
|
|
266
|
+
helper = render_helper(vars)
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
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::
|
|
177
|
-
|
|
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.
|
|
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
|
-
|
|
192
|
-
|
|
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
|
-
|
|
196
|
-
|
|
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
|
-
#
|
|
250
|
-
|
|
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
|
-
#
|
|
272
|
-
#
|
|
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
|
|
301
|
-
#
|
|
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
|