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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module AppQuery
|
|
4
|
+
# Provides helper methods for rendering SQL templates in ERB.
|
|
5
|
+
#
|
|
6
|
+
# These helpers are available within ERB templates when using {Q#render}.
|
|
7
|
+
# They provide safe SQL construction with parameterized queries.
|
|
8
|
+
#
|
|
9
|
+
# @note These methods require +@collected_binds+ (Hash) and
|
|
10
|
+
# +@placeholder_counter+ (Integer) instance variables to be initialized
|
|
11
|
+
# in the including context.
|
|
12
|
+
#
|
|
13
|
+
# @example Basic usage in an ERB template
|
|
14
|
+
# SELECT * FROM users WHERE name = <%= bind(name) %>
|
|
15
|
+
# <%= order_by(sorting) %>
|
|
16
|
+
#
|
|
17
|
+
# @see Q#render
|
|
18
|
+
module RenderHelpers
|
|
19
|
+
# Quotes a value for safe inclusion in SQL using ActiveRecord's quoting.
|
|
20
|
+
#
|
|
21
|
+
# Use this helper when you need to embed a literal value directly in SQL
|
|
22
|
+
# rather than using a bind parameter. This is useful for values that need
|
|
23
|
+
# to be visible in the SQL string itself.
|
|
24
|
+
#
|
|
25
|
+
# @param value [Object] the value to quote (typically a String or Number)
|
|
26
|
+
# @return [String] the SQL-safe quoted value
|
|
27
|
+
#
|
|
28
|
+
# @example Quoting a string with special characters
|
|
29
|
+
# quote("Let's learn SQL!") #=> "'Let''s learn SQL!'"
|
|
30
|
+
#
|
|
31
|
+
# @example In an ERB template
|
|
32
|
+
# INSERT INTO articles (title) VALUES(<%= quote(title) %>)
|
|
33
|
+
#
|
|
34
|
+
# @note Prefer {#bind} for parameterized queries when possible, as it
|
|
35
|
+
# provides better security and query plan caching.
|
|
36
|
+
#
|
|
37
|
+
# @see #bind
|
|
38
|
+
def quote(value)
|
|
39
|
+
ActiveRecord::Base.connection.quote(value)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Creates a named bind parameter placeholder and collects the value.
|
|
43
|
+
#
|
|
44
|
+
# This is the preferred way to include dynamic values in SQL queries.
|
|
45
|
+
# The value is collected internally and a placeholder (e.g., +:b1+) is
|
|
46
|
+
# returned for insertion into the SQL template.
|
|
47
|
+
#
|
|
48
|
+
# @param value [Object] the value to bind (any type supported by ActiveRecord)
|
|
49
|
+
# @return [String] the placeholder string (e.g., ":b1", ":b2", etc.)
|
|
50
|
+
#
|
|
51
|
+
# @example Basic bind usage
|
|
52
|
+
# bind("Some title") #=> ":b1" (with "Some title" added to collected binds)
|
|
53
|
+
#
|
|
54
|
+
# @example In an ERB template
|
|
55
|
+
# SELECT * FROM videos WHERE title = <%= bind(title) %>
|
|
56
|
+
# # Results in: SELECT * FROM videos WHERE title = :b1
|
|
57
|
+
# # With binds: {b1: <value of title>}
|
|
58
|
+
#
|
|
59
|
+
# @example Multiple binds
|
|
60
|
+
# SELECT * FROM t WHERE a = <%= bind(val1) %> AND b = <%= bind(val2) %>
|
|
61
|
+
# # Results in: SELECT * FROM t WHERE a = :b1 AND b = :b2
|
|
62
|
+
#
|
|
63
|
+
# @see #values for binding multiple values in a VALUES clause
|
|
64
|
+
# @see #quote for embedding quoted literals directly
|
|
65
|
+
def bind(value)
|
|
66
|
+
collect_bind(value)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Generates a SQL VALUES clause from a collection with automatic bind parameters.
|
|
70
|
+
#
|
|
71
|
+
# Supports three input formats:
|
|
72
|
+
# 1. *Array of Arrays* - Simple row data without column names
|
|
73
|
+
# 2. *Array of Hashes* - Row data with automatic column name extraction
|
|
74
|
+
# 3. *Collection with block* - Custom value transformation per row
|
|
75
|
+
#
|
|
76
|
+
# @param coll [Array<Array>, Array<Hash>] the collection of row data
|
|
77
|
+
# @param skip_columns [Boolean] when true, omits the column name list
|
|
78
|
+
# (useful for UNION ALL or CTEs where column names are defined elsewhere)
|
|
79
|
+
# @yield [item] optional block to transform each item into an array of SQL expressions
|
|
80
|
+
# @yieldparam item [Object] each item from the collection
|
|
81
|
+
# @yieldreturn [Array<String>] array of SQL expressions for the row values
|
|
82
|
+
# @return [String] the complete VALUES clause SQL fragment
|
|
83
|
+
#
|
|
84
|
+
# @example Array of arrays (simplest form)
|
|
85
|
+
# values([[1, "Title A"], [2, "Title B"]])
|
|
86
|
+
# #=> "VALUES (:b1, :b2),\n(:b3, :b4)"
|
|
87
|
+
# # binds: {b1: 1, b2: "Title A", b3: 2, b4: "Title B"}
|
|
88
|
+
#
|
|
89
|
+
# @example Array of hashes (with automatic column names)
|
|
90
|
+
# values([{id: 1, title: "Video A"}, {id: 2, title: "Video B"}])
|
|
91
|
+
# #=> "(id, title) VALUES (:b1, :b2),\n(:b3, :b4)"
|
|
92
|
+
#
|
|
93
|
+
# @example Hashes with mixed keys (NULL for missing values)
|
|
94
|
+
# values([{title: "A"}, {title: "B", published_on: "2024-01-01"}])
|
|
95
|
+
# #=> "(title, published_on) VALUES (:b1, NULL),\n(:b2, :b3)"
|
|
96
|
+
#
|
|
97
|
+
# @example Skip columns for UNION ALL
|
|
98
|
+
# SELECT id FROM articles UNION ALL <%= values([{id: 1}], skip_columns: true) %>
|
|
99
|
+
# #=> "SELECT id FROM articles UNION ALL VALUES (:b1)"
|
|
100
|
+
#
|
|
101
|
+
# @example With block for custom expressions
|
|
102
|
+
# values(videos) { |v| [bind(v[:id]), quote(v[:title]), 'now()'] }
|
|
103
|
+
# #=> "VALUES (:b1, 'Escaped Title', now()), (:b2, 'Other', now())"
|
|
104
|
+
#
|
|
105
|
+
# @example In a CTE
|
|
106
|
+
# WITH articles(id, title) AS (<%= values(data) %>)
|
|
107
|
+
# SELECT * FROM articles
|
|
108
|
+
#
|
|
109
|
+
# @see #bind for individual value binding
|
|
110
|
+
# @see #quote for quoting literal values
|
|
111
|
+
#
|
|
112
|
+
# TODO: Add types: parameter to cast bind placeholders (needed for UNION ALL
|
|
113
|
+
# where PG can't infer types). E.g. values([[1]], types: [:integer])
|
|
114
|
+
# would generate VALUES (:b1::integer)
|
|
115
|
+
def values(coll, skip_columns: false, &block)
|
|
116
|
+
first = coll.first
|
|
117
|
+
|
|
118
|
+
# For hash collections, collect all unique keys
|
|
119
|
+
if first.is_a?(Hash) && !block
|
|
120
|
+
all_keys = coll.flat_map(&:keys).uniq
|
|
121
|
+
|
|
122
|
+
rows = coll.map do |row|
|
|
123
|
+
vals = all_keys.map { |k| row.key?(k) ? collect_bind(row[k]) : "NULL" }
|
|
124
|
+
"(#{vals.join(", ")})"
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
columns = skip_columns ? "" : "(#{all_keys.join(", ")}) "
|
|
128
|
+
"#{columns}VALUES #{rows.join(",\n")}"
|
|
129
|
+
else
|
|
130
|
+
# Arrays or block - current behavior
|
|
131
|
+
rows = coll.map do |item|
|
|
132
|
+
vals = if block
|
|
133
|
+
block.call(item)
|
|
134
|
+
elsif item.is_a?(Array)
|
|
135
|
+
item.map { |v| collect_bind(v) }
|
|
136
|
+
else
|
|
137
|
+
[collect_bind(item)]
|
|
138
|
+
end
|
|
139
|
+
"(#{vals.join(", ")})"
|
|
140
|
+
end
|
|
141
|
+
"VALUES #{rows.join(",\n")}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Generates an ORDER BY clause from a hash of column directions.
|
|
146
|
+
#
|
|
147
|
+
# Converts a hash of column names and sort directions into a valid
|
|
148
|
+
# SQL ORDER BY clause.
|
|
149
|
+
#
|
|
150
|
+
# @param hash [Hash{Symbol, String => Symbol, String, nil}] column names mapped to
|
|
151
|
+
# sort directions (+:asc+, +:desc+, +"ASC"+, +"DESC"+) or nil for default
|
|
152
|
+
# @return [String] the complete ORDER BY clause
|
|
153
|
+
#
|
|
154
|
+
# @example Basic ordering
|
|
155
|
+
# order_by(year: :desc, month: :desc)
|
|
156
|
+
# #=> "ORDER BY year DESC, month DESC"
|
|
157
|
+
#
|
|
158
|
+
# @example Column without direction (uses database default)
|
|
159
|
+
# order_by(id: nil)
|
|
160
|
+
# #=> "ORDER BY id"
|
|
161
|
+
#
|
|
162
|
+
# @example SQL literal
|
|
163
|
+
# order_by("RANDOM()")
|
|
164
|
+
# #=> "ORDER BY RANDOM()"
|
|
165
|
+
#
|
|
166
|
+
# @example In an ERB template with a variable
|
|
167
|
+
# SELECT * FROM articles
|
|
168
|
+
# <%= order_by(ordering) %>
|
|
169
|
+
#
|
|
170
|
+
# @example Making it optional (when ordering may not be provided)
|
|
171
|
+
# <%= @order.presence && order_by(ordering) %>
|
|
172
|
+
#
|
|
173
|
+
# @example With default fallback
|
|
174
|
+
# <%= order_by(@order.presence || {id: :desc}) %>
|
|
175
|
+
#
|
|
176
|
+
# @raise [ArgumentError] if hash is blank (nil, empty, or not present)
|
|
177
|
+
#
|
|
178
|
+
# @note The hash must not be blank. Use conditional ERB for optional ordering.
|
|
179
|
+
def order_by(order)
|
|
180
|
+
usage = <<~USAGE
|
|
181
|
+
Provide columns to sort by, e.g. order_by(id: :asc), or SQL-literal, e.g. order_by("RANDOM()") (got #{order.inspect}).
|
|
182
|
+
USAGE
|
|
183
|
+
raise ArgumentError, usage unless order.present?
|
|
184
|
+
|
|
185
|
+
case order
|
|
186
|
+
when String then "ORDER BY #{order}"
|
|
187
|
+
when Hash
|
|
188
|
+
"ORDER BY " + order.map do |k, v|
|
|
189
|
+
v.nil? ? k : [k, v.upcase].join(" ")
|
|
190
|
+
end.join(", ")
|
|
191
|
+
else
|
|
192
|
+
raise ArgumentError, usage
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Generates a LIMIT/OFFSET clause for pagination.
|
|
197
|
+
#
|
|
198
|
+
# @param page [Integer] the page number (1-indexed)
|
|
199
|
+
# @param per_page [Integer] the number of items per page
|
|
200
|
+
# @return [String] the LIMIT/OFFSET clause
|
|
201
|
+
#
|
|
202
|
+
# @example Basic pagination
|
|
203
|
+
# paginate(page: 1, per_page: 25)
|
|
204
|
+
# #=> "LIMIT 25 OFFSET 0"
|
|
205
|
+
#
|
|
206
|
+
# @example Second page
|
|
207
|
+
# paginate(page: 2, per_page: 25)
|
|
208
|
+
# #=> "LIMIT 25 OFFSET 25"
|
|
209
|
+
#
|
|
210
|
+
# @example In an ERB template
|
|
211
|
+
# SELECT * FROM articles
|
|
212
|
+
# ORDER BY created_at DESC
|
|
213
|
+
# <%= paginate(page: page, per_page: per_page) %>
|
|
214
|
+
#
|
|
215
|
+
# @raise [ArgumentError] if page or per_page is not a positive integer
|
|
216
|
+
def paginate(page:, per_page:)
|
|
217
|
+
raise ArgumentError, "page must be a positive integer (got #{page.inspect})" unless page.is_a?(Integer) && page > 0
|
|
218
|
+
raise ArgumentError, "per_page must be a positive integer (got #{per_page.inspect})" unless per_page.is_a?(Integer) && per_page > 0
|
|
219
|
+
|
|
220
|
+
offset = (page - 1) * per_page
|
|
221
|
+
"LIMIT #{per_page} OFFSET #{offset}"
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# Collects a value as a bind parameter and returns the placeholder name.
|
|
227
|
+
#
|
|
228
|
+
# This is the internal mechanism used by {#bind} and {#values} to
|
|
229
|
+
# accumulate bind values during template rendering. Each call generates
|
|
230
|
+
# a unique placeholder name (b1, b2, b3, ...).
|
|
231
|
+
#
|
|
232
|
+
# @api private
|
|
233
|
+
# @param value [Object] the value to collect
|
|
234
|
+
# @return [String] the placeholder string with colon prefix (e.g., ":b1")
|
|
235
|
+
def collect_bind(value)
|
|
236
|
+
@placeholder_counter += 1
|
|
237
|
+
key = :"b#{@placeholder_counter}"
|
|
238
|
+
@collected_binds[key] = value
|
|
239
|
+
":#{key}"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -5,6 +5,10 @@ module AppQuery
|
|
|
5
5
|
self.class.default_binds
|
|
6
6
|
end
|
|
7
7
|
|
|
8
|
+
def default_vars
|
|
9
|
+
self.class.default_vars
|
|
10
|
+
end
|
|
11
|
+
|
|
8
12
|
def expand_select(s)
|
|
9
13
|
s.gsub(":cte", cte_name)
|
|
10
14
|
end
|
|
@@ -24,7 +28,7 @@ module AppQuery
|
|
|
24
28
|
def described_query(select: nil)
|
|
25
29
|
select ||= "SELECT * FROM :cte" if cte_name
|
|
26
30
|
select &&= expand_select(select) if cte_name
|
|
27
|
-
self.class.described_query.with_select(select)
|
|
31
|
+
self.class.described_query.render(default_vars).with_select(select)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def cte_name
|
|
@@ -72,6 +76,10 @@ module AppQuery
|
|
|
72
76
|
metadatas.find { _1[:default_binds] }&.[](:default_binds) || []
|
|
73
77
|
end
|
|
74
78
|
|
|
79
|
+
def default_vars
|
|
80
|
+
metadatas.find { _1[:default_vars] }&.[](:default_vars) || {}
|
|
81
|
+
end
|
|
82
|
+
|
|
75
83
|
def included(klass)
|
|
76
84
|
super
|
|
77
85
|
# Inject classmethods into the group.
|
data/lib/app_query/tokenizer.rb
CHANGED
|
@@ -95,8 +95,9 @@ module AppQuery
|
|
|
95
95
|
if eos?
|
|
96
96
|
emit_token "COMMA", v: ","
|
|
97
97
|
emit_token "WHITESPACE", v: "\n"
|
|
98
|
+
elsif match?(/\s/)
|
|
99
|
+
push_return :lex_prepend_cte, :lex_whitespace
|
|
98
100
|
else
|
|
99
|
-
# emit_token "WHITESPACE", v: " "
|
|
100
101
|
push_return :lex_prepend_cte, :lex_recursive_cte
|
|
101
102
|
end
|
|
102
103
|
end
|
data/lib/app_query/version.rb
CHANGED