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