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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: df65d8d2275baa9381d84f764c7fd8761c473e84483c10f7bca49b3b6162764f
4
- data.tar.gz: b90292432beebf3f4b511d7cbd62f0b41eed56b86350ec6766b89f93ec3d414f
3
+ metadata.gz: f10ee5755575ea64229e6242e4fc081565fa5c4455581d5eca6fa47f2a047ee9
4
+ data.tar.gz: '07713045794d44203e419e4935c3abbff43c6f1cf9b5e6ad660b6f7e1f12510b'
5
5
  SHA512:
6
- metadata.gz: b94e4126d5e196dcd05fcce0799e4967517c4b62024e5f109d86ad5034268dc822c038233b6a0b33aca2be3cb9842480160ae24136c8293a18ad3b4de18ab670
7
- data.tar.gz: 13229feeb4ba07c3676668fb76cff54fd6a18bb424e3ad6b4d29ccf794813548a76d1a90db43941fec233417393c9e556ce49e9cf5cda48dad4658640b8fe762
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.1.3
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
- clickhouse: [ '24.6', '24.9', '25.10' ]
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@v4
38
+ - uses: actions/checkout@v6
39
39
 
40
40
  - name: Start ClickHouse ${{ matrix.clickhouse }}
41
- uses: hoverkraft-tech/compose-action@v2.4.1
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@v4
85
+ - uses: actions/checkout@v6
88
86
 
89
87
  - name: Start ClickHouse Cluster ${{ matrix.clickhouse }}
90
- uses: hoverkraft-tech/compose-action@v2.4.1
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, default: db/migrate_clickhouse
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/activerecord"
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
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ export CLICKHOUSE_PORT="${CLICKHOUSE_PORT:-18123}"
5
+ export CLICKHOUSE_DATABASE="${CLICKHOUSE_DATABASE:-default}"
6
+
7
+ exec bundle exec rspec spec/single --format progress "$@"
@@ -28,5 +28,4 @@ Gem::Specification.new do |spec|
28
28
 
29
29
  spec.add_development_dependency 'rake', '~> 13.0'
30
30
  spec.add_development_dependency 'rspec', '~> 3.4'
31
- spec.add_development_dependency 'pry', '~> 0.12'
32
31
  end
@@ -1,13 +1,33 @@
1
1
  module ActiveRecord
2
2
  module ConnectionAdapters
3
3
  module Clickhouse
4
- class Column < ActiveRecord::ConnectionAdapters::Column
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
- attr_reader :codec
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
- prev_format = @response_format
36
- @response_format = format
44
+ stack = response_format_stack
45
+ stack.push(format)
37
46
  yield
38
47
  ensure
39
- @response_format = prev_format
48
+ stack.pop
40
49
  end
41
50
 
42
- def execute(sql, name = nil, format: @response_format, settings: {})
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: @response_format, settings: {})
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: @response_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
- true
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
- result = execute(sql, name)
86
- columns = result['meta'].map { |m| m['name'] }
87
- types = {}
88
- result['meta'].each_with_index do |m, i|
89
- # need use column name and index after commit in 7.2:
90
- # https://github.com/rails/rails/commit/24dbf7637b1d5cd6eb3d7100b8d0f6872c3fee3c
91
- types[m['name']] = types[i] = type_map.lookup(m['type'])
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: @response_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
- return data unless data.empty?
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
- raise ActiveRecord::StatementInvalid, "Could not find table '#{table_name}'"
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
- column_name, sql_type, default_type, default_expression = field
260
- type_metadata = fetch_type_metadata(sql_type)
261
- default_value = extract_value_from_default(default_expression, default_type)
262
- default_function = extract_default_function(default_expression)
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 = [column_name]
299
+ args = [field.name]
267
300
  args << cast_type if ::ActiveRecord::version >= Gem::Version.new('8.1')
268
- args += [default_value, type_metadata, field[1].include?('Nullable'), default_function]
301
+ args += [default_value, type_metadata, field.sql_type.include?('Nullable'), default_function]
269
302
 
270
- Clickhouse::Column.new(*args, codec: field[5].presence)
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: @response_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
- 'Content-Type' => 'application/x-www-form-urlencoded',
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?