chc-r8 0.3.2

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.
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class Configuration
6
+ # Configuration options:
7
+ #
8
+ # *register_database* (method): registers a database, the following arguments are required:
9
+ # - database: database name
10
+ # - url: URL and port to the HTTP interface
11
+ # - username
12
+ # - password
13
+ # - variables (optional): configuration for the client
14
+ #
15
+ # *http_post_proc*: A callable object for invoking the HTTP request.
16
+ # The object must handle the following parameters: url, headers, body
17
+ # and return a ClickHouse::Client::Response object.
18
+ #
19
+ # *json_parser*: object for parsing JSON strings, it should respond to the "parse" method
20
+ #
21
+ # *logger*: object for receiving logger commands. Default `$stdout`
22
+ # *log_proc*: any output (e.g. structure) to wrap around the query for every statement
23
+ #
24
+ # Example:
25
+ #
26
+ # ClickHouse::Client.configure do |c|
27
+ # c.register_database(:main,
28
+ # database: 'gitlab_clickhouse_test',
29
+ # url: 'http://localhost:8123',
30
+ # username: 'default',
31
+ # password: 'clickhouse',
32
+ # variables: {
33
+ # join_use_nulls: 1 # treat JOINs as per SQL standard
34
+ # }
35
+ # )
36
+ #
37
+ # c.logger = MyLogger.new
38
+ # c.log_proc = ->(query) do
39
+ # { query_body: query.to_redacted_sql }
40
+ # end
41
+ #
42
+ # c.http_post_proc = lambda do |url, headers, body|
43
+ # options = {
44
+ # headers: headers,
45
+ # body: body,
46
+ # allow_local_requests: false
47
+ # }
48
+ #
49
+ # response = Gitlab::HTTP.post(url, options)
50
+ # ClickHouse::Client::Response.new(response.body, response.code)
51
+ # end
52
+ #
53
+ # c.json_parser = JSON
54
+ # end
55
+ attr_accessor :http_post_proc, :json_parser, :logger, :log_proc
56
+ attr_reader :databases
57
+
58
+ def initialize
59
+ @databases = {}
60
+ @http_post_proc = nil
61
+ @json_parser = JSON
62
+ @logger = ::Logger.new($stdout)
63
+ @log_proc = ->(query) { query.to_sql }
64
+ end
65
+
66
+ def register_database(name, **args)
67
+ raise ConfigurationError, "The database '#{name}' is already registered" if @databases.key?(name)
68
+
69
+ @databases[name] = Database.new(**args)
70
+ end
71
+
72
+ def validate!
73
+ raise ConfigurationError, "The 'http_post_proc' option is not configured" unless @http_post_proc
74
+ raise ConfigurationError, "The 'json_parser' option is not configured" unless @json_parser
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class Database
6
+ attr_reader :database
7
+
8
+ def initialize(database:, url:, username:, password:, variables: {})
9
+ @database = database
10
+ @url = url
11
+ @username = username
12
+ @password = password
13
+ @variables = {
14
+ database:,
15
+ enable_http_compression: 1 # enable HTTP compression by default
16
+ }.merge(variables).freeze
17
+ end
18
+
19
+ def uri
20
+ @uri ||= build_custom_uri
21
+ end
22
+
23
+ def build_custom_uri(extra_variables: {})
24
+ parsed = Addressable::URI.parse(@url)
25
+ parsed.query_values = @variables.merge(extra_variables)
26
+ parsed
27
+ end
28
+
29
+ def headers
30
+ @headers ||= {
31
+ 'X-ClickHouse-User' => @username,
32
+ 'X-ClickHouse-Key' => @password,
33
+ 'X-ClickHouse-Format' => 'JSON' # always return JSON data
34
+ }.freeze
35
+ end
36
+
37
+ def with_default_database
38
+ self.class.new(
39
+ database: 'default',
40
+ url: @url,
41
+ username: @username,
42
+ password: @password,
43
+ variables: @variables.merge(database: 'default')
44
+ )
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class Formatter
6
+ DEFAULT = ->(value) { value }
7
+
8
+ BASIC_TYPE_CASTERS = {
9
+ 'UInt64' => ->(value) { Integer(value) },
10
+ "DateTime64(6, 'UTC')" => ->(value) { ActiveSupport::TimeZone['UTC'].parse(value) },
11
+ "IntervalSecond" => ->(value) { ActiveSupport::Duration.build(value.to_i) },
12
+ "IntervalMillisecond" => ->(value) { ActiveSupport::Duration.build(value.to_i / 1000.0) }
13
+ }.freeze
14
+
15
+ TYPE_CASTERS = BASIC_TYPE_CASTERS.merge(
16
+ BASIC_TYPE_CASTERS.transform_keys { |type| "Nullable(#{type})" }
17
+ .transform_values { |caster| ->(value) { value.nil? ? nil : caster.call(value) } }
18
+ )
19
+
20
+ def self.format(result)
21
+ name_type_mapping = result['meta'].each_with_object({}) do |column, hash|
22
+ hash[column['name']] = column['type']
23
+ end
24
+
25
+ result['data'].map do |row|
26
+ row.each_with_object({}) do |(column, value), casted_row|
27
+ caster = TYPE_CASTERS.fetch(name_type_mapping[column], DEFAULT)
28
+
29
+ casted_row[column] = caster.call(value)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class Query < QueryLike
6
+ SUBQUERY_PLACEHOLDER_REGEX = /{\w+:Subquery}/ # example: {var:Subquery}, special "internal" type for subqueries
7
+ PLACEHOLDER_REGEX = /{\w+:(\w|[()])+}/ # example: {var:UInt8} or {var:Array(UInt8)}
8
+ PLACEHOLDER_NAME_REGEX = /{(\w+):/ # example: {var:UInt8} => var
9
+
10
+ def self.build(query)
11
+ return query if query.is_a?(ClickHouse::Client::QueryLike)
12
+
13
+ new(raw_query: query)
14
+ end
15
+
16
+ def initialize(raw_query:, placeholders: {})
17
+ raise QueryError, 'Empty query string given' if raw_query.blank?
18
+
19
+ @raw_query = raw_query
20
+ @placeholders = placeholders || {}
21
+ end
22
+
23
+ # List of placeholders to be sent to ClickHouse for replacement.
24
+ # If there are subqueries, merge their placeholders as well.
25
+ def prepared_placeholders
26
+ all_placeholders = placeholders.select { |_, v| !v.is_a?(QueryLike) }
27
+ all_placeholders.transform_values! { |v| prepared_placeholder_value(v) }
28
+
29
+ placeholders.each_value do |value|
30
+ next unless value.is_a?(QueryLike)
31
+
32
+ all_placeholders.merge!(value.prepared_placeholders) do |key, a, b|
33
+ raise QueryError, "mismatching values for the '#{key}' placeholder: #{a} vs #{b}"
34
+ end
35
+ end
36
+
37
+ all_placeholders
38
+ end
39
+
40
+ # Placeholder replacement is handled by ClickHouse, only subquery placeholders
41
+ # will be replaced.
42
+ def to_sql
43
+ raw_query.gsub(SUBQUERY_PLACEHOLDER_REGEX) do |placeholder_in_query|
44
+ value = placeholder_value(placeholder_in_query)
45
+
46
+ if value.is_a?(QueryLike)
47
+ value.to_sql
48
+ else
49
+ placeholder_in_query
50
+ end
51
+ end
52
+ end
53
+
54
+ def to_redacted_sql(bind_index_manager = BindIndexManager.new)
55
+ raw_query.gsub(PLACEHOLDER_REGEX) do |placeholder_in_query|
56
+ value = placeholder_value(placeholder_in_query)
57
+
58
+ if value.is_a?(QueryLike)
59
+ value.to_redacted_sql(bind_index_manager)
60
+ else
61
+ bind_index_manager.next_bind_str
62
+ end
63
+ end
64
+ end
65
+
66
+ private
67
+
68
+ attr_reader :raw_query, :placeholders
69
+
70
+ def placeholder_value(placeholder_in_query)
71
+ placeholder = placeholder_in_query[PLACEHOLDER_NAME_REGEX, 1]
72
+ placeholders.fetch(placeholder.to_sym)
73
+ end
74
+
75
+ def prepared_placeholder_value(value)
76
+ return value unless value.is_a?(Array)
77
+
78
+ Quoting.quote(value)
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_record'
4
+
5
+ module ClickHouse
6
+ module Client
7
+ class QueryBuilder < QueryLike
8
+ attr_reader :table
9
+ attr_accessor :conditions, :manager
10
+
11
+ VALID_NODES = [
12
+ Arel::Nodes::In,
13
+ Arel::Nodes::Equality,
14
+ Arel::Nodes::LessThan,
15
+ Arel::Nodes::LessThanOrEqual,
16
+ Arel::Nodes::GreaterThan,
17
+ Arel::Nodes::GreaterThanOrEqual,
18
+ Arel::Nodes::NamedFunction,
19
+ Arel::Nodes::NotIn,
20
+ Arel::Nodes::NotEqual,
21
+ Arel::Nodes::Between,
22
+ Arel::Nodes::And,
23
+ Arel::Nodes::Or,
24
+ Arel::Nodes::Grouping
25
+ ].freeze
26
+
27
+ def initialize(table_name)
28
+ @table = Arel::Table.new(table_name)
29
+ @manager = Arel::SelectManager.new(Arel::Table.engine).from(@table).project(Arel.star)
30
+ @conditions = []
31
+ end
32
+
33
+ # The `where` method currently only supports IN and equal to queries along
34
+ # with above listed VALID_NODES.
35
+ # For example, using a range (start_date..end_date) will result in incorrect SQL.
36
+ # If you need to query a range, use greater than and less than conditions with Arel.
37
+ #
38
+ # Correct usage:
39
+ # query.where(query.table[:created_at].lteq(Date.today)).to_sql
40
+ # "SELECT * FROM \"table\" WHERE \"table\".\"created_at\" <= '2023-08-01'"
41
+ #
42
+ # This also supports array conditions which will result in an IN query.
43
+ # query.where(entity_id: [1,2,3]).to_sql
44
+ # "SELECT * FROM \"table\" WHERE \"table\".\"entity_id\" IN (1, 2, 3)"
45
+ #
46
+ # Range support and more `Arel::Nodes` could be considered for future iterations.
47
+ # @return [ClickHouse::QueryBuilder] New instance of query builder.
48
+ def where(conditions)
49
+ validate_condition_type!(conditions)
50
+
51
+ deep_clone.tap do |new_instance|
52
+ if conditions.is_a?(Arel::Nodes::Node)
53
+ new_instance.conditions << conditions
54
+ else
55
+ add_conditions_to(new_instance, conditions)
56
+ end
57
+ end
58
+ end
59
+
60
+ def select(*fields)
61
+ deep_clone.tap do |new_instance|
62
+ existing_fields = new_instance.manager.projections.filter_map do |projection|
63
+ if projection.is_a?(Arel::Attributes::Attribute)
64
+ projection.name.to_s
65
+ elsif projection.to_s == '*'
66
+ nil
67
+ end
68
+ end
69
+
70
+ new_projections = (existing_fields + fields).map do |field|
71
+ if field.is_a?(Symbol)
72
+ field.to_s
73
+ else
74
+ field
75
+ end
76
+ end
77
+
78
+ new_instance.manager.projections = new_projections.uniq.map do |field|
79
+ if field.is_a?(Arel::Expressions)
80
+ field
81
+ else
82
+ new_instance.table[field.to_s]
83
+ end
84
+ end
85
+ end
86
+ end
87
+
88
+ def order(field, direction = :asc)
89
+ validate_order_direction!(direction)
90
+
91
+ deep_clone.tap do |new_instance|
92
+ table_field = new_instance.table[field]
93
+ new_order = direction.to_s.casecmp('desc').zero? ? table_field.desc : table_field.asc
94
+ new_instance.manager.order(new_order)
95
+ end
96
+ end
97
+
98
+ def group(*columns)
99
+ deep_clone.tap do |new_instance|
100
+ new_instance.manager.group(*columns)
101
+ end
102
+ end
103
+
104
+ def limit(count)
105
+ manager.take(count)
106
+ self
107
+ end
108
+
109
+ def offset(count)
110
+ manager.skip(count)
111
+ self
112
+ end
113
+
114
+ def to_sql
115
+ apply_conditions!
116
+
117
+ visitor = Arel::Visitors::ToSql.new(ClickHouse::Client::ArelEngine.new)
118
+ visitor.accept(manager.ast, Arel::Collectors::SQLString.new).value
119
+ end
120
+
121
+ def to_redacted_sql(bind_index_manager = ClickHouse::Client::BindIndexManager.new)
122
+ ClickHouse::Client::Redactor.redact(self, bind_index_manager)
123
+ end
124
+
125
+ private
126
+
127
+ def validate_condition_type!(condition)
128
+ return unless condition.is_a?(Arel::Nodes::Node) && VALID_NODES.exclude?(condition.class)
129
+
130
+ raise ArgumentError, "Unsupported Arel node type for QueryBuilder: #{condition.class.name}"
131
+ end
132
+
133
+ def add_conditions_to(instance, conditions)
134
+ conditions.each do |key, value|
135
+ instance.conditions << if value.is_a?(Array)
136
+ instance.table[key].in(value)
137
+ else
138
+ instance.table[key].eq(value)
139
+ end
140
+ end
141
+ end
142
+
143
+ def deep_clone
144
+ self.class.new(table.name).tap do |new_instance|
145
+ new_instance.manager = manager.clone
146
+ new_instance.conditions = conditions.map(&:clone)
147
+ end
148
+ end
149
+
150
+ def apply_conditions!
151
+ manager.constraints.clear
152
+ conditions.each { |condition| manager.where(condition) }
153
+ end
154
+
155
+ def validate_order_direction!(direction)
156
+ return if %w[asc desc].include?(direction.to_s.downcase)
157
+
158
+ raise ArgumentError, "Invalid order direction '#{direction}'. Must be :asc or :desc"
159
+ end
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class QueryLike
6
+ # Build a SQL string that can be executed on a ClickHouse database.
7
+ def to_sql
8
+ raise NotImplementedError
9
+ end
10
+
11
+ # Redacted version of the SQL query generated by the to_sql method where the
12
+ # placeholders are stripped. These queries are meant to be exported to external
13
+ # log aggregation systems.
14
+ def to_redacted_sql(bind_index_manager = BindIndexManager.new)
15
+ raise NotImplementedError
16
+ end
17
+
18
+ # Override when placeholders should be supported
19
+ def prepared_placeholders
20
+ {}
21
+ end
22
+
23
+ private
24
+
25
+ # Override when placeholders should be supported
26
+ def placeholders
27
+ {}
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ module Quoting
6
+ class << self
7
+ def quote(value)
8
+ case value
9
+ when Numeric then value.to_s
10
+ when String, Symbol then "'#{value.to_s.gsub('\\', '\&\&').gsub("'", "''")}'"
11
+ when Array then "[#{value.map { |v| quote(v) }.join(',')}]"
12
+ when nil then "NULL"
13
+ else quote_str(value.to_s)
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def quote_str(value)
20
+ "'#{value.gsub("'", "''")}'"
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ module Redactor
6
+ # Redacts the SQL query represented by the query builder.
7
+ #
8
+ # @param query_builder [::ClickHouse::Querybuilder] The query builder object to be redacted.
9
+ # @return [String] The redacted SQL query as a string.
10
+ # @raise [ArgumentError] when the condition in the query is of an unsupported type.
11
+ #
12
+ # Example:
13
+ # query_builder = ClickHouse::QueryBuilder.new('users').where(name: 'John Doe')
14
+ # redacted_query = ClickHouse::Redactor.redact(query_builder)
15
+ # # The redacted_query will contain the SQL query with values replaced by placeholders.
16
+ # output: "SELECT * FROM \"users\" WHERE \"users\".\"name\" = $1"
17
+ def self.redact(query_builder, bind_manager = ClickHouse::Client::BindIndexManager.new)
18
+ cloned_query_builder = query_builder.clone
19
+
20
+ cloned_query_builder.conditions = cloned_query_builder.conditions.map do |condition|
21
+ redact_condition(condition, bind_manager)
22
+ end
23
+
24
+ cloned_query_builder.manager.constraints.clear
25
+ cloned_query_builder.conditions.each do |condition|
26
+ cloned_query_builder.manager.where(condition)
27
+ end
28
+
29
+ visitor = Arel::Visitors::ToSql.new(ClickHouse::Client::ArelEngine.new)
30
+ visitor.accept(cloned_query_builder.manager.ast, Arel::Collectors::SQLString.new).value
31
+ end
32
+
33
+ def self.redact_condition(condition, bind_manager)
34
+ case condition
35
+ when Arel::Nodes::In
36
+ condition.left.in(Array.new(condition.right.size) { Arel.sql(bind_manager.next_bind_str) })
37
+ when Arel::Nodes::Equality
38
+ condition.left.eq(Arel.sql(bind_manager.next_bind_str))
39
+ when Arel::Nodes::LessThan
40
+ condition.left.lt(Arel.sql(bind_manager.next_bind_str))
41
+ when Arel::Nodes::LessThanOrEqual
42
+ condition.left.lteq(Arel.sql(bind_manager.next_bind_str))
43
+ when Arel::Nodes::GreaterThan
44
+ condition.left.gt(Arel.sql(bind_manager.next_bind_str))
45
+ when Arel::Nodes::GreaterThanOrEqual
46
+ condition.left.gteq(Arel.sql(bind_manager.next_bind_str))
47
+ when Arel::Nodes::NamedFunction
48
+ redact_named_function(condition, bind_manager)
49
+ else
50
+ raise ArgumentError, "Unsupported Arel node type for Redactor: #{condition.class}"
51
+ end
52
+ end
53
+
54
+ def self.redact_named_function(condition, bind_manager)
55
+ redacted_condition =
56
+ Arel::Nodes::NamedFunction.new(condition.name, condition.expressions.dup)
57
+
58
+ case redacted_condition.name
59
+ when 'startsWith'
60
+ redacted_condition.expressions[1] = Arel.sql(bind_manager.next_bind_str)
61
+ else
62
+ redacted_condition.expressions = redacted_condition.expressions.map { Arel.sql(bind_manager.next_bind_str) }
63
+ end
64
+
65
+ redacted_condition
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ class Response
6
+ attr_reader :body, :headers
7
+
8
+ def initialize(body, http_status_code, headers = {})
9
+ @body = body
10
+ @http_status_code = http_status_code
11
+ @headers = headers
12
+ end
13
+
14
+ def success?
15
+ @http_status_code == 200
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ClickHouse
4
+ module Client
5
+ VERSION = "0.3.2"
6
+ end
7
+ end