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,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
class Join
|
|
6
|
+
include Part
|
|
7
|
+
|
|
8
|
+
# Builds a rendered JOIN clause.
|
|
9
|
+
#
|
|
10
|
+
# @param table [String, Symbol, ClickHouse::SQL::Part] Trusted table expression.
|
|
11
|
+
# @param type [String] JOIN type, for example `INNER` or `LEFT`.
|
|
12
|
+
# @param as [String, Symbol, nil] Optional table alias; only valid for
|
|
13
|
+
# string/symbol table names — a ClickHouse::SQL::Table handle owns its alias.
|
|
14
|
+
# @param final [Boolean] Whether to append ClickHouse `FINAL`.
|
|
15
|
+
# @param on [String, ClickHouse::SQL::Part, Array] ON conditions.
|
|
16
|
+
# @return [ClickHouse::SQL::Join] A validated JOIN clause.
|
|
17
|
+
def initialize(table, type:, as: nil, final: false, on:)
|
|
18
|
+
@table = ClickHouse::SQL.coerce_raw_expression(table)
|
|
19
|
+
@type = type
|
|
20
|
+
@alias_name = ClickHouse::SQL.resolve_table_alias(table, as)
|
|
21
|
+
@final = final
|
|
22
|
+
@on = Array(on).flatten.compact.map { |condition| ClickHouse::SQL.coerce_expression(condition) }
|
|
23
|
+
raise Error, "JOIN requires at least one ON condition" if @on.empty?
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Renders the JOIN with ClickHouse typed placeholders intact.
|
|
27
|
+
#
|
|
28
|
+
# @return [String] SQL JOIN clause.
|
|
29
|
+
def to_sql
|
|
30
|
+
render(redacted: false)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Renders the JOIN with typed placeholders replaced by bind markers.
|
|
34
|
+
#
|
|
35
|
+
# @param bind_index_manager [ClickHouse::SQL::BindIndexManager] Redacted bind index state.
|
|
36
|
+
# @return [String] Redacted SQL JOIN clause.
|
|
37
|
+
def to_redacted_sql(bind_index_manager)
|
|
38
|
+
render(redacted: true, bind_index_manager:)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Returns typed placeholder values used by the JOIN.
|
|
42
|
+
#
|
|
43
|
+
# @return [Hash{Symbol=>Object}] Placeholder values keyed by name.
|
|
44
|
+
def placeholders
|
|
45
|
+
ClickHouse::SQL.collect_placeholders([@table, *@on])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Returns ClickHouse placeholder types used by the JOIN.
|
|
49
|
+
#
|
|
50
|
+
# @return [Hash{Symbol=>String}] Placeholder types keyed by name.
|
|
51
|
+
def placeholder_types
|
|
52
|
+
ClickHouse::SQL.collect_placeholder_types([@table, *@on])
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def render(redacted:, bind_index_manager: ClickHouse::SQL::BindIndexManager.new)
|
|
58
|
+
table_sql = ClickHouse::SQL.render_part(@table, redacted:, bind_index_manager:)
|
|
59
|
+
join_sql = +"#{@type} JOIN #{table_sql}"
|
|
60
|
+
join_sql << " FINAL" if @final
|
|
61
|
+
join_sql << " AS #{ClickHouse::SQL.validate_alias!(@alias_name)}" if @alias_name
|
|
62
|
+
join_sql << " ON "
|
|
63
|
+
# Each condition is parenthesized so a condition containing a top-level
|
|
64
|
+
# OR cannot change the meaning of the AND-joined ON clause.
|
|
65
|
+
join_sql << @on.map { |condition| "(#{ClickHouse::SQL.render_part(condition, redacted:, bind_index_manager:)})" }.join(" AND ")
|
|
66
|
+
join_sql
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
# Internal protocol for renderable ClickHouse::SQL builder parts.
|
|
6
|
+
module Part
|
|
7
|
+
# Renders this part with typed placeholders intact.
|
|
8
|
+
#
|
|
9
|
+
# @return [String] SQL text.
|
|
10
|
+
def to_sql
|
|
11
|
+
raise NotImplementedError
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Renders this part with typed placeholders replaced by bind markers.
|
|
15
|
+
#
|
|
16
|
+
# @param bind_index_manager [ClickHouse::SQL::BindIndexManager] Redacted bind index state.
|
|
17
|
+
# @return [String] Redacted SQL text.
|
|
18
|
+
def to_redacted_sql(bind_index_manager = ClickHouse::SQL::BindIndexManager.new)
|
|
19
|
+
raise NotImplementedError
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Returns typed placeholder values used by this part.
|
|
23
|
+
#
|
|
24
|
+
# @return [Hash{Symbol=>Object}] Placeholder values keyed by name.
|
|
25
|
+
def placeholders
|
|
26
|
+
{}
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Returns ClickHouse placeholder types used by this part.
|
|
30
|
+
#
|
|
31
|
+
# @return [Hash{Symbol=>String}] Placeholder types keyed by name.
|
|
32
|
+
def placeholder_types
|
|
33
|
+
{}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
# A parsed `{name}`, `{name:Type}`, or anonymous `{}` placeholder from a
|
|
6
|
+
# SQL fragment.
|
|
7
|
+
#
|
|
8
|
+
# @!attribute raw
|
|
9
|
+
# @return [String] The placeholder text including braces.
|
|
10
|
+
# @!attribute name
|
|
11
|
+
# @return [String, nil] Placeholder name, or nil for anonymous `{}` slots.
|
|
12
|
+
# @!attribute type
|
|
13
|
+
# @return [String, nil] ClickHouse type text, or nil for fragment slots.
|
|
14
|
+
# @!attribute begin_pos
|
|
15
|
+
# @return [Integer] Start offset in the source SQL.
|
|
16
|
+
# @!attribute end_pos
|
|
17
|
+
# @return [Integer] End offset in the source SQL.
|
|
18
|
+
Placeholder = Struct.new(:raw, :name, :type, :begin_pos, :end_pos, keyword_init: true) do
|
|
19
|
+
# Returns whether this placeholder is a ClickHouse typed value.
|
|
20
|
+
#
|
|
21
|
+
# @return [Boolean] True for `{name:Type}`, false for fragment slots.
|
|
22
|
+
def typed?
|
|
23
|
+
!ValueNormalization.blank_string?(type)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns whether this placeholder is an anonymous `{}` fragment slot.
|
|
27
|
+
#
|
|
28
|
+
# @return [Boolean] True for `{}`, false for named placeholders.
|
|
29
|
+
def anonymous?
|
|
30
|
+
name.nil?
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns the placeholder name as a symbol.
|
|
34
|
+
#
|
|
35
|
+
# @return [Symbol, nil] Symbolized placeholder name, or nil for anonymous slots.
|
|
36
|
+
def name_sym
|
|
37
|
+
name&.to_sym
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
class PlaceholderType
|
|
6
|
+
# @return [String] Raw ClickHouse placeholder type text.
|
|
7
|
+
attr_reader :raw
|
|
8
|
+
|
|
9
|
+
# @return [String] Whitespace-normalized ClickHouse placeholder type text.
|
|
10
|
+
attr_reader :normalized
|
|
11
|
+
|
|
12
|
+
# Parses and normalizes a ClickHouse placeholder type.
|
|
13
|
+
#
|
|
14
|
+
# @param raw [String, #to_s] ClickHouse type text from a placeholder.
|
|
15
|
+
# @return [ClickHouse::SQL::PlaceholderType] Parsed placeholder type.
|
|
16
|
+
def initialize(raw)
|
|
17
|
+
@raw = raw.to_s.strip
|
|
18
|
+
@normalized = ClickHouse::SQL.normalize_placeholder_type(@raw)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Validates and normalizes a Ruby value for this ClickHouse type.
|
|
22
|
+
#
|
|
23
|
+
# @param value [Object] Ruby value to validate for the placeholder type.
|
|
24
|
+
# @return [Object] Value normalized for ClickHouse parameter serialization.
|
|
25
|
+
def validate_and_normalize(value)
|
|
26
|
+
base, arguments = parse_base_and_arguments(raw)
|
|
27
|
+
|
|
28
|
+
if value.nil?
|
|
29
|
+
return nil if nullable?
|
|
30
|
+
|
|
31
|
+
raise Error, "#{raw} placeholder does not accept nil"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
case base.downcase
|
|
35
|
+
when "array"
|
|
36
|
+
validate_array(value, arguments)
|
|
37
|
+
when "nullable", "lowcardinality"
|
|
38
|
+
validate_wrapped(value, arguments)
|
|
39
|
+
when "uuid"
|
|
40
|
+
validate_uuid(value)
|
|
41
|
+
when "identifier"
|
|
42
|
+
validate_identifier(value)
|
|
43
|
+
when "string", "fixedstring"
|
|
44
|
+
validate_string(value)
|
|
45
|
+
when "date"
|
|
46
|
+
validate_date(value)
|
|
47
|
+
when "datetime"
|
|
48
|
+
validate_datetime(value, timezone: timezone_argument(arguments.first))
|
|
49
|
+
when "datetime64"
|
|
50
|
+
validate_datetime64(value, arguments)
|
|
51
|
+
when "integer"
|
|
52
|
+
validate_integer(value)
|
|
53
|
+
when /\Auint(\d+)\z/
|
|
54
|
+
validate_sized_integer(value, bits: Regexp.last_match(1).to_i, unsigned: true)
|
|
55
|
+
when /\Aint(\d+)\z/
|
|
56
|
+
validate_sized_integer(value, bits: Regexp.last_match(1).to_i, unsigned: false)
|
|
57
|
+
when "float32", "float64"
|
|
58
|
+
validate_numeric(value)
|
|
59
|
+
else
|
|
60
|
+
value
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Whether this type accepts nil, recursing through LowCardinality
|
|
65
|
+
# wrappers since ClickHouse only permits the LowCardinality(Nullable(T))
|
|
66
|
+
# nesting order.
|
|
67
|
+
#
|
|
68
|
+
# @return [Boolean] True if nil is a valid value for this type.
|
|
69
|
+
def nullable?
|
|
70
|
+
base, arguments = parse_base_and_arguments(raw)
|
|
71
|
+
return true if base.casecmp?("Nullable")
|
|
72
|
+
return self.class.new(arguments.first).nullable? if base.casecmp?("LowCardinality") && !arguments.first.to_s.empty?
|
|
73
|
+
|
|
74
|
+
false
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def parse_base_and_arguments(type)
|
|
80
|
+
match = type.match(/\A\s*([A-Za-z][A-Za-z0-9]*)\s*(?:\((.*)\))?\s*\z/m)
|
|
81
|
+
raise Error, "Invalid ClickHouse placeholder type: #{type.inspect}" unless match
|
|
82
|
+
|
|
83
|
+
[match[1], ClickHouse::SQL.split_top_level_arguments(match[2].to_s)]
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def validate_array(value, arguments)
|
|
87
|
+
raise Error, "Array placeholder requires an Array, got #{value.class}" unless value.is_a?(Array)
|
|
88
|
+
|
|
89
|
+
inner_type = arguments.first
|
|
90
|
+
raise Error, "Array placeholder requires an inner type" if ValueNormalization.blank_string?(inner_type)
|
|
91
|
+
|
|
92
|
+
inner = self.class.new(inner_type)
|
|
93
|
+
value.map { |item| inner.validate_and_normalize(item) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_wrapped(value, arguments)
|
|
97
|
+
inner_type = arguments.first
|
|
98
|
+
raise Error, "#{raw} placeholder requires an inner type" if ValueNormalization.blank_string?(inner_type)
|
|
99
|
+
|
|
100
|
+
self.class.new(inner_type).validate_and_normalize(value)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_uuid(value)
|
|
104
|
+
string = value.to_s
|
|
105
|
+
unless string.match?(UUID_REGEX)
|
|
106
|
+
raise Error, "UUID placeholder requires a UUID-shaped value, got #{ClickHouse::SQL.truncated_inspect(value)}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
value
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def validate_identifier(value)
|
|
113
|
+
string = value.to_s
|
|
114
|
+
unless string.match?(IDENTIFIER_REGEX) && !string.include?("..")
|
|
115
|
+
raise Error, "Identifier placeholder requires a safe identifier, got #{ClickHouse::SQL.truncated_inspect(value)}"
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
string
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def validate_string(value)
|
|
122
|
+
raise Error, "String placeholder requires a String, got #{value.class}" unless value.is_a?(String)
|
|
123
|
+
|
|
124
|
+
value
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# DateTime is a subclass of Date but serializes with a time component,
|
|
128
|
+
# which is not a valid ClickHouse Date literal, so it is rejected.
|
|
129
|
+
def validate_date(value)
|
|
130
|
+
return value if value.is_a?(Date) && !value.is_a?(DateTime)
|
|
131
|
+
return value if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}\z/)
|
|
132
|
+
|
|
133
|
+
raise Error, "Date placeholder requires a Date or YYYY-MM-DD string, got #{ClickHouse::SQL.truncated_inspect(value)}"
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def validate_datetime(value, timezone: nil)
|
|
137
|
+
if time_like?(value)
|
|
138
|
+
reject_non_utc_timezone!(timezone)
|
|
139
|
+
return value.to_time.getutc
|
|
140
|
+
end
|
|
141
|
+
return value if value.is_a?(String)
|
|
142
|
+
return value if value.is_a?(Numeric)
|
|
143
|
+
|
|
144
|
+
raise Error, "DateTime placeholder requires a Time, DateTime, String, or Numeric, got #{ClickHouse::SQL.truncated_inspect(value)}"
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def validate_datetime64(value, arguments)
|
|
148
|
+
value = validate_datetime(value, timezone: timezone_argument(arguments[1]))
|
|
149
|
+
return value unless value.is_a?(Time)
|
|
150
|
+
|
|
151
|
+
precision = Integer(arguments.first, exception: false)
|
|
152
|
+
return value unless precision
|
|
153
|
+
|
|
154
|
+
format_time(value, precision)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def time_like?(value)
|
|
158
|
+
value.is_a?(Time) ||
|
|
159
|
+
value.is_a?(DateTime)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def timezone_argument(argument)
|
|
163
|
+
timezone = argument.to_s.delete("'\"")
|
|
164
|
+
timezone unless ValueNormalization.blank_string?(timezone)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# ClickHouse interprets rendered DateTime strings in the timezone the
|
|
168
|
+
# placeholder type declares, so a Ruby time value can only be formatted
|
|
169
|
+
# safely for UTC. Callers targeting other declared timezones must format
|
|
170
|
+
# the value themselves and pass a String.
|
|
171
|
+
def reject_non_utc_timezone!(timezone)
|
|
172
|
+
return if timezone.nil? || timezone == "UTC"
|
|
173
|
+
|
|
174
|
+
raise Error, "#{raw} placeholder declares a non-UTC timezone; pass a pre-formatted String instead of a time value"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_integer(value)
|
|
178
|
+
return value if value.is_a?(Integer)
|
|
179
|
+
return value.to_i if value.is_a?(String) && value.match?(/\A[+-]?\d+\z/)
|
|
180
|
+
|
|
181
|
+
raise Error, "Integer placeholder requires an Integer or integer string, got #{value.class}"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def validate_sized_integer(value, bits:, unsigned:)
|
|
185
|
+
integer = validate_integer(value)
|
|
186
|
+
validate_integer_range(integer, bits:, unsigned:)
|
|
187
|
+
integer
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def validate_integer_range(value, bits:, unsigned:)
|
|
191
|
+
min, max = if unsigned
|
|
192
|
+
[0, (2**bits) - 1]
|
|
193
|
+
else
|
|
194
|
+
[-(2**(bits - 1)), (2**(bits - 1)) - 1]
|
|
195
|
+
end
|
|
196
|
+
return if value.between?(min, max)
|
|
197
|
+
|
|
198
|
+
raise Error, "#{raw} placeholder value #{value.inspect} is outside #{min}..#{max}"
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def validate_numeric(value)
|
|
202
|
+
raise Error, "Float placeholder requires a Numeric, got #{value.class}" unless value.is_a?(Numeric)
|
|
203
|
+
raise Error, "#{raw} placeholder value must be finite, got #{value.inspect}" unless value.finite?
|
|
204
|
+
|
|
205
|
+
value
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def format_time(time, precision)
|
|
209
|
+
base = time.strftime("%Y-%m-%d %H:%M:%S")
|
|
210
|
+
return base if precision <= 0
|
|
211
|
+
|
|
212
|
+
fraction = time.nsec.to_s.rjust(9, "0")
|
|
213
|
+
fraction = fraction.ljust(precision, "0") if precision > 9
|
|
214
|
+
|
|
215
|
+
"#{base}.#{fraction[0, precision]}"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
end
|
|
219
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
class Query
|
|
6
|
+
include Part
|
|
7
|
+
|
|
8
|
+
REDACTED_BIND_TOKEN = "\0clickhouse_sql_bind\0"
|
|
9
|
+
private_constant :REDACTED_BIND_TOKEN
|
|
10
|
+
|
|
11
|
+
class RedactedBindIndexManager
|
|
12
|
+
# Returns an internal marker used when compiling redacted SQL templates.
|
|
13
|
+
#
|
|
14
|
+
# @return [String] Redacted bind marker token.
|
|
15
|
+
def next_bind_str
|
|
16
|
+
REDACTED_BIND_TOKEN
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
private_constant :RedactedBindIndexManager
|
|
20
|
+
|
|
21
|
+
# Builds a bind index manager for compiled redacted SQL templates.
|
|
22
|
+
#
|
|
23
|
+
# @return [#next_bind_str] Bind index manager that emits internal bind tokens.
|
|
24
|
+
def self.redacted_bind_index_manager
|
|
25
|
+
RedactedBindIndexManager.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Builds an immutable query object satisfying the client query protocol
|
|
29
|
+
# (#to_sql and #placeholders) accepted by ClickHouse::HTTPClient.
|
|
30
|
+
#
|
|
31
|
+
# @param sql [String, #to_s] Fully rendered SQL containing typed ClickHouse placeholders.
|
|
32
|
+
# @param redacted_sql [String, #to_s] Redacted SQL template containing internal bind tokens.
|
|
33
|
+
# @param placeholders [Hash{Symbol,String=>Object}] Placeholder values keyed by name.
|
|
34
|
+
# @param placeholder_types [Hash{Symbol,String=>String}] Placeholder types keyed by name.
|
|
35
|
+
# @return [ClickHouse::SQL::Query] Immutable client query object.
|
|
36
|
+
def initialize(sql:, redacted_sql:, placeholders:, placeholder_types:)
|
|
37
|
+
raise Error, "Empty SQL query given" if ValueNormalization.blank_string?(sql)
|
|
38
|
+
raise Error, "Empty redacted SQL query given" if ValueNormalization.blank_string?(redacted_sql)
|
|
39
|
+
|
|
40
|
+
@sql = sql.to_s.dup.freeze
|
|
41
|
+
@redacted_sql = redacted_sql.to_s.dup.freeze
|
|
42
|
+
@placeholders = immutable_symbolized_hash(placeholders)
|
|
43
|
+
@placeholder_types = immutable_symbolized_hash(placeholder_types)
|
|
44
|
+
|
|
45
|
+
validate_nil_placeholders!
|
|
46
|
+
freeze
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Renders the final SQL with ClickHouse typed placeholders intact.
|
|
50
|
+
#
|
|
51
|
+
# @return [String] SQL query text.
|
|
52
|
+
def to_sql
|
|
53
|
+
@sql
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Renders the final SQL with typed placeholders replaced by bind markers.
|
|
57
|
+
#
|
|
58
|
+
# @param bind_index_manager [ClickHouse::SQL::BindIndexManager] Redacted bind index state.
|
|
59
|
+
# @return [String] Redacted SQL query text.
|
|
60
|
+
def to_redacted_sql(bind_index_manager = ClickHouse::SQL::BindIndexManager.new)
|
|
61
|
+
parts = @redacted_sql.split(REDACTED_BIND_TOKEN, -1)
|
|
62
|
+
return @redacted_sql if parts.length == 1
|
|
63
|
+
|
|
64
|
+
parts.each_with_index.with_object(+"") do |(part, index), rendered|
|
|
65
|
+
rendered << part
|
|
66
|
+
rendered << bind_index_manager.next_bind_str unless index == parts.length - 1
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Returns typed placeholder values used by this query.
|
|
71
|
+
#
|
|
72
|
+
# @return [Hash{Symbol=>Object}] Placeholder values keyed by name.
|
|
73
|
+
attr_reader :placeholders
|
|
74
|
+
|
|
75
|
+
# Returns ClickHouse placeholder types used by this query.
|
|
76
|
+
#
|
|
77
|
+
# @return [Hash{Symbol=>String}] Placeholder types keyed by name.
|
|
78
|
+
attr_reader :placeholder_types
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
def immutable_symbolized_hash(hash)
|
|
83
|
+
ValueNormalization.symbolize_keys(hash)
|
|
84
|
+
.transform_values { |value| immutable_copy(value) }
|
|
85
|
+
.freeze
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def immutable_copy(value)
|
|
89
|
+
case value
|
|
90
|
+
when Array
|
|
91
|
+
value.map { |item| immutable_copy(item) }.freeze
|
|
92
|
+
when Hash
|
|
93
|
+
value.to_h { |key, item| [immutable_copy(key), immutable_copy(item)] }.freeze
|
|
94
|
+
when String
|
|
95
|
+
value.dup.freeze
|
|
96
|
+
else
|
|
97
|
+
value
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# The client sends nil placeholder values as NULL, which only a
|
|
102
|
+
# Nullable placeholder type can represent. For most non-Nullable types
|
|
103
|
+
# ClickHouse rejects NULL loudly, but a plain String placeholder
|
|
104
|
+
# silently parses it as an empty string — so nils are validated against
|
|
105
|
+
# the declared placeholder types here, where both are known.
|
|
106
|
+
def validate_nil_placeholders!
|
|
107
|
+
@placeholders.each do |name, value|
|
|
108
|
+
type = @placeholder_types[name].to_s
|
|
109
|
+
|
|
110
|
+
if value.nil?
|
|
111
|
+
next if !ValueNormalization.blank_string?(type) && PlaceholderType.new(type).nullable?
|
|
112
|
+
|
|
113
|
+
raise Error, "nil bound to non-Nullable placeholder {#{name}:#{type}}; declare the type as Nullable(...) to bind NULL"
|
|
114
|
+
elsif contains_nil?(value)
|
|
115
|
+
# Containment heuristic, not positional: one Nullable(...)
|
|
116
|
+
# member permits nils anywhere in the bound container, so e.g. a
|
|
117
|
+
# nil key in Map(String, Nullable(String)) slips through to
|
|
118
|
+
# ClickHouse. Positional validation would require parsing
|
|
119
|
+
# composite types.
|
|
120
|
+
next if type.include?("Nullable(")
|
|
121
|
+
|
|
122
|
+
raise Error, "nil element bound to non-Nullable placeholder {#{name}:#{type}}; declare the element type as Nullable(...) to bind NULLs"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def contains_nil?(value)
|
|
128
|
+
case value
|
|
129
|
+
when Array
|
|
130
|
+
value.any? { |item| item.nil? || contains_nil?(item) }
|
|
131
|
+
when Hash
|
|
132
|
+
value.any? { |key, item| key.nil? || item.nil? || contains_nil?(key) || contains_nil?(item) }
|
|
133
|
+
else
|
|
134
|
+
false
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
class Raw
|
|
6
|
+
include Part
|
|
7
|
+
|
|
8
|
+
# @return [String, nil] Reason this raw SQL is intentionally unsafe, if any.
|
|
9
|
+
attr_reader :unsafe_reason
|
|
10
|
+
|
|
11
|
+
# Builds a trusted raw SQL expression.
|
|
12
|
+
#
|
|
13
|
+
# @param sql [String, #to_s] Raw SQL text.
|
|
14
|
+
# @param unsafe_reason [String, nil] Reason for using unsafe raw SQL.
|
|
15
|
+
# @return [ClickHouse::SQL::Raw] A raw SQL expression.
|
|
16
|
+
def initialize(sql, unsafe_reason: nil)
|
|
17
|
+
raise Error, "Empty SQL string given" if ValueNormalization.blank_string?(sql)
|
|
18
|
+
|
|
19
|
+
@sql = sql.to_s
|
|
20
|
+
@unsafe_reason = unsafe_reason
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Renders the raw SQL text.
|
|
24
|
+
#
|
|
25
|
+
# @return [String] Raw SQL text.
|
|
26
|
+
def to_sql
|
|
27
|
+
@sql
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Renders the raw SQL text for redacted logs.
|
|
31
|
+
#
|
|
32
|
+
# @param _bind_index_manager [ClickHouse::SQL::BindIndexManager] Unused redacted bind index state.
|
|
33
|
+
# @return [String] Raw SQL text.
|
|
34
|
+
def to_redacted_sql(_bind_index_manager = ClickHouse::SQL::BindIndexManager.new)
|
|
35
|
+
@sql
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns no placeholders because raw SQL cannot bind values.
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash] Empty placeholder values.
|
|
41
|
+
def placeholders
|
|
42
|
+
{}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns no placeholder types because raw SQL cannot bind values.
|
|
46
|
+
#
|
|
47
|
+
# @return [Hash] Empty placeholder types.
|
|
48
|
+
def placeholder_types
|
|
49
|
+
{}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ClickHouse
|
|
4
|
+
module SQL
|
|
5
|
+
# A SELECT expression with an optional validated alias.
|
|
6
|
+
#
|
|
7
|
+
# @!attribute expression
|
|
8
|
+
# @return [ClickHouse::SQL::Part] SQL expression to select.
|
|
9
|
+
# @!attribute alias_name
|
|
10
|
+
# @return [String, Symbol, nil] Optional output alias.
|
|
11
|
+
SelectItem = Struct.new(:expression, :alias_name, keyword_init: true) do
|
|
12
|
+
include Part
|
|
13
|
+
|
|
14
|
+
# Renders the SELECT expression.
|
|
15
|
+
#
|
|
16
|
+
# @return [String] SQL select item.
|
|
17
|
+
def to_sql
|
|
18
|
+
return expression.to_sql unless alias_name
|
|
19
|
+
|
|
20
|
+
"#{expression.to_sql} AS #{ClickHouse::SQL.validate_alias!(alias_name)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Renders the SELECT expression with typed placeholders redacted.
|
|
24
|
+
#
|
|
25
|
+
# @param bind_index_manager [ClickHouse::SQL::BindIndexManager] Redacted bind index state.
|
|
26
|
+
# @return [String] Redacted SQL select item.
|
|
27
|
+
def to_redacted_sql(bind_index_manager)
|
|
28
|
+
return expression.to_redacted_sql(bind_index_manager) unless alias_name
|
|
29
|
+
|
|
30
|
+
"#{expression.to_redacted_sql(bind_index_manager)} AS #{ClickHouse::SQL.validate_alias!(alias_name)}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Returns typed placeholder values used by the SELECT expression.
|
|
34
|
+
#
|
|
35
|
+
# @return [Hash{Symbol=>Object}] Placeholder values keyed by name.
|
|
36
|
+
def placeholders
|
|
37
|
+
expression.placeholders
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Returns ClickHouse placeholder types used by the SELECT expression.
|
|
41
|
+
#
|
|
42
|
+
# @return [Hash{Symbol=>String}] Placeholder types keyed by name.
|
|
43
|
+
def placeholder_types
|
|
44
|
+
expression.placeholder_types
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|