clickhouse-activerecord 1.6.6 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.github/workflows/testing.yml +12 -14
- data/CHANGELOG.md +26 -0
- data/README.md +122 -1
- data/bin/console +1 -1
- data/bin/test-single +7 -0
- data/clickhouse-activerecord.gemspec +0 -1
- data/lib/active_record/connection_adapters/clickhouse/column.rb +23 -3
- data/lib/active_record/connection_adapters/clickhouse/oid/json.rb +23 -0
- data/lib/active_record/connection_adapters/clickhouse/oid/map.rb +16 -0
- data/lib/active_record/connection_adapters/clickhouse/schema_statements.rb +113 -38
- data/lib/active_record/connection_adapters/clickhouse/statement/response_processor.rb +8 -3
- data/lib/active_record/connection_adapters/clickhouse/table_definition.rb +7 -1
- data/lib/active_record/connection_adapters/clickhouse_adapter.rb +105 -11
- data/lib/arel/nodes/final_table.rb +10 -0
- data/lib/arel/visitors/clickhouse.rb +13 -4
- data/lib/clickhouse-activerecord/rspec.rb +1 -1
- data/lib/clickhouse-activerecord/schema_dumper.rb +3 -8
- data/lib/clickhouse-activerecord/tasks.rb +19 -15
- data/lib/clickhouse-activerecord/version.rb +1 -1
- data/lib/clickhouse-activerecord.rb +10 -0
- data/lib/core_extensions/active_record/relation.rb +121 -2
- metadata +6 -20
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f10ee5755575ea64229e6242e4fc081565fa5c4455581d5eca6fa47f2a047ee9
|
|
4
|
+
data.tar.gz: '07713045794d44203e419e4935c3abbff43c6f1cf9b5e6ad660b6f7e1f12510b'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 71d4af6e68d41395dfb924cb5f6dcf202a6d965a8ac49e55757586a544567cea8f60a094da4bdff7aefd438df216d85afb36518526159c750265794e66e4546b
|
|
7
|
+
data.tar.gz: 351ffc5dfd5acae7db007e28a88d5d2833d80b5dcaa9e35ba8b4a2612720bed9beda848a1d5e09559e79737f58055ea7236623af7b9436ee6f1346a2c9fd2e4e
|
|
@@ -22,23 +22,23 @@ jobs:
|
|
|
22
22
|
max-parallel: 1
|
|
23
23
|
matrix:
|
|
24
24
|
version:
|
|
25
|
-
- ruby: 2.7
|
|
26
|
-
rails: 7.1.3
|
|
27
25
|
- ruby: 3.2
|
|
28
|
-
rails: 7.
|
|
29
|
-
- ruby: 3.2
|
|
30
|
-
rails: 7.2.1
|
|
26
|
+
rails: 7.2.3
|
|
31
27
|
- ruby: 3.2
|
|
32
28
|
rails: 8.0.1
|
|
33
29
|
- ruby: 3.2
|
|
34
30
|
rails: 8.1
|
|
35
|
-
|
|
31
|
+
- ruby: 4.0
|
|
32
|
+
rails: 7.2.3
|
|
33
|
+
- ruby: 4.0
|
|
34
|
+
rails: 8.1
|
|
35
|
+
clickhouse: [ '24.9', '25.10' ]
|
|
36
36
|
|
|
37
37
|
steps:
|
|
38
|
-
- uses: actions/checkout@
|
|
38
|
+
- uses: actions/checkout@v6
|
|
39
39
|
|
|
40
40
|
- name: Start ClickHouse ${{ matrix.clickhouse }}
|
|
41
|
-
uses: hoverkraft-tech/compose-action@v2.
|
|
41
|
+
uses: hoverkraft-tech/compose-action@v2.5.0
|
|
42
42
|
env:
|
|
43
43
|
CLICKHOUSE_VERSION: ${{ matrix.clickhouse }}
|
|
44
44
|
with:
|
|
@@ -71,23 +71,21 @@ jobs:
|
|
|
71
71
|
max-parallel: 1
|
|
72
72
|
matrix:
|
|
73
73
|
version:
|
|
74
|
-
- ruby: 2.7
|
|
75
|
-
rails: 7.1.3
|
|
76
|
-
- ruby: 3.2
|
|
77
|
-
rails: 7.1.3
|
|
78
74
|
- ruby: 3.2
|
|
79
75
|
rails: 7.2.1
|
|
80
76
|
- ruby: 3.2
|
|
81
77
|
rails: 8.0.1
|
|
82
78
|
- ruby: 3.2
|
|
83
79
|
rails: 8.1
|
|
80
|
+
- ruby: 4.0
|
|
81
|
+
rails: 8.1
|
|
84
82
|
clickhouse: [ '24.9', '25.10' ]
|
|
85
83
|
|
|
86
84
|
steps:
|
|
87
|
-
- uses: actions/checkout@
|
|
85
|
+
- uses: actions/checkout@v6
|
|
88
86
|
|
|
89
87
|
- name: Start ClickHouse Cluster ${{ matrix.clickhouse }}
|
|
90
|
-
uses: hoverkraft-tech/compose-action@v2.
|
|
88
|
+
uses: hoverkraft-tech/compose-action@v2.5.0
|
|
91
89
|
env:
|
|
92
90
|
CLICKHOUSE_VERSION: ${{ matrix.clickhouse }}
|
|
93
91
|
with:
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
### Version 1.7.0 (Sep 29, 2026)
|
|
2
|
+
|
|
3
|
+
* Fix structure dump table sort
|
|
4
|
+
* Add configurable HTTP auth modes for ClickHouse requests in #240
|
|
5
|
+
* Parse column `DEFAULT` kinds in #246
|
|
6
|
+
* Honor unsigned: false on integer columns without a limit in #253
|
|
7
|
+
* Fix `@response_format` thread race in `with_response_format` in #251
|
|
8
|
+
* Implement URL-based configuration in #249
|
|
9
|
+
* Rails 8 compatibility fixes in #245
|
|
10
|
+
* Update specs in #244
|
|
11
|
+
* Add Float and Bool subtype support for Map OID in #237
|
|
12
|
+
* Fix TimeZoneConverter crash for Map OID attributes in #238
|
|
13
|
+
* Fix Map OID deserialize crash for already-parsed Date/DateTime values in #239
|
|
14
|
+
* Fix multi-column indexes in schema dumper in #234
|
|
15
|
+
* Use `lease_connection` instead of `connection` for Rails 7.2+ in #262
|
|
16
|
+
* Harden HTTP connection: open_timeout, TLS verification, retry, query limits in #266
|
|
17
|
+
* Detect Array columns via Column#array in #260
|
|
18
|
+
* Round-trip Float64 columns through the schema dumper in #269
|
|
19
|
+
* Add support for FINAL when joining tables in #261
|
|
20
|
+
* Clear the query cache when a raw statement writes in #271
|
|
21
|
+
* Treat DEFAULT NULL as a nil column default in #272
|
|
22
|
+
|
|
23
|
+
### Version 1.6.7 (Mar 5, 2026)
|
|
24
|
+
|
|
25
|
+
* Fix insert with default values in Ruby 4.0 and Rails 8.1
|
|
26
|
+
|
|
1
27
|
### Version 1.6.6 (Feb 16, 2026)
|
|
2
28
|
|
|
3
29
|
* Fix error: EOFError (end of file reached)
|
data/README.md
CHANGED
|
@@ -28,14 +28,47 @@ default: &default
|
|
|
28
28
|
port: 8123
|
|
29
29
|
username: username
|
|
30
30
|
password: password
|
|
31
|
+
http_auth: query_params # optional, supports query_params, basic, x_clickhouse_headers
|
|
31
32
|
ssl: true # optional for using ssl connection
|
|
32
33
|
debug: true # use for showing in to log technical information
|
|
33
|
-
migrations_paths: db/clickhouse # optional
|
|
34
|
+
migrations_paths: db/clickhouse # optional
|
|
34
35
|
cluster_name: 'cluster_name' # optional for creating tables in cluster
|
|
35
36
|
replica_name: '{replica}' # replica macros name, optional for creating replicated tables
|
|
36
37
|
read_timeout: 300 # change network timeouts, by default 60 seconds
|
|
37
38
|
write_timeout: 300
|
|
38
39
|
keep_alive_timeout: 300
|
|
40
|
+
open_timeout: 5 # timeout for establishing the TCP connection itself, optional (Net::HTTP's default applies if unset)
|
|
41
|
+
insecure: false # optional, skip TLS certificate verification (default: true, matching prior behavior - set to false to verify)
|
|
42
|
+
sslca: /path/to/ca.pem # optional, custom CA bundle for TLS verification
|
|
43
|
+
max_execution_time: 25 # optional, server-side query time limit in seconds, sent as a ClickHouse session setting
|
|
44
|
+
cancel_http_readonly_queries_on_client_close: true # optional, tell the server to abort a SELECT once the client disconnects
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### URL-based configuration
|
|
48
|
+
|
|
49
|
+
You can configure the adapter with a single `url` key instead of individual fields:
|
|
50
|
+
|
|
51
|
+
```yml
|
|
52
|
+
default: &default
|
|
53
|
+
adapter: clickhouse
|
|
54
|
+
url: clickhouse://username:password@localhost:8123/database
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Optional settings can be passed as query parameters:
|
|
58
|
+
|
|
59
|
+
```
|
|
60
|
+
clickhouse://username:password@localhost:8123/database?ssl=true&http_auth=basic&read_timeout=300&write_timeout=300&keep_alive_timeout=300&open_timeout=5&debug=false&cluster_name=my_cluster
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Supported query parameters: `ssl` (true/false), `debug` (true/false), `insecure` (true/false), `http_auth` (query_params/basic/x_clickhouse_headers), `open_timeout`, `read_timeout`, `write_timeout`, `keep_alive_timeout`, `max_execution_time` (integers), `cancel_http_readonly_queries_on_client_close` (true/false), `cluster_name`, `sslca`.
|
|
64
|
+
|
|
65
|
+
If both a `url` and explicit keys are provided, the explicit keys take precedence:
|
|
66
|
+
|
|
67
|
+
```yml
|
|
68
|
+
default: &default
|
|
69
|
+
adapter: clickhouse
|
|
70
|
+
url: clickhouse://username:password@localhost:8123/database
|
|
71
|
+
host: production.db.internal # overrides the host from the URL
|
|
39
72
|
```
|
|
40
73
|
|
|
41
74
|
Alternatively if you wish to pass a custom `Net::HTTP` transport (or any other
|
|
@@ -53,6 +86,55 @@ class ActionView < ActiveRecord::Base
|
|
|
53
86
|
end
|
|
54
87
|
```
|
|
55
88
|
|
|
89
|
+
### HTTP authentication mode
|
|
90
|
+
|
|
91
|
+
By default, the adapter sends `user` and `password` as URL parameters.
|
|
92
|
+
You can set `http_auth` explicitly (or omit it and keep the same default behavior):
|
|
93
|
+
|
|
94
|
+
```yml
|
|
95
|
+
clickhouse:
|
|
96
|
+
adapter: clickhouse
|
|
97
|
+
host: localhost
|
|
98
|
+
port: 8123
|
|
99
|
+
database: my_db
|
|
100
|
+
username: app_user
|
|
101
|
+
password: secret
|
|
102
|
+
http_auth: x_clickhouse_headers # or basic / query_params
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- Use YAML string values: `http_auth: query_params`, `http_auth: basic`, or `http_auth: x_clickhouse_headers`.
|
|
106
|
+
- Both strings and Ruby symbols are accepted internally.
|
|
107
|
+
- `http_auth: x_clickhouse_headers` sends `X-ClickHouse-User`, `X-ClickHouse-Key`, and `X-ClickHouse-Database` headers.
|
|
108
|
+
- `http_auth: basic` sends `Authorization: Basic ...` and keeps `database` in URL params.
|
|
109
|
+
- `http_auth: query_params` sends `user`, `password`, and `database` in URL params (same as omitting `http_auth`).
|
|
110
|
+
|
|
111
|
+
### Connection hardening
|
|
112
|
+
|
|
113
|
+
- **TLS verification is now configurable.** Every connection has always been made with
|
|
114
|
+
`verify_mode: OpenSSL::SSL::VERIFY_NONE`, so an SSL connection to ClickHouse never actually
|
|
115
|
+
checked the server's certificate. That remains the default (`insecure: true`) to avoid
|
|
116
|
+
breaking existing setups; set `insecure: false` to turn verification on, optionally with
|
|
117
|
+
`sslca` pointing at your CA bundle.
|
|
118
|
+
- **`open_timeout`** bounds how long establishing the underlying TCP connection is allowed to
|
|
119
|
+
take. It's optional and unset by default (`Net::HTTP`'s own default applies), unlike
|
|
120
|
+
`read_timeout`/`write_timeout` which only apply once a connection exists - a network path that
|
|
121
|
+
never completes the TCP handshake (a dead route, a security group silently dropping packets)
|
|
122
|
+
previously had no bound here at all.
|
|
123
|
+
- **Read queries (`SELECT`) are retried once** on a fresh connection when the failure happens
|
|
124
|
+
before the server responds at all (`Net::OpenTimeout`, `EOFError`, `ECONNRESET`, `IOError`) -
|
|
125
|
+
the kind of failure a stale pooled connection produces. Writes are never retried, and
|
|
126
|
+
`Net::ReadTimeout` (the server received the query and hasn't answered yet) is deliberately
|
|
127
|
+
**not** retried on any query - re-sending a query that's already loading the server doesn't
|
|
128
|
+
help. If the retry also fails, `ActiveRecord::ConnectionFailed` is raised.
|
|
129
|
+
- **Timeouts and cancellations now raise distinct, Rails-standard error classes** instead of a
|
|
130
|
+
generic `ActiveRecord::ActiveRecordError`, so callers can `rescue` them the same way they
|
|
131
|
+
would for any other adapter:
|
|
132
|
+
- `ActiveRecord::StatementTimeout` - the server killed the query server-side, via
|
|
133
|
+
`max_execution_time` or `cancel_http_readonly_queries_on_client_close`.
|
|
134
|
+
- `ActiveRecord::AdapterTimeout` - the client gave up waiting for a response
|
|
135
|
+
(`Net::ReadTimeout`); the query may still be running server-side.
|
|
136
|
+
- `ActiveRecord::ConnectionFailed` - the connection-retry above was exhausted.
|
|
137
|
+
|
|
56
138
|
## Usage in Rails
|
|
57
139
|
|
|
58
140
|
Add your `database.yml` connection information with postfix `_clickhouse` for you environment:
|
|
@@ -197,6 +279,13 @@ User.joins(:actions).using(:group_id)
|
|
|
197
279
|
# Clickhouse User Load (10.3ms) SELECT users.* FROM users INNER JOIN actions USING group_id
|
|
198
280
|
#=> #<ActiveRecord::Relation [#<User *** >]>
|
|
199
281
|
|
|
282
|
+
# `final` only applies the FINAL modifier to the primary FROM table. Use
|
|
283
|
+
# `joins_final` to also apply FINAL to a joined table (the join is added for
|
|
284
|
+
# you, like `joins`). Pass association names; joins are matched by table name.
|
|
285
|
+
User.final.joins_final(:actions)
|
|
286
|
+
# Clickhouse User Load (10.3ms) SELECT users.* FROM users FINAL INNER JOIN actions FINAL ON actions.user_id = users.id
|
|
287
|
+
#=> #<ActiveRecord::Relation [#<User *** >]>
|
|
288
|
+
|
|
200
289
|
User.window('x', order: 'date', partition: 'name', rows: 'UNBOUNDED PRECEDING').select('sum(value) OVER x')
|
|
201
290
|
# SELECT sum(value) OVER x FROM users WINDOW x AS (PARTITION BY name ORDER BY date ROWS UNBOUNDED PRECEDING)
|
|
202
291
|
#=> #<ActiveRecord::Relation [#<User *** >]>
|
|
@@ -343,6 +432,38 @@ Donations to this project are going directly to [PNixx](https://github.com/PNixx
|
|
|
343
432
|
|
|
344
433
|
After checking out the repo, run `bin/setup` to install dependencies. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
|
|
345
434
|
|
|
435
|
+
### Run locally
|
|
436
|
+
|
|
437
|
+
1. Start ClickHouse (single node):
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
docker compose -f .docker/docker-compose.yml up -d
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
2. Run single-node specs:
|
|
444
|
+
|
|
445
|
+
```bash
|
|
446
|
+
bin/test-single
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
If your local workflow expects `bin/single_test`, use the same command format as `bin/test-single`:
|
|
450
|
+
|
|
451
|
+
```bash
|
|
452
|
+
CLICKHOUSE_PORT=18123 CLICKHOUSE_DATABASE=default bundle exec rspec spec/single --format progress
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
3. Start ClickHouse cluster:
|
|
456
|
+
|
|
457
|
+
```bash
|
|
458
|
+
docker compose -f .docker/docker-compose.cluster.yml up -d
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
4. Run cluster specs:
|
|
462
|
+
|
|
463
|
+
```bash
|
|
464
|
+
CLICKHOUSE_PORT=28123 CLICKHOUSE_DATABASE=default CLICKHOUSE_CLUSTER=test_cluster bundle exec rspec spec/cluster --format progress
|
|
465
|
+
```
|
|
466
|
+
|
|
346
467
|
To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org).
|
|
347
468
|
|
|
348
469
|
Testing github actions:
|
data/bin/console
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env ruby
|
|
2
2
|
|
|
3
3
|
require "bundler/setup"
|
|
4
|
-
require "clickhouse
|
|
4
|
+
require "clickhouse-activerecord"
|
|
5
5
|
|
|
6
6
|
# You can add fixtures and/or initialization code here to make experimenting
|
|
7
7
|
# with your gem easier. You can also use a different console, if you like.
|
data/bin/test-single
ADDED
|
@@ -1,13 +1,33 @@
|
|
|
1
1
|
module ActiveRecord
|
|
2
2
|
module ConnectionAdapters
|
|
3
3
|
module Clickhouse
|
|
4
|
-
|
|
4
|
+
DescribedColumn =
|
|
5
|
+
Data.define(:name, :sql_type, :default_type, :default_expression, :comment, :codec) do
|
|
6
|
+
def ephemeral?
|
|
7
|
+
default_type.to_s.downcase == 'ephemeral'
|
|
8
|
+
end
|
|
9
|
+
end
|
|
5
10
|
|
|
6
|
-
|
|
11
|
+
class Column < ActiveRecord::ConnectionAdapters::Column
|
|
12
|
+
attr_reader :codec, :default_kind
|
|
7
13
|
|
|
8
|
-
def initialize(*, codec: nil, **)
|
|
14
|
+
def initialize(*, codec: nil, default_kind: nil, **)
|
|
9
15
|
super
|
|
10
16
|
@codec = codec
|
|
17
|
+
@default_kind = ActiveSupport::StringInquirer.new(default_kind.to_s.downcase.presence || 'none')
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def virtual?
|
|
21
|
+
default_kind.materialized? || default_kind.alias?
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Whether the column holds a ClickHouse `Array(...)` type.
|
|
25
|
+
#
|
|
26
|
+
# ActiveRecord core never calls this, but tooling such as annotaterb
|
|
27
|
+
# detects array columns via `column.respond_to?(:array) && column.array`,
|
|
28
|
+
# so the base adapter's missing `array` reader left them undetected.
|
|
29
|
+
def array
|
|
30
|
+
sql_type.start_with?('Array(')
|
|
11
31
|
end
|
|
12
32
|
|
|
13
33
|
private
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ActiveRecord
|
|
4
|
+
module ConnectionAdapters
|
|
5
|
+
module Clickhouse
|
|
6
|
+
module OID # :nodoc:
|
|
7
|
+
class Json < Type::Json # :nodoc:
|
|
8
|
+
|
|
9
|
+
def deserialize(value)
|
|
10
|
+
if value.is_a?(::String)
|
|
11
|
+
::JSON.parse(value)
|
|
12
|
+
else
|
|
13
|
+
super
|
|
14
|
+
end
|
|
15
|
+
rescue ::JSON::ParserError => e
|
|
16
|
+
ActiveSupport.error_reporter.report(e, source: "application.active_record")
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -11,6 +11,10 @@ module ActiveRecord
|
|
|
11
11
|
when /U?Int(\d+)/
|
|
12
12
|
@subtype = :integer
|
|
13
13
|
@limit = bits_to_limit(Regexp.last_match(1)&.to_i)
|
|
14
|
+
when /Float/
|
|
15
|
+
@subtype = :float
|
|
16
|
+
when /Bool/
|
|
17
|
+
@subtype = :boolean
|
|
14
18
|
when /DateTime/
|
|
15
19
|
@subtype = :datetime
|
|
16
20
|
when /Date/
|
|
@@ -31,9 +35,14 @@ module ActiveRecord
|
|
|
31
35
|
value.map { |item| deserialize(item) }
|
|
32
36
|
else
|
|
33
37
|
return value if value.nil?
|
|
38
|
+
return value if already_deserialized?(value)
|
|
34
39
|
case @subtype
|
|
35
40
|
when :integer
|
|
36
41
|
value.to_i
|
|
42
|
+
when :float
|
|
43
|
+
value.to_f
|
|
44
|
+
when :boolean
|
|
45
|
+
ActiveRecord::Type::Boolean.new.cast(value)
|
|
37
46
|
when :datetime
|
|
38
47
|
::DateTime.parse(value)
|
|
39
48
|
when :date
|
|
@@ -51,6 +60,8 @@ module ActiveRecord
|
|
|
51
60
|
value.map { |item| serialize(item) }
|
|
52
61
|
else
|
|
53
62
|
return value if value.nil?
|
|
63
|
+
return value.to_f if @subtype == :float
|
|
64
|
+
return ActiveRecord::Type::Boolean.new.cast(value) if @subtype == :boolean
|
|
54
65
|
case @subtype
|
|
55
66
|
when :integer
|
|
56
67
|
value.to_i
|
|
@@ -68,6 +79,11 @@ module ActiveRecord
|
|
|
68
79
|
|
|
69
80
|
private
|
|
70
81
|
|
|
82
|
+
def already_deserialized?(value)
|
|
83
|
+
(@subtype == :date && value.is_a?(::Date)) ||
|
|
84
|
+
(@subtype == :datetime && (value.is_a?(::DateTime) || value.is_a?(::Time)))
|
|
85
|
+
end
|
|
86
|
+
|
|
71
87
|
def bits_to_limit(bits)
|
|
72
88
|
case bits
|
|
73
89
|
when 8 then 1
|
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'base64'
|
|
4
|
+
require 'net/http'
|
|
3
5
|
require 'clickhouse-activerecord/version'
|
|
4
6
|
|
|
5
7
|
module ActiveRecord
|
|
6
8
|
module ConnectionAdapters
|
|
7
9
|
module Clickhouse
|
|
8
10
|
module SchemaStatements
|
|
11
|
+
HTTP_AUTH_QUERY_PARAMS = :query_params
|
|
12
|
+
HTTP_AUTH_BASIC = :basic
|
|
13
|
+
HTTP_AUTH_X_HEADERS = :x_clickhouse_headers
|
|
14
|
+
HTTP_AUTH_TYPES = [HTTP_AUTH_QUERY_PARAMS, HTTP_AUTH_BASIC, HTTP_AUTH_X_HEADERS].freeze
|
|
15
|
+
|
|
16
|
+
# Connection-level failures worth a single retry on a fresh connection.
|
|
17
|
+
RETRYABLE_CONNECTION_ERRORS = [Net::OpenTimeout, EOFError, Errno::ECONNRESET, IOError].freeze
|
|
9
18
|
|
|
10
19
|
def with_settings(**settings)
|
|
11
20
|
@block_settings ||= {}
|
|
@@ -32,14 +41,14 @@ module ActiveRecord
|
|
|
32
41
|
# end
|
|
33
42
|
# # sends and executes "SELECT * FROM table"
|
|
34
43
|
def with_response_format(format)
|
|
35
|
-
|
|
36
|
-
|
|
44
|
+
stack = response_format_stack
|
|
45
|
+
stack.push(format)
|
|
37
46
|
yield
|
|
38
47
|
ensure
|
|
39
|
-
|
|
48
|
+
stack.pop
|
|
40
49
|
end
|
|
41
50
|
|
|
42
|
-
def execute(sql, name = nil, format:
|
|
51
|
+
def execute(sql, name = nil, format: response_format, settings: {})
|
|
43
52
|
with_response_format(format) do
|
|
44
53
|
log(sql, [adapter_name, name].compact.join(' ')) do
|
|
45
54
|
raw_execute(sql, settings: settings)
|
|
@@ -55,16 +64,13 @@ module ActiveRecord
|
|
|
55
64
|
|
|
56
65
|
# Execute an SQL query and save the result to a file in stream mode
|
|
57
66
|
# @return [Tempfile]
|
|
58
|
-
def execute_to_file(sql, name = nil, format:
|
|
67
|
+
def execute_to_file(sql, name = nil, format: response_format, settings: {})
|
|
59
68
|
with_response_format(format) do
|
|
60
69
|
log(sql, [adapter_name, 'Stream', name].compact.join(' ')) do
|
|
61
|
-
statement = Statement.new(sql, format:
|
|
70
|
+
statement = Statement.new(sql, format: response_format)
|
|
62
71
|
result = nil
|
|
63
72
|
@lock.synchronize do
|
|
64
|
-
req = Net::HTTP::Post.new("/?#{settings_params(settings)}",
|
|
65
|
-
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
66
|
-
'User-Agent' => ClickhouseAdapter::USER_AGENT,
|
|
67
|
-
})
|
|
73
|
+
req = Net::HTTP::Post.new("/?#{settings_params(settings)}", build_request_headers)
|
|
68
74
|
@connection.start unless @connection.started?
|
|
69
75
|
@connection.request(req, statement.formatted_sql) do |response|
|
|
70
76
|
result = statement.streaming_response(response)
|
|
@@ -78,23 +84,39 @@ module ActiveRecord
|
|
|
78
84
|
def exec_insert(sql, name = nil, _binds = [], _pk = nil, _sequence_name = nil, returning: nil)
|
|
79
85
|
new_sql = sql.sub(/ (DEFAULT )?VALUES/, " VALUES")
|
|
80
86
|
with_response_format(nil) { execute(new_sql, name) }
|
|
81
|
-
|
|
87
|
+
nil
|
|
82
88
|
end
|
|
83
89
|
|
|
84
90
|
def internal_exec_query(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
connection_retries = 0
|
|
92
|
+
begin
|
|
93
|
+
result = execute(sql, name)
|
|
94
|
+
columns = result['meta'].map { |m| m['name'] }
|
|
95
|
+
types = {}
|
|
96
|
+
result['meta'].each_with_index do |m, i|
|
|
97
|
+
# need use column name and index after commit in 7.2:
|
|
98
|
+
# https://github.com/rails/rails/commit/24dbf7637b1d5cd6eb3d7100b8d0f6872c3fee3c
|
|
99
|
+
types[m['name']] = types[i] = type_map.lookup(m['type'])
|
|
100
|
+
end
|
|
101
|
+
ActiveRecord::Result.new(columns, result['data'], types)
|
|
102
|
+
rescue ActiveRecord::ActiveRecordError => e
|
|
103
|
+
raise e
|
|
104
|
+
rescue Net::ReadTimeout => e
|
|
105
|
+
raise ActiveRecord::AdapterTimeout, "Response: #{e.message}"
|
|
106
|
+
rescue *RETRYABLE_CONNECTION_ERRORS => e
|
|
107
|
+
raise ActiveRecord::ConnectionFailed, "Response: #{e.message}" if connection_retries.positive?
|
|
108
|
+
|
|
109
|
+
connection_retries += 1
|
|
110
|
+
logger&.warn("[clickhouse-activerecord] retrying read query after connection failure (#{e.class}: #{e.message})")
|
|
111
|
+
begin
|
|
112
|
+
reconnect
|
|
113
|
+
rescue StandardError
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
retry
|
|
117
|
+
rescue StandardError => e
|
|
118
|
+
raise ActiveRecord::ActiveRecordError, "Response: #{e.message}"
|
|
92
119
|
end
|
|
93
|
-
ActiveRecord::Result.new(columns, result['data'], types)
|
|
94
|
-
rescue ActiveRecord::ActiveRecordError => e
|
|
95
|
-
raise e
|
|
96
|
-
rescue StandardError => e
|
|
97
|
-
raise ActiveRecord::ActiveRecordError, "Response: #{e.message}"
|
|
98
120
|
end
|
|
99
121
|
|
|
100
122
|
def exec_insert_all(sql, name)
|
|
@@ -111,7 +133,7 @@ module ActiveRecord
|
|
|
111
133
|
# @link https://clickhouse.com/docs/en/sql-reference/statements/delete
|
|
112
134
|
def exec_delete(sql, name = nil, _binds = [])
|
|
113
135
|
log(sql, "#{adapter_name} #{name}") do
|
|
114
|
-
statement = Statement.new(sql, format:
|
|
136
|
+
statement = Statement.new(sql, format: response_format)
|
|
115
137
|
res = request(statement)
|
|
116
138
|
begin
|
|
117
139
|
data = JSON.parse(res.header['x-clickhouse-summary'])
|
|
@@ -239,11 +261,23 @@ module ActiveRecord
|
|
|
239
261
|
result = do_system_execute("DESCRIBE TABLE `#{table_name}`", table_name)
|
|
240
262
|
data = result['data']
|
|
241
263
|
|
|
242
|
-
|
|
264
|
+
raise ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'" if data.empty?
|
|
265
|
+
|
|
266
|
+
data.map do |row|
|
|
267
|
+
Clickhouse::DescribedColumn.new(
|
|
268
|
+
name: row[0],
|
|
269
|
+
sql_type: row[1],
|
|
270
|
+
default_type: row[2],
|
|
271
|
+
default_expression: row[3],
|
|
272
|
+
comment: row[4],
|
|
273
|
+
codec: row[5],
|
|
274
|
+
)
|
|
275
|
+
end
|
|
276
|
+
end
|
|
243
277
|
|
|
244
|
-
|
|
278
|
+
def column_definitions(table_name)
|
|
279
|
+
table_structure(table_name).reject(&:ephemeral?)
|
|
245
280
|
end
|
|
246
|
-
alias column_definitions table_structure
|
|
247
281
|
|
|
248
282
|
private
|
|
249
283
|
|
|
@@ -256,22 +290,22 @@ module ActiveRecord
|
|
|
256
290
|
end
|
|
257
291
|
|
|
258
292
|
def new_column_from_field(table_name, field, _definitions)
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
cast_type = lookup_cast_type(sql_type)
|
|
293
|
+
type_metadata = fetch_type_metadata(field.sql_type)
|
|
294
|
+
default_value = extract_value_from_default(field.default_expression, field.default_type)
|
|
295
|
+
default_function = extract_default_function(field.default_expression)
|
|
296
|
+
cast_type = lookup_cast_type(field.sql_type)
|
|
264
297
|
default_value = cast_type.cast(default_value)
|
|
265
298
|
|
|
266
|
-
args = [
|
|
299
|
+
args = [field.name]
|
|
267
300
|
args << cast_type if ::ActiveRecord::version >= Gem::Version.new('8.1')
|
|
268
|
-
args += [default_value, type_metadata, field
|
|
301
|
+
args += [default_value, type_metadata, field.sql_type.include?('Nullable'), default_function]
|
|
269
302
|
|
|
270
|
-
Clickhouse::Column.new(*args, codec: field
|
|
303
|
+
Clickhouse::Column.new(*args, codec: field.codec.presence, default_kind: field.default_type)
|
|
271
304
|
end
|
|
272
305
|
|
|
273
306
|
def extract_value_from_default(default_expression, default_type)
|
|
274
307
|
return nil if default_type != 'DEFAULT' || default_expression.blank?
|
|
308
|
+
return nil if default_expression == 'NULL'
|
|
275
309
|
return nil if has_default_function?(default_expression)
|
|
276
310
|
|
|
277
311
|
# Convert string
|
|
@@ -289,7 +323,7 @@ module ActiveRecord
|
|
|
289
323
|
end
|
|
290
324
|
|
|
291
325
|
def raw_execute(sql, settings: {}, except_params: [])
|
|
292
|
-
statement = Statement.new(sql, format:
|
|
326
|
+
statement = Statement.new(sql, format: response_format)
|
|
293
327
|
response = request(statement, settings: settings, except_params: except_params)
|
|
294
328
|
statement.processed_response(response)
|
|
295
329
|
end
|
|
@@ -303,8 +337,7 @@ module ActiveRecord
|
|
|
303
337
|
@lock.synchronize do
|
|
304
338
|
@connection.post("/?#{settings_params(settings, except: except_params)}",
|
|
305
339
|
statement.formatted_sql,
|
|
306
|
-
|
|
307
|
-
'User-Agent' => ClickhouseAdapter::USER_AGENT)
|
|
340
|
+
build_request_headers(include_database: !except_params.include?(:database)))
|
|
308
341
|
end
|
|
309
342
|
end
|
|
310
343
|
|
|
@@ -313,15 +346,57 @@ module ActiveRecord
|
|
|
313
346
|
log(sql, "#{name} (system)") { yield }
|
|
314
347
|
end
|
|
315
348
|
|
|
349
|
+
# The response format that should be applied to the next request.
|
|
350
|
+
# Reads the per-thread override pushed by `with_response_format`, falling
|
|
351
|
+
# back to the adapter-level default when no override is in scope.
|
|
352
|
+
def response_format
|
|
353
|
+
stack = response_format_stack
|
|
354
|
+
stack.empty? ? @default_response_format : stack.last
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def response_format_stack
|
|
358
|
+
key = (@response_format_stack_key ||= :"clickhouse_response_format_stack_#{object_id}")
|
|
359
|
+
Thread.current[key] ||= []
|
|
360
|
+
end
|
|
361
|
+
|
|
316
362
|
def settings_params(settings = {}, except: [])
|
|
317
363
|
request_params = @connection_config || {}
|
|
318
364
|
block_settings = @block_settings || {}
|
|
365
|
+
|
|
366
|
+
case @http_auth
|
|
367
|
+
when HTTP_AUTH_BASIC
|
|
368
|
+
request_params = request_params.except(:user, :password)
|
|
369
|
+
when HTTP_AUTH_X_HEADERS
|
|
370
|
+
request_params = request_params.except(:user, :password, :database)
|
|
371
|
+
end
|
|
372
|
+
|
|
319
373
|
request_params.merge(block_settings)
|
|
320
374
|
.merge(settings)
|
|
321
375
|
.except(*except)
|
|
322
376
|
.to_param
|
|
323
377
|
end
|
|
324
378
|
|
|
379
|
+
def build_request_headers(include_database: true)
|
|
380
|
+
request_headers = {
|
|
381
|
+
'Content-Type' => 'application/x-www-form-urlencoded',
|
|
382
|
+
'User-Agent' => ClickhouseAdapter::USER_AGENT
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
case @http_auth
|
|
386
|
+
when HTTP_AUTH_BASIC
|
|
387
|
+
if @config[:username] && @config[:password]
|
|
388
|
+
credentials = Base64.strict_encode64("#{@config[:username]}:#{@config[:password]}")
|
|
389
|
+
request_headers['Authorization'] = "Basic #{credentials}"
|
|
390
|
+
end
|
|
391
|
+
when HTTP_AUTH_X_HEADERS
|
|
392
|
+
request_headers['X-ClickHouse-User'] = @config[:username].to_s if @config[:username]
|
|
393
|
+
request_headers['X-ClickHouse-Key'] = @config[:password].to_s if @config[:password]
|
|
394
|
+
request_headers['X-ClickHouse-Database'] = @config[:database].to_s if include_database && @config[:database]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
request_headers
|
|
398
|
+
end
|
|
399
|
+
|
|
325
400
|
# Returns a hash of table names to their engine types
|
|
326
401
|
def table_engines(table_names = nil)
|
|
327
402
|
table_names_sql = if table_names.present?
|