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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 48ec3fe01120adc07f02f2f8c8ff989d8866f8ce054efff32e63940e13dbc29c
4
- data.tar.gz: 28f2c0189a4ef073849ea84bae2bb900bdd6c2e59014730e6a683714209f1b88
3
+ metadata.gz: f10ee5755575ea64229e6242e4fc081565fa5c4455581d5eca6fa47f2a047ee9
4
+ data.tar.gz: '07713045794d44203e419e4935c3abbff43c6f1cf9b5e6ad660b6f7e1f12510b'
5
5
  SHA512:
6
- metadata.gz: 940ae4fc464c3bd1526a1c9694ed7ce21496e76dfea85127ad3c37a3cc2aa09661b67e4ad3ec9f640c009ac032106f5c99d70afdcb03c7d0ecc7b6511a1d2523
7
- data.tar.gz: c98f507ba8e99e0096251eeff287f18259e2568b62fb60f8cc10385943c18b4877bae0b518c2ba4389af04a965885c4a04b171e033fd15ac38b3e6742d828c07
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.6', '24.9', '25.10' ]
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
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@v4
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, 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 "$@"
@@ -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)
@@ -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
- 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?
@@ -54,7 +54,7 @@ module ActiveRecord
54
54
 
55
55
  # @return [String, Hash, Array]
56
56
  def process_successful_response
57
- raise_generic!(@sql) if @body.include?('DB::Exception') && @body.match?(DB_EXCEPTION_REGEXP)
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 = { user: @config[:username], password: @config[:password], database: @config[:database] }.compact
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
- @response_format = @config[:format] || DEFAULT_RESPONSE_FORMAT
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 %r(JSON)i, ActiveRecord::Type::Json.new
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[0] == 'id'
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).select{|v| v[0] == column_name.to_s}.first
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[1].gsub(/(Nullable\()?(.*?)\)?/, '\2'), {null: null, default: default}.compact
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(result)
543
- result
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(@connection_parameters[:host], @connection_parameters[:port], use_ssl: @connection_parameters[:ssl], verify_mode: OpenSSL::SSL::VERIFY_NONE)
556
-
557
- @connection.ca_file = @connection_parameters[:ca_file] if @connection_parameters[:ca_file]
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
- # replacing function name for materialized view
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.gsub(/\W+/, "")
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
- @connection.sanitize_as_setting_name(value)
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+ \S+ TYPE .*? GRANULARITY \d+/)
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
- (column.sql_type =~ /Array\(/).nil? ? nil : true
163
+ column.array ? true : nil
169
164
  end
170
165
 
171
166
  def schema_map(column)
172
- if column.sql_type =~ /Map\(([^,]+),\s*(Array)\)/
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(*args)
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
- tables = connection.execute("SHOW TABLES FROM #{@configuration.database} WHERE name NOT LIKE '.inner_id.%'")['data'].flatten.map do |table|
42
- connection.show_create_table(table, single_line: false).gsub("#{@configuration.database}.", '')
43
- end.compact
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
- # sort view to last
46
- tables.sort_by! {|table| table.match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) ? 1 : 0}
50
+ # separate views from tables
51
+ views, tables = table_defs.partition { |sql| sql.match(/^CREATE\s+(MATERIALIZED\s+)?VIEW/) }
47
52
 
48
- # get all functions
49
- functions = connection.execute("SELECT create_query FROM system.functions WHERE origin = 'SQLUserDefined' ORDER BY name")['data'].flatten
53
+ # separate materialized from regular views
54
+ mat_views, views = views.partition { |sql| sql.match(/^CREATE\s+MATERIALIZED\s+VIEW/) }
50
55
 
51
- # put to file
52
- File.open(args.first, 'w:utf-8') do |file|
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
- tables.each do |table|
58
- file.puts table + ";\n\n"
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
@@ -1,3 +1,3 @@
1
1
  module ClickhouseActiverecord
2
- VERSION = '1.6.7'
2
+ VERSION = '1.7.0'
3
3
  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
- return super unless connection.is_a?(::ActiveRecord::ConnectionAdapters::ClickhouseAdapter)
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.6.7
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.6
159
+ rubygems_version: 4.0.9
157
160
  specification_version: 4
158
161
  summary: ClickHouse ActiveRecord
159
162
  test_files: []