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.
@@ -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.
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module AppQuery
4
- VERSION = "0.4.0.rc1"
4
+ VERSION = "0.5.0"
5
5
  end