clickhouse-sql 0.1.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 +7 -0
- data/CHANGELOG.md +9 -0
- data/LICENSE +25 -0
- data/README.md +426 -0
- data/lib/clickhouse/http_client/database.rb +42 -0
- data/lib/clickhouse/http_client/error.rb +18 -0
- data/lib/clickhouse/http_client/parameter_serializer.rb +127 -0
- data/lib/clickhouse/http_client/query.rb +54 -0
- data/lib/clickhouse/http_client/response_formatter.rb +78 -0
- data/lib/clickhouse/http_client/value_normalization.rb +29 -0
- data/lib/clickhouse/http_client.rb +288 -0
- data/lib/clickhouse/sql/bind_index_manager.rb +26 -0
- data/lib/clickhouse/sql/error.rb +13 -0
- data/lib/clickhouse/sql/fragment.rb +171 -0
- data/lib/clickhouse/sql/join.rb +70 -0
- data/lib/clickhouse/sql/part.rb +37 -0
- data/lib/clickhouse/sql/placeholder.rb +41 -0
- data/lib/clickhouse/sql/placeholder_type.rb +219 -0
- data/lib/clickhouse/sql/query.rb +139 -0
- data/lib/clickhouse/sql/raw.rb +53 -0
- data/lib/clickhouse/sql/select_item.rb +48 -0
- data/lib/clickhouse/sql/select_query.rb +393 -0
- data/lib/clickhouse/sql/setting.rb +89 -0
- data/lib/clickhouse/sql/table.rb +97 -0
- data/lib/clickhouse/sql/version.rb +7 -0
- data/lib/clickhouse/sql.rb +562 -0
- data/lib/clickhouse-sql.rb +5 -0
- metadata +133 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
|
|
5
|
+
require_relative "sql/error"
|
|
6
|
+
require_relative "sql/bind_index_manager"
|
|
7
|
+
require_relative "sql/part"
|
|
8
|
+
require_relative "sql/query"
|
|
9
|
+
require_relative "sql/placeholder"
|
|
10
|
+
require_relative "sql/select_item"
|
|
11
|
+
require_relative "sql/raw"
|
|
12
|
+
require_relative "sql/setting"
|
|
13
|
+
require_relative "sql/placeholder_type"
|
|
14
|
+
require_relative "sql/fragment"
|
|
15
|
+
require_relative "sql/table"
|
|
16
|
+
require_relative "sql/join"
|
|
17
|
+
require_relative "sql/select_query"
|
|
18
|
+
|
|
19
|
+
module ClickHouse
|
|
20
|
+
# Builds composable ClickHouse SELECT queries for ClickHouse::HTTPClient.
|
|
21
|
+
#
|
|
22
|
+
# Keep ClickHouse SQL visible, but pass runtime values through named typed
|
|
23
|
+
# placeholders (`{name:Type}`). Untyped placeholders are fragment slots for
|
|
24
|
+
# trusted SQL structure such as column references or subqueries: anonymous
|
|
25
|
+
# `{}` slots are filled by positional arguments in source order, and named
|
|
26
|
+
# `{name}` slots by keyword arguments.
|
|
27
|
+
#
|
|
28
|
+
# Table handles (ClickHouse::SQL.table) build validated, qualified column
|
|
29
|
+
# references so shared fragments stay unambiguous when queries join tables
|
|
30
|
+
# with overlapping column names.
|
|
31
|
+
#
|
|
32
|
+
# @example Build and execute a typed SELECT query joining two tables
|
|
33
|
+
# CH = ClickHouse::SQL
|
|
34
|
+
#
|
|
35
|
+
# events = CH.table("events")
|
|
36
|
+
# accounts = CH.table("accounts", as: "a")
|
|
37
|
+
#
|
|
38
|
+
# query = CH
|
|
39
|
+
# .select(events.columns(:id, :timestamp), accounts[:status])
|
|
40
|
+
# .from(events)
|
|
41
|
+
# .left_join(accounts, final: true, on: [
|
|
42
|
+
# CH.fragment("{} = {}", accounts[:id], events[:account_id]),
|
|
43
|
+
# ])
|
|
44
|
+
# .where("{} = {account_id:UUID}", events[:account_id], account_id: account_id)
|
|
45
|
+
# .where("{}.{tag_key:Identifier} = {value:String}", events[:tags], tag_key: "geo.country", value: "AU")
|
|
46
|
+
#
|
|
47
|
+
# ClickHouse::HTTPClient.select(query.to_query, :analytics)
|
|
48
|
+
module SQL
|
|
49
|
+
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
|
50
|
+
|
|
51
|
+
# Validates `{x:Identifier}` placeholder values, which ClickHouse
|
|
52
|
+
# substitutes server-side with proper identifier quoting; permissive
|
|
53
|
+
# enough for hyphenated tag keys.
|
|
54
|
+
IDENTIFIER_REGEX = /\A[A-Za-z_][0-9A-Za-z_.-]*\z/
|
|
55
|
+
|
|
56
|
+
# Validates identifiers that Ruby interpolates into raw SQL with no
|
|
57
|
+
# quoting (table handles). Stricter than IDENTIFIER_REGEX: each
|
|
58
|
+
# dot-separated segment must be a simple identifier, so `-` (a SQL
|
|
59
|
+
# comment starter or subtraction) and stray dots never reach SQL text.
|
|
60
|
+
QUALIFIED_IDENTIFIER_REGEX = /\A[A-Za-z_]\w*(?:\.[A-Za-z_]\w*)*\z/
|
|
61
|
+
GENERATED_BIND_PREFIX_REGEX = /\A[A-Za-z_]\w*\z/
|
|
62
|
+
# Braces would terminate a generated placeholder early (or misalign the
|
|
63
|
+
# parser), smuggling the remainder of the type into the SQL as raw text,
|
|
64
|
+
# so a generated bind type must not contain them. Anything else stays
|
|
65
|
+
# inside the placeholder and is validated downstream.
|
|
66
|
+
GENERATED_BIND_TYPE_REGEX = /\A[^{}]+\z/
|
|
67
|
+
SQL_ALIAS_REGEX = /\A[A-Za-z_]\w*\z/
|
|
68
|
+
SETTING_NAME_REGEX = /\A[A-Za-z_]\w*\z/
|
|
69
|
+
|
|
70
|
+
module ValueNormalization
|
|
71
|
+
BLANK_STRING = /\A[[:space:]]*\z/
|
|
72
|
+
|
|
73
|
+
def self.blank_string?(value)
|
|
74
|
+
!value || BLANK_STRING.match?(value.to_s)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.symbolize_keys(hash)
|
|
78
|
+
hash.transform_keys do |key|
|
|
79
|
+
key.respond_to?(:to_sym) ? key.to_sym : key
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
private_constant :ValueNormalization
|
|
84
|
+
|
|
85
|
+
module_function
|
|
86
|
+
|
|
87
|
+
# Builds a SQL expression with typed placeholder values and fragment slots.
|
|
88
|
+
#
|
|
89
|
+
# Anonymous `{}` slots are filled in source order by positional SQL parts.
|
|
90
|
+
# Named `{name}` slots and `{name:Type}` values take keyword bindings.
|
|
91
|
+
#
|
|
92
|
+
# @param sql [String, #to_s] SQL containing `{name:Type}` value placeholders,
|
|
93
|
+
# `{name}` fragment slots, and/or anonymous `{}` fragment slots.
|
|
94
|
+
# @param args [Array<ClickHouse::SQL::Part, Hash>] Positional fills for `{}`
|
|
95
|
+
# slots in source order; a Hash argument is treated as named bindings.
|
|
96
|
+
# @param keyword_bindings [Hash] Additional placeholder values keyed by name.
|
|
97
|
+
# @return [ClickHouse::SQL::Fragment] A composable SQL fragment.
|
|
98
|
+
def fragment(sql, *args, **keyword_bindings)
|
|
99
|
+
hashes, fills = args.partition { |arg| arg.is_a?(Hash) }
|
|
100
|
+
bindings = hashes.reduce({}, :merge)
|
|
101
|
+
Fragment.new(sql, merge_bindings(bindings, keyword_bindings), fills:)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Wraps trusted static SQL that has no placeholder values.
|
|
105
|
+
#
|
|
106
|
+
# @param sql [String, #to_s] Trusted SQL structure or expression.
|
|
107
|
+
# @return [ClickHouse::SQL::Raw] A raw SQL expression.
|
|
108
|
+
def raw_static(sql)
|
|
109
|
+
Raw.new(sql)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Wraps an explicitly reviewed dynamic SQL escape hatch.
|
|
113
|
+
#
|
|
114
|
+
# @param sql [String, #to_s] SQL that cannot be represented with placeholders.
|
|
115
|
+
# @param reason [String] Durable explanation for why raw SQL is safe here.
|
|
116
|
+
# @return [ClickHouse::SQL::Raw] A raw SQL expression with an audit reason.
|
|
117
|
+
def unsafe_raw(sql, reason:)
|
|
118
|
+
raise Error, "unsafe_raw requires a reason" if ValueNormalization.blank_string?(reason)
|
|
119
|
+
|
|
120
|
+
Raw.new(sql, unsafe_reason: reason)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Builds a validated table handle that qualifies column references.
|
|
124
|
+
#
|
|
125
|
+
# @param name [String, Symbol, #to_s] Trusted table name.
|
|
126
|
+
# @param as [String, Symbol, nil] Optional table alias.
|
|
127
|
+
# @return [ClickHouse::SQL::Table] A validated table handle.
|
|
128
|
+
def table(name, as: nil)
|
|
129
|
+
Table.new(name, as:)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Builds a standalone JOIN clause for SelectQuery#joins.
|
|
133
|
+
#
|
|
134
|
+
# Useful for join specs shared between queries, so the joined table, its
|
|
135
|
+
# alias, FINAL, and the ON conditions travel together.
|
|
136
|
+
#
|
|
137
|
+
# @param table [String, Symbol, ClickHouse::SQL::Part] Trusted table expression.
|
|
138
|
+
# @param type [String] JOIN type, for example `INNER` or `LEFT`.
|
|
139
|
+
# @param as [String, Symbol, nil] Optional table alias; only valid for
|
|
140
|
+
# string/symbol table names — a ClickHouse::SQL::Table handle owns its alias.
|
|
141
|
+
# @param final [Boolean] Whether to append ClickHouse `FINAL`.
|
|
142
|
+
# @param on [String, ClickHouse::SQL::Part, Array] ON conditions.
|
|
143
|
+
# @return [ClickHouse::SQL::Join] A validated JOIN clause.
|
|
144
|
+
def join(table, type: "INNER", as: nil, final: false, on:)
|
|
145
|
+
Join.new(table, type:, as:, final:, on:)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Builds a standalone LEFT JOIN clause for SelectQuery#joins.
|
|
149
|
+
#
|
|
150
|
+
# @param table [String, Symbol, ClickHouse::SQL::Part] Trusted table expression.
|
|
151
|
+
# @param as [String, Symbol, nil] Optional table alias; only valid for
|
|
152
|
+
# string/symbol table names — a ClickHouse::SQL::Table handle owns its alias.
|
|
153
|
+
# @param final [Boolean] Whether to append ClickHouse `FINAL`.
|
|
154
|
+
# @param on [String, ClickHouse::SQL::Part, Array] ON conditions.
|
|
155
|
+
# @return [ClickHouse::SQL::Join] A validated LEFT JOIN clause.
|
|
156
|
+
def left_join(table, as: nil, final: false, on:)
|
|
157
|
+
join(table, type: "LEFT", as:, final:, on:)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Returns a trusted `*` select expression.
|
|
161
|
+
#
|
|
162
|
+
# @return [ClickHouse::SQL::Raw] A raw `*` expression.
|
|
163
|
+
def star
|
|
164
|
+
raw_static("*")
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Starts a mutable SELECT query.
|
|
168
|
+
#
|
|
169
|
+
# @param columns [Array<String, Symbol, ClickHouse::SQL::Part>] SELECT expressions.
|
|
170
|
+
# @param aliases [Hash{Symbol,String=>String,Symbol,ClickHouse::SQL::Part}] Aliased expressions.
|
|
171
|
+
# @return [ClickHouse::SQL::SelectQuery] A chainable SELECT query.
|
|
172
|
+
def select(*columns, **aliases)
|
|
173
|
+
SelectQuery.new.select(*columns, **aliases)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Joins present fragments with `AND`, parenthesizing each fragment so a
|
|
177
|
+
# fragment containing a top-level `OR` cannot change the clause's meaning.
|
|
178
|
+
#
|
|
179
|
+
# @param fragments [Array<String, Symbol, ClickHouse::SQL::Part, nil>] SQL fragments to join.
|
|
180
|
+
# @return [ClickHouse::SQL::Fragment, nil] Joined fragment, or nil when no fragments are present.
|
|
181
|
+
def and(fragments)
|
|
182
|
+
join_fragments(fragments, " AND ", wrap: true)
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Joins present fragments with `OR`, parenthesizing each fragment so a
|
|
186
|
+
# fragment containing a top-level `AND` cannot change the clause's meaning.
|
|
187
|
+
#
|
|
188
|
+
# @param fragments [Array<String, Symbol, ClickHouse::SQL::Part, nil>] SQL fragments to join.
|
|
189
|
+
# @return [ClickHouse::SQL::Fragment, nil] Joined fragment, or nil when no fragments are present.
|
|
190
|
+
def or(fragments)
|
|
191
|
+
join_fragments(fragments, " OR ", wrap: true)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# Joins present fragments with `, `.
|
|
195
|
+
#
|
|
196
|
+
# @param fragments [Array<String, Symbol, ClickHouse::SQL::Part, nil>] SQL fragments to join.
|
|
197
|
+
# @return [ClickHouse::SQL::Fragment, nil] Joined fragment, or nil when no fragments are present.
|
|
198
|
+
def csv(fragments)
|
|
199
|
+
join_fragments(fragments, ", ")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Wraps a fragment in parentheses.
|
|
203
|
+
#
|
|
204
|
+
# @param fragment [String, Symbol, ClickHouse::SQL::Part] SQL expression to wrap.
|
|
205
|
+
# @return [ClickHouse::SQL::Fragment] Parenthesized SQL fragment.
|
|
206
|
+
def parens(fragment)
|
|
207
|
+
Fragment.new("({})", fills: [coerce_expression(fragment)])
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
# Builds a comma-separated list of generated typed placeholders.
|
|
211
|
+
#
|
|
212
|
+
# @param prefix [String, Symbol] Safe prefix for generated placeholder names.
|
|
213
|
+
# @param values [Array, Object] Values to bind; non-arrays are wrapped.
|
|
214
|
+
# @param type [String] ClickHouse placeholder type for every value.
|
|
215
|
+
# @param compact [Boolean] Whether to use shorter base-36 placeholder suffixes.
|
|
216
|
+
# @return [ClickHouse::SQL::Fragment] Comma-separated placeholder fragment.
|
|
217
|
+
def bind_list(prefix, values, type:, compact: false)
|
|
218
|
+
unless prefix.to_s.match?(GENERATED_BIND_PREFIX_REGEX)
|
|
219
|
+
raise Error, "Generated bind prefix must be a simple identifier: #{prefix.inspect}"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
unless type.to_s.match?(GENERATED_BIND_TYPE_REGEX)
|
|
223
|
+
raise Error, "Generated bind type must not be blank or contain braces: #{type.inspect}"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
values = Array(values)
|
|
227
|
+
raise Error, "bind_list requires at least one value" if values.empty?
|
|
228
|
+
|
|
229
|
+
bindings = {}
|
|
230
|
+
placeholders = values.each_with_index.map do |value, index|
|
|
231
|
+
name = generated_bind_name(prefix, index, compact:)
|
|
232
|
+
bindings[name.to_sym] = value
|
|
233
|
+
"{#{name}:#{type}}"
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
Fragment.new(placeholders.join(", "), bindings)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Builds a validated ClickHouse SETTINGS assignment.
|
|
240
|
+
#
|
|
241
|
+
# @param name [String, Symbol] ClickHouse setting name.
|
|
242
|
+
# @param value [String, Integer, Float, Boolean] Inline setting value.
|
|
243
|
+
# @return [ClickHouse::SQL::Setting] A rendered setting expression.
|
|
244
|
+
def setting(name, value)
|
|
245
|
+
Setting.new(name, value)
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Parses ClickHouse placeholder markers from SQL text.
|
|
249
|
+
#
|
|
250
|
+
# @param sql [String, #to_s] SQL text containing `{name}` or `{name:Type}` markers.
|
|
251
|
+
# @return [Array<ClickHouse::SQL::Placeholder>] Parsed placeholders in source order.
|
|
252
|
+
def parse_placeholders(sql)
|
|
253
|
+
sql = sql.to_s
|
|
254
|
+
placeholders = []
|
|
255
|
+
index = 0
|
|
256
|
+
|
|
257
|
+
while (open_pos = sql.index("{", index))
|
|
258
|
+
close_pos = sql.index("}", open_pos + 1)
|
|
259
|
+
raise Error, "Unclosed ClickHouse placeholder in SQL fragment" unless close_pos
|
|
260
|
+
|
|
261
|
+
raw = sql[open_pos..close_pos]
|
|
262
|
+
content = raw[1...-1]
|
|
263
|
+
if ValueNormalization.blank_string?(content)
|
|
264
|
+
# `{}` is an anonymous fragment slot, filled positionally.
|
|
265
|
+
placeholders << Placeholder.new(
|
|
266
|
+
raw:,
|
|
267
|
+
name: nil,
|
|
268
|
+
type: nil,
|
|
269
|
+
begin_pos: open_pos,
|
|
270
|
+
end_pos: close_pos + 1
|
|
271
|
+
)
|
|
272
|
+
index = close_pos + 1
|
|
273
|
+
next
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# A colon must be followed by a non-empty type; `{name:}` is invalid
|
|
277
|
+
# rather than silently becoming an untyped fragment slot.
|
|
278
|
+
match = content.match(/\A\s*([A-Za-z_]\w*)\s*(?::\s*(\S.*?)\s*)?\z/m)
|
|
279
|
+
raise Error, "Invalid ClickHouse placeholder #{raw.inspect}" unless match
|
|
280
|
+
|
|
281
|
+
placeholders << Placeholder.new(
|
|
282
|
+
raw:,
|
|
283
|
+
name: match[1],
|
|
284
|
+
type: match[2]&.strip,
|
|
285
|
+
begin_pos: open_pos,
|
|
286
|
+
end_pos: close_pos + 1
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
index = close_pos + 1
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
placeholders
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Validates a simple SQL alias.
|
|
296
|
+
#
|
|
297
|
+
# @param alias_name [String, Symbol, #to_s] Alias to validate.
|
|
298
|
+
# @return [String] The validated alias string.
|
|
299
|
+
def validate_alias!(alias_name)
|
|
300
|
+
alias_name = alias_name.to_s
|
|
301
|
+
return alias_name if alias_name.match?(SQL_ALIAS_REGEX)
|
|
302
|
+
|
|
303
|
+
raise Error, "SQL alias must be a simple identifier: #{alias_name.inspect}"
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Resolves a clause alias against a table expression's own alias.
|
|
307
|
+
#
|
|
308
|
+
# A ClickHouse::SQL::Table handle owns its alias so qualified column
|
|
309
|
+
# references built from it always match the rendered FROM/JOIN clause.
|
|
310
|
+
# An explicit clause alias alongside a handle — even one matching the
|
|
311
|
+
# handle's alias — would create a second source of truth that can drift,
|
|
312
|
+
# so it raises instead.
|
|
313
|
+
#
|
|
314
|
+
# @param table [Object] Table expression, possibly a ClickHouse::SQL::Table.
|
|
315
|
+
# @param as [String, Symbol, nil] Explicit clause alias, if any.
|
|
316
|
+
# @return [String, Symbol, nil] The alias to render, if any.
|
|
317
|
+
def resolve_table_alias(table, as)
|
|
318
|
+
return as unless table.is_a?(ClickHouse::SQL::Table)
|
|
319
|
+
|
|
320
|
+
unless as.nil?
|
|
321
|
+
raise Error, "A table handle owns its alias; pass the alias when creating the handle: " \
|
|
322
|
+
"ClickHouse::SQL.table(#{table.name.inspect}, as: #{as.to_s.inspect})"
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
table.alias_name
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Validates a ClickHouse setting name.
|
|
329
|
+
#
|
|
330
|
+
# @param name [String, Symbol, #to_s] Setting name to validate.
|
|
331
|
+
# @return [String] The validated setting name.
|
|
332
|
+
def validate_setting_name!(name)
|
|
333
|
+
name = name.to_s
|
|
334
|
+
return name if name.match?(SETTING_NAME_REGEX)
|
|
335
|
+
|
|
336
|
+
raise Error, "ClickHouse setting name must be a simple identifier: #{name.inspect}"
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Coerces a value into a SQL expression that may contain placeholders.
|
|
340
|
+
#
|
|
341
|
+
# @param value [String, Symbol, ClickHouse::SQL::Part, nil] Expression value.
|
|
342
|
+
# @return [ClickHouse::SQL::Part, nil] Coerced SQL expression.
|
|
343
|
+
def coerce_expression(value)
|
|
344
|
+
case value
|
|
345
|
+
when nil
|
|
346
|
+
nil
|
|
347
|
+
when ClickHouse::SQL::Part
|
|
348
|
+
value
|
|
349
|
+
when String
|
|
350
|
+
Fragment.new(value)
|
|
351
|
+
when Symbol
|
|
352
|
+
Raw.new(value.to_s)
|
|
353
|
+
else
|
|
354
|
+
raise Error, "Expected a SQL fragment or trusted SQL string, got #{value.class}"
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Coerces a value into trusted raw SQL expression context.
|
|
359
|
+
#
|
|
360
|
+
# @param value [String, Symbol, ClickHouse::SQL::Part, nil] Raw expression value.
|
|
361
|
+
# @return [ClickHouse::SQL::Part, nil] Coerced raw SQL expression.
|
|
362
|
+
def coerce_raw_expression(value)
|
|
363
|
+
case value
|
|
364
|
+
when nil
|
|
365
|
+
nil
|
|
366
|
+
when ClickHouse::SQL::Part
|
|
367
|
+
value
|
|
368
|
+
when String
|
|
369
|
+
Raw.new(value)
|
|
370
|
+
when Symbol
|
|
371
|
+
Raw.new(value.to_s)
|
|
372
|
+
else
|
|
373
|
+
raise Error, "Expected a trusted SQL string, got #{value.class}"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Inspects a value for an error message without embedding whole
|
|
378
|
+
# user-supplied values, which would otherwise travel into error trackers
|
|
379
|
+
# when a caller bug raises.
|
|
380
|
+
#
|
|
381
|
+
# @param value [Object] Value to render into an error message.
|
|
382
|
+
# @param limit [Integer] Maximum inspected length kept verbatim.
|
|
383
|
+
# @return [String] Inspect output, truncated when oversized.
|
|
384
|
+
def truncated_inspect(value, limit: 32)
|
|
385
|
+
inspected = value.inspect
|
|
386
|
+
return inspected if inspected.length <= limit
|
|
387
|
+
|
|
388
|
+
"#{inspected[0, limit]}… (truncated #{value.class}, #{inspected.length} chars)"
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Merges placeholder values, rejecting duplicate names with different values.
|
|
392
|
+
#
|
|
393
|
+
# @param target [Hash{Symbol=>Object}] Placeholder values to mutate.
|
|
394
|
+
# @param source [Hash{Symbol,String=>Object}] Placeholder values to merge in.
|
|
395
|
+
# @return [Hash{Symbol=>Object}] The mutated target hash.
|
|
396
|
+
def merge_placeholders!(target, source)
|
|
397
|
+
source.each do |key, value|
|
|
398
|
+
key = key.to_sym
|
|
399
|
+
if target.key?(key) && target[key] != value
|
|
400
|
+
raise Error, "mismatching values for the '#{key}' placeholder: " \
|
|
401
|
+
"#{truncated_inspect(target[key])} vs #{truncated_inspect(value)}"
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
target[key] = value
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
target
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
# Merges placeholder types, rejecting duplicate names with different types.
|
|
411
|
+
#
|
|
412
|
+
# @param target [Hash{Symbol=>String}] Placeholder types to mutate.
|
|
413
|
+
# @param source [Hash{Symbol,String=>String}] Placeholder types to merge in.
|
|
414
|
+
# @return [Hash{Symbol=>String}] The mutated target hash.
|
|
415
|
+
def merge_placeholder_types!(target, source)
|
|
416
|
+
source.each do |key, type|
|
|
417
|
+
key = key.to_sym
|
|
418
|
+
if target.key?(key) && target[key] != type
|
|
419
|
+
raise Error, "mismatching types for the '#{key}' placeholder: #{target[key]} vs #{type}"
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
target[key] = type
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
target
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Normalizes placeholder type spelling for comparison.
|
|
429
|
+
#
|
|
430
|
+
# @param type [String, #to_s] ClickHouse placeholder type.
|
|
431
|
+
# @return [String] Type with whitespace removed.
|
|
432
|
+
def normalize_placeholder_type(type)
|
|
433
|
+
type.to_s.gsub(/\s+/, "")
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
# Renders a query part in normal or redacted mode.
|
|
437
|
+
#
|
|
438
|
+
# @param part [ClickHouse::SQL::Part] Query part to render.
|
|
439
|
+
# @param redacted [Boolean] Whether to render typed placeholders as `?`-style bind markers.
|
|
440
|
+
# @param bind_index_manager [ClickHouse::SQL::BindIndexManager] Redacted bind index state.
|
|
441
|
+
# @return [String] Rendered SQL.
|
|
442
|
+
def render_part(part, redacted:, bind_index_manager:)
|
|
443
|
+
if redacted
|
|
444
|
+
part.to_redacted_sql(bind_index_manager)
|
|
445
|
+
else
|
|
446
|
+
part.to_sql
|
|
447
|
+
end
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
# Collects placeholder values from query parts.
|
|
451
|
+
#
|
|
452
|
+
# @param parts [Array<#placeholders, nil>] Query parts to inspect.
|
|
453
|
+
# @return [Hash{Symbol=>Object}] Merged placeholder values.
|
|
454
|
+
def collect_placeholders(parts)
|
|
455
|
+
parts.compact.each_with_object({}) do |part, placeholders|
|
|
456
|
+
next unless part.respond_to?(:placeholders)
|
|
457
|
+
|
|
458
|
+
merge_placeholders!(placeholders, part.placeholders)
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
|
|
462
|
+
# Collects placeholder types from query parts.
|
|
463
|
+
#
|
|
464
|
+
# @param parts [Array<#placeholder_types, nil>] Query parts to inspect.
|
|
465
|
+
# @return [Hash{Symbol=>String}] Merged placeholder type names.
|
|
466
|
+
def collect_placeholder_types(parts)
|
|
467
|
+
parts.compact.each_with_object({}) do |part, placeholder_types|
|
|
468
|
+
next unless part.respond_to?(:placeholder_types)
|
|
469
|
+
|
|
470
|
+
merge_placeholder_types!(placeholder_types, part.placeholder_types)
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# Splits a ClickHouse type argument list on top-level commas.
|
|
475
|
+
#
|
|
476
|
+
# @param input [String, #to_s] Argument list without the outer type name.
|
|
477
|
+
# @return [Array<String>] Top-level argument strings.
|
|
478
|
+
def split_top_level_arguments(input)
|
|
479
|
+
input = input.to_s
|
|
480
|
+
arguments = []
|
|
481
|
+
start = 0
|
|
482
|
+
depth = 0
|
|
483
|
+
quote = nil
|
|
484
|
+
escaped = false
|
|
485
|
+
|
|
486
|
+
input.each_char.with_index do |char, index|
|
|
487
|
+
if quote
|
|
488
|
+
# An escaped character never closes the string; a backslash that is
|
|
489
|
+
# not itself escaped escapes the character after it.
|
|
490
|
+
if escaped
|
|
491
|
+
escaped = false
|
|
492
|
+
elsif char == "\\"
|
|
493
|
+
escaped = true
|
|
494
|
+
elsif char == quote
|
|
495
|
+
quote = nil
|
|
496
|
+
end
|
|
497
|
+
next
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
case char
|
|
501
|
+
when "'", '"'
|
|
502
|
+
quote = char
|
|
503
|
+
when "("
|
|
504
|
+
depth += 1
|
|
505
|
+
when ")"
|
|
506
|
+
depth -= 1 if depth.positive?
|
|
507
|
+
when ","
|
|
508
|
+
next unless depth.zero?
|
|
509
|
+
|
|
510
|
+
arguments << input[start...index].strip
|
|
511
|
+
start = index + 1
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
arguments << input[start..].to_s.strip
|
|
516
|
+
arguments
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
# Merges positional and keyword bindings for a fragment.
|
|
520
|
+
#
|
|
521
|
+
# @param bindings [Hash, nil] Explicit bindings hash.
|
|
522
|
+
# @param keyword_bindings [Hash] Keyword bindings hash.
|
|
523
|
+
# @return [Hash{Symbol=>Object}] Symbol-keyed merged bindings.
|
|
524
|
+
def merge_bindings(bindings, keyword_bindings)
|
|
525
|
+
ValueNormalization.symbolize_keys((bindings || {}).merge(keyword_bindings))
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Builds a generated placeholder name.
|
|
529
|
+
#
|
|
530
|
+
# @param prefix [String, Symbol, #to_s] Placeholder name prefix.
|
|
531
|
+
# @param index [Integer] Zero-based generated placeholder index.
|
|
532
|
+
# @param compact [Boolean] Whether to use base-36 suffixes.
|
|
533
|
+
# @return [String] Generated placeholder name.
|
|
534
|
+
def generated_bind_name(prefix, index, compact:)
|
|
535
|
+
return "#{prefix}_#{index}" unless compact
|
|
536
|
+
|
|
537
|
+
"#{prefix}#{index.to_s(36)}"
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# Returns whether a value is a renderable SQL builder part.
|
|
541
|
+
#
|
|
542
|
+
# @param value [Object] Value to check.
|
|
543
|
+
# @return [Boolean] True when value implements ClickHouse::SQL's internal part protocol.
|
|
544
|
+
def part?(value)
|
|
545
|
+
value.is_a?(ClickHouse::SQL::Part)
|
|
546
|
+
end
|
|
547
|
+
|
|
548
|
+
# Joins fragments with a separator while preserving nested placeholders.
|
|
549
|
+
#
|
|
550
|
+
# @param fragments [Array<String, Symbol, ClickHouse::SQL::Part, nil>] Fragments to join.
|
|
551
|
+
# @param separator [String] SQL separator to place between fragments.
|
|
552
|
+
# @param wrap [Boolean] Whether to parenthesize each fragment, for boolean separators.
|
|
553
|
+
# @return [ClickHouse::SQL::Fragment, nil] Joined fragment, or nil when empty.
|
|
554
|
+
def join_fragments(fragments, separator, wrap: false)
|
|
555
|
+
fills = Array(fragments).flatten.compact.map { |fragment| coerce_expression(fragment) }
|
|
556
|
+
return nil if fills.empty?
|
|
557
|
+
|
|
558
|
+
slots = fills.map { wrap ? "({})" : "{}" }
|
|
559
|
+
Fragment.new(slots.join(separator), fills:)
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: clickhouse-sql
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Buildkite
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: activesupport
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '8.0'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '9'
|
|
22
|
+
type: :development
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '8.0'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '9'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rake
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - "~>"
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '13.4'
|
|
39
|
+
type: :development
|
|
40
|
+
prerelease: false
|
|
41
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - "~>"
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '13.4'
|
|
46
|
+
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: rspec
|
|
48
|
+
requirement: !ruby/object:Gem::Requirement
|
|
49
|
+
requirements:
|
|
50
|
+
- - "~>"
|
|
51
|
+
- !ruby/object:Gem::Version
|
|
52
|
+
version: '3.13'
|
|
53
|
+
type: :development
|
|
54
|
+
prerelease: false
|
|
55
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
56
|
+
requirements:
|
|
57
|
+
- - "~>"
|
|
58
|
+
- !ruby/object:Gem::Version
|
|
59
|
+
version: '3.13'
|
|
60
|
+
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: webmock
|
|
62
|
+
requirement: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - "~>"
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '3.26'
|
|
67
|
+
type: :development
|
|
68
|
+
prerelease: false
|
|
69
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
70
|
+
requirements:
|
|
71
|
+
- - "~>"
|
|
72
|
+
- !ruby/object:Gem::Version
|
|
73
|
+
version: '3.26'
|
|
74
|
+
description: Build composable ClickHouse SELECT queries with typed placeholders and
|
|
75
|
+
execute them over HTTP.
|
|
76
|
+
email:
|
|
77
|
+
- paul@buildkite.com
|
|
78
|
+
executables: []
|
|
79
|
+
extensions: []
|
|
80
|
+
extra_rdoc_files: []
|
|
81
|
+
files:
|
|
82
|
+
- CHANGELOG.md
|
|
83
|
+
- LICENSE
|
|
84
|
+
- README.md
|
|
85
|
+
- lib/clickhouse-sql.rb
|
|
86
|
+
- lib/clickhouse/http_client.rb
|
|
87
|
+
- lib/clickhouse/http_client/database.rb
|
|
88
|
+
- lib/clickhouse/http_client/error.rb
|
|
89
|
+
- lib/clickhouse/http_client/parameter_serializer.rb
|
|
90
|
+
- lib/clickhouse/http_client/query.rb
|
|
91
|
+
- lib/clickhouse/http_client/response_formatter.rb
|
|
92
|
+
- lib/clickhouse/http_client/value_normalization.rb
|
|
93
|
+
- lib/clickhouse/sql.rb
|
|
94
|
+
- lib/clickhouse/sql/bind_index_manager.rb
|
|
95
|
+
- lib/clickhouse/sql/error.rb
|
|
96
|
+
- lib/clickhouse/sql/fragment.rb
|
|
97
|
+
- lib/clickhouse/sql/join.rb
|
|
98
|
+
- lib/clickhouse/sql/part.rb
|
|
99
|
+
- lib/clickhouse/sql/placeholder.rb
|
|
100
|
+
- lib/clickhouse/sql/placeholder_type.rb
|
|
101
|
+
- lib/clickhouse/sql/query.rb
|
|
102
|
+
- lib/clickhouse/sql/raw.rb
|
|
103
|
+
- lib/clickhouse/sql/select_item.rb
|
|
104
|
+
- lib/clickhouse/sql/select_query.rb
|
|
105
|
+
- lib/clickhouse/sql/setting.rb
|
|
106
|
+
- lib/clickhouse/sql/table.rb
|
|
107
|
+
- lib/clickhouse/sql/version.rb
|
|
108
|
+
homepage: https://github.com/buildkite/clickhouse-sql
|
|
109
|
+
licenses:
|
|
110
|
+
- MIT
|
|
111
|
+
metadata:
|
|
112
|
+
homepage_uri: https://github.com/buildkite/clickhouse-sql
|
|
113
|
+
source_code_uri: https://github.com/buildkite/clickhouse-sql/tree/main
|
|
114
|
+
changelog_uri: https://github.com/buildkite/clickhouse-sql/blob/main/CHANGELOG.md
|
|
115
|
+
rubygems_mfa_required: 'true'
|
|
116
|
+
rdoc_options: []
|
|
117
|
+
require_paths:
|
|
118
|
+
- lib
|
|
119
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - ">="
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '3.4'
|
|
124
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '0'
|
|
129
|
+
requirements: []
|
|
130
|
+
rubygems_version: 3.6.9
|
|
131
|
+
specification_version: 4
|
|
132
|
+
summary: A typed ClickHouse SQL query builder and HTTP transport client
|
|
133
|
+
test_files: []
|