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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 45e5becdc6a5a3195e53c6dd9f8dd13b154dcb8bbe4fb9862f1656fc380a9b96
4
- data.tar.gz: c71340ef169715de6fad45ce9e9795b342ac4a4e953e4f6183d2077c43f996e8
3
+ metadata.gz: 3f16a02cdd51358de67461ac920dcecdb2dcec096b7c47898a91e2d555b5f0b0
4
+ data.tar.gz: 49fdee335e90e3bdae9a28a5bce316d024f2161b45758128cdd7c424611da7d8
5
5
  SHA512:
6
- metadata.gz: b1db549fd86bc894a8b43df4870d9465a9eec4a9858fd0c9b4866bd4c67aadc587228d0089e135781fb9e9d6c389f6c6c61f88542fdf8d46a3bf958e90b9856f
7
- data.tar.gz: 614a15173f4c2ac7aa502ba21aa610f5bf189a754c068a7764c1da503aa52655eb3619c02a1c3455f509ae690ba8d2c3e24c492a4c843fb5256b59bb23ec35a8
6
+ metadata.gz: f13d3bcfce5f752f6e6c2b2ba4a559bfdae3b192d04ac27034e2d49b1151a4c659d392d005a44e53dc03d997a8cbc9631650a5d80756bb23bb4f416bd8ddffdb
7
+ data.tar.gz: 2be555427541cf9329053c10acd00265e46780b6dd401b8309333e8d606b4d7a235fb18a35aacb4331c56abccbe48b725664dab2ceb91f81910d16b3fe314b90
data/CHANGELOG.md CHANGED
@@ -1,4 +1,92 @@
1
- # 0.1.0 (unreleased)
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 get time-ordered ids (Snowflake-style / UUIDv7) assigned before INSERT.
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 (~1,600 tests) against the adapter. See `spec/rails_compat/`.
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
 
@@ -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.0"
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.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ikraam Ghoor