activerecord-clickhouse-adapter 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +89 -1
- data/README.md +22 -2
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +13 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +13 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +1 -1
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +102 -6
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +9 -2
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +3 -7
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +73 -11
- data/lib/active_record/connection_adapters/clickhouse/types.rb +6 -3
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +26 -5
- data/lib/arel/visitors/clickhouse.rb +25 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3f16a02cdd51358de67461ac920dcecdb2dcec096b7c47898a91e2d555b5f0b0
|
|
4
|
+
data.tar.gz: 49fdee335e90e3bdae9a28a5bce316d024f2161b45758128cdd7c424611da7d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f13d3bcfce5f752f6e6c2b2ba4a559bfdae3b192d04ac27034e2d49b1151a4c659d392d005a44e53dc03d997a8cbc9631650a5d80756bb23bb4f416bd8ddffdb
|
|
7
|
+
data.tar.gz: 2be555427541cf9329053c10acd00265e46780b6dd401b8309333e8d606b4d7a235fb18a35aacb4331c56abccbe48b725664dab2ceb91f81910d16b3fe314b90
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,92 @@
|
|
|
1
|
-
# 0.
|
|
1
|
+
# 0.2.0 (2026-07-23)
|
|
2
|
+
|
|
3
|
+
Breaking-ish (schema diff noise, not data changes):
|
|
4
|
+
|
|
5
|
+
- Updated `t.datetime` to default to precision 6 (microseconds, Rails' convention) —
|
|
6
|
+
previously an unqualified datetime rendered `DateTime64(3)`. Existing consumer
|
|
7
|
+
schemas diff as `DateTime64(3)` → `DateTime64(6)`; declare `precision: 3` to keep
|
|
8
|
+
the old shape
|
|
9
|
+
- Fixed decimal DDL bounds: `precision:` without `scale:` now means scale 0 (the old
|
|
10
|
+
`Decimal(N, 10)` shape was invalid for N < 10), and `scale:` without `precision:`
|
|
11
|
+
raises the same `ArgumentError` as Rails' bundled adapters
|
|
12
|
+
|
|
13
|
+
Added:
|
|
14
|
+
|
|
15
|
+
- Added a runnable OLAP-on-Rails example (`examples/olap_on_rails.rb`): fact table,
|
|
16
|
+
streaming ingestion, dictionary lookups, the aggregate-state pipeline, mutable
|
|
17
|
+
dimensions via ReplacingMergeTree + `final`, partitions, and instrumentation —
|
|
18
|
+
guarded by a live spec so it cannot drift
|
|
19
|
+
- Added `:binary`/`:blob` column types, mapped to `String` (ClickHouse strings are
|
|
20
|
+
arbitrary byte sequences)
|
|
21
|
+
- Added `payload[:affected_rows]` to `sql.active_record` notifications, populated
|
|
22
|
+
from the server summary's written rows on every query and on `insert_stream`
|
|
23
|
+
- Added case-insensitive `matches`/`does_not_match` rendering via ClickHouse's
|
|
24
|
+
native `ILIKE` (LIKE is case-sensitive here, unlike MySQL); a custom ESCAPE
|
|
25
|
+
character raises `NotImplementedError` because ClickHouse has no ESCAPE clause
|
|
26
|
+
- Added a no-op `FOR UPDATE` visitor (reads are isolated snapshots of parts; no row
|
|
27
|
+
locks exist), so shared `Model.lock`/`with_lock` code runs instead of dying —
|
|
28
|
+
optimistic locking via `lock_version` works end-to-end
|
|
29
|
+
- Added multi-replica support: `hosts:` lists interchangeable endpoints ("host" or
|
|
30
|
+
"host:port"); connections round-robin their starting endpoint, fail over on
|
|
31
|
+
connect-phase errors only (a request that never reached a server cannot double a
|
|
32
|
+
write — mid-flight failures still raise), and skip endpoints that refused within
|
|
33
|
+
`failover_cooldown:` seconds (default 30)
|
|
34
|
+
- Added `read_only: true` connection option: stamps `readonly=2` on every request so
|
|
35
|
+
the server itself refuses writes; the refusal (code 164) raises
|
|
36
|
+
`ActiveRecord::ReadOnlyError` — the same class Rails' `while_preventing_writes`
|
|
37
|
+
uses — including for server-configured readonly users
|
|
38
|
+
- Added `ClickHouse::AccessDenied` (< `ActiveRecord::StatementInvalid`) for the
|
|
39
|
+
server's grant refusals (code 497)
|
|
40
|
+
- Added `Errno::ENETUNREACH` to the failover connect-error list (connect-phase by
|
|
41
|
+
nature, added on review — not reproducible in the test container)
|
|
42
|
+
- Added primary-key reporting for tables whose sorting key is a single integer or
|
|
43
|
+
UUID column: Rails now auto-detects the model's primary key on id-keyed tables
|
|
44
|
+
(`find`/`update`/`destroy` and client-generated ids work without
|
|
45
|
+
`self.primary_key`); composite, expression, and non-id sorting keys still report
|
|
46
|
+
none — ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee —
|
|
47
|
+
and schema dumps keep the explicit `id: false` + `order:` shape
|
|
48
|
+
- Added Rails-style `create_table id: :bigint/:uuid` (and bare `id: :primary_key`,
|
|
49
|
+
a plain Int64): the pk column doubles as the sorting key so no `order:` is
|
|
50
|
+
needed, and ids are generated client-side — no autoincrement exists
|
|
51
|
+
|
|
52
|
+
Fixed:
|
|
53
|
+
|
|
54
|
+
- Fixed `lookup_cast_type` to resolve ClickHouse type names through the adapter's own
|
|
55
|
+
parser: the abstract TYPE_MAP degraded `Nullable(...)`, `UUID`, `Bool`, and `Map`
|
|
56
|
+
to bare `Type::Value` and even matched `Tuple(String, Int64)` as Integer; results
|
|
57
|
+
are frozen so lookups stay Ractor-shareable
|
|
58
|
+
- Fixed `create_table` with a composite `primary_key:` array to render a quoted
|
|
59
|
+
`PRIMARY KEY (a, b)` tuple; a PRIMARY KEY clause alone now satisfies the
|
|
60
|
+
sorting-key requirement (the server infers ORDER BY from it)
|
|
61
|
+
- Fixed `disconnect!` to close the raw HTTP connection while still holding the
|
|
62
|
+
adapter lock (the postgresql adapter's pattern) — closing after release let a
|
|
63
|
+
concurrently queued query start its read on a dying socket
|
|
64
|
+
- Fixed SQL containing invalid UTF-8 bytes to reach the server instead of raising
|
|
65
|
+
`ArgumentError` client-side (ClickHouse strings are byte sequences; the server
|
|
66
|
+
accepts raw bytes in literals and backtick identifiers)
|
|
67
|
+
- Fixed datetime DDL bounds: precision past 9 (nanoseconds, ClickHouse's maximum) now
|
|
68
|
+
raises `ArgumentError` at migration time instead of a server error, and an explicit
|
|
69
|
+
`precision: nil` maps to the second-precision base `DateTime` (a bare `t.datetime`
|
|
70
|
+
still gets Rails' default microseconds)
|
|
71
|
+
- Fixed schema dumps of datetime precision to follow Rails conventions: the default 6
|
|
72
|
+
is omitted and a precision-less `DateTime` dumps as `precision: nil`
|
|
73
|
+
- Fixed attribute-less creates: Rails' `INSERT INTO t DEFAULT VALUES` is not ClickHouse
|
|
74
|
+
syntax; the adapter now emits `FORMAT JSONEachRow {}` (one row, all table defaults)
|
|
75
|
+
- Updated datetime and date columns to expose `ActiveRecord::Type::DateTime`/`::Date`
|
|
76
|
+
(not the ActiveModel types): they respect `ActiveRecord.default_timezone`, and
|
|
77
|
+
Rails' time-zone-aware attribute machinery type-checks for them
|
|
78
|
+
|
|
79
|
+
Internal:
|
|
80
|
+
|
|
81
|
+
- Grew the Rails compatibility harness from ~2,200 to ~4,500 vendored upstream Active
|
|
82
|
+
Record tests (scoping, autosave, migrations, nested attributes, serialized
|
|
83
|
+
attributes, enums, dirty tracking, timestamps, batches, query cache, delegated
|
|
84
|
+
types, instrumentation, and two dozen more suites), each skip documented with the
|
|
85
|
+
dialect truth behind it
|
|
86
|
+
- Isolated the harness subprocess in a pid-suffixed ClickHouse database so concurrent
|
|
87
|
+
runs cannot collide
|
|
88
|
+
|
|
89
|
+
# 0.1.0 (2026-07-15)
|
|
2
90
|
|
|
3
91
|
First release. Highlights:
|
|
4
92
|
|
data/README.md
CHANGED
|
@@ -25,6 +25,10 @@ Requires Active Record 8.1+, Ruby 3.2+, and ClickHouse 25.8+ (each LTS from 25.8
|
|
|
25
25
|
|
|
26
26
|
## Getting Started
|
|
27
27
|
|
|
28
|
+
For a runnable end-to-end tour — fact table, streaming ingestion, dictionary
|
|
29
|
+
lookups, the aggregate-state pipeline, partitions — see
|
|
30
|
+
[`examples/olap_on_rails.rb`](examples/olap_on_rails.rb).
|
|
31
|
+
|
|
28
32
|
Add a ClickHouse database to `config/database.yml`:
|
|
29
33
|
|
|
30
34
|
```yaml
|
|
@@ -76,6 +80,14 @@ end
|
|
|
76
80
|
|
|
77
81
|
Columns are non-nullable by default, matching ClickHouse. Use `null: true` for `Nullable(...)`.
|
|
78
82
|
|
|
83
|
+
Rails-style id tables also work — `id: :bigint` (plain Int64, no autoincrement) or `id: :uuid` creates the pk column as its own sorting key, ids arrive client-generated, and models auto-detect the primary key:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
create_table :accounts, id: :bigint do |t|
|
|
87
|
+
t.string :name, default: ""
|
|
88
|
+
end
|
|
89
|
+
```
|
|
90
|
+
|
|
79
91
|
The full alter surface works on existing tables — `rename_column`, `change_column`, `change_column_null` (with the Rails backfill default), `change_column_default`, `change_column_comment`, `change_table_comment`, and `add_index`/`remove_index` for data-skipping indexes. `create_join_table` defaults its sorting key to the two reference columns.
|
|
80
92
|
|
|
81
93
|
ClickHouse-specific column options:
|
|
@@ -242,6 +254,8 @@ ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
|
242
254
|
end
|
|
243
255
|
```
|
|
244
256
|
|
|
257
|
+
The standard `payload[:affected_rows]` reports the server's written rows on inserts.
|
|
258
|
+
|
|
245
259
|
`explain` supports ClickHouse variants:
|
|
246
260
|
|
|
247
261
|
```ruby
|
|
@@ -260,6 +274,11 @@ clickhouse:
|
|
|
260
274
|
database: analytics_production
|
|
261
275
|
username: rails
|
|
262
276
|
password: secret
|
|
277
|
+
hosts: # interchangeable replicas ("host" or "host:port");
|
|
278
|
+
- ch-1.internal # connections round-robin start positions and fail over
|
|
279
|
+
- ch-2.internal:8124 # to the next host on connect-phase errors
|
|
280
|
+
failover_cooldown: 30 # seconds new connections skip an endpoint that refused
|
|
281
|
+
read_only: true # server-enforced reads only (readonly=2 on every request)
|
|
263
282
|
ssl: true # HTTPS to the server
|
|
264
283
|
ssl_verify: false # escape hatch for self-signed certificates (default: verify)
|
|
265
284
|
connect_timeout: 5
|
|
@@ -275,9 +294,10 @@ clickhouse:
|
|
|
275
294
|
## Semantics Worth Knowing
|
|
276
295
|
|
|
277
296
|
- **No transactions.** ClickHouse has none; `transaction` blocks run their contents without BEGIN/COMMIT and cannot roll back.
|
|
278
|
-
- **Primary keys are client-generated.** Tables with a single-column integer or UUID sorting key
|
|
297
|
+
- **Primary keys are client-generated and auto-detected.** Tables with a single-column integer or UUID sorting key report it as the Active Record primary key — models auto-detect it, and ids are assigned client-side before INSERT (Snowflake-style / UUIDv7). Composite, expression, and other sorting keys report none (ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee); declare `self.primary_key` explicitly when such a key is unique by design, e.g. a ReplacingMergeTree slug.
|
|
279
298
|
- **Mutation counts are best-effort.** `update_all`/`delete_all` return a pre-mutation `SELECT count()` — ClickHouse reports no affected-row counts.
|
|
280
299
|
- **Eventual merges.** `ReplacingMergeTree` deduplicates at merge time; read with `.final` when you need collapsed rows.
|
|
300
|
+
- **Failover never replays a request.** With `hosts:`, only connect-phase failures (refused, unreachable, open timeout) move to the next replica — those cannot have executed anything. A mid-flight failure (read timeout, reset) raises, because the statement may already have run.
|
|
281
301
|
|
|
282
302
|
## Development
|
|
283
303
|
|
|
@@ -297,7 +317,7 @@ RAILS_SOURCE=edge bundle install
|
|
|
297
317
|
RAILS_SOURCE=edge bundle exec rspec
|
|
298
318
|
```
|
|
299
319
|
|
|
300
|
-
The suite includes a Rails compatibility harness that runs vendored upstream Active Record test suites (~
|
|
320
|
+
The suite includes a Rails compatibility harness that runs vendored upstream Active Record test suites (~4,500 tests) against the adapter. See `spec/rails_compat/`.
|
|
301
321
|
|
|
302
322
|
## History
|
|
303
323
|
|
|
@@ -139,6 +139,13 @@ module ActiveRecord
|
|
|
139
139
|
HIGH_PRECISION_CURRENT_TIMESTAMP
|
|
140
140
|
end
|
|
141
141
|
|
|
142
|
+
# Rails renders an attribute-less create as "INSERT INTO t DEFAULT VALUES",
|
|
143
|
+
# which ClickHouse doesn't parse. An empty JSONEachRow row is the equivalent:
|
|
144
|
+
# one row, every column at its table default (probed live, PLAN.md §2).
|
|
145
|
+
def empty_insert_statement_value(_primary_key = nil)
|
|
146
|
+
"FORMAT JSONEachRow {}"
|
|
147
|
+
end
|
|
148
|
+
|
|
142
149
|
# Rails nests a SavepointTransaction inside any dirty transaction (e.g. the
|
|
143
150
|
# retry in create_or_find_by) even though supports_savepoints? is false. Like
|
|
144
151
|
# begin/commit/rollback (PLAN.md §5 decision 4), the savepoint verbs are honest
|
|
@@ -254,6 +261,7 @@ module ActiveRecord
|
|
|
254
261
|
with_raw_connection do |raw_connection|
|
|
255
262
|
result = raw_connection.execute_stream(sql, lines)
|
|
256
263
|
verified!
|
|
264
|
+
notification_payload[:affected_rows] = result.written_rows
|
|
257
265
|
notification_payload[:clickhouse] = result.stats
|
|
258
266
|
result.written_rows
|
|
259
267
|
end
|
|
@@ -295,6 +303,10 @@ module ActiveRecord
|
|
|
295
303
|
# rejects trailing comments (probed 2026-07-13), so sqlcommenter tags appended
|
|
296
304
|
# by Rails' QueryLogs are hoisted to the front of INSERT statements.
|
|
297
305
|
def hoist_trailing_comments(sql)
|
|
306
|
+
# ClickHouse strings are byte sequences; SQL carrying invalid UTF-8 must reach
|
|
307
|
+
# the server rather than die in these regexes, so it drops to binary (the
|
|
308
|
+
# patterns are pure ASCII and match byte-wise).
|
|
309
|
+
sql = sql.b unless sql.valid_encoding?
|
|
298
310
|
return sql unless INSERT_STATEMENT.match?(sql)
|
|
299
311
|
|
|
300
312
|
comments = sql[TRAILING_COMMENTS]
|
|
@@ -322,6 +334,7 @@ module ActiveRecord
|
|
|
322
334
|
result = raw_connection.execute(sql, params: params)
|
|
323
335
|
verified!
|
|
324
336
|
if notification_payload
|
|
337
|
+
notification_payload[:affected_rows] = result.written_rows
|
|
325
338
|
notification_payload[:row_count] = result.rows.size
|
|
326
339
|
notification_payload[:clickhouse] = result.stats
|
|
327
340
|
end
|
|
@@ -8,6 +8,7 @@ module ActiveRecord
|
|
|
8
8
|
class MemoryLimitExceeded < ActiveRecord::StatementInvalid; end
|
|
9
9
|
class QueryTimeout < ActiveRecord::StatementInvalid; end
|
|
10
10
|
class AuthenticationError < ActiveRecord::StatementInvalid; end
|
|
11
|
+
class AccessDenied < ActiveRecord::StatementInvalid; end
|
|
11
12
|
|
|
12
13
|
module ErrorTranslation
|
|
13
14
|
EXCEPTION_CLASS_BY_CODE = {
|
|
@@ -16,6 +17,7 @@ module ActiveRecord
|
|
|
16
17
|
241 => MemoryLimitExceeded,
|
|
17
18
|
159 => QueryTimeout,
|
|
18
19
|
160 => QueryTimeout,
|
|
20
|
+
497 => AccessDenied,
|
|
19
21
|
516 => AuthenticationError
|
|
20
22
|
}.freeze
|
|
21
23
|
|
|
@@ -23,9 +25,16 @@ module ActiveRecord
|
|
|
23
25
|
# input_format_null_as_default = 0) is a Rails not-null violation.
|
|
24
26
|
NULL_INSERT_MESSAGE = /Cannot insert NULL value into a column of type/
|
|
25
27
|
|
|
28
|
+
# READONLY: the server refusing a write for a readonly user or a
|
|
29
|
+
# read_only: true connection — same refusal Rails models client-side
|
|
30
|
+
# with while_preventing_writes, so it raises Rails' class for it.
|
|
31
|
+
READONLY_CODE = 164
|
|
32
|
+
|
|
26
33
|
private
|
|
27
34
|
|
|
28
35
|
def translate_exception(exception, message:, sql:, binds:)
|
|
36
|
+
return ActiveRecord::ReadOnlyError.new(message) if readonly_refusal?(exception)
|
|
37
|
+
|
|
29
38
|
server_error = server_exception_class(exception)
|
|
30
39
|
return server_error.new(message, sql: sql, binds: binds) if server_error
|
|
31
40
|
|
|
@@ -37,6 +46,10 @@ module ActiveRecord
|
|
|
37
46
|
end
|
|
38
47
|
end
|
|
39
48
|
|
|
49
|
+
def readonly_refusal?(exception)
|
|
50
|
+
exception.is_a?(HTTPConnection::ExecutionError) && exception.code == READONLY_CODE
|
|
51
|
+
end
|
|
52
|
+
|
|
40
53
|
def server_exception_class(exception)
|
|
41
54
|
return nil unless exception.is_a?(HTTPConnection::ExecutionError)
|
|
42
55
|
|
|
@@ -20,6 +20,50 @@ module ActiveRecord
|
|
|
20
20
|
BINARY_FORMAT = "RowBinaryWithNamesAndTypes"
|
|
21
21
|
JSON_FORMAT = "JSONCompactEachRowWithNamesAndTypes"
|
|
22
22
|
|
|
23
|
+
# Failures raised before the request reaches a server: retrying them on
|
|
24
|
+
# another replica can never double a write. Anything mid-flight (read
|
|
25
|
+
# timeout, reset) raises instead — the statement may have executed.
|
|
26
|
+
# ENETUNREACH added blind (approved 2026-07-22): connect-phase by nature,
|
|
27
|
+
# but not reproducible in the test container.
|
|
28
|
+
CONNECT_ERRORS = [
|
|
29
|
+
Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, SocketError, Net::OpenTimeout
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
# Process-wide replica ledger: rotates the starting endpoint across
|
|
33
|
+
# connections and remembers connect failures so fresh connections skip
|
|
34
|
+
# an endpoint that refused within the (per-config) cooldown window.
|
|
35
|
+
@start_counter = 0
|
|
36
|
+
@connect_failure_times = {}
|
|
37
|
+
@ledger_lock = Mutex.new
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
def claim_start_index(endpoints, cooldown)
|
|
41
|
+
@ledger_lock.synchronize do
|
|
42
|
+
start = @start_counter % endpoints.size
|
|
43
|
+
@start_counter += 1
|
|
44
|
+
healthy_offset = endpoints.size.times.find do |offset|
|
|
45
|
+
!recently_failed?(endpoints[(start + offset) % endpoints.size], cooldown)
|
|
46
|
+
end
|
|
47
|
+
(start + (healthy_offset || 0)) % endpoints.size
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def record_connect_failure(endpoint)
|
|
52
|
+
@ledger_lock.synchronize do
|
|
53
|
+
@connect_failure_times[endpoint] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def recently_failed?(endpoint, cooldown)
|
|
60
|
+
failed_at = @connect_failure_times[format_endpoint(endpoint)]
|
|
61
|
+
failed_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) - failed_at < cooldown
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_endpoint(endpoint) = endpoint.join(":")
|
|
65
|
+
end
|
|
66
|
+
|
|
23
67
|
class ExecutionError < StandardError
|
|
24
68
|
attr_reader :code
|
|
25
69
|
|
|
@@ -57,7 +101,13 @@ module ActiveRecord
|
|
|
57
101
|
def initialize(config)
|
|
58
102
|
@config = config
|
|
59
103
|
@select_format = config[:select_format].to_s == "json" ? JSON_FORMAT : BINARY_FORMAT
|
|
60
|
-
@
|
|
104
|
+
@endpoints = build_endpoints
|
|
105
|
+
@endpoint_index = self.class.claim_start_index(@endpoints, failover_cooldown)
|
|
106
|
+
@http = nil
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def current_endpoint
|
|
110
|
+
@endpoints[@endpoint_index].join(":")
|
|
61
111
|
end
|
|
62
112
|
|
|
63
113
|
# Adapts an enumerator of encoded lines to the partial-read IO contract
|
|
@@ -99,7 +149,7 @@ module ActiveRecord
|
|
|
99
149
|
request = post_request(query_params({}, JSON_FORMAT).merge(query: sql))
|
|
100
150
|
request["Transfer-Encoding"] = "chunked"
|
|
101
151
|
request.body_stream = ChunkedBody.new(lines)
|
|
102
|
-
parse(raise_unless_success(
|
|
152
|
+
parse(raise_unless_success(send_request(request)), JSON_FORMAT)
|
|
103
153
|
end
|
|
104
154
|
|
|
105
155
|
# Scopes extra server settings to the requests made inside the block — the
|
|
@@ -120,15 +170,58 @@ module ActiveRecord
|
|
|
120
170
|
end
|
|
121
171
|
|
|
122
172
|
def close
|
|
123
|
-
@http.finish if @http
|
|
173
|
+
@http.finish if @http&.started?
|
|
124
174
|
rescue IOError
|
|
125
175
|
nil
|
|
126
176
|
end
|
|
127
177
|
|
|
128
178
|
private
|
|
129
179
|
|
|
180
|
+
def http
|
|
181
|
+
@http ||= build_http
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Walks the endpoint list on connect-phase failures, at most one attempt
|
|
185
|
+
# per endpoint. A single-host config raises immediately, as before.
|
|
186
|
+
def send_request(request)
|
|
187
|
+
attempts = 0
|
|
188
|
+
begin
|
|
189
|
+
http.request(request)
|
|
190
|
+
rescue *CONNECT_ERRORS
|
|
191
|
+
attempts += 1
|
|
192
|
+
raise if attempts >= @endpoints.size
|
|
193
|
+
|
|
194
|
+
rotate_endpoint
|
|
195
|
+
retry
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def rotate_endpoint
|
|
200
|
+
self.class.record_connect_failure(current_endpoint)
|
|
201
|
+
close
|
|
202
|
+
@http = nil
|
|
203
|
+
@endpoint_index = (@endpoint_index + 1) % @endpoints.size
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# hosts: lists interchangeable replicas as "host" or "host:port" strings
|
|
207
|
+
# (the port: key is the default); host:/port: alone stay a single endpoint.
|
|
208
|
+
def build_endpoints
|
|
209
|
+
hosts = Array(@config[:hosts])
|
|
210
|
+
return [[@config[:host], @config[:port]]] if hosts.empty?
|
|
211
|
+
|
|
212
|
+
hosts.map do |entry|
|
|
213
|
+
host, port = entry.to_s.split(":", 2)
|
|
214
|
+
[host, port ? Integer(port) : @config[:port] || 8123]
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def failover_cooldown
|
|
219
|
+
@config.fetch(:failover_cooldown, 30)
|
|
220
|
+
end
|
|
221
|
+
|
|
130
222
|
def build_http
|
|
131
|
-
|
|
223
|
+
host, port = @endpoints[@endpoint_index]
|
|
224
|
+
http = Net::HTTP.new(host, port)
|
|
132
225
|
configure_tls(http)
|
|
133
226
|
http.open_timeout = @config[:connect_timeout] if @config[:connect_timeout]
|
|
134
227
|
http.read_timeout = @config[:read_timeout] if @config[:read_timeout]
|
|
@@ -146,7 +239,7 @@ module ActiveRecord
|
|
|
146
239
|
def perform(sql, params, format)
|
|
147
240
|
request = post_request(query_params(params, format))
|
|
148
241
|
request.body = sql
|
|
149
|
-
raise_unless_success(
|
|
242
|
+
raise_unless_success(send_request(request))
|
|
150
243
|
end
|
|
151
244
|
|
|
152
245
|
def post_request(params)
|
|
@@ -193,7 +286,10 @@ module ActiveRecord
|
|
|
193
286
|
input_format_null_as_default: 0,
|
|
194
287
|
# Server gzips responses ~3.6x smaller; Net::HTTP decompresses transparently
|
|
195
288
|
# (it sends Accept-Encoding: gzip by default). Probed 2026-07-12, PLAN.md §2.
|
|
196
|
-
enable_http_compression: @config.fetch(:compression, true) ? 1 : 0
|
|
289
|
+
enable_http_compression: @config.fetch(:compression, true) ? 1 : 0,
|
|
290
|
+
# readonly=2 (not 1): strict readonly refuses the settings above with
|
|
291
|
+
# code 164 before any query runs — probed live 2026-07-19.
|
|
292
|
+
readonly: @config[:read_only] ? 2 : nil
|
|
197
293
|
)
|
|
198
294
|
end
|
|
199
295
|
|
|
@@ -4,7 +4,7 @@ module ActiveRecord
|
|
|
4
4
|
module ConnectionAdapters
|
|
5
5
|
module ClickHouse
|
|
6
6
|
class TableDefinition < ConnectionAdapters::TableDefinition
|
|
7
|
-
attr_reader :engine, :
|
|
7
|
+
attr_reader :engine, :partition, :ttl, :table_settings, :primary_key_clause, :sample
|
|
8
8
|
|
|
9
9
|
def initialize(conn, name, engine: "MergeTree", order: nil, partition: nil, ttl: nil,
|
|
10
10
|
settings: nil, primary_key_clause: nil, sample: nil, **)
|
|
@@ -17,6 +17,12 @@ module ActiveRecord
|
|
|
17
17
|
@sample = sample
|
|
18
18
|
super(conn, name, **)
|
|
19
19
|
end
|
|
20
|
+
|
|
21
|
+
# A Rails-style id: table carries its own obvious sorting key — the pk
|
|
22
|
+
# column — so create_table works without an explicit order: (decision #64).
|
|
23
|
+
def order
|
|
24
|
+
@order || columns.find { |column| column.options[:primary_key] }&.name&.to_s
|
|
25
|
+
end
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
# Carries the ClickHouse-only column metadata the dumper needs: compression codec
|
|
@@ -117,7 +123,8 @@ module ActiveRecord
|
|
|
117
123
|
end
|
|
118
124
|
|
|
119
125
|
def add_table_options!(create_sql, o)
|
|
120
|
-
|
|
126
|
+
# PRIMARY KEY alone is enough: the server infers ORDER BY from it (probed live).
|
|
127
|
+
if o.engine.include?("MergeTree") && o.order.nil? && o.primary_key_clause.nil?
|
|
121
128
|
raise ArgumentError, "#{o.engine} tables require order: (the sorting key); use order: \"tuple()\" for none"
|
|
122
129
|
end
|
|
123
130
|
|
|
@@ -54,8 +54,10 @@ module ActiveRecord
|
|
|
54
54
|
|
|
55
55
|
# Projections dump right after their table as add_projection calls, parsed back
|
|
56
56
|
# out of the stored query text (SELECT ... [GROUP BY ...] [ORDER BY ...]).
|
|
57
|
+
# Primary-key reporting is suppressed so every table dumps as id: false +
|
|
58
|
+
# explicit columns + order: — an implied id column would lose UInt64 on reload.
|
|
57
59
|
def table(table, stream)
|
|
58
|
-
super
|
|
60
|
+
@connection.with_suppressed_primary_key_reporting { super }
|
|
59
61
|
projections(table).each do |row|
|
|
60
62
|
stream.puts(" #{projection_statement(table, row)}")
|
|
61
63
|
stream.puts
|
|
@@ -143,12 +145,6 @@ module ActiveRecord
|
|
|
143
145
|
spec
|
|
144
146
|
end
|
|
145
147
|
|
|
146
|
-
# Rails omits precision 6 as the datetime default, but this adapter's default is
|
|
147
|
-
# DateTime64(3) — always dump the real precision.
|
|
148
|
-
def schema_precision(column)
|
|
149
|
-
column.precision&.inspect
|
|
150
|
-
end
|
|
151
|
-
|
|
152
148
|
WRAPPER_TYPES = { /\ALowCardinality\((.*)\)\z/m => :low_cardinality, /\ANullable\((.*)\)\z/m => :null }.freeze
|
|
153
149
|
private_constant :WRAPPER_TYPES
|
|
154
150
|
|
|
@@ -14,7 +14,9 @@ module ActiveRecord
|
|
|
14
14
|
# With id: false Rails' own primary_key: kwarg is inert, so the DSL reuses the
|
|
15
15
|
# ClickHouse clause name; renamed here because super would swallow it. With an
|
|
16
16
|
# explicit id column the Rails meaning (pk column name) wins untouched.
|
|
17
|
-
|
|
17
|
+
if id == false && options.key?(:primary_key)
|
|
18
|
+
options[:primary_key_clause] = primary_key_clause(options.delete(:primary_key))
|
|
19
|
+
end
|
|
18
20
|
super
|
|
19
21
|
end
|
|
20
22
|
|
|
@@ -271,10 +273,27 @@ module ActiveRecord
|
|
|
271
273
|
data_sources.include?(name.to_s)
|
|
272
274
|
end
|
|
273
275
|
|
|
274
|
-
# ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee
|
|
275
|
-
#
|
|
276
|
-
|
|
277
|
-
|
|
276
|
+
# ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee. The
|
|
277
|
+
# one shape safe to report as Active Record identity is a sorting key that is
|
|
278
|
+
# exactly one id-typed column — the same class of keys the adapter generates
|
|
279
|
+
# client-side (decision #64), so Rails auto-detects the pk on id-keyed tables.
|
|
280
|
+
# Composite, expression, and non-id sorting keys report none; models may still
|
|
281
|
+
# declare self.primary_key explicitly (decision #13).
|
|
282
|
+
def primary_keys(table_name)
|
|
283
|
+
return [] if @suppress_primary_key_reporting
|
|
284
|
+
|
|
285
|
+
column, = generatable_primary_key(table_name)
|
|
286
|
+
column ? [column] : []
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# The schema dumper opts out: with a reported pk Rails' dumper would imply the
|
|
290
|
+
# id column (degrading UInt64 to Int64 on reload) instead of dumping the
|
|
291
|
+
# id: false + explicit-columns + order: shape this adapter round-trips.
|
|
292
|
+
def with_suppressed_primary_key_reporting
|
|
293
|
+
@suppress_primary_key_reporting = true
|
|
294
|
+
yield
|
|
295
|
+
ensure
|
|
296
|
+
@suppress_primary_key_reporting = false
|
|
278
297
|
end
|
|
279
298
|
|
|
280
299
|
def indexes(table_name)
|
|
@@ -310,14 +329,17 @@ module ActiveRecord
|
|
|
310
329
|
def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
|
|
311
330
|
case type.to_s
|
|
312
331
|
when "integer" then integer_to_sql(limit)
|
|
313
|
-
|
|
314
|
-
when "
|
|
332
|
+
# No autoincrement; a Rails-style pk is a plain Int64 filled client-side.
|
|
333
|
+
when "bigint", "primary_key" then "Int64"
|
|
334
|
+
# binary/blob included: ClickHouse String is an arbitrary byte sequence.
|
|
335
|
+
when "string", "text", "binary", "blob" then "String"
|
|
315
336
|
when "float" then "Float64"
|
|
316
|
-
when "decimal", "numeric" then
|
|
317
|
-
# Rails' shared tests pass mysql-style parenthesized precision ("datetime(6)")
|
|
318
|
-
#
|
|
337
|
+
when "decimal", "numeric" then decimal_to_sql(precision, scale)
|
|
338
|
+
# Rails' shared tests pass mysql-style parenthesized precision ("datetime(6)").
|
|
339
|
+
# A nil precision here was explicit — Rails injects 6 for a bare t.datetime —
|
|
340
|
+
# so it means the second-precision base type, like MySQL's plain datetime.
|
|
319
341
|
when /\A(?:datetime|timestamp)(?:\((\d+)\))?\z/
|
|
320
|
-
|
|
342
|
+
datetime_to_sql(Regexp.last_match(1) || precision)
|
|
321
343
|
when "date" then "Date32"
|
|
322
344
|
when "boolean" then "Bool"
|
|
323
345
|
when "uuid" then "UUID"
|
|
@@ -416,6 +438,9 @@ module ActiveRecord
|
|
|
416
438
|
end
|
|
417
439
|
|
|
418
440
|
def changed_column_sql_type(type, options)
|
|
441
|
+
# Mirror new_column_definition: a datetime change without a precision key gets
|
|
442
|
+
# Rails' default microseconds; an explicit precision: nil stays plain DateTime.
|
|
443
|
+
options = { precision: 6, **options } if %i[datetime timestamp].include?(type.to_s.to_sym)
|
|
419
444
|
sql_type = type_to_sql(type, **options.slice(:limit, :precision, :scale))
|
|
420
445
|
sql_type = "Nullable(#{sql_type})" if options[:null]
|
|
421
446
|
sql_type = "LowCardinality(#{sql_type})" if options[:low_cardinality]
|
|
@@ -599,6 +624,15 @@ module ActiveRecord
|
|
|
599
624
|
settings.presence
|
|
600
625
|
end
|
|
601
626
|
|
|
627
|
+
# Rails' composite-key convention passes primary_key: as an array of column
|
|
628
|
+
# names; ClickHouse takes them as a PRIMARY KEY tuple (and infers ORDER BY
|
|
629
|
+
# from it, probed live). A string is a raw clause and passes untouched.
|
|
630
|
+
def primary_key_clause(value)
|
|
631
|
+
return value unless value.is_a?(Array)
|
|
632
|
+
|
|
633
|
+
"(#{value.map { |column| quote_column_name(column) }.join(", ")})"
|
|
634
|
+
end
|
|
635
|
+
|
|
602
636
|
def internal_table_options(table_name, options)
|
|
603
637
|
case table_name.to_s
|
|
604
638
|
when ActiveRecord::Base.schema_migrations_table_name
|
|
@@ -613,6 +647,34 @@ module ActiveRecord
|
|
|
613
647
|
end
|
|
614
648
|
end
|
|
615
649
|
|
|
650
|
+
# DateTime64 tops out at nanoseconds; the server rejects scale 10+ with
|
|
651
|
+
# ARGUMENT_OUT_OF_BOUND (code 69, probed live). Raise Rails' own wording at
|
|
652
|
+
# DDL-build time instead, matching the bundled adapters' 0..6 checks.
|
|
653
|
+
def datetime_to_sql(precision)
|
|
654
|
+
return "DateTime('UTC')" if precision.nil?
|
|
655
|
+
|
|
656
|
+
unless (0..9).cover?(precision.to_i)
|
|
657
|
+
raise ArgumentError, "No timestamp type has precision of #{precision}. " \
|
|
658
|
+
"The allowed range of precision is from 0 to 9"
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
"DateTime64(#{precision}, 'UTC')"
|
|
662
|
+
end
|
|
663
|
+
|
|
664
|
+
# Bare precision means scale 0 (SQL convention; Decimal(2, 10) is
|
|
665
|
+
# ARGUMENT_OUT_OF_BOUND — scale may not exceed precision). Bare scale is the
|
|
666
|
+
# same ArgumentError Rails' other adapters raise. No bounds at all keeps the
|
|
667
|
+
# wide Decimal(38, 10) default.
|
|
668
|
+
def decimal_to_sql(precision, scale)
|
|
669
|
+
if precision
|
|
670
|
+
"Decimal(#{precision}, #{scale || 0})"
|
|
671
|
+
elsif scale
|
|
672
|
+
raise ArgumentError, "Error adding decimal column: precision cannot be empty if scale is specified"
|
|
673
|
+
else
|
|
674
|
+
"Decimal(38, 10)"
|
|
675
|
+
end
|
|
676
|
+
end
|
|
677
|
+
|
|
616
678
|
def integer_to_sql(limit)
|
|
617
679
|
case limit
|
|
618
680
|
when nil, 3, 4 then "Int32"
|
|
@@ -27,9 +27,12 @@ module ActiveRecord
|
|
|
27
27
|
when "Decimal", "Decimal32", "Decimal64", "Decimal128", "Decimal256"
|
|
28
28
|
precision, scale = node.args.grep(Integer)
|
|
29
29
|
ActiveModel::Type::Decimal.new(precision: precision, scale: scale)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# The Active Record (not ActiveModel) temporal types: they respect
|
|
31
|
+
# ActiveRecord.default_timezone, and Rails' time-zone-aware attribute
|
|
32
|
+
# machinery type-checks for them.
|
|
33
|
+
when "Date", "Date32" then ActiveRecord::Type::Date.new
|
|
34
|
+
when "DateTime" then ActiveRecord::Type::DateTime.new
|
|
35
|
+
when "DateTime64" then ActiveRecord::Type::DateTime.new(precision: node.args.first)
|
|
33
36
|
when "Bool" then ActiveModel::Type::Boolean.new
|
|
34
37
|
when "String", "FixedString", "Enum8", "Enum16", "UUID", "IPv4", "IPv6" then ActiveModel::Type::String.new
|
|
35
38
|
when "JSON" then ActiveRecord::Type::Json.new
|
|
@@ -31,8 +31,8 @@ module ActiveRecord
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
CONNECTION_PARAMETER_KEYS = %i[
|
|
34
|
-
host port username password database ssl ssl_verify select_format
|
|
35
|
-
connect_timeout read_timeout write_timeout
|
|
34
|
+
host port hosts username password database ssl ssl_verify select_format
|
|
35
|
+
connect_timeout read_timeout write_timeout read_only failover_cooldown
|
|
36
36
|
mutations_sync compression join_use_nulls async_insert wait_for_async_insert
|
|
37
37
|
].freeze
|
|
38
38
|
|
|
@@ -55,10 +55,15 @@ module ActiveRecord
|
|
|
55
55
|
cluster ? " ON CLUSTER #{quote_table_name(cluster)}" : ""
|
|
56
56
|
end
|
|
57
57
|
|
|
58
|
+
# Closing inside @lock keeps a queued query (which holds the lock for its whole
|
|
59
|
+
# HTTP round-trip) from starting on a socket that dies mid-read — the postgresql
|
|
60
|
+
# adapter's own disconnect! pattern.
|
|
58
61
|
def disconnect!
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
@lock.synchronize do
|
|
63
|
+
super
|
|
64
|
+
@raw_connection&.close
|
|
65
|
+
@raw_connection = nil
|
|
66
|
+
end
|
|
62
67
|
end
|
|
63
68
|
|
|
64
69
|
def supports_explain? = true
|
|
@@ -78,6 +83,9 @@ module ActiveRecord
|
|
|
78
83
|
def build_insert_sql(insert) = "INSERT #{insert.into} #{insert.values_list}" # :nodoc:
|
|
79
84
|
|
|
80
85
|
NATIVE_DATABASE_TYPES = {
|
|
86
|
+
# No autoincrement exists; ids are generated client-side (decision #19),
|
|
87
|
+
# so a Rails-style pk is just a plain Int64 column.
|
|
88
|
+
primary_key: { name: "Int64" },
|
|
81
89
|
string: { name: "String" },
|
|
82
90
|
text: { name: "String" },
|
|
83
91
|
integer: { name: "Int32", limit: 4 },
|
|
@@ -93,10 +101,23 @@ module ActiveRecord
|
|
|
93
101
|
|
|
94
102
|
def native_database_types = NATIVE_DATABASE_TYPES
|
|
95
103
|
|
|
104
|
+
# Rails' class-level valid_type? reads this off the class.
|
|
105
|
+
def self.native_database_types = NATIVE_DATABASE_TYPES
|
|
106
|
+
|
|
96
107
|
# Column types the dumper can't map to AR symbols (Array, Map, Tuple, ...) are
|
|
97
108
|
# dumped verbatim, so every introspected type is valid by construction.
|
|
98
109
|
def valid_type?(_type) = true
|
|
99
110
|
|
|
111
|
+
# The abstract TYPE_MAP pattern-matches generic SQL names: ClickHouse-only
|
|
112
|
+
# shapes (Nullable(...), UUID, Bool, Map, ...) degrade to unfrozen
|
|
113
|
+
# Type::Value there, and Tuple(String, Int64) even false-matches Integer.
|
|
114
|
+
# Frozen so lookups stay Ractor-shareable like Rails' own type maps.
|
|
115
|
+
def lookup_cast_type(sql_type) # :nodoc:
|
|
116
|
+
ClickHouse::Types.active_record_cast_type(sql_type).freeze
|
|
117
|
+
rescue ClickHouse::TypeParser::Error
|
|
118
|
+
super
|
|
119
|
+
end
|
|
120
|
+
|
|
100
121
|
def create_schema_dumper(options) # :nodoc:
|
|
101
122
|
ClickHouse::SchemaDumper.create(self, options)
|
|
102
123
|
end
|
|
@@ -64,6 +64,29 @@ module Arel
|
|
|
64
64
|
|
|
65
65
|
private
|
|
66
66
|
|
|
67
|
+
# Arel's matches/does_not_match default to case-insensitive; ClickHouse LIKE is
|
|
68
|
+
# case-sensitive but ships native ILIKE (probed live), so this renders exactly
|
|
69
|
+
# like the postgresql visitor. ClickHouse has no ESCAPE clause — backslash is
|
|
70
|
+
# the fixed escape character — so a custom escape refuses loudly.
|
|
71
|
+
def visit_Arel_Nodes_Matches(o, collector)
|
|
72
|
+
raise NotImplementedError, "ClickHouse LIKE has no ESCAPE clause" if o.escape
|
|
73
|
+
|
|
74
|
+
infix_value o, collector, o.case_sensitive ? " LIKE " : " ILIKE "
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def visit_Arel_Nodes_DoesNotMatch(o, collector)
|
|
78
|
+
raise NotImplementedError, "ClickHouse LIKE has no ESCAPE clause" if o.escape
|
|
79
|
+
|
|
80
|
+
infix_value o, collector, o.case_sensitive ? " NOT LIKE " : " NOT ILIKE "
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# No row locks in ClickHouse (reads are isolated snapshots of parts); FOR UPDATE
|
|
84
|
+
# drops silently so shared Model.lock / with_lock code keeps working — the same
|
|
85
|
+
# contract as the sqlite3 adapter's visitor.
|
|
86
|
+
def visit_Arel_Nodes_Lock(_o, collector)
|
|
87
|
+
collector
|
|
88
|
+
end
|
|
89
|
+
|
|
67
90
|
# ClickHouse's mutation machinery (lightweight DELETE, ALTER UPDATE) resolves WHERE
|
|
68
91
|
# against an internal projection where table-qualified column names raise
|
|
69
92
|
# UNKNOWN_IDENTIFIER (code 47), and it requires an explicit WHERE clause — so
|
|
@@ -83,7 +106,8 @@ module Arel
|
|
|
83
106
|
collector = visit o.relation, collector
|
|
84
107
|
collector << " UPDATE "
|
|
85
108
|
collector = inject_join o.values, collector, ", "
|
|
86
|
-
collect_update_wheres(o, collector)
|
|
109
|
+
collector = collect_update_wheres(o, collector)
|
|
110
|
+
maybe_visit o.comment, collector
|
|
87
111
|
end
|
|
88
112
|
end
|
|
89
113
|
|