activerecord-clickhouse-adapter 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45e5becdc6a5a3195e53c6dd9f8dd13b154dcb8bbe4fb9862f1656fc380a9b96
4
- data.tar.gz: c71340ef169715de6fad45ce9e9795b342ac4a4e953e4f6183d2077c43f996e8
3
+ metadata.gz: 961d1de0d406b53d1244395ce2159a5629a335eda4f74e71b5bcdbf489c3ed01
4
+ data.tar.gz: e7022e7db80a7520c083c88fd058d98ddb79d0c4d8450628ba954304a104dbaf
5
5
  SHA512:
6
- metadata.gz: b1db549fd86bc894a8b43df4870d9465a9eec4a9858fd0c9b4866bd4c67aadc587228d0089e135781fb9e9d6c389f6c6c61f88542fdf8d46a3bf958e90b9856f
7
- data.tar.gz: 614a15173f4c2ac7aa502ba21aa610f5bf189a754c068a7764c1da503aa52655eb3619c02a1c3455f509ae690ba8d2c3e24c492a4c843fb5256b59bb23ec35a8
6
+ metadata.gz: 3b8e3c624094591a222e1fc8bdc0f74abf8a8681bc9732c08b1c587b68a57f89273dbd1fb7f551e40d6ac8aa6f890cc98e531a329c4be45fa2b7a0db1efd458f
7
+ data.tar.gz: 2598a56859fd5b5c1882da961b8d333fe32e8131abea8173c176a0765cb7df49e733b3a037781cae349c0bc24fa191c50b5a2e5eaf55bb7f15dba8b0ee14cf57
data/CHANGELOG.md CHANGED
@@ -1,4 +1,102 @@
1
- # 0.1.0 (unreleased)
1
+ # 0.2.1 (2026-07-24)
2
+
3
+ Fixed:
4
+
5
+ - Fixed comment hoisting corrupting `INSERT ... VALUES` when a string value contains an
6
+ unclosed `/*` (e.g. a request path of `/*`). The trailing-comment scan is now
7
+ string-literal aware, so a `/*` inside a value is never taken for the start of a trailing
8
+ sqlcommenter tag. Previously such a value mangled the statement and the server rejected the
9
+ whole batch with a `SYNTAX_ERROR`, dropping every row in it
10
+
11
+ # 0.2.0 (2026-07-23)
12
+
13
+ Breaking-ish (schema diff noise, not data changes):
14
+
15
+ - Updated `t.datetime` to default to precision 6 (microseconds, Rails' convention) —
16
+ previously an unqualified datetime rendered `DateTime64(3)`. Existing consumer
17
+ schemas diff as `DateTime64(3)` → `DateTime64(6)`; declare `precision: 3` to keep
18
+ the old shape
19
+ - Fixed decimal DDL bounds: `precision:` without `scale:` now means scale 0 (the old
20
+ `Decimal(N, 10)` shape was invalid for N < 10), and `scale:` without `precision:`
21
+ raises the same `ArgumentError` as Rails' bundled adapters
22
+
23
+ Added:
24
+
25
+ - Added a runnable OLAP-on-Rails example (`examples/olap_on_rails.rb`): fact table,
26
+ streaming ingestion, dictionary lookups, the aggregate-state pipeline, mutable
27
+ dimensions via ReplacingMergeTree + `final`, partitions, and instrumentation —
28
+ guarded by a live spec so it cannot drift
29
+ - Added `:binary`/`:blob` column types, mapped to `String` (ClickHouse strings are
30
+ arbitrary byte sequences)
31
+ - Added `payload[:affected_rows]` to `sql.active_record` notifications, populated
32
+ from the server summary's written rows on every query and on `insert_stream`
33
+ - Added case-insensitive `matches`/`does_not_match` rendering via ClickHouse's
34
+ native `ILIKE` (LIKE is case-sensitive here, unlike MySQL); a custom ESCAPE
35
+ character raises `NotImplementedError` because ClickHouse has no ESCAPE clause
36
+ - Added a no-op `FOR UPDATE` visitor (reads are isolated snapshots of parts; no row
37
+ locks exist), so shared `Model.lock`/`with_lock` code runs instead of dying —
38
+ optimistic locking via `lock_version` works end-to-end
39
+ - Added multi-replica support: `hosts:` lists interchangeable endpoints ("host" or
40
+ "host:port"); connections round-robin their starting endpoint, fail over on
41
+ connect-phase errors only (a request that never reached a server cannot double a
42
+ write — mid-flight failures still raise), and skip endpoints that refused within
43
+ `failover_cooldown:` seconds (default 30)
44
+ - Added `read_only: true` connection option: stamps `readonly=2` on every request so
45
+ the server itself refuses writes; the refusal (code 164) raises
46
+ `ActiveRecord::ReadOnlyError` — the same class Rails' `while_preventing_writes`
47
+ uses — including for server-configured readonly users
48
+ - Added `ClickHouse::AccessDenied` (< `ActiveRecord::StatementInvalid`) for the
49
+ server's grant refusals (code 497)
50
+ - Added `Errno::ENETUNREACH` to the failover connect-error list (connect-phase by
51
+ nature, added on review — not reproducible in the test container)
52
+ - Added primary-key reporting for tables whose sorting key is a single integer or
53
+ UUID column: Rails now auto-detects the model's primary key on id-keyed tables
54
+ (`find`/`update`/`destroy` and client-generated ids work without
55
+ `self.primary_key`); composite, expression, and non-id sorting keys still report
56
+ none — ClickHouse PRIMARY KEY is an index prefix, not a uniqueness guarantee —
57
+ and schema dumps keep the explicit `id: false` + `order:` shape
58
+ - Added Rails-style `create_table id: :bigint/:uuid` (and bare `id: :primary_key`,
59
+ a plain Int64): the pk column doubles as the sorting key so no `order:` is
60
+ needed, and ids are generated client-side — no autoincrement exists
61
+
62
+ Fixed:
63
+
64
+ - Fixed `lookup_cast_type` to resolve ClickHouse type names through the adapter's own
65
+ parser: the abstract TYPE_MAP degraded `Nullable(...)`, `UUID`, `Bool`, and `Map`
66
+ to bare `Type::Value` and even matched `Tuple(String, Int64)` as Integer; results
67
+ are frozen so lookups stay Ractor-shareable
68
+ - Fixed `create_table` with a composite `primary_key:` array to render a quoted
69
+ `PRIMARY KEY (a, b)` tuple; a PRIMARY KEY clause alone now satisfies the
70
+ sorting-key requirement (the server infers ORDER BY from it)
71
+ - Fixed `disconnect!` to close the raw HTTP connection while still holding the
72
+ adapter lock (the postgresql adapter's pattern) — closing after release let a
73
+ concurrently queued query start its read on a dying socket
74
+ - Fixed SQL containing invalid UTF-8 bytes to reach the server instead of raising
75
+ `ArgumentError` client-side (ClickHouse strings are byte sequences; the server
76
+ accepts raw bytes in literals and backtick identifiers)
77
+ - Fixed datetime DDL bounds: precision past 9 (nanoseconds, ClickHouse's maximum) now
78
+ raises `ArgumentError` at migration time instead of a server error, and an explicit
79
+ `precision: nil` maps to the second-precision base `DateTime` (a bare `t.datetime`
80
+ still gets Rails' default microseconds)
81
+ - Fixed schema dumps of datetime precision to follow Rails conventions: the default 6
82
+ is omitted and a precision-less `DateTime` dumps as `precision: nil`
83
+ - Fixed attribute-less creates: Rails' `INSERT INTO t DEFAULT VALUES` is not ClickHouse
84
+ syntax; the adapter now emits `FORMAT JSONEachRow {}` (one row, all table defaults)
85
+ - Updated datetime and date columns to expose `ActiveRecord::Type::DateTime`/`::Date`
86
+ (not the ActiveModel types): they respect `ActiveRecord.default_timezone`, and
87
+ Rails' time-zone-aware attribute machinery type-checks for them
88
+
89
+ Internal:
90
+
91
+ - Grew the Rails compatibility harness from ~2,200 to ~4,500 vendored upstream Active
92
+ Record tests (scoping, autosave, migrations, nested attributes, serialized
93
+ attributes, enums, dirty tracking, timestamps, batches, query cache, delegated
94
+ types, instrumentation, and two dozen more suites), each skip documented with the
95
+ dialect truth behind it
96
+ - Isolated the harness subprocess in a pid-suffixed ClickHouse database so concurrent
97
+ runs cannot collide
98
+
99
+ # 0.1.0 (2026-07-15)
2
100
 
3
101
  First release. Highlights:
4
102
 
data/README.md CHANGED
@@ -8,6 +8,7 @@ A fully featured Active Record adapter for [ClickHouse](https://clickhouse.com)
8
8
  - OLAP query surface: `FINAL`, `PREWHERE`, `SAMPLE`, `LIMIT BY`, time bucketing, approximate aggregates
9
9
  - Real instrumentation: rows read, bytes read, and server elapsed time on every query
10
10
  - Fast wire: RowBinary reads and chunked streaming inserts
11
+ - Multi-replica failover and server-enforced read-only connections
11
12
 
12
13
  Tested against a live ClickHouse server only — no mocked responses, ever.
13
14
 
@@ -25,6 +26,10 @@ Requires Active Record 8.1+, Ruby 3.2+, and ClickHouse 25.8+ (each LTS from 25.8
25
26
 
26
27
  ## Getting Started
27
28
 
29
+ For a runnable end-to-end tour — fact table, streaming ingestion, dictionary
30
+ lookups, the aggregate-state pipeline, partitions — see
31
+ [`examples/olap_on_rails.rb`](examples/olap_on_rails.rb).
32
+
28
33
  Add a ClickHouse database to `config/database.yml`:
29
34
 
30
35
  ```yaml
@@ -76,6 +81,16 @@ end
76
81
 
77
82
  Columns are non-nullable by default, matching ClickHouse. Use `null: true` for `Nullable(...)`.
78
83
 
84
+ `t.datetime` defaults to precision 6 (microseconds, Rails' convention); pass `precision: nil` for the second-precision base `DateTime`. `t.binary`/`t.blob` map to `String` — ClickHouse strings are arbitrary byte sequences.
85
+
86
+ 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:
87
+
88
+ ```ruby
89
+ create_table :accounts, id: :bigint do |t|
90
+ t.string :name, default: ""
91
+ end
92
+ ```
93
+
79
94
  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
95
 
81
96
  ClickHouse-specific column options:
@@ -242,6 +257,8 @@ ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
242
257
  end
243
258
  ```
244
259
 
260
+ The standard `payload[:affected_rows]` reports the server's written rows on inserts.
261
+
245
262
  `explain` supports ClickHouse variants:
246
263
 
247
264
  ```ruby
@@ -260,6 +277,11 @@ clickhouse:
260
277
  database: analytics_production
261
278
  username: rails
262
279
  password: secret
280
+ hosts: # interchangeable replicas ("host" or "host:port");
281
+ - ch-1.internal # connections round-robin start positions and fail over
282
+ - ch-2.internal:8124 # to the next host on connect-phase errors
283
+ failover_cooldown: 30 # seconds new connections skip an endpoint that refused
284
+ read_only: true # server-enforced reads only (readonly=2 on every request)
263
285
  ssl: true # HTTPS to the server
264
286
  ssl_verify: false # escape hatch for self-signed certificates (default: verify)
265
287
  connect_timeout: 5
@@ -275,9 +297,30 @@ clickhouse:
275
297
  ## Semantics Worth Knowing
276
298
 
277
299
  - **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 get time-ordered ids (Snowflake-style / UUIDv7) assigned before INSERT.
300
+ - **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
301
  - **Mutation counts are best-effort.** `update_all`/`delete_all` return a pre-mutation `SELECT count()` — ClickHouse reports no affected-row counts.
280
302
  - **Eventual merges.** `ReplacingMergeTree` deduplicates at merge time; read with `.final` when you need collapsed rows.
303
+ - **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.
304
+ - **Read-only is server-enforced.** `read_only: true` stamps `readonly=2` on every request; the server's refusal (code 164) raises `ActiveRecord::ReadOnlyError` — the same class Rails' `while_preventing_writes` uses — including for server-configured readonly users. Grant refusals (code 497) raise `ClickHouse::AccessDenied`.
305
+ - **`matches` renders `ILIKE`.** ClickHouse `LIKE` is case-sensitive (unlike MySQL), so Arel's case-insensitive `matches`/`does_not_match` use native `ILIKE`.
306
+ - **No row locks.** Reads are isolated snapshots of parts, so `FOR UPDATE` renders as nothing and shared `lock`/`with_lock` code runs instead of dying. Optimistic locking via `lock_version` works end-to-end.
307
+
308
+ ## Migrating from clickhouse-activerecord
309
+
310
+ This gem registers the same `"clickhouse"` adapter name as [clickhouse-activerecord](https://github.com/PNixx/clickhouse-activerecord) and accepts its `database.yml` shape — swap the gem and the config carries over. The two gems cannot be loaded at once.
311
+
312
+ Schema-version bookkeeping does not carry over. The incumbent records applied versions in a `(version, active, ver)` ReplacingMergeTree with `active = 0` tombstones; this adapter uses Rails' plain `schema_migrations` table and does not read that shape, so a sink migrated by the incumbent reports zero applied versions.
313
+
314
+ Never re-run migrations over a live sink to fix that — replaying DDL over final-state tables can corrupt data (enum narrowing, for one). Adopt the version state once instead:
315
+
316
+ ```ruby
317
+ versions = connection.select_values("SELECT version FROM schema_migrations FINAL WHERE active = 1")
318
+ connection.execute("RENAME TABLE schema_migrations TO schema_migrations_legacy")
319
+ pool.schema_migration.create_table
320
+ versions.each { |v| pool.schema_migration.create_version(v) }
321
+ ```
322
+
323
+ Pending migrations run normally afterwards. To roll back the adoption, drop the new `schema_migrations` table and rename the legacy one back.
281
324
 
282
325
  ## Development
283
326
 
@@ -297,7 +340,7 @@ RAILS_SOURCE=edge bundle install
297
340
  RAILS_SOURCE=edge bundle exec rspec
298
341
  ```
299
342
 
300
- The suite includes a Rails compatibility harness that runs vendored upstream Active Record test suites (~1,600 tests) against the adapter. See `spec/rails_compat/`.
343
+ The suite includes a Rails compatibility harness that runs vendored upstream Active Record test suites (~5,600 tests — 5,558 runs, 447 skips, each skip documenting the dialect truth behind it) against the adapter. See `spec/rails_compat/`.
301
344
 
302
345
  ## History
303
346
 
@@ -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
@@ -287,20 +295,45 @@ module ActiveRecord
287
295
  end
288
296
  end
289
297
 
290
- TRAILING_COMMENTS = %r{(?:\s*/\*(?:[^*]|\*(?!/))*\*/)+\s*\z}
298
+ COMMENT = %r{/\*(?:[^*]|\*(?!/))*\*/}m
291
299
  INSERT_STATEMENT = /\A\s*INSERT\b/i
292
- private_constant :TRAILING_COMMENTS, :INSERT_STATEMENT
293
-
294
- # ClickHouse reads everything after VALUES with the Values input format, which
295
- # rejects trailing comments (probed 2026-07-13), so sqlcommenter tags appended
296
- # by Rails' QueryLogs are hoisted to the front of INSERT statements.
297
- def hoist_trailing_comments(sql)
300
+ # Data outside strings/comments: any non-space run, but a `/` only when it doesn't open a comment.
301
+ DATA_RUN = %r{(?:[^\s'`/]|/(?!\*))+}
302
+ private_constant :COMMENT, :INSERT_STATEMENT, :DATA_RUN
303
+
304
+ # ClickHouse reads everything after VALUES with the Values input format, which rejects
305
+ # trailing comments (probed 2026-07-13), so sqlcommenter tags Rails' QueryLogs appends
306
+ # are hoisted to the front of INSERTs. Scans string-aware — a /* inside a value (a request
307
+ # path like "/*") is opaque and never taken for a comment, so the VALUES list stays intact.
308
+ def hoist_trailing_comments(sql) # rubocop:disable Metrics/MethodLength
309
+ # ClickHouse strings are byte sequences; SQL carrying invalid UTF-8 must reach the server
310
+ # rather than die in the scan, so it drops to binary (the patterns are pure ASCII).
311
+ sql = sql.b unless sql.valid_encoding?
298
312
  return sql unless INSERT_STATEMENT.match?(sql)
299
313
 
300
- comments = sql[TRAILING_COMMENTS]
301
- return sql unless comments
314
+ scanner = StringScanner.new(sql)
315
+ data_end = 0
316
+ trailing = []
317
+ until scanner.eos?
318
+ next if scanner.skip(/\s+/)
319
+
320
+ if (comment = scanner.scan(COMMENT))
321
+ trailing << comment
322
+ else
323
+ consume_data_token(scanner)
324
+ trailing.clear
325
+ data_end = scanner.pos
326
+ end
327
+ end
328
+ return sql if trailing.empty?
329
+
330
+ "#{trailing.join(" ")} #{sql[0...data_end]}"
331
+ end
302
332
 
303
- "#{comments.strip} #{sql[0...-comments.length]}"
333
+ # One opaque data token: a string literal or backtick identifier (skipped whole, so a /*
334
+ # inside never reads as a comment), an ordinary data run, or a lone char that starts none.
335
+ def consume_data_token(scanner)
336
+ scanner.scan(STRING_LITERAL) || scanner.scan(BACKTICK_IDENTIFIER) || scanner.scan(DATA_RUN) || scanner.getch
304
337
  end
305
338
 
306
339
  # Rails main (8.2) funnels execution through a QueryIntent object; released
@@ -322,6 +355,7 @@ module ActiveRecord
322
355
  result = raw_connection.execute(sql, params: params)
323
356
  verified!
324
357
  if notification_payload
358
+ notification_payload[:affected_rows] = result.written_rows
325
359
  notification_payload[:row_count] = result.rows.size
326
360
  notification_payload[:clickhouse] = result.stats
327
361
  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
 
@@ -3,7 +3,7 @@
3
3
  module ActiveRecord
4
4
  module ConnectionAdapters
5
5
  module ClickHouse
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.1"
7
7
  end
8
8
  end
9
9
  end
@@ -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
- @http = build_http
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(@http.request(request)), JSON_FORMAT)
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.started?
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
- http = Net::HTTP.new(@config[:host], @config[:port])
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(@http.request(request))
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, :order, :partition, :ttl, :table_settings, :primary_key_clause, :sample
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
- if o.engine.include?("MergeTree") && o.order.nil?
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
- options[:primary_key_clause] = options.delete(:primary_key) if id == false && options.key?(:primary_key)
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, so no
275
- # column is safe to expose as an Active Record primary key.
276
- def primary_keys(_table_name)
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
- when "bigint" then "Int64"
314
- when "string", "text" then "String"
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 "Decimal(#{precision || 38}, #{scale || 10})"
317
- # Rails' shared tests pass mysql-style parenthesized precision ("datetime(6)");
318
- # the naked default matches Rails' microsecond convention, not CH's ms.
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
- "DateTime64(#{Regexp.last_match(1) || precision || 6}, 'UTC')"
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
- when "Date", "Date32" then ActiveModel::Type::Date.new
31
- when "DateTime" then ActiveModel::Type::DateTime.new
32
- when "DateTime64" then ActiveModel::Type::DateTime.new(precision: node.args.first)
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
- super
60
- @raw_connection&.close
61
- @raw_connection = nil
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
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-clickhouse-adapter
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ikraam Ghoor
@@ -76,7 +76,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
76
76
  - !ruby/object:Gem::Version
77
77
  version: '0'
78
78
  requirements: []
79
- rubygems_version: 4.0.10
79
+ rubygems_version: 4.0.3
80
80
  specification_version: 4
81
81
  summary: ClickHouse database adapter for Active Record
82
82
  test_files: []