umbrellio-utils 1.15.0 → 1.16.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/Gemfile.lock +1 -1
- data/README.md +31 -0
- data/lib/umbrellio_utils/click_house/backends/base.rb +146 -7
- data/lib/umbrellio_utils/click_house/backends/legacy.rb +4 -4
- data/lib/umbrellio_utils/click_house/backends/native.rb +6 -6
- data/lib/umbrellio_utils/click_house/table_metadata.rb +110 -0
- data/lib/umbrellio_utils/click_house.rb +2 -1
- data/lib/umbrellio_utils/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 60839e931cbc201d236aaf7480342ae61f8186a2417aa015224891b6e5fa383c
|
|
4
|
+
data.tar.gz: 56a6658d397624ef5df7dacd9993755138a864a3ebe7423e656f3b4615f18ce5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 81635ff47f5d31d4e7c49c13ef8fa1c7b9d7f86aa44f987c90814fc50a651ace5b1f4893b61d99db640ce3825d9854e7a13027f4b3474df999f71231b19c1c20
|
|
7
|
+
data.tar.gz: b5c55d4d3d0debe6d87e327141b21779be531b99c108e02eb54db78d8b3e0cc18ed488bb39e602368167dc86397a55ff2306fb8e94f8bd09678a699d81e329a9
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -115,6 +115,37 @@ end
|
|
|
115
115
|
Utils::Constants.useful_method #=> "Just string"
|
|
116
116
|
```
|
|
117
117
|
|
|
118
|
+
### ClickHouse deduplication
|
|
119
|
+
|
|
120
|
+
Datasets built with `CH.from` understand ClickHouse's `LIMIT n BY`:
|
|
121
|
+
|
|
122
|
+
```ruby
|
|
123
|
+
CH.from(:events).order(Sequel.desc(:version)).limit_by(:user_id, rows: 3)
|
|
124
|
+
#=> SELECT * FROM "events" ORDER BY "version" DESC LIMIT 3 BY "user_id"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
On a `ReplacingMergeTree` table, `#deduplicate` uses that to collapse row versions by hand rather than relying on the `final` setting, which merges the entire table even for a point lookup:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
CH.from(:external_operations_distributed)
|
|
131
|
+
.where(order_id: 42) # inside the dedup subquery
|
|
132
|
+
.deduplicate # boundary
|
|
133
|
+
.order(:created_at) # outside
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
The sorting key, version column and `is_deleted` column are read from `system.tables` and cached per process; `Distributed` tables are resolved through to the local table they wrap. Deduplicated datasets are sent with `final: 0` automatically, since running FINAL inside the subquery would be both slow and redundant. An explicit `final:` passed to `query` / `count` always wins.
|
|
137
|
+
|
|
138
|
+
`#deduplicate` is a boundary, and which side a filter lands on matters:
|
|
139
|
+
|
|
140
|
+
- **Before it — immutable selectors only** (primary keys, foreign keys). Filtering a *mutable* column first can match a superseded row version and resurrect a row that FINAL would have dropped.
|
|
141
|
+
- **After it — everything else**, including any filter on a column that changes over a row's lifetime.
|
|
142
|
+
|
|
143
|
+
`is_deleted` is applied after the boundary for exactly that reason, and is omitted when the engine declares no such column.
|
|
144
|
+
|
|
145
|
+
Two chain methods are handled rather than passed through, because the dedup subquery has to control them: the subquery always projects `SELECT *` (so the outer query can still filter on `is_deleted` and on columns you did not select) and any projection you set is re-applied outside it; and the version ordering leads the subquery's `ORDER BY`, since it decides which row survives, with any ordering you set kept after it as a tiebreaker.
|
|
146
|
+
|
|
147
|
+
`#deduplicate` raises on a table it cannot collapse — a non-Replacing engine, a Replacing engine declared without a version argument, or a dataset that is not a single table source (joined, multi-source, or a subquery).
|
|
148
|
+
|
|
118
149
|
### Instrumentation
|
|
119
150
|
|
|
120
151
|
The gem ships a set of opt-in files for collecting GVL and allocation stats
|
|
@@ -13,13 +13,103 @@ module UmbrellioUtils
|
|
|
13
13
|
class Base
|
|
14
14
|
include Singleton
|
|
15
15
|
|
|
16
|
-
# ClickHouse
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
# ClickHouse-specific dataset behaviour: string escaping plus grammar
|
|
17
|
+
# Sequel's Postgres dataset doesn't know about.
|
|
18
|
+
module ClickHouseDatasetMethods
|
|
19
|
+
# ClickHouse uses C-style escape sequences in string literals, so
|
|
20
|
+
# backslashes must be doubled. Sequel's default (Postgres) escaping
|
|
21
|
+
# only escapes single-quotes.
|
|
20
22
|
def literal_string_append(sql, str)
|
|
21
23
|
sql << "'" << str.gsub("\\") { "\\\\" }.gsub("'", "''") << "'"
|
|
22
24
|
end
|
|
25
|
+
|
|
26
|
+
# ClickHouse `LIMIT n BY expr, ...` — keeps the first n rows per
|
|
27
|
+
# distinct combination of the expressions, applied after ORDER BY.
|
|
28
|
+
def limit_by(*exprs, rows: 1)
|
|
29
|
+
raise Sequel::Error, "limit_by requires at least one expression" if exprs.empty?
|
|
30
|
+
clone(limit_by: { exprs:, rows: })
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `LIMIT n BY` precedes the regular LIMIT/OFFSET in ClickHouse.
|
|
34
|
+
def select_limit_sql(sql)
|
|
35
|
+
if (limit_by = @opts[:limit_by])
|
|
36
|
+
sql << " LIMIT "
|
|
37
|
+
literal_append(sql, limit_by[:rows])
|
|
38
|
+
sql << " BY "
|
|
39
|
+
expression_list_append(sql, limit_by[:exprs])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
super
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Collapse a ReplacingMergeTree's row versions by hand instead of
|
|
46
|
+
# relying on the `final` setting, which merges the whole table.
|
|
47
|
+
#
|
|
48
|
+
# This is a boundary: everything chained BEFORE it goes inside the
|
|
49
|
+
# dedup subquery, everything chained AFTER applies to the result.
|
|
50
|
+
# Only immutable selectors belong before it — filtering a mutable
|
|
51
|
+
# column first can match a superseded version and resurrect a row
|
|
52
|
+
# that FINAL would have dropped. `is_deleted` is applied after the
|
|
53
|
+
# boundary for the same reason.
|
|
54
|
+
def deduplicate
|
|
55
|
+
table_name, db_name = deduplication_source
|
|
56
|
+
meta = ClickHouse.table_metadata(table_name, **db_name)
|
|
57
|
+
|
|
58
|
+
unless meta.replacing?
|
|
59
|
+
raise Sequel::Error,
|
|
60
|
+
"#{table_name} is a #{meta.engine}; deduplicate needs a ReplacingMergeTree"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
unless meta.version
|
|
64
|
+
raise Sequel::Error,
|
|
65
|
+
"#{table_name} declares no version column; deduplicate needs one"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
wrapped = ClickHouse.from(wrap_source(deduplicated_source(meta)))
|
|
69
|
+
.clone(ch_dedup: true)
|
|
70
|
+
.then { |ds| @opts[:select] ? ds.select(*@opts[:select]) : ds }
|
|
71
|
+
|
|
72
|
+
meta.is_deleted ? wrapped.where(meta.is_deleted => 0) : wrapped
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# The subquery must project every column, both so the outer query can
|
|
78
|
+
# filter on `is_deleted` and so a caller's projection still resolves;
|
|
79
|
+
# that projection is re-applied outside instead.
|
|
80
|
+
#
|
|
81
|
+
# The version ordering leads, since it decides which row survives —
|
|
82
|
+
# any ordering the caller set is kept after it as a tiebreaker.
|
|
83
|
+
def deduplicated_source(meta)
|
|
84
|
+
clone(select: nil)
|
|
85
|
+
.order(Sequel.desc(meta.version), *@opts[:order])
|
|
86
|
+
.limit_by(*meta.sorting_key.map { |expr| Sequel.lit(expr) })
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def wrap_source(inner)
|
|
90
|
+
source = Array(@opts[:from]).first
|
|
91
|
+
source.is_a?(Sequel::SQL::AliasedExpression) ? inner.as(source.alias) : inner
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# => [table_name, {} | { db_name: ... }]
|
|
95
|
+
def deduplication_source
|
|
96
|
+
sources = Array(@opts[:from])
|
|
97
|
+
source = sources.first
|
|
98
|
+
source = source.expression if source.is_a?(Sequel::SQL::AliasedExpression)
|
|
99
|
+
|
|
100
|
+
if sources.size != 1 || @opts[:join]
|
|
101
|
+
raise Sequel::Error, "deduplicate requires a single table source"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
case source
|
|
105
|
+
when Sequel::SQL::QualifiedIdentifier
|
|
106
|
+
[source.column, { db_name: source.table }]
|
|
107
|
+
when Sequel::SQL::Identifier
|
|
108
|
+
[source.value, {}]
|
|
109
|
+
else
|
|
110
|
+
raise Sequel::Error, "deduplicate requires a single table source"
|
|
111
|
+
end
|
|
112
|
+
end
|
|
23
113
|
end
|
|
24
114
|
|
|
25
115
|
# Concrete backends implement the low-level ops (execute / query /
|
|
@@ -36,11 +126,23 @@ module UmbrellioUtils
|
|
|
36
126
|
else
|
|
37
127
|
DB.from(source)
|
|
38
128
|
end
|
|
39
|
-
ds.clone(ch: true).with_extend(
|
|
129
|
+
ds.clone(ch: true).with_extend(ClickHouseDatasetMethods)
|
|
40
130
|
end
|
|
41
131
|
|
|
42
|
-
def count(dataset)
|
|
43
|
-
query_value(dataset.select(SQL.ch_count))
|
|
132
|
+
def count(dataset, **)
|
|
133
|
+
query_value(dataset.select(SQL.ch_count), **)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Sorting key / version / is_deleted of a ReplacingMergeTree table.
|
|
137
|
+
# Distributed tables carry none of these, so they are resolved through
|
|
138
|
+
# to the local table they wrap. Memoized per process, like the layout
|
|
139
|
+
# it describes: a table's engine does not change under a running app.
|
|
140
|
+
def table_metadata(table_name, db_name: self.db_name)
|
|
141
|
+
key = [db_name.to_s, table_name.to_s]
|
|
142
|
+
@table_metadata_cache ||= {}
|
|
143
|
+
return @table_metadata_cache[key] if @table_metadata_cache.key?(key)
|
|
144
|
+
|
|
145
|
+
@table_metadata_cache[key] = build_table_metadata(*key)
|
|
44
146
|
end
|
|
45
147
|
|
|
46
148
|
def db_name
|
|
@@ -144,6 +246,43 @@ module UmbrellioUtils
|
|
|
144
246
|
|
|
145
247
|
protected
|
|
146
248
|
|
|
249
|
+
# Every read path needs both halves, and pairing them here keeps a new
|
|
250
|
+
# one from silently reinstating session-wide FINAL inside a dedup
|
|
251
|
+
# subquery — the exact cost `deduplicate` exists to remove.
|
|
252
|
+
def prepare_query(dataset, opts)
|
|
253
|
+
[sql_for(dataset), settings_for(dataset, opts)]
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
# `final` is usually a session-wide default, which would make a
|
|
257
|
+
# deduplicated query merge the whole table inside its own subquery —
|
|
258
|
+
# slow and redundant, since the subquery already collapses versions.
|
|
259
|
+
# An explicit `final:` from the caller always wins.
|
|
260
|
+
def settings_for(dataset, opts)
|
|
261
|
+
return opts if opts.key?(:final)
|
|
262
|
+
return opts unless dataset.is_a?(Sequel::Dataset) && dataset.opts[:ch_dedup]
|
|
263
|
+
|
|
264
|
+
opts.merge(final: 0)
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def build_table_metadata(db_name, table_name)
|
|
268
|
+
row = query(
|
|
269
|
+
from(:tables, db_name: :system)
|
|
270
|
+
.where(database: db_name, name: table_name)
|
|
271
|
+
.select(:engine, :engine_full, :sorting_key),
|
|
272
|
+
).first
|
|
273
|
+
|
|
274
|
+
unless row
|
|
275
|
+
raise ClickHouse::TableMetadata::UnknownTable, "#{db_name}.#{table_name} not found"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
if row[:engine] == "Distributed"
|
|
279
|
+
database, table = ClickHouse::TableMetadata.distributed_target(row[:engine_full])
|
|
280
|
+
return table_metadata(table, db_name: database)
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
ClickHouse::TableMetadata.parse(**row)
|
|
284
|
+
end
|
|
285
|
+
|
|
147
286
|
def log_errors(sql)
|
|
148
287
|
yield
|
|
149
288
|
rescue self.class::SERVER_ERROR => e
|
|
@@ -16,15 +16,15 @@ module UmbrellioUtils
|
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def query(dataset, host: nil, **opts)
|
|
19
|
-
sql =
|
|
19
|
+
sql, settings = prepare_query(dataset, opts)
|
|
20
20
|
log_errors(sql) do
|
|
21
|
-
select_all(sql, host:, **
|
|
21
|
+
select_all(sql, host:, **settings).map { |x| Misc::StrictHash[x.symbolize_keys] }
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def query_value(dataset, host: nil, **opts)
|
|
26
|
-
sql =
|
|
27
|
-
log_errors(sql) { select_value(sql, host:, **
|
|
26
|
+
sql, settings = prepare_query(dataset, opts)
|
|
27
|
+
log_errors(sql) { select_value(sql, host:, **settings) }
|
|
28
28
|
end
|
|
29
29
|
|
|
30
30
|
def query_each(dataset, host: nil, **, &)
|
|
@@ -29,18 +29,18 @@ module UmbrellioUtils
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def query(dataset, host: nil, **opts) # rubocop:disable Lint/UnusedMethodArgument
|
|
32
|
-
sql =
|
|
33
|
-
log_errors(sql) { pool.query(sql, settings:
|
|
32
|
+
sql, settings = prepare_query(dataset, opts)
|
|
33
|
+
log_errors(sql) { pool.query(sql, settings:) }
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
def query_value(dataset, host: nil, **opts) # rubocop:disable Lint/UnusedMethodArgument
|
|
37
|
-
sql =
|
|
38
|
-
log_errors(sql) { pool.query_value(sql, settings:
|
|
37
|
+
sql, settings = prepare_query(dataset, opts)
|
|
38
|
+
log_errors(sql) { pool.query_value(sql, settings:) }
|
|
39
39
|
end
|
|
40
40
|
|
|
41
41
|
def query_each(dataset, host: nil, **opts, &) # rubocop:disable Lint/UnusedMethodArgument
|
|
42
|
-
sql =
|
|
43
|
-
log_errors(sql) { pool.query_each(sql, settings
|
|
42
|
+
sql, settings = prepare_query(dataset, opts)
|
|
43
|
+
log_errors(sql) { pool.query_each(sql, settings:, &) }
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def insert(table_name, db_name: self.db_name, rows: [])
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module UmbrellioUtils
|
|
4
|
+
module ClickHouse
|
|
5
|
+
# Layout of a MergeTree-family table, read out of `system.tables`.
|
|
6
|
+
#
|
|
7
|
+
# ReplacingMergeTree collapses rows sharing the full sorting key, keeping
|
|
8
|
+
# the one with the highest version and dropping it entirely when the
|
|
9
|
+
# is_deleted column is set. `Dataset#deduplicate` reproduces that by hand,
|
|
10
|
+
# so it needs all three parts; `version` and `is_deleted` are only
|
|
11
|
+
# meaningful on a Replacing engine, and nil elsewhere.
|
|
12
|
+
TableMetadata = Struct.new(:engine, :sorting_key, :version, :is_deleted)
|
|
13
|
+
|
|
14
|
+
# Reopened rather than declared with a block: constants defined inside a
|
|
15
|
+
# `Struct.new do ... end` block leak to the enclosing lexical scope.
|
|
16
|
+
class TableMetadata
|
|
17
|
+
class UnknownTable < StandardError
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
REPLICATED_ARGS_COUNT = 2 # zookeeper path + replica name
|
|
21
|
+
|
|
22
|
+
def replacing?
|
|
23
|
+
engine.include?("Replacing")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
# Built first so it can answer #replacing? for itself — the engine
|
|
28
|
+
# test lives in one place only.
|
|
29
|
+
def parse(engine:, engine_full:, sorting_key:)
|
|
30
|
+
meta = new(engine, split_args(sorting_key), nil, nil)
|
|
31
|
+
return meta unless meta.replacing?
|
|
32
|
+
|
|
33
|
+
args = engine_args(engine_full)
|
|
34
|
+
args = args.drop(REPLICATED_ARGS_COUNT) if engine.start_with?("Replicated")
|
|
35
|
+
meta.version, meta.is_deleted = args[0]&.to_sym, args[1]&.to_sym
|
|
36
|
+
|
|
37
|
+
meta
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Distributed('cluster', 'database', 'table'[, sharding_key])
|
|
41
|
+
def distributed_target(engine_full)
|
|
42
|
+
_cluster, database, table = engine_args(engine_full)
|
|
43
|
+
[unquote(database), unquote(table)]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Arguments of the leading engine call, or [] when the engine takes none.
|
|
47
|
+
# Only a parenthesis directly after the engine name counts — later
|
|
48
|
+
# clauses such as `PARTITION BY toYYYYMM(created_at)` must not be read.
|
|
49
|
+
def engine_args(engine_full)
|
|
50
|
+
open_index = engine_full.index("(")
|
|
51
|
+
return [] unless open_index
|
|
52
|
+
return [] unless engine_full[0...open_index].match?(/\A\w+\z/)
|
|
53
|
+
|
|
54
|
+
split_args(engine_full[(open_index + 1)...close_index(engine_full, open_index)])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Split on top-level commas only: sorting keys hold function calls and
|
|
58
|
+
# engine arguments hold quoted paths, both of which may contain commas.
|
|
59
|
+
def split_args(source)
|
|
60
|
+
args = []
|
|
61
|
+
current = +""
|
|
62
|
+
|
|
63
|
+
scan(source) do |char, depth, in_string|
|
|
64
|
+
if char == "," && depth.zero? && !in_string
|
|
65
|
+
args << current.strip
|
|
66
|
+
current = +""
|
|
67
|
+
else
|
|
68
|
+
current << char
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
args << current.strip
|
|
73
|
+
args.reject(&:empty?)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
# Single lexer for both scans, so they can't disagree about what counts
|
|
79
|
+
# as a parenthesis: a ZooKeeper path may legally contain one inside
|
|
80
|
+
# quotes, and treating it as structure truncates the argument list.
|
|
81
|
+
def scan(source)
|
|
82
|
+
depth = 0
|
|
83
|
+
in_string = false
|
|
84
|
+
|
|
85
|
+
source.to_s.each_char.with_index do |char, index|
|
|
86
|
+
case char
|
|
87
|
+
when "'" then in_string = !in_string
|
|
88
|
+
when "(" then depth += 1 unless in_string
|
|
89
|
+
when ")" then depth -= 1 unless in_string
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
yield(char, depth, in_string, index)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def close_index(source, open_index)
|
|
97
|
+
scan(source[open_index..]) do |_char, depth, in_string, index|
|
|
98
|
+
return open_index + index if depth.zero? && !in_string
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "unbalanced parentheses in engine: #{source}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def unquote(value)
|
|
105
|
+
value.to_s.delete_prefix("'").delete_suffix("'")
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -10,12 +10,13 @@ module UmbrellioUtils
|
|
|
10
10
|
extend self
|
|
11
11
|
|
|
12
12
|
autoload :Backends, "umbrellio_utils/click_house/backends"
|
|
13
|
+
autoload :TableMetadata, "umbrellio_utils/click_house/table_metadata"
|
|
13
14
|
|
|
14
15
|
VALID_BACKENDS = %i[legacy native].freeze
|
|
15
16
|
|
|
16
17
|
DELEGATED = %i[
|
|
17
18
|
execute query query_value query_each count insert
|
|
18
|
-
from describe_table server_version tables
|
|
19
|
+
from describe_table server_version tables table_metadata
|
|
19
20
|
create_database drop_database db_name config
|
|
20
21
|
truncate_table! drop_table! optimize_table! on_cluster
|
|
21
22
|
parse_value pg_table_connection populate_temp_table! with_temp_table
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: umbrellio-utils
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.16.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Team Umbrellio
|
|
@@ -56,6 +56,7 @@ files:
|
|
|
56
56
|
- lib/umbrellio_utils/click_house/backends/legacy.rb
|
|
57
57
|
- lib/umbrellio_utils/click_house/backends/native.rb
|
|
58
58
|
- lib/umbrellio_utils/click_house/config.rb
|
|
59
|
+
- lib/umbrellio_utils/click_house/table_metadata.rb
|
|
59
60
|
- lib/umbrellio_utils/constants.rb
|
|
60
61
|
- lib/umbrellio_utils/control.rb
|
|
61
62
|
- lib/umbrellio_utils/database.rb
|