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