clickhouse-activerecord 1.6.7 → 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 +3 -3
- data/CHANGELOG.md +22 -0
- data/README.md +122 -1
- data/bin/console +1 -1
- data/bin/test-single +7 -0
- 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 +112 -37
- data/lib/active_record/connection_adapters/clickhouse/statement/response_processor.rb +6 -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/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 +5 -2
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
|
|
@@ -32,10 +32,10 @@ jobs:
|
|
|
32
32
|
rails: 7.2.3
|
|
33
33
|
- ruby: 4.0
|
|
34
34
|
rails: 8.1
|
|
35
|
-
clickhouse: [ '24.
|
|
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
41
|
uses: hoverkraft-tech/compose-action@v2.5.0
|
|
@@ -82,7 +82,7 @@ jobs:
|
|
|
82
82
|
clickhouse: [ '24.9', '25.10' ]
|
|
83
83
|
|
|
84
84
|
steps:
|
|
85
|
-
- uses: actions/checkout@
|
|
85
|
+
- uses: actions/checkout@v6
|
|
86
86
|
|
|
87
87
|
- name: Start ClickHouse Cluster ${{ matrix.clickhouse }}
|
|
88
88
|
uses: hoverkraft-tech/compose-action@v2.5.0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,25 @@
|
|
|
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
|
+
|
|
1
23
|
### Version 1.6.7 (Mar 5, 2026)
|
|
2
24
|
|
|
3
25
|
* Fix insert with default values in Ruby 4.0 and Rails 8.1
|
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)
|
|
@@ -82,19 +88,35 @@ module ActiveRecord
|
|
|
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?
|
|
@@ -54,7 +54,7 @@ module ActiveRecord
|
|
|
54
54
|
|
|
55
55
|
# @return [String, Hash, Array]
|
|
56
56
|
def process_successful_response
|
|
57
|
-
|
|
57
|
+
raise_database_error!(@sql) if @body.include?('DB::Exception') && @body.match?(DB_EXCEPTION_REGEXP)
|
|
58
58
|
|
|
59
59
|
format_body_response
|
|
60
60
|
end
|
|
@@ -103,14 +103,17 @@ module ActiveRecord
|
|
|
103
103
|
JSON.parse(payload, decimal_class: BigDecimal)
|
|
104
104
|
end
|
|
105
105
|
|
|
106
|
-
def raise_database_error!
|
|
106
|
+
def raise_database_error!(sql = nil)
|
|
107
107
|
case @body
|
|
108
108
|
when /DB::Exception:.*\(UNKNOWN_DATABASE\)/
|
|
109
109
|
raise ActiveRecord::NoDatabaseError
|
|
110
110
|
when /DB::Exception:.*\(DATABASE_ALREADY_EXISTS\)/
|
|
111
111
|
raise ActiveRecord::DatabaseAlreadyExists
|
|
112
|
+
when /DB::Exception:.*\(TIMEOUT_EXCEEDED\)/
|
|
113
|
+
# server-side cancellation, vs. ActiveRecord::AdapterTimeout (client gave up)
|
|
114
|
+
raise ActiveRecord::StatementTimeout, "Response code: #{@raw_response.code}:\n#{@body}#{"\nQuery: #{sql}" if sql}"
|
|
112
115
|
else
|
|
113
|
-
raise_generic!
|
|
116
|
+
raise_generic!(sql)
|
|
114
117
|
end
|
|
115
118
|
end
|
|
116
119
|
|
|
@@ -41,7 +41,7 @@ module ActiveRecord
|
|
|
41
41
|
unsigned = options[:unsigned]
|
|
42
42
|
unsigned = true if unsigned.nil?
|
|
43
43
|
|
|
44
|
-
kind = :uint32 # default
|
|
44
|
+
kind = unsigned ? :uint32 : :int32 # default
|
|
45
45
|
|
|
46
46
|
if options[:limit]
|
|
47
47
|
if unsigned
|
|
@@ -63,6 +63,12 @@ module ActiveRecord
|
|
|
63
63
|
args.each { |name| column(name, kind, **options.except(:limit, :unsigned)) }
|
|
64
64
|
end
|
|
65
65
|
|
|
66
|
+
def float(*args, **options)
|
|
67
|
+
kind = options[:limit] == 8 ? :float64 : :float
|
|
68
|
+
|
|
69
|
+
args.each { |name| column(name, kind, **options.except(:limit)) }
|
|
70
|
+
end
|
|
71
|
+
|
|
66
72
|
def datetime(*args, **options)
|
|
67
73
|
kind = :datetime
|
|
68
74
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'arel/visitors/clickhouse'
|
|
4
4
|
require 'arel/nodes/final'
|
|
5
|
+
require 'arel/nodes/final_table'
|
|
5
6
|
require 'arel/nodes/grouping_sets'
|
|
6
7
|
require 'arel/nodes/settings'
|
|
7
8
|
require 'arel/nodes/using'
|
|
@@ -11,6 +12,7 @@ require 'active_record/connection_adapters/clickhouse/oid/date'
|
|
|
11
12
|
require 'active_record/connection_adapters/clickhouse/oid/date_time'
|
|
12
13
|
require 'active_record/connection_adapters/clickhouse/oid/big_integer'
|
|
13
14
|
require 'active_record/connection_adapters/clickhouse/oid/map'
|
|
15
|
+
require 'active_record/connection_adapters/clickhouse/oid/json'
|
|
14
16
|
require 'active_record/connection_adapters/clickhouse/oid/uuid'
|
|
15
17
|
require 'active_record/connection_adapters/clickhouse/column'
|
|
16
18
|
require 'active_record/connection_adapters/clickhouse/quoting'
|
|
@@ -20,6 +22,7 @@ require 'active_record/connection_adapters/clickhouse/statement'
|
|
|
20
22
|
require 'active_record/connection_adapters/clickhouse/table_definition'
|
|
21
23
|
require 'net/http'
|
|
22
24
|
require 'openssl'
|
|
25
|
+
require 'uri'
|
|
23
26
|
|
|
24
27
|
module ActiveRecord
|
|
25
28
|
class Base
|
|
@@ -28,10 +31,22 @@ module ActiveRecord
|
|
|
28
31
|
def clickhouse_connection(config)
|
|
29
32
|
config = config.symbolize_keys
|
|
30
33
|
|
|
34
|
+
if config[:url]
|
|
35
|
+
url_config = ConnectionAdapters::ClickhouseAdapter.parse_clickhouse_url(config.delete(:url))
|
|
36
|
+
config = url_config.merge(config)
|
|
37
|
+
end
|
|
38
|
+
|
|
31
39
|
unless config.key?(:database)
|
|
32
40
|
raise ArgumentError, 'No database specified. Missing argument: database.'
|
|
33
41
|
end
|
|
34
42
|
|
|
43
|
+
if config[:http_auth]
|
|
44
|
+
unless ConnectionAdapters::Clickhouse::SchemaStatements::HTTP_AUTH_TYPES.include?(config[:http_auth]&.to_sym)
|
|
45
|
+
raise ArgumentError, "Unknown :http_auth mode #{config[:http_auth].inspect}. " \
|
|
46
|
+
+ "Use one of #{ConnectionAdapters::Clickhouse::SchemaStatements::HTTP_AUTH_TYPES}."
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
35
50
|
ConnectionAdapters::ClickhouseAdapter.new(config)
|
|
36
51
|
end
|
|
37
52
|
end
|
|
@@ -52,6 +67,8 @@ module ActiveRecord
|
|
|
52
67
|
module ModelSchema
|
|
53
68
|
module ClassMethods
|
|
54
69
|
delegate :final, :final!,
|
|
70
|
+
:joins_final, :joins_final!,
|
|
71
|
+
:left_joins_final, :left_joins_final!,
|
|
55
72
|
:group_by_grouping_sets, :group_by_grouping_sets!,
|
|
56
73
|
:settings, :settings!,
|
|
57
74
|
:window, :window!,
|
|
@@ -90,6 +107,7 @@ module ActiveRecord
|
|
|
90
107
|
integer: { name: 'UInt32' },
|
|
91
108
|
big_integer: { name: 'UInt64' },
|
|
92
109
|
float: { name: 'Float32' },
|
|
110
|
+
float64: { name: 'Float64' },
|
|
93
111
|
decimal: { name: 'Decimal' },
|
|
94
112
|
datetime: { name: 'DateTime' },
|
|
95
113
|
datetime64: { name: 'DateTime64' },
|
|
@@ -119,8 +137,16 @@ module ActiveRecord
|
|
|
119
137
|
|
|
120
138
|
include Clickhouse::SchemaStatements
|
|
121
139
|
|
|
140
|
+
# our `execute` shadows the wrapper `QueryCache` installs on `AbstractAdapter`
|
|
141
|
+
ActiveRecord::ConnectionAdapters::QueryCache.dirties_query_cache(self, :execute)
|
|
142
|
+
|
|
122
143
|
# Initializes and connects a Clickhouse adapter.
|
|
123
144
|
def initialize(config_or_deprecated_connection, deprecated_logger = nil, deprecated_connection_options = nil, deprecated_config = nil)
|
|
145
|
+
if config_or_deprecated_connection.is_a?(Hash) && config_or_deprecated_connection[:url]
|
|
146
|
+
config_or_deprecated_connection = config_or_deprecated_connection.dup
|
|
147
|
+
url_config = self.class.parse_clickhouse_url(config_or_deprecated_connection.delete(:url))
|
|
148
|
+
config_or_deprecated_connection = url_config.merge(config_or_deprecated_connection)
|
|
149
|
+
end
|
|
124
150
|
super
|
|
125
151
|
if @config[:connection]
|
|
126
152
|
connection = {
|
|
@@ -132,7 +158,9 @@ module ActiveRecord
|
|
|
132
158
|
host: @config[:host] || 'localhost',
|
|
133
159
|
port: port,
|
|
134
160
|
ssl: @config[:ssl].present? ? @config[:ssl] : port == 443,
|
|
161
|
+
insecure: @config.fetch(:insecure, true),
|
|
135
162
|
sslca: @config[:sslca],
|
|
163
|
+
open_timeout: @config[:open_timeout],
|
|
136
164
|
read_timeout: @config[:read_timeout],
|
|
137
165
|
write_timeout: @config[:write_timeout],
|
|
138
166
|
keep_alive_timeout: @config[:keep_alive_timeout]
|
|
@@ -140,9 +168,16 @@ module ActiveRecord
|
|
|
140
168
|
end
|
|
141
169
|
@connection_parameters = connection
|
|
142
170
|
|
|
143
|
-
@connection_config = {
|
|
171
|
+
@connection_config = {
|
|
172
|
+
user: @config[:username],
|
|
173
|
+
password: @config[:password],
|
|
174
|
+
database: @config[:database],
|
|
175
|
+
max_execution_time: @config[:max_execution_time],
|
|
176
|
+
cancel_http_readonly_queries_on_client_close: @config[:cancel_http_readonly_queries_on_client_close],
|
|
177
|
+
}.compact
|
|
144
178
|
@debug = @config[:debug] || false
|
|
145
|
-
@
|
|
179
|
+
@default_response_format = @config[:format] || DEFAULT_RESPONSE_FORMAT
|
|
180
|
+
@http_auth = @config[:http_auth]&.to_sym
|
|
146
181
|
|
|
147
182
|
@prepared_statements = false
|
|
148
183
|
|
|
@@ -185,11 +220,55 @@ module ActiveRecord
|
|
|
185
220
|
!native_database_types[type].nil?
|
|
186
221
|
end
|
|
187
222
|
|
|
223
|
+
# Rails appends the limit to the native type name (e.g. "Float32(8)"), which is not valid in ClickHouse.
|
|
224
|
+
# Resolve the float width by limit here so that add_column / change_column behave like TableDefinition#float.
|
|
225
|
+
def type_to_sql(type, limit: nil, precision: nil, scale: nil, **)
|
|
226
|
+
return native_database_types[limit == 8 ? :float64 : :float][:name] if type.to_s == 'float'
|
|
227
|
+
|
|
228
|
+
super
|
|
229
|
+
end
|
|
230
|
+
|
|
188
231
|
def supports_indexes_in_create?
|
|
189
232
|
true
|
|
190
233
|
end
|
|
191
234
|
|
|
192
235
|
class << self
|
|
236
|
+
def parse_clickhouse_url(url)
|
|
237
|
+
uri = URI.parse(url)
|
|
238
|
+
config = {}
|
|
239
|
+
|
|
240
|
+
config[:host] = uri.host if uri.host
|
|
241
|
+
config[:port] = uri.port if uri.port
|
|
242
|
+
config[:username] = URI.decode_www_form_component(uri.user) if uri.user
|
|
243
|
+
config[:password] = URI.decode_www_form_component(uri.password) if uri.password
|
|
244
|
+
|
|
245
|
+
if uri.path && uri.path.length > 1
|
|
246
|
+
config[:database] = uri.path.delete_prefix('/')
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
if uri.query
|
|
250
|
+
URI.decode_www_form(uri.query).each do |key, value|
|
|
251
|
+
case key
|
|
252
|
+
when 'ssl' then config[:ssl] = (value == 'true')
|
|
253
|
+
when 'debug' then config[:debug] = (value == 'true')
|
|
254
|
+
when 'insecure' then config[:insecure] = (value == 'true')
|
|
255
|
+
when 'open_timeout' then config[:open_timeout] = value.to_i
|
|
256
|
+
when 'read_timeout' then config[:read_timeout] = value.to_i
|
|
257
|
+
when 'write_timeout' then config[:write_timeout] = value.to_i
|
|
258
|
+
when 'keep_alive_timeout' then config[:keep_alive_timeout] = value.to_i
|
|
259
|
+
when 'http_auth' then config[:http_auth] = value
|
|
260
|
+
when 'cluster_name' then config[:cluster_name] = value
|
|
261
|
+
when 'sslca' then config[:sslca] = value
|
|
262
|
+
when 'max_execution_time' then config[:max_execution_time] = value.to_i
|
|
263
|
+
when 'cancel_http_readonly_queries_on_client_close'
|
|
264
|
+
config[:cancel_http_readonly_queries_on_client_close] = (value == 'true')
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
config
|
|
270
|
+
end
|
|
271
|
+
|
|
193
272
|
def extract_limit(sql_type) # :nodoc:
|
|
194
273
|
case sql_type
|
|
195
274
|
when /(Nullable)?\(?String\)?/
|
|
@@ -204,6 +283,10 @@ module ActiveRecord
|
|
|
204
283
|
8
|
|
205
284
|
when /(Nullable)?\(?U?Int128\)?/
|
|
206
285
|
16
|
|
286
|
+
when /(Nullable)?\(?Float32\)?/
|
|
287
|
+
nil
|
|
288
|
+
when /(Nullable)?\(?Float64\)?/
|
|
289
|
+
8
|
|
207
290
|
else
|
|
208
291
|
super
|
|
209
292
|
end
|
|
@@ -254,7 +337,7 @@ module ActiveRecord
|
|
|
254
337
|
Clickhouse::OID::Map.new(sql_type)
|
|
255
338
|
end
|
|
256
339
|
|
|
257
|
-
m.register_type
|
|
340
|
+
m.register_type(%r(JSON)) { Clickhouse::OID::Json.new }
|
|
258
341
|
end
|
|
259
342
|
end
|
|
260
343
|
|
|
@@ -304,7 +387,7 @@ module ActiveRecord
|
|
|
304
387
|
end
|
|
305
388
|
|
|
306
389
|
pk = table_structure(table_name).first
|
|
307
|
-
return ['id'] if pk.present? && pk
|
|
390
|
+
return ['id'] if pk.present? && pk.name == 'id'
|
|
308
391
|
[]
|
|
309
392
|
end
|
|
310
393
|
|
|
@@ -426,9 +509,9 @@ module ActiveRecord
|
|
|
426
509
|
end
|
|
427
510
|
|
|
428
511
|
def change_column_null(table_name, column_name, null, default = nil)
|
|
429
|
-
structure = table_structure(table_name).
|
|
512
|
+
structure = table_structure(table_name).find { |col| col.name == column_name.to_s }
|
|
430
513
|
raise "Column #{column_name} not found in table #{table_name}" if structure.nil?
|
|
431
|
-
change_column table_name, column_name, structure
|
|
514
|
+
change_column table_name, column_name, structure.sql_type.gsub(/(Nullable\()?(.*?)\)?/, '\2'), {null: null, default: default}.compact
|
|
432
515
|
end
|
|
433
516
|
|
|
434
517
|
def change_column_default(table_name, column_name, default)
|
|
@@ -539,8 +622,13 @@ module ActiveRecord
|
|
|
539
622
|
|
|
540
623
|
protected
|
|
541
624
|
|
|
542
|
-
def last_inserted_id(
|
|
543
|
-
|
|
625
|
+
def last_inserted_id(_result)
|
|
626
|
+
# Rails 7.1 persistence.rb:1258:
|
|
627
|
+
# _write_attribute(column, value) # wrote true directly — ugly but no crash
|
|
628
|
+
|
|
629
|
+
# Rails 8.0 persistence.rb:933:
|
|
630
|
+
# _write_attribute(column, type_for_attribute(column).deserialize(value)) # now deserializes first
|
|
631
|
+
nil
|
|
544
632
|
end
|
|
545
633
|
|
|
546
634
|
def change_column_for_alter(table_name, column_name, type, **options)
|
|
@@ -552,9 +640,15 @@ module ActiveRecord
|
|
|
552
640
|
private
|
|
553
641
|
|
|
554
642
|
def connect
|
|
555
|
-
@connection = @connection_parameters[:connection] || Net::HTTP.start(
|
|
556
|
-
|
|
557
|
-
|
|
643
|
+
@connection = @connection_parameters[:connection] || Net::HTTP.start(
|
|
644
|
+
@connection_parameters[:host],
|
|
645
|
+
@connection_parameters[:port],
|
|
646
|
+
use_ssl: @connection_parameters[:ssl],
|
|
647
|
+
verify_mode: @connection_parameters[:insecure] ? OpenSSL::SSL::VERIFY_NONE : OpenSSL::SSL::VERIFY_PEER,
|
|
648
|
+
**{ open_timeout: @connection_parameters[:open_timeout] }.compact
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
@connection.ca_file = @connection_parameters[:sslca] if @connection_parameters[:sslca]
|
|
558
652
|
@connection.read_timeout = @connection_parameters[:read_timeout] if @connection_parameters[:read_timeout]
|
|
559
653
|
@connection.write_timeout = @connection_parameters[:write_timeout] if @connection_parameters[:write_timeout]
|
|
560
654
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
module Arel # :nodoc: all
|
|
2
|
+
module Nodes
|
|
3
|
+
# Wraps the table source of a JOIN so the ClickHouse visitor renders
|
|
4
|
+
# `<table> FINAL ON ...` for that join. Built by
|
|
5
|
+
# ActiveRecord::Relation#joins_final, which marks the matching join
|
|
6
|
+
# sources after Arel has been constructed.
|
|
7
|
+
class FinalTable < Arel::Nodes::Unary
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -10,8 +10,7 @@ module Arel
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def aggregate(name, o, collector)
|
|
13
|
-
|
|
14
|
-
if o.expressions.first && o.expressions.first != '*' && !o.expressions.first.is_a?(String) && o.expressions.first.relation&.is_view
|
|
13
|
+
if o.expressions.first && o.expressions.first != '*' && !o.expressions.first.is_a?(String) && o.expressions.first.respond_to?(:relation) && o.expressions.first.relation&.is_view
|
|
15
14
|
super("#{name.downcase}Merge", o, collector)
|
|
16
15
|
else
|
|
17
16
|
super
|
|
@@ -89,6 +88,16 @@ module Arel
|
|
|
89
88
|
collector
|
|
90
89
|
end
|
|
91
90
|
|
|
91
|
+
# Renders the FINAL modifier for a single JOIN source, e.g.
|
|
92
|
+
# `INNER JOIN espm_binaries FINAL ON ...`. The join visitors inherited
|
|
93
|
+
# from ToSql all emit their table via `visit o.left`, so wrapping that
|
|
94
|
+
# table in a FinalTable node is enough to add FINAL to any join type.
|
|
95
|
+
def visit_Arel_Nodes_FinalTable(o, collector)
|
|
96
|
+
collector = visit o.expr, collector
|
|
97
|
+
collector << ' FINAL'
|
|
98
|
+
collector
|
|
99
|
+
end
|
|
100
|
+
|
|
92
101
|
def visit_Arel_Nodes_GroupingSets(o, collector)
|
|
93
102
|
collector << 'GROUPING SETS '
|
|
94
103
|
grouping_array_or_grouping_element(o.expr, collector)
|
|
@@ -100,7 +109,7 @@ module Arel
|
|
|
100
109
|
collector << "SETTINGS "
|
|
101
110
|
o.expr.each_with_index do |(key, value), i|
|
|
102
111
|
collector << ", " if i > 0
|
|
103
|
-
collector << key.to_s
|
|
112
|
+
collector << sanitize_as_setting_name(key).to_s
|
|
104
113
|
collector << " = "
|
|
105
114
|
collector << sanitize_as_setting_value(value)
|
|
106
115
|
end
|
|
@@ -146,7 +155,7 @@ module Arel
|
|
|
146
155
|
|
|
147
156
|
def sanitize_as_setting_name(value)
|
|
148
157
|
return value if Arel::Nodes::SqlLiteral === value
|
|
149
|
-
|
|
158
|
+
value.to_s.gsub(/\W+/, "")
|
|
150
159
|
end
|
|
151
160
|
|
|
152
161
|
private
|
|
@@ -99,7 +99,7 @@ module ClickhouseActiverecord
|
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
102
|
-
indexes = sql.scan(/INDEX \S+
|
|
102
|
+
indexes = sql.scan(/INDEX \S+ .+? TYPE .*? GRANULARITY \d+/)
|
|
103
103
|
if indexes.any?
|
|
104
104
|
tbl.puts ''
|
|
105
105
|
indexes.flatten.map!(&:strip).each do |index|
|
|
@@ -154,22 +154,17 @@ module ClickhouseActiverecord
|
|
|
154
154
|
end
|
|
155
155
|
end
|
|
156
156
|
|
|
157
|
-
def schema_limit(column)
|
|
158
|
-
return nil if column.type == :float
|
|
159
|
-
super
|
|
160
|
-
end
|
|
161
|
-
|
|
162
157
|
def schema_unsigned(column)
|
|
163
158
|
return nil unless column.type == :integer && !simple
|
|
164
159
|
(column.sql_type =~ /(Nullable)?\(?UInt\d+\)?/).nil? ? false : nil
|
|
165
160
|
end
|
|
166
161
|
|
|
167
162
|
def schema_array(column)
|
|
168
|
-
|
|
163
|
+
column.array ? true : nil
|
|
169
164
|
end
|
|
170
165
|
|
|
171
166
|
def schema_map(column)
|
|
172
|
-
if column.sql_type =~ /Map\(
|
|
167
|
+
if column.sql_type =~ /Map\([^,]+,\s*Array\(/
|
|
173
168
|
return :array
|
|
174
169
|
end
|
|
175
170
|
|
|
@@ -34,28 +34,32 @@ module ClickhouseActiverecord
|
|
|
34
34
|
create
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def structure_dump(*
|
|
37
|
+
def structure_dump(path, *)
|
|
38
38
|
establish_master_connection
|
|
39
39
|
|
|
40
|
+
# get all functions
|
|
41
|
+
functions = connection.execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined' ORDER BY name")['data']
|
|
42
|
+
.flatten
|
|
43
|
+
.map { |function| function.gsub('\\n', "\n") }
|
|
44
|
+
|
|
40
45
|
# get all tables
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
table_defs = connection.execute("SHOW TABLES FROM #{@configuration.database} WHERE name NOT LIKE '.inner_id.%'")['data']
|
|
47
|
+
.flatten
|
|
48
|
+
.map { |name| connection.show_create_table(name, single_line: false).gsub("#{@configuration.database}.", '') }
|
|
44
49
|
|
|
45
|
-
#
|
|
46
|
-
tables.
|
|
50
|
+
# separate views from tables
|
|
51
|
+
views, tables = table_defs.partition { |sql| sql.match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) }
|
|
47
52
|
|
|
48
|
-
#
|
|
49
|
-
|
|
53
|
+
# separate materialized from regular views
|
|
54
|
+
mat_views, views = views.partition { |sql| sql.match(/^CREATE\s+MATERIALIZED\s+VIEW/) }
|
|
50
55
|
|
|
51
|
-
#
|
|
52
|
-
|
|
53
|
-
functions.each do |function|
|
|
54
|
-
file.puts function.gsub('\\n', "\n") + ";\n\n"
|
|
55
|
-
end
|
|
56
|
+
# sort: UDFs -> tables -> materialized views -> views
|
|
57
|
+
ordered_definitions = functions.sort + tables.sort + mat_views.sort + views.sort
|
|
56
58
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
+
# puts to file
|
|
60
|
+
File.open(path, 'w:utf-8') do |file|
|
|
61
|
+
ordered_definitions.each do |sql|
|
|
62
|
+
file.puts "#{sql};\n\n"
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
end
|
|
@@ -30,5 +30,15 @@ module ClickhouseActiverecord
|
|
|
30
30
|
Arel::Nodes::SelectStatement.prepend(CoreExtensions::Arel::Nodes::SelectStatement)
|
|
31
31
|
Arel::SelectManager.prepend(CoreExtensions::Arel::SelectManager)
|
|
32
32
|
Arel::Table.prepend(CoreExtensions::Arel::Table)
|
|
33
|
+
|
|
34
|
+
# Prevent TimeZoneConverter wrapping for Map OID with DateTime subtype.
|
|
35
|
+
# Map(String, DateTime) would crash with "Hash values are not supported"
|
|
36
|
+
# when TimeZoneConverter tries to convert Hash values.
|
|
37
|
+
ActiveRecord::AttributeMethods::TimeZoneConversion::ClassMethods.prepend(Module.new do
|
|
38
|
+
def create_time_zone_conversion_attribute?(name, cast_type)
|
|
39
|
+
return false if cast_type.is_a?(ActiveRecord::ConnectionAdapters::Clickhouse::OID::Map)
|
|
40
|
+
super
|
|
41
|
+
end
|
|
42
|
+
end)
|
|
33
43
|
end
|
|
34
44
|
end
|
|
@@ -3,11 +3,12 @@ module CoreExtensions
|
|
|
3
3
|
module Relation
|
|
4
4
|
|
|
5
5
|
def self.prepended(base)
|
|
6
|
-
base::VALID_UNSCOPING_VALUES << :final << :settings
|
|
6
|
+
base::VALID_UNSCOPING_VALUES << :final << :settings << :joins_final
|
|
7
7
|
end
|
|
8
8
|
|
|
9
9
|
def reverse_order!
|
|
10
|
-
|
|
10
|
+
conn = respond_to?(:lease_connection) ? lease_connection : connection
|
|
11
|
+
return super unless conn.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter)
|
|
11
12
|
|
|
12
13
|
orders = order_values.uniq.reject(&:blank?)
|
|
13
14
|
return super unless orders.empty? && !primary_key
|
|
@@ -90,6 +91,79 @@ module CoreExtensions
|
|
|
90
91
|
@values.fetch(:final, nil)
|
|
91
92
|
end
|
|
92
93
|
|
|
94
|
+
# Apply the FINAL modifier to one or more joined tables. Unlike #final,
|
|
95
|
+
# which only adds FINAL to the query's primary FROM table, #joins_final
|
|
96
|
+
# adds the join(s) and renders FINAL on the joined table so ClickHouse
|
|
97
|
+
# merges those rows before joining.
|
|
98
|
+
# For example:
|
|
99
|
+
#
|
|
100
|
+
# AppControlEvent.final.joins_final(:espm_binary)
|
|
101
|
+
# # SELECT ... FROM espm_app_control_events FINAL
|
|
102
|
+
# # INNER JOIN espm_binaries FINAL ON ...
|
|
103
|
+
#
|
|
104
|
+
# Each argument is an association name and is added as a join (like
|
|
105
|
+
# #joins); FINAL is then rendered on the joined table. Joins are matched
|
|
106
|
+
# by table name, so if the same table is joined more than once every join
|
|
107
|
+
# of that table receives FINAL. An
|
|
108
|
+
# <tt>ActiveRecord::ActiveRecordError</tt> will be raised if the database
|
|
109
|
+
# is not ClickHouse.
|
|
110
|
+
#
|
|
111
|
+
# @param [Array] args
|
|
112
|
+
def joins_final(*args)
|
|
113
|
+
spawn.joins_final!(*args)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# @param [Array] args
|
|
117
|
+
def joins_final!(*args)
|
|
118
|
+
check_command!('FINAL')
|
|
119
|
+
joins!(*args)
|
|
120
|
+
self.joins_final_values |= args
|
|
121
|
+
self
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Like #joins_final, but adds the join(s) as LEFT OUTER JOINs (via
|
|
125
|
+
# #left_outer_joins) and renders FINAL on the joined table. Use this when
|
|
126
|
+
# the joined rows must be preserved even with no match (e.g. an optional
|
|
127
|
+
# catalog lookup) while still merging the joined table with FINAL.
|
|
128
|
+
# For example:
|
|
129
|
+
#
|
|
130
|
+
# AppControlEvent.left_joins_final(:espm_binary)
|
|
131
|
+
# # SELECT ... FROM espm_app_control_events
|
|
132
|
+
# # LEFT OUTER JOIN espm_binaries FINAL ON ...
|
|
133
|
+
#
|
|
134
|
+
# Each argument is an association name and is added as a left outer join
|
|
135
|
+
# (like #left_outer_joins); FINAL is then rendered on the joined table.
|
|
136
|
+
# Joins are matched by table name, so if the same table is joined more
|
|
137
|
+
# than once every join of that table receives FINAL. An
|
|
138
|
+
# <tt>ActiveRecord::ActiveRecordError</tt> will be raised if the database
|
|
139
|
+
# is not ClickHouse.
|
|
140
|
+
#
|
|
141
|
+
# @param [Array] args
|
|
142
|
+
def left_joins_final(*args)
|
|
143
|
+
spawn.left_joins_final!(*args)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @param [Array] args
|
|
147
|
+
def left_joins_final!(*args)
|
|
148
|
+
check_command!('FINAL')
|
|
149
|
+
left_outer_joins!(*args)
|
|
150
|
+
self.joins_final_values |= args
|
|
151
|
+
self
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def joins_final_values
|
|
155
|
+
@values.fetch(:joins_final, ::ActiveRecord::QueryMethods::FROZEN_EMPTY_ARRAY)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def joins_final_values=(value)
|
|
159
|
+
if ::ActiveRecord::version >= Gem::Version.new('7.2')
|
|
160
|
+
assert_modifiable!
|
|
161
|
+
else
|
|
162
|
+
assert_mutability!
|
|
163
|
+
end
|
|
164
|
+
@values[:joins_final] = value
|
|
165
|
+
end
|
|
166
|
+
|
|
93
167
|
# GROUPING SETS allows you to specify multiple groupings in the GROUP BY clause.
|
|
94
168
|
# Whereas GROUP BY CUBE generates all possible groupings, GROUP BY GROUPING SETS generates only the specified groupings.
|
|
95
169
|
# For example:
|
|
@@ -175,6 +249,50 @@ module CoreExtensions
|
|
|
175
249
|
raise ::ActiveRecord::ActiveRecordError, cmd + ' is a ClickHouse specific query clause' unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter)
|
|
176
250
|
end
|
|
177
251
|
|
|
252
|
+
# Wrap the left (table) side of every join source that matches a
|
|
253
|
+
# requested #joins_final argument in an Arel::Nodes::FinalTable so the
|
|
254
|
+
# ClickHouse visitor renders FINAL on that join. Called before
|
|
255
|
+
# +arel.final!+ so the join source is still the plain JoinSource (and not
|
|
256
|
+
# yet wrapped in the FROM-level Final node).
|
|
257
|
+
def mark_final_joins!(arel)
|
|
258
|
+
names = final_join_table_names
|
|
259
|
+
return if names.empty?
|
|
260
|
+
|
|
261
|
+
arel.join_sources.each do |join|
|
|
262
|
+
next unless join.respond_to?(:left) && join.respond_to?(:left=)
|
|
263
|
+
|
|
264
|
+
left = join.left
|
|
265
|
+
next if left.is_a?(::Arel::Nodes::FinalTable)
|
|
266
|
+
next unless final_join_match?(left, names)
|
|
267
|
+
|
|
268
|
+
join.left = ::Arel::Nodes::FinalTable.new(left)
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Resolve the #joins_final arguments to the set of table names that
|
|
273
|
+
# should carry FINAL. Association names are mapped to their table;
|
|
274
|
+
# anything else is treated as a literal table name.
|
|
275
|
+
def final_join_table_names
|
|
276
|
+
joins_final_values.flatten.filter_map do |arg|
|
|
277
|
+
case arg
|
|
278
|
+
when Symbol, String
|
|
279
|
+
reflection = klass.reflect_on_association(arg.to_sym)
|
|
280
|
+
reflection ? reflection.table_name : arg.to_s
|
|
281
|
+
end
|
|
282
|
+
end.to_set
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Does the join's table source match one of the requested FINAL names?
|
|
286
|
+
# Handles both Arel::Table (name) and Arel::Nodes::TableAlias (alias name
|
|
287
|
+
# plus the underlying relation's name).
|
|
288
|
+
def final_join_match?(left, names)
|
|
289
|
+
candidates = []
|
|
290
|
+
candidates << left.name if left.respond_to?(:name)
|
|
291
|
+
candidates << left.table_alias if left.respond_to?(:table_alias) && left.table_alias
|
|
292
|
+
candidates << left.relation.name if left.respond_to?(:relation) && left.relation.respond_to?(:name)
|
|
293
|
+
candidates.any? { |c| names.include?(c.to_s) }
|
|
294
|
+
end
|
|
295
|
+
|
|
178
296
|
def build_arel(connection_or_aliases = nil, aliases = nil)
|
|
179
297
|
requirement = Gem::Requirement.new('>= 7.2', '< 8.1')
|
|
180
298
|
|
|
@@ -184,6 +302,7 @@ module CoreExtensions
|
|
|
184
302
|
arel = super(connection_or_aliases)
|
|
185
303
|
end
|
|
186
304
|
|
|
305
|
+
mark_final_joins!(arel) if joins_final_values.present?
|
|
187
306
|
arel.final! if final_value
|
|
188
307
|
arel.limit_by(*@values[:limit_by]) if @values[:limit_by].present?
|
|
189
308
|
arel.settings(settings_values) unless settings_values.empty?
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: clickhouse-activerecord
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.7.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Sergey Odintsov
|
|
@@ -96,12 +96,14 @@ files:
|
|
|
96
96
|
- Rakefile
|
|
97
97
|
- bin/console
|
|
98
98
|
- bin/setup
|
|
99
|
+
- bin/test-single
|
|
99
100
|
- clickhouse-activerecord.gemspec
|
|
100
101
|
- lib/active_record/connection_adapters/clickhouse/column.rb
|
|
101
102
|
- lib/active_record/connection_adapters/clickhouse/oid/array.rb
|
|
102
103
|
- lib/active_record/connection_adapters/clickhouse/oid/big_integer.rb
|
|
103
104
|
- lib/active_record/connection_adapters/clickhouse/oid/date.rb
|
|
104
105
|
- lib/active_record/connection_adapters/clickhouse/oid/date_time.rb
|
|
106
|
+
- lib/active_record/connection_adapters/clickhouse/oid/json.rb
|
|
105
107
|
- lib/active_record/connection_adapters/clickhouse/oid/map.rb
|
|
106
108
|
- lib/active_record/connection_adapters/clickhouse/oid/uuid.rb
|
|
107
109
|
- lib/active_record/connection_adapters/clickhouse/quoting.rb
|
|
@@ -113,6 +115,7 @@ files:
|
|
|
113
115
|
- lib/active_record/connection_adapters/clickhouse/table_definition.rb
|
|
114
116
|
- lib/active_record/connection_adapters/clickhouse_adapter.rb
|
|
115
117
|
- lib/arel/nodes/final.rb
|
|
118
|
+
- lib/arel/nodes/final_table.rb
|
|
116
119
|
- lib/arel/nodes/grouping_sets.rb
|
|
117
120
|
- lib/arel/nodes/limit_by.rb
|
|
118
121
|
- lib/arel/nodes/settings.rb
|
|
@@ -153,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
153
156
|
- !ruby/object:Gem::Version
|
|
154
157
|
version: '0'
|
|
155
158
|
requirements: []
|
|
156
|
-
rubygems_version: 4.0.
|
|
159
|
+
rubygems_version: 4.0.9
|
|
157
160
|
specification_version: 4
|
|
158
161
|
summary: ClickHouse ActiveRecord
|
|
159
162
|
test_files: []
|