umbrellio-utils 1.12.0 → 1.13.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/test.yml +1 -1
- data/.gitignore +1 -0
- data/.rubocop.yml +1 -1
- data/Gemfile +1 -0
- data/Gemfile.lock +181 -175
- data/bin/clickhouse-server +1 -2
- data/lib/umbrellio_utils/cards.rb +4 -3
- data/lib/umbrellio_utils/click_house/backends/base.rb +178 -0
- data/lib/umbrellio_utils/click_house/backends/legacy.rb +92 -0
- data/lib/umbrellio_utils/click_house/backends/native.rb +112 -0
- data/lib/umbrellio_utils/click_house/backends.rb +12 -0
- data/lib/umbrellio_utils/click_house/config.rb +13 -0
- data/lib/umbrellio_utils/click_house.rb +28 -166
- data/lib/umbrellio_utils/control.rb +2 -2
- data/lib/umbrellio_utils/database.rb +5 -2
- data/lib/umbrellio_utils/http_client.rb +6 -6
- data/lib/umbrellio_utils/migrations.rb +6 -5
- data/lib/umbrellio_utils/sql.rb +8 -8
- data/lib/umbrellio_utils/tasks/clickhouse_connect.rake +6 -5
- data/lib/umbrellio_utils/version.rb +1 -1
- data/lib/umbrellio_utils.rb +17 -1
- data/umbrellio_utils.gemspec +1 -1
- metadata +12 -4
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "singleton"
|
|
4
|
+
|
|
5
|
+
module UmbrellioUtils
|
|
6
|
+
module ClickHouse
|
|
7
|
+
module Backends
|
|
8
|
+
# Abstract backend. Each concrete backend (Legacy for the `click_house`
|
|
9
|
+
# gem, Native for the `clickhouse-native` gem) implements the low-level
|
|
10
|
+
# ops (execute / query / insert / describe_table / server_version /
|
|
11
|
+
# tables / create_database / drop_database / config / logger) and a
|
|
12
|
+
# SERVER_ERROR constant used by `log_errors`.
|
|
13
|
+
class Base
|
|
14
|
+
include Singleton
|
|
15
|
+
|
|
16
|
+
# Concrete backends implement the low-level ops (execute / query /
|
|
17
|
+
# insert / describe_table / server_version / tables / admin_execute
|
|
18
|
+
# / config / logger) and define SERVER_ERROR.
|
|
19
|
+
|
|
20
|
+
def from(source, db_name: self.db_name)
|
|
21
|
+
ds =
|
|
22
|
+
case source
|
|
23
|
+
when Symbol
|
|
24
|
+
DB.from(db_name == self.db_name ? SQL[source] : SQL[db_name][source])
|
|
25
|
+
when nil
|
|
26
|
+
DB.dataset
|
|
27
|
+
else
|
|
28
|
+
DB.from(source)
|
|
29
|
+
end
|
|
30
|
+
ds.clone(ch: true)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def count(dataset)
|
|
34
|
+
query_value(dataset.select(SQL.ch_count))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def db_name
|
|
38
|
+
config.database.to_sym
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def create_database(name, if_not_exists: false, cluster: nil, engine: nil)
|
|
42
|
+
admin_execute(
|
|
43
|
+
format(
|
|
44
|
+
"CREATE DATABASE %<exists>s %<name>s %<cluster>s %<engine>s",
|
|
45
|
+
exists: if_not_exists ? "IF NOT EXISTS" : "",
|
|
46
|
+
name:,
|
|
47
|
+
cluster: cluster ? "ON CLUSTER #{cluster}" : "",
|
|
48
|
+
engine: engine ? "ENGINE = #{engine}" : "",
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def drop_database(name, if_exists: false, cluster: nil)
|
|
54
|
+
admin_execute(
|
|
55
|
+
format(
|
|
56
|
+
"DROP DATABASE %<exists>s %<name>s %<cluster>s",
|
|
57
|
+
exists: if_exists ? "IF EXISTS" : "",
|
|
58
|
+
name:,
|
|
59
|
+
cluster: cluster ? "ON CLUSTER #{cluster}" : "",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Returns the `ON CLUSTER <name> [SYNC]` clause for DDL, or "" if
|
|
65
|
+
# `UmbrellioUtils.config.clickhouse_cluster` is blank or we're in
|
|
66
|
+
# a Rails test env. Test-env suppression saves hundreds of ms per
|
|
67
|
+
# DDL on a single-node CH (each ON CLUSTER op blocks waiting for
|
|
68
|
+
# replicas that don't exist). The cluster *name* is still used
|
|
69
|
+
# by callers like Distributed engine declarations, regardless of
|
|
70
|
+
# this clause.
|
|
71
|
+
def on_cluster(sync: false)
|
|
72
|
+
name = UmbrellioUtils.config.clickhouse_cluster
|
|
73
|
+
return "" if name.blank?
|
|
74
|
+
return "" if defined?(Rails) && Rails.env.test?
|
|
75
|
+
sync ? "ON CLUSTER #{name} SYNC" : "ON CLUSTER #{name}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def truncate_table!(table_name, db_name: self.db_name)
|
|
79
|
+
execute("TRUNCATE TABLE #{db_name}.#{table_name} #{on_cluster(sync: true)}")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def drop_table!(table_name, db_name: self.db_name)
|
|
83
|
+
execute("DROP TABLE #{db_name}.#{table_name} #{on_cluster(sync: true)}")
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def optimize_table!(table_name, db_name: self.db_name)
|
|
87
|
+
Timeout.timeout(UmbrellioUtils.config.ch_optimize_timeout) do
|
|
88
|
+
execute("OPTIMIZE TABLE #{db_name}.#{table_name} #{on_cluster} FINAL")
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def parse_value(value, type:)
|
|
93
|
+
case type
|
|
94
|
+
when /Array/ then Array.wrap(value)
|
|
95
|
+
when /DateTime/
|
|
96
|
+
case value
|
|
97
|
+
when String then value.present? ? Time.zone.parse(value) : nil
|
|
98
|
+
else value
|
|
99
|
+
end
|
|
100
|
+
when /String/ then value&.to_s
|
|
101
|
+
else value
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def pg_table_connection(table, schema: "public")
|
|
106
|
+
host = ENV["PGHOST"] || DB.opts[:host].presence || "localhost"
|
|
107
|
+
port = DB.opts[:port] || 5432
|
|
108
|
+
# Etc.getlogin returns "root" under non-TTY shells (e.g. rake from
|
|
109
|
+
# a CI runner), which is almost never a real PG role. Prefer $USER.
|
|
110
|
+
login = ENV["USER"].presence || Etc.getlogin
|
|
111
|
+
database = DB.opts[:database].presence || login
|
|
112
|
+
username = DB.opts[:user].presence || login
|
|
113
|
+
password = DB.opts[:password]
|
|
114
|
+
SQL.func(:postgresql, "#{host}:#{port}", database, table, username, password, schema)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def populate_temp_table!(temp_table_name, dataset, schema: "public")
|
|
118
|
+
execute(<<~SQL.squish)
|
|
119
|
+
INSERT INTO TABLE FUNCTION #{DB.literal(pg_table_connection(temp_table_name, schema:))}
|
|
120
|
+
#{dataset.sql}
|
|
121
|
+
SQL
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def with_temp_table(
|
|
125
|
+
dataset, temp_table_name:, primary_key: [:id], primary_key_types: [:integer], **, &
|
|
126
|
+
)
|
|
127
|
+
unless DB.table_exists?(temp_table_name)
|
|
128
|
+
UmbrellioUtils::Database.create_temp_table(
|
|
129
|
+
nil, primary_key:, primary_key_types:, temp_table_name:, &
|
|
130
|
+
)
|
|
131
|
+
populate_temp_table!(temp_table_name, dataset)
|
|
132
|
+
end
|
|
133
|
+
UmbrellioUtils::Database.with_temp_table(nil, primary_key:, temp_table_name:, **, &)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
protected
|
|
137
|
+
|
|
138
|
+
def log_errors(sql)
|
|
139
|
+
yield
|
|
140
|
+
rescue self.class::SERVER_ERROR => e
|
|
141
|
+
logger.error("ClickHouse error: #{e.inspect}\nSQL: #{sql}")
|
|
142
|
+
raise e
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def sql_for(dataset)
|
|
146
|
+
return dataset if dataset.is_a?(String)
|
|
147
|
+
unless ch_dataset?(dataset)
|
|
148
|
+
raise "Non-ClickHouse dataset: #{dataset.inspect}. " \
|
|
149
|
+
"You should use `CH.from` instead of `DB`"
|
|
150
|
+
end
|
|
151
|
+
dataset.sql
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def ch_dataset?(dataset)
|
|
155
|
+
case dataset
|
|
156
|
+
when Sequel::Dataset
|
|
157
|
+
dataset.opts[:ch] && Array(dataset.opts[:from]).all? { |x| ch_dataset?(x) }
|
|
158
|
+
when Sequel::SQL::AliasedExpression
|
|
159
|
+
ch_dataset?(dataset.expression)
|
|
160
|
+
when Sequel::SQL::Identifier, Sequel::SQL::QualifiedIdentifier
|
|
161
|
+
true
|
|
162
|
+
else
|
|
163
|
+
raise "Unknown dataset type: #{dataset.inspect}"
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def normalize_identifier(name)
|
|
168
|
+
name = name.value if name.is_a?(Sequel::SQL::Identifier)
|
|
169
|
+
name.to_s
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def full_table_name(table_name, db_name)
|
|
173
|
+
"#{db_name}.#{normalize_identifier(table_name)}"
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "click_house"
|
|
4
|
+
|
|
5
|
+
module UmbrellioUtils
|
|
6
|
+
module ClickHouse
|
|
7
|
+
module Backends
|
|
8
|
+
# Adapter for the umbrellio/click_house gem (HTTP driver).
|
|
9
|
+
class Legacy < Base
|
|
10
|
+
include Memery
|
|
11
|
+
|
|
12
|
+
SERVER_ERROR = ::ClickHouse::Error
|
|
13
|
+
|
|
14
|
+
def execute(sql, host: nil, **opts)
|
|
15
|
+
log_errors(sql) { client(host).execute(sql, params: opts) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def query(dataset, host: nil, **opts)
|
|
19
|
+
sql = sql_for(dataset)
|
|
20
|
+
log_errors(sql) do
|
|
21
|
+
select_all(sql, host:, **opts).map { |x| Misc::StrictHash[x.symbolize_keys] }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def query_value(dataset, host: nil, **opts)
|
|
26
|
+
sql = sql_for(dataset)
|
|
27
|
+
log_errors(sql) { select_value(sql, host:, **opts) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def query_each(dataset, host: nil, **, &)
|
|
31
|
+
query(dataset, host:, **).each(&)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def insert(table_name, db_name: self.db_name, rows: [])
|
|
35
|
+
client.insert(full_table_name(table_name, db_name), rows, format: "JSONEachRow")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def describe_table(table_name, db_name: self.db_name)
|
|
39
|
+
sql = "DESCRIBE TABLE #{full_table_name(table_name, db_name)} FORMAT JSON"
|
|
40
|
+
log_errors(sql) { select_all(sql).map { |x| Misc::StrictHash[x.symbolize_keys] } }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def server_version
|
|
44
|
+
select_value("SELECT version()").to_f
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def tables
|
|
48
|
+
client.tables
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Legacy HTTP driver can issue DDL directly; no admin side-channel
|
|
52
|
+
# needed. Base#create_database / #drop_database call this.
|
|
53
|
+
def admin_execute(sql)
|
|
54
|
+
client.execute(sql)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def config
|
|
58
|
+
client.config
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def logger
|
|
62
|
+
client.config.logger
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
def client(host = nil)
|
|
68
|
+
cfg = ::ClickHouse.config
|
|
69
|
+
cfg.host = resolve(host) if host
|
|
70
|
+
::ClickHouse::Connection.new(cfg)
|
|
71
|
+
end
|
|
72
|
+
memoize :client, ttl: 1.minute
|
|
73
|
+
|
|
74
|
+
def resolve(host)
|
|
75
|
+
IPSocket.getaddress(host)
|
|
76
|
+
rescue => e
|
|
77
|
+
Exceptions.notify!(e, raise_errors: false)
|
|
78
|
+
config.host
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def select_all(sql, host: nil, **opts)
|
|
82
|
+
response = client(host).get(body: sql, query: { default_format: "JSON", **opts })
|
|
83
|
+
::ClickHouse::Response::Factory.response(response, client(host).config)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def select_value(...)
|
|
87
|
+
select_all(...).first.to_a.dig(0, -1)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "clickhouse-native"
|
|
4
|
+
require_relative "../config"
|
|
5
|
+
|
|
6
|
+
module UmbrellioUtils
|
|
7
|
+
module ClickHouse
|
|
8
|
+
module Backends
|
|
9
|
+
# Adapter for the clickhouse-native gem (TCP driver).
|
|
10
|
+
#
|
|
11
|
+
# Intentional differences from the HTTP-era module:
|
|
12
|
+
# - Values returned by query / query_value are real Ruby types
|
|
13
|
+
# (Time, Integer, etc.), not JSON-stringified.
|
|
14
|
+
# - The `host:` kwarg on execute / query / query_value is accepted
|
|
15
|
+
# for source compatibility but ignored — hostname is bound at
|
|
16
|
+
# Pool construction, not per query.
|
|
17
|
+
class Native < Base
|
|
18
|
+
SERVER_ERROR = ::ClickhouseNative::ServerError
|
|
19
|
+
|
|
20
|
+
# Server-side error codes that mean "object doesn't exist". Used by
|
|
21
|
+
# describe_table callers that want to tolerate eager-load against a
|
|
22
|
+
# database that hasn't been created yet (e.g. rake ch:create).
|
|
23
|
+
UNKNOWN_TABLE = 60
|
|
24
|
+
UNKNOWN_DATABASE = 81
|
|
25
|
+
|
|
26
|
+
def execute(sql, host: nil, **_opts) # rubocop:disable Lint/UnusedMethodArgument
|
|
27
|
+
sql_string = sql.is_a?(String) ? sql : sql.sql
|
|
28
|
+
log_errors(sql_string) { pool.execute(sql_string) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def query(dataset, host: nil, **_opts) # rubocop:disable Lint/UnusedMethodArgument
|
|
32
|
+
sql = sql_for(dataset)
|
|
33
|
+
log_errors(sql) { pool.query(sql) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def query_value(dataset, host: nil, **_opts) # rubocop:disable Lint/UnusedMethodArgument
|
|
37
|
+
sql = sql_for(dataset)
|
|
38
|
+
log_errors(sql) { pool.query_value(sql) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def query_each(dataset, host: nil, **_opts, &) # rubocop:disable Lint/UnusedMethodArgument
|
|
42
|
+
sql = sql_for(dataset)
|
|
43
|
+
log_errors(sql) { pool.query_each(sql, &) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def insert(table_name, db_name: self.db_name, rows: [])
|
|
47
|
+
return if rows.empty?
|
|
48
|
+
pool.insert(normalize_identifier(table_name), rows, db_name: db_name.to_s)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def describe_table(table_name, db_name: self.db_name)
|
|
52
|
+
pool.describe_table(normalize_identifier(table_name), db_name: db_name.to_s)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def server_version
|
|
56
|
+
pool.with(&:server_version).to_f
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def tables
|
|
60
|
+
pool.query("SHOW TABLES").pluck(:name)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def config
|
|
64
|
+
::ClickHouse.config
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Read through pool so test mocks of `pool` also redirect `db_name`.
|
|
68
|
+
def db_name
|
|
69
|
+
pool.database.to_sym
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def logger
|
|
73
|
+
@logger ||= UmbrellioUtils.config.clickhouse_native_logger ||
|
|
74
|
+
(defined?(Rails) && Rails.logger) ||
|
|
75
|
+
Logger.new($stdout)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def pool
|
|
79
|
+
@pool ||= ::ClickhouseNative::Pool.new(
|
|
80
|
+
**client_options(database: (config[:database] || "default").to_s),
|
|
81
|
+
pool_size: Integer(config[:pool_size] || 5),
|
|
82
|
+
pool_timeout: Integer(config[:pool_timeout] || 10),
|
|
83
|
+
settings: UmbrellioUtils.config.clickhouse_native_settings || {},
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# DDL that creates/drops the configured database can't run through
|
|
88
|
+
# the main pool (which is bound to that database). Open a one-shot
|
|
89
|
+
# client connected to the always-present "default" db instead.
|
|
90
|
+
def admin_execute(sql)
|
|
91
|
+
admin = ::ClickhouseNative::Client.new(**client_options(database: "default"))
|
|
92
|
+
admin.execute(sql)
|
|
93
|
+
ensure
|
|
94
|
+
admin&.close
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def client_options(database:)
|
|
100
|
+
{
|
|
101
|
+
host: config[:host] || "localhost",
|
|
102
|
+
port: Integer(config[:port] || 9000),
|
|
103
|
+
database:,
|
|
104
|
+
user: (config[:username] || "default").to_s,
|
|
105
|
+
password: (config[:password] || "").to_s,
|
|
106
|
+
logger:,
|
|
107
|
+
}
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "backends/base"
|
|
4
|
+
|
|
5
|
+
module UmbrellioUtils
|
|
6
|
+
module ClickHouse
|
|
7
|
+
module Backends
|
|
8
|
+
autoload :Legacy, "umbrellio_utils/click_house/backends/legacy"
|
|
9
|
+
autoload :Native, "umbrellio_utils/click_house/backends/native"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Provides `::ClickHouse.config` when the legacy `click_house` gem is
|
|
4
|
+
# not loaded. The legacy gem defines `::ClickHouse::Connection` and its
|
|
5
|
+
# own `::ClickHouse.config`; we only step in when it's absent (typical
|
|
6
|
+
# for consumers that have migrated to the `clickhouse-native` gem).
|
|
7
|
+
unless defined?(ClickHouse::Connection)
|
|
8
|
+
module ClickHouse
|
|
9
|
+
def self.config
|
|
10
|
+
@config ||= Rails.application.config_for(:clickhouse)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -1,188 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module UmbrellioUtils
|
|
4
|
+
# Polymorphic ClickHouse facade. The active backend is picked up from
|
|
5
|
+
# `UmbrellioUtils.config.clickhouse_backend` — `:legacy` routes through
|
|
6
|
+
# the `click_house` gem (HTTP), `:native` through the `clickhouse-native`
|
|
7
|
+
# gem (TCP). Both backends expose the same public surface so consumer
|
|
8
|
+
# code (including UmbrellioUtils::Migrations) is backend-agnostic.
|
|
4
9
|
module ClickHouse
|
|
5
|
-
include Memery
|
|
6
|
-
|
|
7
10
|
extend self
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def insert(table_name, db_name: self.db_name, rows: [])
|
|
12
|
-
client.insert(full_table_name(table_name, db_name), rows, format: "JSONEachRow")
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def from(source, db_name: self.db_name)
|
|
16
|
-
ds =
|
|
17
|
-
case source
|
|
18
|
-
when Symbol
|
|
19
|
-
DB.from(db_name == self.db_name ? SQL[source] : SQL[db_name][source])
|
|
20
|
-
when nil
|
|
21
|
-
DB.dataset
|
|
22
|
-
else
|
|
23
|
-
DB.from(source)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
ds.clone(ch: true)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def execute(sql, host: nil, **opts)
|
|
30
|
-
log_errors(sql) do
|
|
31
|
-
client(host).execute(sql, params: opts)
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def query(dataset, host: nil, **opts)
|
|
36
|
-
sql = sql_for(dataset)
|
|
37
|
-
|
|
38
|
-
log_errors(sql) do
|
|
39
|
-
select_all(sql, host:, **opts).map { |x| Misc::StrictHash[x.symbolize_keys] }
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def query_value(dataset, host: nil, **opts)
|
|
44
|
-
sql = sql_for(dataset)
|
|
45
|
-
|
|
46
|
-
log_errors(sql) do
|
|
47
|
-
select_value(sql, host:, **opts)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def count(dataset)
|
|
52
|
-
query_value(dataset.select(SQL.ch_count))
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def optimize_table!(table_name, db_name: self.db_name)
|
|
56
|
-
Timeout.timeout(UmbrellioUtils.config.ch_optimize_timeout) do
|
|
57
|
-
execute("OPTIMIZE TABLE #{db_name}.#{table_name} ON CLUSTER click_cluster FINAL")
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def truncate_table!(table_name, db_name: self.db_name)
|
|
62
|
-
execute("TRUNCATE TABLE #{db_name}.#{table_name} ON CLUSTER click_cluster SYNC")
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def drop_table!(table_name, db_name: self.db_name)
|
|
66
|
-
execute("DROP TABLE #{db_name}.#{table_name} ON CLUSTER click_cluster SYNC")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def describe_table(table_name, db_name: self.db_name)
|
|
70
|
-
sql = "DESCRIBE TABLE #{full_table_name(table_name, db_name)} FORMAT JSON"
|
|
12
|
+
autoload :Backends, "umbrellio_utils/click_house/backends"
|
|
71
13
|
|
|
72
|
-
|
|
73
|
-
select_all(sql).map { |x| Misc::StrictHash[x.symbolize_keys] }
|
|
74
|
-
end
|
|
75
|
-
end
|
|
14
|
+
VALID_BACKENDS = %i[legacy native].freeze
|
|
76
15
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
16
|
+
DELEGATED = %i[
|
|
17
|
+
execute query query_value query_each count insert
|
|
18
|
+
from describe_table server_version tables
|
|
19
|
+
create_database drop_database db_name config
|
|
20
|
+
truncate_table! drop_table! optimize_table! on_cluster
|
|
21
|
+
parse_value pg_table_connection populate_temp_table! with_temp_table
|
|
22
|
+
].freeze
|
|
80
23
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
value&.to_s
|
|
85
|
-
when /DateTime/
|
|
86
|
-
Time.zone.parse(value) if value
|
|
87
|
-
else
|
|
88
|
-
value
|
|
24
|
+
DELEGATED.each do |method_name|
|
|
25
|
+
define_method(method_name) do |*args, **kwargs, &block|
|
|
26
|
+
backend.public_send(method_name, *args, **kwargs, &block)
|
|
89
27
|
end
|
|
90
28
|
end
|
|
91
29
|
|
|
92
|
-
def
|
|
93
|
-
|
|
30
|
+
def backend
|
|
31
|
+
@backend ||= backend_for(UmbrellioUtils.config.clickhouse_backend)
|
|
94
32
|
end
|
|
95
33
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
username = DB.opts[:user]
|
|
101
|
-
password = DB.opts[:password]
|
|
102
|
-
|
|
103
|
-
Sequel.function(:postgresql, "#{host}:#{port}", database, table, username, password)
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def with_temp_table(
|
|
107
|
-
dataset, temp_table_name:, primary_key: [:id], primary_key_types: [:integer], **opts, &
|
|
108
|
-
)
|
|
109
|
-
unless DB.table_exists?(temp_table_name)
|
|
110
|
-
UmbrellioUtils::Database.create_temp_table(
|
|
111
|
-
nil, primary_key:, primary_key_types:, temp_table_name:, &
|
|
112
|
-
)
|
|
113
|
-
populate_temp_table!(temp_table_name, dataset)
|
|
114
|
-
end
|
|
115
|
-
UmbrellioUtils::Database.with_temp_table(nil, primary_key:, temp_table_name:, **opts, &)
|
|
34
|
+
# Testing hook — clears the memoized backend so specs can flip
|
|
35
|
+
# `clickhouse_backend` mid-run. Not part of the public API.
|
|
36
|
+
def reset_backend!
|
|
37
|
+
@backend = nil
|
|
116
38
|
end
|
|
117
39
|
|
|
118
40
|
private
|
|
119
41
|
|
|
120
|
-
def
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
::
|
|
124
|
-
|
|
125
|
-
memoize :client, ttl: 1.minute
|
|
126
|
-
|
|
127
|
-
def resolve(host)
|
|
128
|
-
IPSocket.getaddress(host)
|
|
129
|
-
rescue => e
|
|
130
|
-
Exceptions.notify!(e, raise_errors: false)
|
|
131
|
-
config.host
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
def logger
|
|
135
|
-
client.config.logger
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def log_errors(sql)
|
|
139
|
-
yield
|
|
140
|
-
rescue ::ClickHouse::Error => e
|
|
141
|
-
logger.error("ClickHouse error: #{e.inspect}\nSQL: #{sql}")
|
|
142
|
-
raise e
|
|
143
|
-
end
|
|
144
|
-
|
|
145
|
-
def sql_for(dataset)
|
|
146
|
-
unless ch_dataset?(dataset)
|
|
147
|
-
raise "Non-ClickHouse dataset: #{dataset.inspect}. " \
|
|
148
|
-
"You should use `CH.from` instead of `DB`"
|
|
42
|
+
def backend_for(name)
|
|
43
|
+
case name
|
|
44
|
+
when :legacy then Backends::Legacy.instance
|
|
45
|
+
when :native then Backends::Native.instance
|
|
46
|
+
else raise "Unknown clickhouse_backend: #{name.inspect} (expected one of #{VALID_BACKENDS})"
|
|
149
47
|
end
|
|
150
|
-
|
|
151
|
-
dataset.sql
|
|
152
|
-
end
|
|
153
|
-
|
|
154
|
-
def ch_dataset?(dataset)
|
|
155
|
-
case dataset
|
|
156
|
-
when Sequel::Dataset
|
|
157
|
-
dataset.opts[:ch] && Array(dataset.opts[:from]).all? { |x| ch_dataset?(x) }
|
|
158
|
-
when Sequel::SQL::AliasedExpression
|
|
159
|
-
ch_dataset?(dataset.expression)
|
|
160
|
-
when Sequel::SQL::Identifier, Sequel::SQL::QualifiedIdentifier
|
|
161
|
-
true
|
|
162
|
-
else
|
|
163
|
-
raise "Unknown dataset type: #{dataset.inspect}"
|
|
164
|
-
end
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def full_table_name(table_name, db_name)
|
|
168
|
-
table_name = table_name.value if table_name.is_a?(Sequel::SQL::Identifier)
|
|
169
|
-
"#{db_name}.#{table_name}"
|
|
170
|
-
end
|
|
171
|
-
|
|
172
|
-
def select_all(sql, host: nil, **opts)
|
|
173
|
-
response = client(host).get(body: sql, query: { default_format: "JSON", **opts })
|
|
174
|
-
::ClickHouse::Response::Factory.response(response, client(host).config)
|
|
175
|
-
end
|
|
176
|
-
|
|
177
|
-
def select_value(...)
|
|
178
|
-
select_all(...).first.to_a.dig(0, -1)
|
|
179
|
-
end
|
|
180
|
-
|
|
181
|
-
def populate_temp_table!(temp_table_name, dataset)
|
|
182
|
-
execute(<<~SQL.squish)
|
|
183
|
-
INSERT INTO TABLE FUNCTION #{DB.literal(pg_table_connection(temp_table_name))}
|
|
184
|
-
#{dataset.sql}
|
|
185
|
-
SQL
|
|
186
48
|
end
|
|
187
49
|
end
|
|
188
50
|
end
|
|
@@ -45,8 +45,8 @@ module UmbrellioUtils
|
|
|
45
45
|
end
|
|
46
46
|
end
|
|
47
47
|
|
|
48
|
-
def run_non_critical(rescue_all: false, in_transaction: false, &
|
|
49
|
-
in_transaction ? DB.transaction(savepoint: true, &
|
|
48
|
+
def run_non_critical(rescue_all: false, in_transaction: false, &)
|
|
49
|
+
in_transaction ? DB.transaction(savepoint: true, &) : yield
|
|
50
50
|
rescue (rescue_all ? Exception : StandardError) => e
|
|
51
51
|
Exceptions.notify!(e)
|
|
52
52
|
nil
|
|
@@ -4,8 +4,11 @@ module UmbrellioUtils
|
|
|
4
4
|
module Database
|
|
5
5
|
extend self
|
|
6
6
|
|
|
7
|
-
HandledConstaintError
|
|
8
|
-
|
|
7
|
+
class HandledConstaintError < StandardError
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
class InvalidPkError < StandardError
|
|
11
|
+
end
|
|
9
12
|
|
|
10
13
|
def handle_constraint_error(constraint_name, &)
|
|
11
14
|
DB.transaction(savepoint: true, &)
|
|
@@ -6,16 +6,16 @@ module UmbrellioUtils
|
|
|
6
6
|
class HTTPClient
|
|
7
7
|
include Singleton
|
|
8
8
|
|
|
9
|
-
def perform(
|
|
10
|
-
client.perform(
|
|
9
|
+
def perform(*, **)
|
|
10
|
+
client.perform(*, **)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
def perform!(
|
|
14
|
-
client.perform!(
|
|
13
|
+
def perform!(*, **)
|
|
14
|
+
client.perform!(*, **)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def request(
|
|
18
|
-
client.request(
|
|
17
|
+
def request(*, **)
|
|
18
|
+
client.request(*, **)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
private
|