clickhouse-ruby 0.2.0 → 0.3.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.
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ class <%= class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>]
4
+ <%- if @migration_action == :create_table -%>
5
+ def change
6
+ create_table :<%= table_name %>, **clickhouse_options do |t|
7
+ <%- attributes.each do |attribute| -%>
8
+ <%- if attribute.type == :references -%>
9
+ t.bigint :<%= attribute.name %>_id<%= attribute.has_index? ? ", index: true" : "" %>
10
+ <%- else -%>
11
+ t.<%= attribute.type %> :<%= attribute.name %>
12
+ <%- end -%>
13
+ <%- end -%>
14
+ <%- if attributes.empty? -%>
15
+ t.uuid :id
16
+ <%- end -%>
17
+ t.timestamps
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ # ClickHouse-specific table options
24
+ #
25
+ # Customize these options based on your data patterns:
26
+ # - engine: Table engine (MergeTree family for analytics)
27
+ # - order_by: Determines data sorting and query performance
28
+ # - partition_by: Data partitioning for faster queries and easier maintenance
29
+ # - primary_key: Sparse index for faster lookups (defaults to order_by)
30
+ #
31
+ # @return [Hash] ClickHouse table options
32
+ def clickhouse_options
33
+ {
34
+ engine: "<%= engine_with_cluster %>",
35
+ <%- if order_by_clause -%>
36
+ order_by: "<%= order_by_clause %>",
37
+ <%- end -%>
38
+ <%- if partition_by_clause -%>
39
+ partition_by: "<%= partition_by_clause %>",
40
+ <%- end -%>
41
+ <%- if primary_key_clause -%>
42
+ primary_key: "<%= primary_key_clause %>",
43
+ <%- end -%>
44
+ <%- if settings_clause -%>
45
+ settings: "<%= settings_clause %>",
46
+ <%- end -%>
47
+ }
48
+ end
49
+ <%- elsif @migration_action == :add_column -%>
50
+ def change
51
+ <%- attributes.each do |attribute| -%>
52
+ add_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %>
53
+ <%- end -%>
54
+ <%- if attributes.empty? && column_name -%>
55
+ add_column :<%= table_name %>, :<%= column_name %>, :string
56
+ <%- end -%>
57
+ end
58
+ <%- elsif @migration_action == :remove_column -%>
59
+ def change
60
+ <%- attributes.each do |attribute| -%>
61
+ remove_column :<%= table_name %>, :<%= attribute.name %>, :<%= attribute.type %>
62
+ <%- end -%>
63
+ <%- if attributes.empty? && column_name -%>
64
+ # Note: Specify the column type for reversible migration
65
+ remove_column :<%= table_name %>, :<%= column_name %>, :string
66
+ <%- end -%>
67
+ end
68
+ <%- else -%>
69
+ def up
70
+ # Add your ClickHouse migration code here
71
+ # Example:
72
+ # execute <<~SQL
73
+ # ALTER TABLE <%= table_name %>
74
+ # ADD COLUMN new_column String
75
+ # SQL
76
+ end
77
+
78
+ def down
79
+ # Reverse the migration
80
+ # Example:
81
+ # execute <<~SQL
82
+ # ALTER TABLE <%= table_name %>
83
+ # DROP COLUMN new_column
84
+ # SQL
85
+ end
86
+ <%- end -%>
87
+ end
@@ -8,6 +8,8 @@ module ClickhouseRuby
8
8
  #
9
9
  # This Railtie hooks into Rails to:
10
10
  # - Register the ClickHouse adapter with ActiveRecord
11
+ # - Register the ClickHouse migration generator
12
+ # - Register the ClickHouse schema dumper
11
13
  # - Configure default settings for Rails environments
12
14
  # - Set up logging integration
13
15
  #
@@ -26,6 +28,10 @@ module ClickhouseRuby
26
28
  # ssl: true
27
29
  # ssl_verify: true
28
30
  #
31
+ # @example Generate a ClickHouse migration
32
+ # rails generate clickhouse:migration CreateEvents user_id:integer name:string
33
+ # rails generate clickhouse:migration CreateEvents --engine=ReplacingMergeTree --order-by=user_id
34
+ #
29
35
  class Railtie < ::Rails::Railtie
30
36
  # Initialize the adapter when ActiveRecord loads
31
37
  initializer "clickhouse_ruby.initialize_active_record" do
@@ -51,6 +57,13 @@ module ClickhouseRuby
51
57
  end
52
58
  end
53
59
 
60
+ # Load the schema dumper when ActiveRecord loads
61
+ initializer "clickhouse_ruby.load_schema_dumper" do
62
+ ::ActiveSupport.on_load(:active_record) do
63
+ require_relative "schema_dumper"
64
+ end
65
+ end
66
+
54
67
  # Configure the connection pool for Rails
55
68
  config.after_initialize do
56
69
  # Set up connection pool based on Rails configuration
@@ -66,7 +79,7 @@ module ClickhouseRuby
66
79
 
67
80
  # Add generators namespace for Rails generators
68
81
  generators do
69
- require_relative "generators/migration_generator" if defined?(::Rails::Generators)
82
+ require_relative "generators/migration_generator"
70
83
  end
71
84
 
72
85
  # Log deprecation warnings for known issues
@@ -74,7 +87,8 @@ module ClickhouseRuby
74
87
  ::ActiveSupport.on_load(:active_record) do
75
88
  # Warn about features that don't work with ClickHouse
76
89
  if defined?(Rails.logger) && Rails.logger
77
- Rails.logger.debug "[ClickhouseRuby] Note: ClickHouse does not support transactions, savepoints, or foreign keys"
90
+ Rails.logger.debug "[ClickhouseRuby] Note: ClickHouse does not support " \
91
+ "transactions, savepoints, or foreign keys"
78
92
  end
79
93
  end
80
94
  end
@@ -0,0 +1,458 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record/schema_dumper"
4
+
5
+ module ClickhouseRuby
6
+ module ActiveRecord
7
+ # Custom schema dumper for ClickHouse databases
8
+ #
9
+ # Extends ActiveRecord::SchemaDumper to properly dump ClickHouse-specific
10
+ # schema elements like engines, ORDER BY, PARTITION BY, and SETTINGS.
11
+ #
12
+ # @example Usage
13
+ # # Automatically used when running:
14
+ # rails db:schema:dump
15
+ #
16
+ # @example Manual usage
17
+ # File.open("db/schema.rb", "w") do |file|
18
+ # ClickhouseRuby::ActiveRecord::SchemaDumper.dump(connection, file)
19
+ # end
20
+ #
21
+ class SchemaDumper < ::ActiveRecord::SchemaDumper
22
+ # Dump the schema to a stream
23
+ #
24
+ # @param connection [ConnectionAdapter] the database connection
25
+ # @param stream [IO] the output stream
26
+ # @param _config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] database config (unused)
27
+ # @return [void]
28
+ def self.dump(connection = ::ActiveRecord::Base.connection, stream = $stdout, _config = nil)
29
+ new(connection, generate_options).dump(stream)
30
+ stream
31
+ end
32
+
33
+ # Generate options for the dumper
34
+ #
35
+ # @return [Hash] dumper options
36
+ def self.generate_options
37
+ {
38
+ table_name_prefix: ::ActiveRecord::Base.table_name_prefix,
39
+ table_name_suffix: ::ActiveRecord::Base.table_name_suffix,
40
+ }
41
+ end
42
+
43
+ private
44
+
45
+ # Dump all tables to the stream
46
+ #
47
+ # @param stream [IO] the output stream
48
+ # @return [void]
49
+ def tables(stream)
50
+ table_names = @connection.tables.sort
51
+
52
+ table_names.each do |table_name|
53
+ table(table_name, stream)
54
+ end
55
+
56
+ # Dump views after tables
57
+ views(stream) if @connection.respond_to?(:views)
58
+ end
59
+
60
+ # Dump views to the stream
61
+ #
62
+ # @param stream [IO] the output stream
63
+ # @return [void]
64
+ def views(stream)
65
+ return unless @connection.respond_to?(:views)
66
+
67
+ view_names = @connection.views.sort
68
+ return if view_names.empty?
69
+
70
+ stream.puts
71
+ stream.puts " # Views"
72
+
73
+ view_names.each do |view_name|
74
+ view(view_name, stream)
75
+ end
76
+ end
77
+
78
+ # Dump a single table to the stream
79
+ #
80
+ # @param table_name [String] the table name
81
+ # @param stream [IO] the output stream
82
+ # @return [void]
83
+ def table(table_name, stream)
84
+ columns = @connection.columns(table_name)
85
+ table_options = TableOptionsExtractor.new(@connection, table_name).extract
86
+
87
+ # Begin create_table block
88
+ stream.print " create_table #{table_name.inspect}"
89
+ stream.print ", #{format_options(table_options)}" unless table_options.empty?
90
+ stream.puts " do |t|"
91
+
92
+ # Dump columns
93
+ columns.each do |column|
94
+ ColumnDumper.new(column, stream).dump
95
+ end
96
+
97
+ stream.puts " end"
98
+ stream.puts
99
+
100
+ # Dump indexes
101
+ dump_indexes(table_name, stream)
102
+ end
103
+
104
+ # Dump a view definition to the stream
105
+ #
106
+ # @param view_name [String] the view name
107
+ # @param stream [IO] the output stream
108
+ # @return [void]
109
+ def view(view_name, stream)
110
+ view_definition = extract_view_definition(view_name)
111
+ return unless view_definition
112
+
113
+ stream.puts " execute <<~SQL"
114
+ stream.puts " #{view_definition}"
115
+ stream.puts " SQL"
116
+ stream.puts
117
+ end
118
+
119
+ # Extract view definition
120
+ #
121
+ # @param view_name [String] the view name
122
+ # @return [String, nil] the CREATE VIEW statement or nil
123
+ def extract_view_definition(view_name)
124
+ sql = "SHOW CREATE TABLE `#{@connection.quote_string(view_name)}`"
125
+ result = @connection.execute(sql, "SCHEMA")
126
+ return nil if result.empty?
127
+
128
+ result.first["statement"] || result.first["Create Table"]
129
+ rescue StandardError
130
+ nil
131
+ end
132
+
133
+ # Format options hash as Ruby code
134
+ #
135
+ # @param options [Hash] options hash
136
+ # @return [String] formatted options string
137
+ def format_options(options)
138
+ options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
139
+ end
140
+
141
+ # Dump indexes for a table
142
+ #
143
+ # @param table_name [String] the table name
144
+ # @param stream [IO] the output stream
145
+ # @return [void]
146
+ def dump_indexes(table_name, stream)
147
+ return unless @connection.respond_to?(:indexes)
148
+
149
+ table_indexes = @connection.indexes(table_name)
150
+ return if table_indexes.empty?
151
+
152
+ table_indexes.each do |index|
153
+ dump_single_index(table_name, index, stream)
154
+ end
155
+
156
+ stream.puts
157
+ end
158
+
159
+ # Dump a single index
160
+ #
161
+ # @param table_name [String] the table name
162
+ # @param index [Hash] the index information
163
+ # @param stream [IO] the output stream
164
+ # @return [void]
165
+ def dump_single_index(table_name, index, stream)
166
+ stream.print " add_index #{table_name.inspect}"
167
+ stream.print ", #{index[:expression].inspect}"
168
+ stream.print ", name: #{index[:name].inspect}"
169
+ stream.print ", type: #{index[:type].inspect}" if index[:type]
170
+ stream.print ", granularity: #{index[:granularity]}" if index[:granularity]
171
+ stream.puts
172
+ end
173
+
174
+ # Header comment for the schema file
175
+ #
176
+ # @param stream [IO] the output stream
177
+ # @return [void]
178
+ def header(stream)
179
+ write_header_comments(stream)
180
+ stream.puts
181
+ stream.puts "ActiveRecord::Schema[#{::ActiveRecord::Migration.current_version}].define(" \
182
+ "version: #{schema_version}) do"
183
+ end
184
+
185
+ # Write header comments to stream
186
+ #
187
+ # @param stream [IO] the output stream
188
+ # @return [void]
189
+ def write_header_comments(stream)
190
+ stream.puts "# This file is auto-generated from the current state of the database. Instead"
191
+ stream.puts "# of editing this file, please use the migrations feature of Active Record to"
192
+ stream.puts "# incrementally modify your database, and then regenerate this schema definition."
193
+ stream.puts "#"
194
+ stream.puts "# This file is the source Rails uses to define your schema when running"
195
+ stream.puts "# `bin/rails db:schema:load`."
196
+ stream.puts "#"
197
+ stream.puts "# Note: ClickHouse-specific options (engine, order_by, partition_by) are preserved"
198
+ stream.puts "# and required for proper table recreation."
199
+ stream.puts "#"
200
+ stream.puts "# Database: ClickHouse"
201
+ stream.puts "# Adapter: clickhouse_ruby"
202
+ stream.puts "#"
203
+ stream.puts "# It's strongly recommended that you check this file into version control."
204
+ end
205
+
206
+ # Get the current schema version
207
+ #
208
+ # @return [String] the schema version
209
+ def schema_version
210
+ if @connection.respond_to?(:migration_context)
211
+ @connection.migration_context.current_version.to_s
212
+ else
213
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
214
+ end
215
+ end
216
+
217
+ # Footer for the schema file
218
+ #
219
+ # @param stream [IO] the output stream
220
+ # @return [void]
221
+ def trailer(stream)
222
+ stream.puts "end"
223
+ end
224
+ end
225
+
226
+ # Extracts ClickHouse-specific table options
227
+ class TableOptionsExtractor
228
+ def initialize(connection, table_name)
229
+ @connection = connection
230
+ @table_name = table_name
231
+ end
232
+
233
+ # Extract table options from system.tables
234
+ #
235
+ # @return [Hash] table options
236
+ def extract
237
+ row = fetch_table_metadata
238
+ return {} unless row
239
+
240
+ build_options(row)
241
+ end
242
+
243
+ private
244
+
245
+ # Fetch table metadata from system.tables
246
+ #
247
+ # @return [Hash, nil] the table metadata row
248
+ def fetch_table_metadata
249
+ sql = <<~SQL
250
+ SELECT engine, sorting_key, partition_key, primary_key, engine_full
251
+ FROM system.tables
252
+ WHERE database = currentDatabase()
253
+ AND name = '#{@connection.quote_string(@table_name)}'
254
+ SQL
255
+
256
+ result = @connection.execute(sql, "SCHEMA")
257
+ result.first
258
+ end
259
+
260
+ # Build options hash from metadata row
261
+ #
262
+ # @param row [Hash] the metadata row
263
+ # @return [Hash] the options hash
264
+ def build_options(row)
265
+ options = {}
266
+ options[:engine] = row["engine"] if row["engine"] && row["engine"] != "MergeTree"
267
+ options[:order_by] = row["sorting_key"] if row["sorting_key"].present?
268
+ options[:partition_by] = row["partition_key"] if row["partition_key"].present?
269
+ add_primary_key(options, row)
270
+ add_settings(options, row)
271
+ options
272
+ end
273
+
274
+ # Add primary key if different from sorting key
275
+ def add_primary_key(options, row)
276
+ return unless row["primary_key"].present? && row["primary_key"] != row["sorting_key"]
277
+
278
+ options[:primary_key] = row["primary_key"]
279
+ end
280
+
281
+ # Add settings from engine_full
282
+ def add_settings(options, row)
283
+ return unless row["engine_full"]&.include?("SETTINGS")
284
+
285
+ settings = row["engine_full"][/SETTINGS\s+(.+)$/i, 1]
286
+ options[:settings] = settings if settings.present?
287
+ end
288
+ end
289
+
290
+ # Dumps a single column definition
291
+ class ColumnDumper
292
+ def initialize(column, stream)
293
+ @column = column
294
+ @stream = stream
295
+ end
296
+
297
+ # Dump the column definition
298
+ #
299
+ # @return [void]
300
+ def dump
301
+ type = schema_type
302
+ options = column_options
303
+
304
+ @stream.print " t.#{type} #{@column.name.inspect}"
305
+ @stream.print ", #{format_options(options)}" unless options.empty?
306
+ @stream.puts
307
+ end
308
+
309
+ private
310
+
311
+ # Get the schema type for the column
312
+ #
313
+ # @return [Symbol] the schema type
314
+ def schema_type
315
+ SchemaTypeMapper.map(@column.sql_type.to_s)
316
+ end
317
+
318
+ # Extract column options
319
+ #
320
+ # @return [Hash] column options
321
+ def column_options
322
+ ColumnOptionsExtractor.new(@column).extract
323
+ end
324
+
325
+ # Format options hash as Ruby code
326
+ def format_options(options)
327
+ options.map { |key, value| "#{key}: #{value.inspect}" }.join(", ")
328
+ end
329
+ end
330
+
331
+ # Maps SQL types to schema types
332
+ module SchemaTypeMapper
333
+ # Type patterns and their schema types
334
+ PATTERNS = [
335
+ [/^UInt64$/i, :bigint],
336
+ [/^UInt(8|16|32)$/i, :integer],
337
+ [/^Int(8|16|32)$/i, :integer],
338
+ [/^Int64$/i, :bigint],
339
+ [/^Float(32|64)$/i, :float],
340
+ [/^Decimal/i, :decimal],
341
+ [/^String$/i, :string],
342
+ [/^FixedString/i, :string],
343
+ [/^Date$/i, :date],
344
+ [/^DateTime64/i, :datetime],
345
+ [/^DateTime$/i, :datetime],
346
+ [/^UUID$/i, :uuid],
347
+ ].freeze
348
+
349
+ class << self
350
+ # Map a SQL type to schema type
351
+ #
352
+ # @param sql_type [String] the SQL type
353
+ # @return [Symbol] the schema type
354
+ def map(sql_type)
355
+ # Handle wrapper types
356
+ return handle_nullable(sql_type) if sql_type.match?(/^Nullable\(/i)
357
+ return handle_low_cardinality(sql_type) if sql_type.match?(/^LowCardinality\(/i)
358
+
359
+ # Handle standard types
360
+ PATTERNS.each do |pattern, type|
361
+ return type if sql_type.match?(pattern)
362
+ end
363
+
364
+ :string
365
+ end
366
+
367
+ private
368
+
369
+ # Handle Nullable wrapper
370
+ def handle_nullable(sql_type)
371
+ inner = sql_type.match(/^Nullable\((.+)\)/i)[1]
372
+ map(inner)
373
+ end
374
+
375
+ # Handle LowCardinality wrapper
376
+ def handle_low_cardinality(sql_type)
377
+ inner = sql_type.match(/^LowCardinality\((.+)\)/i)[1]
378
+ map(inner)
379
+ end
380
+ end
381
+ end
382
+
383
+ # Extracts column options from a column
384
+ class ColumnOptionsExtractor
385
+ def initialize(column)
386
+ @column = column
387
+ @sql_type = column.sql_type.to_s
388
+ end
389
+
390
+ # Extract all column options
391
+ #
392
+ # @return [Hash] the column options
393
+ def extract
394
+ options = {}
395
+ add_nullable(options)
396
+ add_limit(options)
397
+ add_decimal_options(options)
398
+ add_datetime_precision(options)
399
+ add_default(options)
400
+ add_comment(options)
401
+ options
402
+ end
403
+
404
+ private
405
+
406
+ def add_nullable(options)
407
+ options[:null] = true if @sql_type.match?(/^Nullable/i)
408
+ end
409
+
410
+ def add_limit(options)
411
+ return unless (match = @sql_type.match(/^FixedString\((\d+)\)/i))
412
+
413
+ options[:limit] = match[1].to_i
414
+ end
415
+
416
+ def add_decimal_options(options)
417
+ return unless (match = @sql_type.match(/^Decimal\((\d+),\s*(\d+)\)/i))
418
+
419
+ options[:precision] = match[1].to_i
420
+ options[:scale] = match[2].to_i
421
+ end
422
+
423
+ def add_datetime_precision(options)
424
+ return unless (match = @sql_type.match(/^DateTime64\((\d+)\)/i))
425
+
426
+ options[:precision] = match[1].to_i
427
+ end
428
+
429
+ def add_default(options)
430
+ options[:default] = @column.default if @column.default.present?
431
+ end
432
+
433
+ def add_comment(options)
434
+ options[:comment] = @column.comment if @column.comment.present?
435
+ end
436
+ end
437
+ end
438
+ end
439
+
440
+ # Register the custom schema dumper with ActiveRecord
441
+ if defined?(ActiveRecord::SchemaDumper)
442
+ # Override the default dumper for ClickHouse connections
443
+ module ClickhouseRuby
444
+ module ActiveRecord
445
+ module SchemaDumperExtension
446
+ def dump(connection = ::ActiveRecord::Base.connection, stream = $stdout, config = nil)
447
+ if connection.adapter_name == "Clickhouse"
448
+ ClickhouseRuby::ActiveRecord::SchemaDumper.dump(connection, stream, config)
449
+ else
450
+ super
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end
456
+
457
+ ActiveRecord::SchemaDumper.singleton_class.prepend(ClickhouseRuby::ActiveRecord::SchemaDumperExtension)
458
+ end
@@ -7,6 +7,7 @@ require_relative "active_record/arel_visitor"
7
7
  require_relative "active_record/schema_statements"
8
8
  require_relative "active_record/relation_extensions"
9
9
  require_relative "active_record/connection_adapter"
10
+ require_relative "active_record/schema_dumper"
10
11
 
11
12
  # Load Railtie if Rails is available
12
13
  require_relative "active_record/railtie" if defined?(Rails::Railtie)