activerecord-clickhouse-adapter 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 +19 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +464 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +52 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +9 -0
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +233 -0
- data/lib/active_record/connection_adapters/clickhouse/querying.rb +417 -0
- data/lib/active_record/connection_adapters/clickhouse/quoting.rb +92 -0
- data/lib/active_record/connection_adapters/clickhouse/row_binary.rb +190 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +146 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +201 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +722 -0
- data/lib/active_record/connection_adapters/clickhouse/type_parser.rb +207 -0
- data/lib/active_record/connection_adapters/clickhouse/types.rb +238 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +126 -0
- data/lib/active_record/tasks/clickhouse_database_tasks.rb +91 -0
- data/lib/activerecord-clickhouse-adapter.rb +21 -0
- data/lib/arel/visitors/clickhouse.rb +172 -0
- metadata +82 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
require "active_record/connection_adapters/clickhouse/row_binary"
|
|
9
|
+
|
|
10
|
+
module ActiveRecord
|
|
11
|
+
module ConnectionAdapters
|
|
12
|
+
module ClickHouse
|
|
13
|
+
# Raw connection to a ClickHouse server over its HTTP interface: one persistent
|
|
14
|
+
# keep-alive Net::HTTP socket per adapter instance (the adapter lock serializes use).
|
|
15
|
+
# Results arrive as RowBinaryWithNamesAndTypes by default — names, type strings,
|
|
16
|
+
# then packed binary rows — so every value comes back with its server type; queries
|
|
17
|
+
# whose types have no binary decoder retry transparently on the JSON wire
|
|
18
|
+
# (select_format: :json in the config forces JSON for everything).
|
|
19
|
+
class HTTPConnection
|
|
20
|
+
BINARY_FORMAT = "RowBinaryWithNamesAndTypes"
|
|
21
|
+
JSON_FORMAT = "JSONCompactEachRowWithNamesAndTypes"
|
|
22
|
+
|
|
23
|
+
class ExecutionError < StandardError
|
|
24
|
+
attr_reader :code
|
|
25
|
+
|
|
26
|
+
def initialize(message, code: nil)
|
|
27
|
+
super(message)
|
|
28
|
+
@code = code
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class RawResult
|
|
33
|
+
attr_reader :columns, :types, :rows, :summary, :query_id
|
|
34
|
+
|
|
35
|
+
def initialize(columns: [], types: [], rows: [], summary: {}, query_id: nil)
|
|
36
|
+
@columns = columns
|
|
37
|
+
@types = types
|
|
38
|
+
@rows = rows
|
|
39
|
+
@summary = summary
|
|
40
|
+
@query_id = query_id
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def written_rows = Integer(summary.fetch("written_rows", 0))
|
|
44
|
+
|
|
45
|
+
# Server-side execution stats, for the sql.active_record notification payload.
|
|
46
|
+
def stats
|
|
47
|
+
{
|
|
48
|
+
query_id: query_id,
|
|
49
|
+
read_rows: Integer(summary.fetch("read_rows", 0)),
|
|
50
|
+
read_bytes: Integer(summary.fetch("read_bytes", 0)),
|
|
51
|
+
written_rows: written_rows,
|
|
52
|
+
elapsed_ns: Integer(summary.fetch("elapsed_ns", 0))
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def initialize(config)
|
|
58
|
+
@config = config
|
|
59
|
+
@select_format = config[:select_format].to_s == "json" ? JSON_FORMAT : BINARY_FORMAT
|
|
60
|
+
@http = build_http
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Adapts an enumerator of encoded lines to the partial-read IO contract
|
|
64
|
+
# Net::HTTP uses for chunked transfer encoding, so a streamed INSERT never
|
|
65
|
+
# holds more than one chunk of the body in memory.
|
|
66
|
+
class ChunkedBody
|
|
67
|
+
CHUNK_BYTES = 64 * 1024
|
|
68
|
+
|
|
69
|
+
def initialize(lines)
|
|
70
|
+
@lines = lines
|
|
71
|
+
@buffer = +""
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def read(length = CHUNK_BYTES, out = +"")
|
|
75
|
+
buffer_lines(length)
|
|
76
|
+
return nil if @buffer.empty?
|
|
77
|
+
|
|
78
|
+
out.replace(@buffer.slice!(0, length))
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def buffer_lines(length)
|
|
84
|
+
@buffer << @lines.next << "\n" while @buffer.bytesize < length
|
|
85
|
+
rescue StopIteration
|
|
86
|
+
nil
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def execute(sql, params: {})
|
|
91
|
+
parse(perform(sql, params, @select_format), @select_format)
|
|
92
|
+
rescue RowBinary::Undecodable
|
|
93
|
+
parse(perform(sql, params, JSON_FORMAT), JSON_FORMAT)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Streams pre-encoded body lines as one chunked POST; the statement travels
|
|
97
|
+
# in the query string because the request body is the data.
|
|
98
|
+
def execute_stream(sql, lines)
|
|
99
|
+
request = post_request(query_params({}, JSON_FORMAT).merge(query: sql))
|
|
100
|
+
request["Transfer-Encoding"] = "chunked"
|
|
101
|
+
request.body_stream = ChunkedBody.new(lines)
|
|
102
|
+
parse(raise_unless_success(@http.request(request)), JSON_FORMAT)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Scopes extra server settings to the requests made inside the block — the
|
|
106
|
+
# write-side counterpart of the SETTINGS clause SELECTs carry in-SQL.
|
|
107
|
+
def with_request_settings(settings)
|
|
108
|
+
previous = @request_settings
|
|
109
|
+
@request_settings = (previous || {}).merge(settings)
|
|
110
|
+
yield
|
|
111
|
+
ensure
|
|
112
|
+
@request_settings = previous
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def ping
|
|
116
|
+
execute("SELECT 1")
|
|
117
|
+
true
|
|
118
|
+
rescue StandardError
|
|
119
|
+
false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def close
|
|
123
|
+
@http.finish if @http.started?
|
|
124
|
+
rescue IOError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
private
|
|
129
|
+
|
|
130
|
+
def build_http
|
|
131
|
+
http = Net::HTTP.new(@config[:host], @config[:port])
|
|
132
|
+
configure_tls(http)
|
|
133
|
+
http.open_timeout = @config[:connect_timeout] if @config[:connect_timeout]
|
|
134
|
+
http.read_timeout = @config[:read_timeout] if @config[:read_timeout]
|
|
135
|
+
http.write_timeout = @config[:write_timeout] if @config[:write_timeout]
|
|
136
|
+
http
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Verification stays ON by default; ssl_verify: false is the explicit escape
|
|
140
|
+
# hatch for sinks terminating TLS with a self-signed certificate.
|
|
141
|
+
def configure_tls(http)
|
|
142
|
+
http.use_ssl = @config[:ssl] if @config.key?(:ssl)
|
|
143
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if @config[:ssl_verify] == false
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def perform(sql, params, format)
|
|
147
|
+
request = post_request(query_params(params, format))
|
|
148
|
+
request.body = sql
|
|
149
|
+
raise_unless_success(@http.request(request))
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def post_request(params)
|
|
153
|
+
request = Net::HTTP::Post.new("/?#{URI.encode_www_form(params)}")
|
|
154
|
+
request["X-ClickHouse-User"] = @config[:username] if @config[:username]
|
|
155
|
+
request["X-ClickHouse-Key"] = @config[:password] if @config[:password]
|
|
156
|
+
request
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def raise_unless_success(response)
|
|
160
|
+
raise_execution_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
161
|
+
|
|
162
|
+
response
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def query_params(params, format)
|
|
166
|
+
session_settings(format).merge(async_insert_params)
|
|
167
|
+
.merge(@request_settings || {})
|
|
168
|
+
.merge(params.transform_keys { |key| "param_#{key}" }).compact
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Defaults corrupt silently: JSON Decimals float-parse lossily, NaN/Inf become
|
|
172
|
+
# null; binary JSON columns have no stable layout, so they travel as text.
|
|
173
|
+
OUTPUT_SETTINGS = {
|
|
174
|
+
output_format_json_quote_decimals: 1,
|
|
175
|
+
output_format_json_quote_denormals: 1,
|
|
176
|
+
output_format_binary_write_json_as_string: 1
|
|
177
|
+
}.freeze
|
|
178
|
+
private_constant :OUTPUT_SETTINGS
|
|
179
|
+
|
|
180
|
+
def session_settings(format)
|
|
181
|
+
OUTPUT_SETTINGS.merge(
|
|
182
|
+
database: @config[:database],
|
|
183
|
+
default_format: format,
|
|
184
|
+
wait_end_of_query: 1,
|
|
185
|
+
# 1/2 make ALTER UPDATE/DELETE mutations block until applied (spec determinism).
|
|
186
|
+
mutations_sync: @config[:mutations_sync],
|
|
187
|
+
# ClickHouse fills non-matched outer-join columns with type defaults (0, '');
|
|
188
|
+
# every other AR adapter returns SQL NULLs, so default to standard semantics.
|
|
189
|
+
join_use_nulls: @config.fetch(:join_use_nulls, 1),
|
|
190
|
+
# By default the server coerces NULL to the type default on insert into a
|
|
191
|
+
# non-Nullable column — silent data corruption by AR semantics. Off, the
|
|
192
|
+
# insert raises (code 53, probed 2026-07-14) and maps to NotNullViolation.
|
|
193
|
+
input_format_null_as_default: 0,
|
|
194
|
+
# Server gzips responses ~3.6x smaller; Net::HTTP decompresses transparently
|
|
195
|
+
# (it sends Accept-Encoding: gzip by default). Probed 2026-07-12, PLAN.md §2.
|
|
196
|
+
enable_http_compression: @config.fetch(:compression, true) ? 1 : 0
|
|
197
|
+
)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# Server-side batching for high-frequency small INSERTs. wait_for_async_insert
|
|
201
|
+
# defaults to 1 so an acked insert is durable; 0 (fire-and-forget) is an
|
|
202
|
+
# explicit opt-in because it loses acked rows on a server crash.
|
|
203
|
+
def async_insert_params
|
|
204
|
+
return {} unless @config[:async_insert]
|
|
205
|
+
|
|
206
|
+
{ async_insert: 1, wait_for_async_insert: @config.fetch(:wait_for_async_insert, 1) }
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def raise_execution_error(response)
|
|
210
|
+
code = response["x-clickhouse-exception-code"]&.to_i
|
|
211
|
+
raise ExecutionError.new(response.body.to_s.strip, code: code)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def parse(response, format)
|
|
215
|
+
names, types, rows = decode_body(response.body.to_s, format)
|
|
216
|
+
|
|
217
|
+
RawResult.new(
|
|
218
|
+
columns: names, types: types, rows: rows,
|
|
219
|
+
summary: JSON.parse(response["x-clickhouse-summary"] || "{}"),
|
|
220
|
+
query_id: response["x-clickhouse-query-id"]
|
|
221
|
+
)
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def decode_body(body, format)
|
|
225
|
+
return RowBinary.decode(body) if format == BINARY_FORMAT
|
|
226
|
+
|
|
227
|
+
names, types, *rows = body.each_line.map { |line| JSON.parse(line) }
|
|
228
|
+
[names || [], types || [], rows]
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
end
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module ClickHouse
|
|
6
|
+
# Custom Arel nodes carrying ClickHouse dialect state through the AST; only
|
|
7
|
+
# Arel::Visitors::ClickHouse knows how to render them.
|
|
8
|
+
module Nodes
|
|
9
|
+
# Wraps the FROM table to append FINAL and/or SAMPLE (table modifiers).
|
|
10
|
+
class TableWithModifiers < ::Arel::Nodes::Unary
|
|
11
|
+
attr_accessor :final, :sample
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# A condition rendered as PREWHERE, smuggled through SelectCore#wheres.
|
|
15
|
+
class Prewhere < ::Arel::Nodes::Unary
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Rides in SelectStatement#lock (ClickHouse has no row locks): LIMIT BY
|
|
19
|
+
# renders before LIMIT, SETTINGS renders last.
|
|
20
|
+
class DialectSuffix < ::Arel::Nodes::Node
|
|
21
|
+
attr_reader :limit_by, :settings
|
|
22
|
+
|
|
23
|
+
def initialize(limit_by: nil, settings: nil)
|
|
24
|
+
super()
|
|
25
|
+
@limit_by = limit_by
|
|
26
|
+
@settings = settings
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Wraps an ORDER BY expression to append WITH FILL [STEP n] (gap filling).
|
|
31
|
+
class OrderWithFill < ::Arel::Nodes::Unary
|
|
32
|
+
attr_accessor :step
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# ARRAY JOIN unnests array columns into rows; rides the join list so it renders
|
|
36
|
+
# after FROM and any regular JOINs, before WHERE.
|
|
37
|
+
class ArrayJoin < ::Arel::Nodes::Node
|
|
38
|
+
attr_reader :columns, :left, :alias_name
|
|
39
|
+
|
|
40
|
+
def initialize(columns, left:, alias_name:)
|
|
41
|
+
super()
|
|
42
|
+
@columns = columns
|
|
43
|
+
@left = left
|
|
44
|
+
@alias_name = alias_name
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Relation methods for the ClickHouse query surface. Injected via
|
|
50
|
+
# `extending` (public Relation API) by ClickHouse::Querying — no reopens.
|
|
51
|
+
module RelationMethods
|
|
52
|
+
def final = spawn.final!
|
|
53
|
+
|
|
54
|
+
def final!
|
|
55
|
+
assign_clickhouse_dialect(final: true)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def sample(fraction) = spawn.sample!(fraction)
|
|
59
|
+
|
|
60
|
+
def sample!(fraction)
|
|
61
|
+
assign_clickhouse_dialect(sample: Float(fraction))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def prewhere(condition) = spawn.prewhere!(condition)
|
|
65
|
+
|
|
66
|
+
def prewhere!(condition)
|
|
67
|
+
sanitized = klass.send(:sanitize_sql_for_conditions, condition)
|
|
68
|
+
assign_clickhouse_dialect(prewhere: [*clickhouse_dialect[:prewhere], sanitized])
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def settings(values) = spawn.settings!(values)
|
|
72
|
+
|
|
73
|
+
def settings!(values)
|
|
74
|
+
assign_clickhouse_dialect(settings: (clickhouse_dialect[:settings] || {}).merge(values))
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def limit_by(count, *columns) = spawn.limit_by!(count, *columns)
|
|
78
|
+
|
|
79
|
+
def limit_by!(count, *columns)
|
|
80
|
+
assign_clickhouse_dialect(limit_by: [Integer(count), columns.flatten])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# toStartOfInterval buckets any period the server knows; grouping and ordering
|
|
84
|
+
# by the same expression makes `.count` come back as a chronological series.
|
|
85
|
+
GROUP_BY_PERIODS = %w[minute hour day week month quarter year].freeze
|
|
86
|
+
|
|
87
|
+
def group_by_period(period, column) = spawn.group_by_period!(period, column)
|
|
88
|
+
|
|
89
|
+
def group_by_period!(period, column)
|
|
90
|
+
unless GROUP_BY_PERIODS.include?(period.to_s)
|
|
91
|
+
raise ArgumentError, "unknown period #{period.inspect}; use one of #{GROUP_BY_PERIODS.join(", ")}"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
bucket = ::Arel::Nodes::NamedFunction.new(
|
|
95
|
+
"toStartOfInterval", [klass.arel_table[column], ::Arel.sql("INTERVAL 1 #{period}")]
|
|
96
|
+
)
|
|
97
|
+
group!(bucket).order!(bucket)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def fill(step: nil) = spawn.fill!(step: step)
|
|
101
|
+
|
|
102
|
+
def fill!(step: nil)
|
|
103
|
+
assign_clickhouse_dialect(fill: { step: fill_step_sql(step) })
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Unnests array columns into one row per element; as: names the element so the
|
|
107
|
+
# original array column stays addressable. left: true keeps empty-array rows.
|
|
108
|
+
def array_join(*columns, left: false, as: nil) = spawn.array_join!(*columns, left: left, as: as)
|
|
109
|
+
|
|
110
|
+
def array_join!(*columns, left: false, as: nil)
|
|
111
|
+
raise ArgumentError, "as: names a single element, so array_join takes one column with it" if
|
|
112
|
+
as && columns.many?
|
|
113
|
+
|
|
114
|
+
assign_clickhouse_dialect(array_join: { columns: columns.flatten, left: left, as: as })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# ClickHouse renders WITH TOTALS out-of-band, which the row-stream wire format
|
|
118
|
+
# drops (probed 2026-07-13) — ROLLUP delivers totals as ordinary rows instead,
|
|
119
|
+
# keyed nil thanks to group_by_use_nulls.
|
|
120
|
+
def rollup = spawn.rollup!
|
|
121
|
+
|
|
122
|
+
def rollup!
|
|
123
|
+
settings!(group_by_use_nulls: 1)
|
|
124
|
+
assign_clickhouse_dialect(rollup: true)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def clickhouse_dialect
|
|
128
|
+
@clickhouse_dialect ||= {}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def assign_clickhouse_dialect(changes)
|
|
134
|
+
@clickhouse_dialect = clickhouse_dialect.merge(changes)
|
|
135
|
+
self
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def fill_step_sql(step)
|
|
139
|
+
case step
|
|
140
|
+
when nil then nil
|
|
141
|
+
when ActiveSupport::Duration then "INTERVAL #{Integer(step.to_i)} SECOND"
|
|
142
|
+
else Integer(step).to_s
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def build_arel(...)
|
|
147
|
+
manager = super
|
|
148
|
+
apply_clickhouse_dialect(manager.ast) unless clickhouse_dialect.empty?
|
|
149
|
+
manager
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Projects one window expression per call, e.g.
|
|
154
|
+
# window(:sum, :battery, as: :drained, partition_by: :device_id, order_by: :observed_at).
|
|
155
|
+
# Arel's own Window/Over nodes do the rendering; the function name, alias and
|
|
156
|
+
# frame are the only free-form strings, so they are validated here.
|
|
157
|
+
module RelationWindowing
|
|
158
|
+
WINDOW_IDENTIFIER = /\A[A-Za-z_]\w*\z/
|
|
159
|
+
WINDOW_FRAME = /\A(?:ROWS|RANGE|GROUPS)\b[\w\s]+\z/i
|
|
160
|
+
|
|
161
|
+
def window(function, *columns, as:, partition_by: nil, order_by: nil, frame: nil)
|
|
162
|
+
spawn.window!(function, *columns, as: as, partition_by: partition_by, order_by: order_by, frame: frame)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def window!(function, *columns, as:, partition_by: nil, order_by: nil, frame: nil)
|
|
166
|
+
projection = window_projection(function, columns, window_definition(partition_by, order_by, frame))
|
|
167
|
+
self.select_values += [klass.arel_table[::Arel.star]] if select_values.empty?
|
|
168
|
+
self.select_values += [::Arel::Nodes::As.new(projection, ::Arel.sql(window_identifier(as, "alias")))]
|
|
169
|
+
self
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
private
|
|
173
|
+
|
|
174
|
+
def window_projection(function, columns, definition)
|
|
175
|
+
arguments = columns.map { |column| klass.arel_table[column] }
|
|
176
|
+
::Arel::Nodes::NamedFunction.new(window_identifier(function, "function"), arguments).over(definition)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def window_identifier(value, role)
|
|
180
|
+
raise ArgumentError, "window #{role} must be an identifier" unless WINDOW_IDENTIFIER.match?(value.to_s)
|
|
181
|
+
|
|
182
|
+
value.to_s
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def window_definition(partition_by, order_by, frame)
|
|
186
|
+
raise ArgumentError, "window frame must start with ROWS, RANGE or GROUPS" if
|
|
187
|
+
frame && !WINDOW_FRAME.match?(frame)
|
|
188
|
+
|
|
189
|
+
definition = ::Arel::Nodes::Window.new
|
|
190
|
+
Array(partition_by).each { |column| definition.partition(klass.arel_table[column]) }
|
|
191
|
+
window_orderings(order_by).each { |ordering| definition.order(ordering) }
|
|
192
|
+
definition.frame(::Arel.sql(frame)) if frame
|
|
193
|
+
definition
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def window_orderings(order_by)
|
|
197
|
+
return order_by.map { |column, direction| window_ordering(column, direction) } if order_by.is_a?(Hash)
|
|
198
|
+
|
|
199
|
+
Array(order_by).map { |column| window_ordering(column, :asc) }
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def window_ordering(column, direction)
|
|
203
|
+
direction.to_s == "desc" ? klass.arel_table[column].desc : klass.arel_table[column].asc
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Projects a dictionary lookup alongside the row — dictGet replaces the
|
|
208
|
+
# dimension JOIN of a star schema. Dictionary/attribute names travel as quoted
|
|
209
|
+
# string literals; only the alias needs identifier validation.
|
|
210
|
+
module RelationDictionaries
|
|
211
|
+
def dict_get(dictionary, attribute, key:, as: nil, default: nil)
|
|
212
|
+
spawn.dict_get!(dictionary, attribute, key: key, as: as, default: default)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def dict_get!(dictionary, attribute, key:, as: nil, default: nil)
|
|
216
|
+
alias_name = (as || attribute).to_s
|
|
217
|
+
raise ArgumentError, "dict_get alias must be an identifier" unless
|
|
218
|
+
RelationWindowing::WINDOW_IDENTIFIER.match?(alias_name)
|
|
219
|
+
|
|
220
|
+
self.select_values += [klass.arel_table[::Arel.star]] if select_values.empty?
|
|
221
|
+
self.select_values += [::Arel::Nodes::As.new(dict_get_node(dictionary, attribute, key, default),
|
|
222
|
+
::Arel.sql(alias_name))]
|
|
223
|
+
self
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
def dict_get_node(dictionary, attribute, key, default)
|
|
229
|
+
arguments = [::Arel::Nodes.build_quoted(dictionary.to_s), ::Arel::Nodes.build_quoted(attribute.to_s),
|
|
230
|
+
klass.arel_table[key]]
|
|
231
|
+
return ::Arel::Nodes::NamedFunction.new("dictGet", arguments) if default.nil?
|
|
232
|
+
|
|
233
|
+
::Arel::Nodes::NamedFunction.new("dictGetOrDefault", arguments + [::Arel::Nodes.build_quoted(default)])
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Compiles the dialect state RelationMethods accumulated into the Arel AST right
|
|
238
|
+
# before SQL generation; only RelationMethods#build_arel calls in here.
|
|
239
|
+
module RelationDialectCompilation
|
|
240
|
+
private
|
|
241
|
+
|
|
242
|
+
def apply_clickhouse_dialect(ast)
|
|
243
|
+
dialect = clickhouse_dialect
|
|
244
|
+
core = ast.cores.last
|
|
245
|
+
apply_table_modifiers(core, dialect)
|
|
246
|
+
apply_array_join(core, dialect)
|
|
247
|
+
apply_prewheres(core, dialect)
|
|
248
|
+
apply_rollup(core, dialect)
|
|
249
|
+
apply_fill(ast, dialect)
|
|
250
|
+
return unless dialect[:limit_by] || dialect[:settings]
|
|
251
|
+
|
|
252
|
+
ast.lock = Nodes::DialectSuffix.new(limit_by: dialect[:limit_by], settings: dialect[:settings])
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def apply_array_join(core, dialect)
|
|
256
|
+
spec = dialect[:array_join] or return
|
|
257
|
+
|
|
258
|
+
columns = spec[:columns].map { |column| klass.arel_table[column] }
|
|
259
|
+
core.source.right << Nodes::ArrayJoin.new(columns, left: spec[:left], alias_name: spec[:as])
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def apply_rollup(core, dialect)
|
|
263
|
+
return unless dialect[:rollup]
|
|
264
|
+
raise ArgumentError, "rollup requires a grouped relation" if core.groups.empty?
|
|
265
|
+
|
|
266
|
+
core.groups = [::Arel::Nodes::Group.new(::Arel::Nodes::RollUp.new(core.groups.map(&:expr)))]
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def apply_fill(ast, dialect)
|
|
270
|
+
fill = dialect[:fill] or return
|
|
271
|
+
raise ArgumentError, "fill requires an ordered relation (WITH FILL rides on ORDER BY)" if ast.orders.empty?
|
|
272
|
+
|
|
273
|
+
filled = Nodes::OrderWithFill.new(ast.orders.pop)
|
|
274
|
+
filled.step = fill[:step]
|
|
275
|
+
ast.orders.push(filled)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def apply_prewheres(core, dialect)
|
|
279
|
+
Array(dialect[:prewhere]).each do |condition|
|
|
280
|
+
core.wheres.unshift(Nodes::Prewhere.new(::Arel.sql(condition)))
|
|
281
|
+
end
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
def apply_table_modifiers(core, dialect)
|
|
285
|
+
return unless dialect[:final] || dialect[:sample]
|
|
286
|
+
|
|
287
|
+
modified = Nodes::TableWithModifiers.new(core.source.left)
|
|
288
|
+
modified.final = dialect[:final]
|
|
289
|
+
modified.sample = dialect[:sample]
|
|
290
|
+
core.source.left = modified
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
# Writes can't carry an in-SQL SETTINGS clause the way SELECTs do (ALTER and
|
|
295
|
+
# INSERT grammars each differ), so a relation's settings travel as per-request
|
|
296
|
+
# HTTP query parameters instead.
|
|
297
|
+
module RelationWrites
|
|
298
|
+
def update_all(...) = with_write_settings { super }
|
|
299
|
+
|
|
300
|
+
def delete_all(...) = with_write_settings { super }
|
|
301
|
+
|
|
302
|
+
def insert_all(...) = with_write_settings { super }
|
|
303
|
+
|
|
304
|
+
def insert_all!(...) = with_write_settings { super }
|
|
305
|
+
|
|
306
|
+
private
|
|
307
|
+
|
|
308
|
+
def with_write_settings(&block)
|
|
309
|
+
settings = clickhouse_dialect[:settings]
|
|
310
|
+
return yield if settings.blank?
|
|
311
|
+
|
|
312
|
+
klass.with_connection { |connection| connection.with_request_settings(settings, &block) }
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Terminal calculations over ClickHouse's aggregate-function library; separate
|
|
317
|
+
# from RelationMethods because these execute immediately rather than build state.
|
|
318
|
+
# All accept merge: true (-Merge, finishing AggregateFunction state columns) and
|
|
319
|
+
# if: condition (-If, per-row conditional aggregation in a single scan); `if` is
|
|
320
|
+
# a Ruby keyword, so it travels in **options.
|
|
321
|
+
module RelationCalculations
|
|
322
|
+
def uniq_count(column, exact: false, merge: false, **options)
|
|
323
|
+
aggregate(exact ? "uniqExact" : "uniq", [klass.arel_table[column]],
|
|
324
|
+
merge: merge, condition: options[:if])
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Parametric aggregates render as name(param)(args); Float/Integer coercion is
|
|
328
|
+
# the injection guard for the parameter.
|
|
329
|
+
def quantile(fraction, column, merge: false, **options)
|
|
330
|
+
aggregate("quantile", [klass.arel_table[column]],
|
|
331
|
+
parameter: Float(fraction), merge: merge, condition: options[:if])
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def top_k(count, column, merge: false, **options)
|
|
335
|
+
aggregate("topK", [klass.arel_table[column]],
|
|
336
|
+
parameter: Integer(count), merge: merge, condition: options[:if])
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
def arg_max(column, criterion, **options)
|
|
340
|
+
aggregate("argMax", [klass.arel_table[column], klass.arel_table[criterion]], condition: options[:if])
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def arg_min(column, criterion, **options)
|
|
344
|
+
aggregate("argMin", [klass.arel_table[column], klass.arel_table[criterion]], condition: options[:if])
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Table-level row estimate from metadata — O(1), ignores relation scopes.
|
|
348
|
+
def estimated_count
|
|
349
|
+
klass.with_connection do |connection|
|
|
350
|
+
connection.select_value(<<~SQL.squish, "#{klass.name} Estimated Count").to_i
|
|
351
|
+
SELECT total_rows FROM system.tables
|
|
352
|
+
WHERE database = currentDatabase() AND name = #{connection.quote(klass.table_name)}
|
|
353
|
+
SQL
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
private
|
|
358
|
+
|
|
359
|
+
def aggregate(name, columns, parameter: nil, merge: false, condition: nil)
|
|
360
|
+
raise ArgumentError, "merge: and if: cannot combine (ClickHouse has no -MergeIf)" if merge && condition
|
|
361
|
+
|
|
362
|
+
function = +name
|
|
363
|
+
function << "Merge" if merge
|
|
364
|
+
function << "If" if condition
|
|
365
|
+
function << "(#{parameter})" unless parameter.nil?
|
|
366
|
+
columns << condition_node(condition) if condition
|
|
367
|
+
pick_aggregate(function, columns)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
# Hashes go through the relation's own predicate builder (ranges, arrays and
|
|
371
|
+
# casting come free); strings/arrays through sanitize_sql like prewhere.
|
|
372
|
+
def condition_node(condition)
|
|
373
|
+
return klass.unscoped.where(condition).where_clause.ast if condition.is_a?(Hash)
|
|
374
|
+
|
|
375
|
+
::Arel.sql(klass.send(:sanitize_sql_for_conditions, condition))
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
# Ungrouped: one value. Grouped: a hash keyed like Rails' grouped calculations
|
|
379
|
+
# (scalar for one group column, array for several).
|
|
380
|
+
def pick_aggregate(function, columns)
|
|
381
|
+
node = ::Arel::Nodes::NamedFunction.new(function, columns)
|
|
382
|
+
return unscope(:order, :select).pick(node) if group_values.empty?
|
|
383
|
+
|
|
384
|
+
pluck(*group_values, node)
|
|
385
|
+
.to_h { |row| [group_values.many? ? row[0..-2] : row.first, row.last] }
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Opt-in model concern: `include ActiveRecord::ConnectionAdapters::ClickHouse::Querying`
|
|
390
|
+
# gives every relation of the model the ClickHouse dialect surface
|
|
391
|
+
# (.final/.sample/.prewhere/.settings/.limit_by/.group_by_period/.fill/.rollup)
|
|
392
|
+
# and the OLAP calculations (.uniq_count/.quantile/.top_k/.arg_max/.arg_min).
|
|
393
|
+
module Querying
|
|
394
|
+
extend ActiveSupport::Concern
|
|
395
|
+
|
|
396
|
+
included do
|
|
397
|
+
default_scope do
|
|
398
|
+
extending(RelationMethods, RelationWindowing, RelationDictionaries,
|
|
399
|
+
RelationDialectCompilation, RelationWrites, RelationCalculations)
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
403
|
+
class_methods do
|
|
404
|
+
delegate :final, :sample, :prewhere, :settings, :limit_by, :array_join,
|
|
405
|
+
:group_by_period, :fill, :rollup, :window, :dict_get, :uniq_count,
|
|
406
|
+
:quantile, :top_k, :arg_max, :arg_min, :estimated_count, to: :all
|
|
407
|
+
|
|
408
|
+
# insert_all's streaming sibling: the batch goes over the wire as one
|
|
409
|
+
# chunked INSERT instead of being rendered into a SQL string first.
|
|
410
|
+
def insert_stream(rows, columns: nil)
|
|
411
|
+
with_connection { |connection| connection.insert_stream(table_name, rows, columns: columns) }
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|