activerecord-clickhouse-adapter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +19 -0
- data/LICENSE +21 -0
- data/README.md +313 -0
- data/lib/active_record/connection_adapters/clickhouse/database_statements.rb +464 -0
- data/lib/active_record/connection_adapters/clickhouse/error_translation.rb +52 -0
- data/lib/active_record/connection_adapters/clickhouse/gem_version.rb +9 -0
- data/lib/active_record/connection_adapters/clickhouse/http_connection.rb +233 -0
- data/lib/active_record/connection_adapters/clickhouse/querying.rb +417 -0
- data/lib/active_record/connection_adapters/clickhouse/quoting.rb +92 -0
- data/lib/active_record/connection_adapters/clickhouse/row_binary.rb +190 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_definitions.rb +146 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_dumper.rb +201 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +722 -0
- data/lib/active_record/connection_adapters/clickhouse/type_parser.rb +207 -0
- data/lib/active_record/connection_adapters/clickhouse/types.rb +238 -0
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +126 -0
- data/lib/active_record/tasks/clickhouse_database_tasks.rb +91 -0
- data/lib/activerecord-clickhouse-adapter.rb +21 -0
- data/lib/arel/visitors/clickhouse.rb +172 -0
- metadata +82 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 45e5becdc6a5a3195e53c6dd9f8dd13b154dcb8bbe4fb9862f1656fc380a9b96
|
|
4
|
+
data.tar.gz: c71340ef169715de6fad45ce9e9795b342ac4a4e953e4f6183d2077c43f996e8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b1db549fd86bc894a8b43df4870d9465a9eec4a9858fd0c9b4866bd4c67aadc587228d0089e135781fb9e9d6c389f6c6c61f88542fdf8d46a3bf958e90b9856f
|
|
7
|
+
data.tar.gz: 614a15173f4c2ac7aa502ba21aa610f5bf189a754c068a7764c1da503aa52655eb3619c02a1c3455f509ae690ba8d2c3e24c492a4c843fb5256b59bb23ec35a8
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# 0.1.0 (unreleased)
|
|
2
|
+
|
|
3
|
+
First release. Highlights:
|
|
4
|
+
|
|
5
|
+
- Full read-path type system: every ClickHouse type decodes to the right Ruby object
|
|
6
|
+
- RowBinary wire format by default, with transparent per-query JSON fallback
|
|
7
|
+
- Server-side bind parameters (`{pN:Type}` HTTP params) — no string interpolation
|
|
8
|
+
- MergeTree-aware migrations: engines, sorting keys, partitions, TTLs, codecs, projections, materialized views
|
|
9
|
+
- Full alter surface: `rename_column`, `change_column`, `change_column_null`, `change_column_default`, comments, and post-create `add_index`/`remove_index`
|
|
10
|
+
- `schema.rb` and `structure.sql` round-trip ClickHouse DDL, including projections and dictionaries
|
|
11
|
+
- OLAP relation surface: `final`, `prewhere`, `sample`, `settings`, `limit_by`, `array_join`, `group_by_period`, `fill`, `rollup`, `window`, `dict_get`
|
|
12
|
+
- Dictionaries: `create_dictionary` (columns inferred from source, credentials injected, cross-database via `database:`), `drop_dictionary`, `reload_dictionary`
|
|
13
|
+
- `ON CLUSTER` DDL via a `cluster:` connection setting
|
|
14
|
+
- Approximate aggregates: `uniq_count`, `quantile`, `top_k`, `arg_max`, `arg_min`, `estimated_count`, with `-If`/`-Merge` combinators
|
|
15
|
+
- `insert_stream`: chunked streaming bulk ingestion for lazy enumerables
|
|
16
|
+
- Client-side primary key generation (UUIDv7 / time-ordered Int64) via the prefetch seam
|
|
17
|
+
- Real instrumentation: `read_rows`, `read_bytes`, `written_rows`, `elapsed_ns` on every `sql.active_record` event
|
|
18
|
+
- TLS with verification on by default; `ssl_verify: false` for self-signed servers
|
|
19
|
+
- Rails compatibility harness: ~2,200 vendored upstream Active Record tests run green
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ikraam Ghoor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
# activerecord-clickhouse-adapter
|
|
2
|
+
|
|
3
|
+
A fully featured Active Record adapter for [ClickHouse](https://clickhouse.com)
|
|
4
|
+
|
|
5
|
+
- Native types on every read — `Decimal`, `DateTime64`, `Enum`, `Array`, `Map`, `Tuple`, `IPv4/6`, `UUID`, and more
|
|
6
|
+
- Server-side bind parameters, never string interpolation
|
|
7
|
+
- MergeTree-aware migrations, `schema.rb`, and `structure.sql`
|
|
8
|
+
- OLAP query surface: `FINAL`, `PREWHERE`, `SAMPLE`, `LIMIT BY`, time bucketing, approximate aggregates
|
|
9
|
+
- Real instrumentation: rows read, bytes read, and server elapsed time on every query
|
|
10
|
+
- Fast wire: RowBinary reads and chunked streaming inserts
|
|
11
|
+
|
|
12
|
+
Tested against a live ClickHouse server only — no mocked responses, ever.
|
|
13
|
+
|
|
14
|
+
**Status: pre-1.0, under active development.** See [PLAN.md](PLAN.md) for architecture and roadmap.
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
Add this line to your application's Gemfile:
|
|
19
|
+
|
|
20
|
+
```ruby
|
|
21
|
+
gem "activerecord-clickhouse-adapter"
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Requires Active Record 8.1+, Ruby 3.2+, and ClickHouse 25.8+ (each LTS from 25.8 through `latest` runs in CI).
|
|
25
|
+
|
|
26
|
+
## Getting Started
|
|
27
|
+
|
|
28
|
+
Add a ClickHouse database to `config/database.yml`:
|
|
29
|
+
|
|
30
|
+
```yaml
|
|
31
|
+
production:
|
|
32
|
+
primary:
|
|
33
|
+
# ... your existing database ...
|
|
34
|
+
clickhouse:
|
|
35
|
+
adapter: clickhouse
|
|
36
|
+
host: localhost
|
|
37
|
+
port: 8123
|
|
38
|
+
database: analytics_production
|
|
39
|
+
username: rails
|
|
40
|
+
password: <%= ENV["CLICKHOUSE_PASSWORD"] %>
|
|
41
|
+
migrations_paths: db/migrate_clickhouse
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Create an abstract base class on its own pool:
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
class AnalyticsRecord < ActiveRecord::Base
|
|
48
|
+
self.abstract_class = true
|
|
49
|
+
|
|
50
|
+
connects_to database: { writing: :clickhouse, reading: :clickhouse }
|
|
51
|
+
end
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Define models as usual:
|
|
55
|
+
|
|
56
|
+
```ruby
|
|
57
|
+
class Event < AnalyticsRecord
|
|
58
|
+
include ActiveRecord::ConnectionAdapters::ClickHouse::Querying
|
|
59
|
+
end
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The `Querying` concern is optional. It adds the ClickHouse relation methods below.
|
|
63
|
+
|
|
64
|
+
## Migrations
|
|
65
|
+
|
|
66
|
+
Tables default to `id: false` — ClickHouse has no autoincrement. The sorting key (`order:`) is required:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
create_table :events, order: "(device_id, ts)", partition: "toDate(ts)", ttl: "toDateTime(ts) + INTERVAL 30 DAY" do |t|
|
|
70
|
+
t.integer :device_id, limit: 8
|
|
71
|
+
t.datetime :ts, precision: 3, default: -> { "now64(3)" }
|
|
72
|
+
t.string :event_type, low_cardinality: true, default: ""
|
|
73
|
+
t.integer :duration_ms, null: true
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Columns are non-nullable by default, matching ClickHouse. Use `null: true` for `Nullable(...)`.
|
|
78
|
+
|
|
79
|
+
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
|
+
|
|
81
|
+
ClickHouse-specific column options:
|
|
82
|
+
|
|
83
|
+
```ruby
|
|
84
|
+
t.string :status, low_cardinality: true
|
|
85
|
+
t.integer :bytes, codec: "Delta, ZSTD"
|
|
86
|
+
t.date :day, materialized: "toDate(ts)"
|
|
87
|
+
t.string :upper_status, alias: "upper(status)"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Engines, projections, and materialized views:
|
|
91
|
+
|
|
92
|
+
```ruby
|
|
93
|
+
create_table :daily_counts, engine: "SummingMergeTree", order: "day"
|
|
94
|
+
|
|
95
|
+
create_materialized_view :events_to_daily, to: "daily_counts", as: "SELECT toDate(ts) AS day, count() AS n FROM events GROUP BY day"
|
|
96
|
+
|
|
97
|
+
add_projection :events, :by_type, order: "event_type"
|
|
98
|
+
materialize_projection :events, :by_type
|
|
99
|
+
optimize_table :events
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Partition lifecycle:
|
|
103
|
+
|
|
104
|
+
```ruby
|
|
105
|
+
partitions :events # => ["20260701", "20260702", ...]
|
|
106
|
+
detach_partition :events, "20260701"
|
|
107
|
+
attach_partition :events, "20260701"
|
|
108
|
+
drop_partition :events, "20260701"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Dictionaries replace star-schema dimension JOINs with in-memory lookups. Columns are inferred from the source table, and the adapter's credentials are injected into the SOURCE clause:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
create_dictionary :device_names, source: "devices", primary_key: :id
|
|
115
|
+
create_dictionary :device_names, source: "devices", primary_key: :id, layout: :hashed, lifetime: 60..300
|
|
116
|
+
create_dictionary :device_names, source: "devices", database: "dimensions", primary_key: :id
|
|
117
|
+
reload_dictionary :device_names
|
|
118
|
+
drop_dictionary :device_names, if_exists: true
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Dictionaries round-trip through `schema.rb` (as `create_dictionary` calls that re-infer columns and re-inject credentials on load) and `structure.sql` (credentials are masked in the file and swapped back in by `db:schema:load`).
|
|
122
|
+
|
|
123
|
+
Set `cluster:` in `database.yml` to stamp schema DDL with `ON CLUSTER`, sending it through the distributed DDL queue:
|
|
124
|
+
|
|
125
|
+
```yaml
|
|
126
|
+
production:
|
|
127
|
+
adapter: clickhouse
|
|
128
|
+
cluster: my_cluster
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Both `schema.rb` and `structure.sql` round-trip engines, sorting keys, partitions, TTLs, codecs, settings, and projections (dumped as `add_projection` statements).
|
|
132
|
+
|
|
133
|
+
## Querying
|
|
134
|
+
|
|
135
|
+
Standard Active Record works as expected:
|
|
136
|
+
|
|
137
|
+
```ruby
|
|
138
|
+
Event.where(device_id: 42).order(:ts).limit(10)
|
|
139
|
+
Event.group(:event_type).count
|
|
140
|
+
Event.where("duration_ms > ?", 100).average(:duration_ms)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
ClickHouse dialect methods (via the `Querying` concern):
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
Event.final # FROM events FINAL
|
|
147
|
+
Event.sample(0.1) # SAMPLE 0.1
|
|
148
|
+
Event.prewhere(device_id: 42) # PREWHERE, before WHERE
|
|
149
|
+
Event.limit_by(1, :device_id) # LIMIT 1 BY device_id
|
|
150
|
+
Event.settings(max_threads: 8) # SETTINGS max_threads = 8
|
|
151
|
+
Event.array_join(:tags, as: :tag) # one row per array element
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Time series:
|
|
155
|
+
|
|
156
|
+
```ruby
|
|
157
|
+
Event.group_by_period(:hour, :ts).count # chronological buckets
|
|
158
|
+
Event.group_by_period(:day, :ts).fill.count # gap-filled with WITH FILL
|
|
159
|
+
Event.group(:device_id).rollup.count # totals row, keyed nil
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Window functions project alongside the row:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
Event.window(:row_number, as: :position, partition_by: :device_id, order_by: :ts)
|
|
166
|
+
Event.window(:sum, :duration_ms, as: :running_total, order_by: :ts)
|
|
167
|
+
Event.window(:lag, :battery, as: :previous, partition_by: :device_id, order_by: :ts,
|
|
168
|
+
frame: "ROWS BETWEEN 1 PRECEDING AND CURRENT ROW")
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Dictionary lookups project alongside the row:
|
|
172
|
+
|
|
173
|
+
```ruby
|
|
174
|
+
Event.dict_get(:device_names, :name, key: :device_id) # ... AS name
|
|
175
|
+
Event.dict_get(:device_names, :name, key: :device_id, as: :device_name)
|
|
176
|
+
Event.dict_get(:device_names, :name, key: :device_id, default: "unknown") # dictGetOrDefault
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Approximate and positional aggregates:
|
|
180
|
+
|
|
181
|
+
```ruby
|
|
182
|
+
Event.uniq_count(:device_id) # uniq() — fast, approximate
|
|
183
|
+
Event.uniq_count(:device_id, exact: true) # uniqExact()
|
|
184
|
+
Event.quantile(0.95, :duration_ms) # p95
|
|
185
|
+
Event.top_k(10, :event_type) # most frequent values
|
|
186
|
+
Event.arg_max(:event_type, :ts) # value at max ts
|
|
187
|
+
Event.estimated_count # O(1) row estimate from metadata
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
All aggregates accept `if:` for conditional aggregation in one scan:
|
|
191
|
+
|
|
192
|
+
```ruby
|
|
193
|
+
Event.quantile(0.95, :duration_ms, if: { event_type: "render" })
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
`AggregateFunction` state columns merge with `merge: true`:
|
|
197
|
+
|
|
198
|
+
```ruby
|
|
199
|
+
DailyRollup.group(:day).uniq_count(:visitors_state, merge: true)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Writing Data
|
|
203
|
+
|
|
204
|
+
Single-row writes work, but ClickHouse wants batches:
|
|
205
|
+
|
|
206
|
+
```ruby
|
|
207
|
+
Event.insert_all!(rows) # one INSERT statement
|
|
208
|
+
Event.insert_all(rows) # same — with no unique constraints, nothing can conflict
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
Stream any Enumerable without materializing it:
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
Event.insert_stream(rows) # one chunked HTTP request, lazy enumerators welcome
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
Updates and deletes become mutations (`ALTER TABLE ... UPDATE / DELETE`):
|
|
218
|
+
|
|
219
|
+
```ruby
|
|
220
|
+
Event.where(device_id: 42).update_all(event_type: "gone")
|
|
221
|
+
Event.where(device_id: 42).delete_all
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Sorting-key columns cannot be updated. `upsert_all` raises — use a `ReplacingMergeTree` or `SummingMergeTree` engine instead.
|
|
225
|
+
|
|
226
|
+
For high-frequency small inserts, enable server-side batching:
|
|
227
|
+
|
|
228
|
+
```yaml
|
|
229
|
+
clickhouse:
|
|
230
|
+
adapter: clickhouse
|
|
231
|
+
async_insert: true
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Instrumentation
|
|
235
|
+
|
|
236
|
+
Every query's `sql.active_record` notification carries server statistics:
|
|
237
|
+
|
|
238
|
+
```ruby
|
|
239
|
+
ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
240
|
+
stats = event.payload[:clickhouse]
|
|
241
|
+
# => { query_id:, read_rows:, read_bytes:, written_rows:, elapsed_ns: }
|
|
242
|
+
end
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`explain` supports ClickHouse variants:
|
|
246
|
+
|
|
247
|
+
```ruby
|
|
248
|
+
Event.where(device_id: 42).explain # EXPLAIN
|
|
249
|
+
Event.where(device_id: 42).explain(:pipeline) # EXPLAIN PIPELINE
|
|
250
|
+
Event.where(device_id: 42).explain(:indexes) # EXPLAIN indexes = 1
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
## Connection Options
|
|
254
|
+
|
|
255
|
+
```yaml
|
|
256
|
+
clickhouse:
|
|
257
|
+
adapter: clickhouse
|
|
258
|
+
host: localhost
|
|
259
|
+
port: 8123
|
|
260
|
+
database: analytics_production
|
|
261
|
+
username: rails
|
|
262
|
+
password: secret
|
|
263
|
+
ssl: true # HTTPS to the server
|
|
264
|
+
ssl_verify: false # escape hatch for self-signed certificates (default: verify)
|
|
265
|
+
connect_timeout: 5
|
|
266
|
+
read_timeout: 60
|
|
267
|
+
write_timeout: 60
|
|
268
|
+
compression: true # gzip responses (default: on)
|
|
269
|
+
join_use_nulls: 1 # SQL-standard outer-join NULLs (default: on)
|
|
270
|
+
mutations_sync: 1 # block until mutations apply (default: async)
|
|
271
|
+
async_insert: false # server-side insert batching
|
|
272
|
+
select_format: binary # RowBinary reads; use `json` to force the JSON wire
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
## Semantics Worth Knowing
|
|
276
|
+
|
|
277
|
+
- **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.
|
|
279
|
+
- **Mutation counts are best-effort.** `update_all`/`delete_all` return a pre-mutation `SELECT count()` — ClickHouse reports no affected-row counts.
|
|
280
|
+
- **Eventual merges.** `ReplacingMergeTree` deduplicates at merge time; read with `.final` when you need collapsed rows.
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
Everything runs against a real ClickHouse server:
|
|
285
|
+
|
|
286
|
+
```sh
|
|
287
|
+
docker compose up -d --wait
|
|
288
|
+
bundle install
|
|
289
|
+
bundle exec rspec
|
|
290
|
+
bundle exec rubocop
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
Run against Rails main:
|
|
294
|
+
|
|
295
|
+
```sh
|
|
296
|
+
RAILS_SOURCE=edge bundle install
|
|
297
|
+
RAILS_SOURCE=edge bundle exec rspec
|
|
298
|
+
```
|
|
299
|
+
|
|
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/`.
|
|
301
|
+
|
|
302
|
+
## History
|
|
303
|
+
|
|
304
|
+
View the [changelog](CHANGELOG.md).
|
|
305
|
+
|
|
306
|
+
## Contributing
|
|
307
|
+
|
|
308
|
+
Everyone is encouraged to help improve this project. Here are a few ways you can help:
|
|
309
|
+
|
|
310
|
+
- [Report bugs](https://github.com/ikraamg/activerecord-clickhouse-adapter/issues)
|
|
311
|
+
- Fix bugs and [submit pull requests](https://github.com/ikraamg/activerecord-clickhouse-adapter/pulls)
|
|
312
|
+
- Write, clarify, or fix documentation
|
|
313
|
+
- Suggest or add new features
|