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.
- checksums.yaml +4 -4
- data/.irbrc +9 -0
- data/.yardopts +11 -0
- data/CHANGELOG.md +53 -2
- data/README.md +80 -307
- data/lib/app_query/render_helpers.rb +242 -0
- 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 +565 -210
- data/mise.toml +1 -1
- data/rakelib/yard.rake +17 -0
- metadata +5 -2
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
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
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
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
112
|
-
|
|
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
|
|
116
|
-
(
|
|
117
|
-
|
|
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
|
|
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).
|
|
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(
|
|
251
|
-
with_select(
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
287
|
-
|
|
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
|
-
|
|
291
|
-
|
|
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
|
-
#
|
|
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)
|
|
494
|
+
# INSERT INTO videos(title, created_at, updated_at) VALUES($1, now(), now())
|
|
297
495
|
# SQL
|
|
298
496
|
#
|
|
299
|
-
#
|
|
300
|
-
#
|
|
301
|
-
#
|
|
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
|
-
|
|
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
|
|
312
|
-
|
|
313
|
-
|
|
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.
|
|
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
|
-
#
|
|
338
|
-
#
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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.
|
|
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
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
430
|
-
|
|
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
|
-
#
|
|
452
|
-
#
|
|
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
|
|
481
|
-
#
|
|
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
|