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.
@@ -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