activerecord-duckdb 0.1.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 +7 -0
- data/.fasterer.yml +31 -0
- data/.rspec +3 -0
- data/.rubocop.yml +105 -0
- data/.simplecov +18 -0
- data/.tool-versions +1 -0
- data/.yardopts +2 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +131 -0
- data/LICENSE.txt +21 -0
- data/README.md +168 -0
- data/Rakefile +19 -0
- data/TODO.md +16 -0
- data/docs/TABLE_DEFINITION.md +322 -0
- data/lib/active_record/connection_adapters/duckdb/column.rb +67 -0
- data/lib/active_record/connection_adapters/duckdb/database_limits.rb +54 -0
- data/lib/active_record/connection_adapters/duckdb/database_statements.rb +303 -0
- data/lib/active_record/connection_adapters/duckdb/database_tasks.rb +22 -0
- data/lib/active_record/connection_adapters/duckdb/quoting.rb +59 -0
- data/lib/active_record/connection_adapters/duckdb/schema_creation.rb +43 -0
- data/lib/active_record/connection_adapters/duckdb/schema_definitions.rb +145 -0
- data/lib/active_record/connection_adapters/duckdb/schema_dumper.rb +165 -0
- data/lib/active_record/connection_adapters/duckdb/schema_statements.rb +599 -0
- data/lib/active_record/connection_adapters/duckdb_adapter.rb +436 -0
- data/lib/active_record/tasks/duckdb_database_tasks.rb +170 -0
- data/lib/activerecord/duckdb/version.rb +11 -0
- data/lib/activerecord-duckdb.rb +60 -0
- data/sig/activerecord/duckdb.rbs +6 -0
- data/tmp/.gitkeep +0 -0
- metadata +107 -0
@@ -0,0 +1,436 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'duckdb'
|
4
|
+
require 'active_record'
|
5
|
+
require 'active_record/connection_adapters/abstract_adapter'
|
6
|
+
require 'active_record/connection_adapters/duckdb/column'
|
7
|
+
require 'active_record/connection_adapters/duckdb/database_limits'
|
8
|
+
require 'active_record/connection_adapters/duckdb/database_statements'
|
9
|
+
require 'active_record/connection_adapters/duckdb/quoting'
|
10
|
+
require 'active_record/connection_adapters/duckdb/schema_creation'
|
11
|
+
require 'active_record/connection_adapters/duckdb/schema_statements'
|
12
|
+
require 'active_record/connection_adapters/duckdb/schema_definitions'
|
13
|
+
require 'active_record/connection_adapters/duckdb/schema_dumper'
|
14
|
+
|
15
|
+
# Inspired by the SQLite adapter
|
16
|
+
# duckdb: https://github.com/duckdb/duckdb-ruby
|
17
|
+
# sqlite3 adapter: https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb
|
18
|
+
|
19
|
+
module ActiveRecord
|
20
|
+
module ConnectionHandling
|
21
|
+
# Establishes a connection to a DuckDB database
|
22
|
+
# @param config [Hash] Database configuration options
|
23
|
+
# @return [ActiveRecord::ConnectionAdapters::DuckdbAdapter] The database adapter instance
|
24
|
+
# @raise [ActiveRecord::ConnectionNotEstablished] If connection fails
|
25
|
+
def duckdb_connection(config)
|
26
|
+
config = config.symbolize_keys
|
27
|
+
begin
|
28
|
+
# Create adapter first, then let it establish connection
|
29
|
+
adapter = ConnectionAdapters::DuckdbAdapter.new(nil, logger, {}, config)
|
30
|
+
adapter.send(:connect)
|
31
|
+
adapter
|
32
|
+
rescue StandardError => e
|
33
|
+
raise ActiveRecord::ConnectionNotEstablished,
|
34
|
+
"Could not connect to DuckDB database: #{e.message}"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
module ConnectionAdapters
|
40
|
+
# This adapter provides a connection to a DuckDB database.
|
41
|
+
class DuckdbAdapter < AbstractAdapter
|
42
|
+
ADAPTER_NAME = 'DuckDB'
|
43
|
+
|
44
|
+
include Duckdb::DatabaseLimits
|
45
|
+
include Duckdb::DatabaseStatements
|
46
|
+
include Duckdb::Quoting
|
47
|
+
include Duckdb::SchemaStatements
|
48
|
+
include Duckdb::SchemaDumper
|
49
|
+
|
50
|
+
# Allow customization of primary key type like PostgreSQL and MySQL adapters do
|
51
|
+
class_attribute :primary_key_type, default: :bigint
|
52
|
+
|
53
|
+
# DB configuration if used in memory mode
|
54
|
+
MEMORY_MODE_KEYS = [:memory, 'memory', ':memory:', ':memory'].freeze
|
55
|
+
|
56
|
+
# https://duckdb.org/docs/stable/sql/data_types/overview.html
|
57
|
+
NATIVE_DATABASE_TYPES = {
|
58
|
+
primary_key: 'INTEGER PRIMARY KEY',
|
59
|
+
string: { name: 'VARCHAR' },
|
60
|
+
integer: { name: 'INTEGER' },
|
61
|
+
float: { name: 'REAL' },
|
62
|
+
decimal: { name: 'DECIMAL' },
|
63
|
+
datetime: { name: 'TIMESTAMP' },
|
64
|
+
time: { name: 'TIME' },
|
65
|
+
date: { name: 'DATE' },
|
66
|
+
bigint: { name: 'BIGINT' },
|
67
|
+
binary: { name: 'BLOB' },
|
68
|
+
boolean: { name: 'BOOLEAN' },
|
69
|
+
uuid: { name: 'UUID' }
|
70
|
+
}.freeze
|
71
|
+
|
72
|
+
# Initializes a new DuckDB adapter instance
|
73
|
+
# @param args [Array] Arguments passed to the parent AbstractAdapter
|
74
|
+
def initialize(*args)
|
75
|
+
super
|
76
|
+
end
|
77
|
+
|
78
|
+
# Reconnects to the DuckDB database by disconnecting and connecting again
|
79
|
+
# @return [void]
|
80
|
+
def reconnect
|
81
|
+
disconnect
|
82
|
+
connect
|
83
|
+
end
|
84
|
+
|
85
|
+
# Disconnects from the DuckDB database and cleans up the connection
|
86
|
+
# @return [void]
|
87
|
+
def disconnect
|
88
|
+
@connection&.close
|
89
|
+
@connection = nil
|
90
|
+
end
|
91
|
+
|
92
|
+
# Checks if the database connection is active
|
93
|
+
# @return [Boolean] true if connection is active, false otherwise
|
94
|
+
def active?
|
95
|
+
!!(@raw_connection || @connection)
|
96
|
+
end
|
97
|
+
|
98
|
+
# Returns the raw DuckDB connection object
|
99
|
+
# @return [DuckDB::Connection, nil] The raw connection object or nil if not connected
|
100
|
+
def raw_connection
|
101
|
+
@raw_connection || @connection
|
102
|
+
end
|
103
|
+
|
104
|
+
class << self
|
105
|
+
# Opens the DuckDB command line console
|
106
|
+
# @param config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] Database configuration
|
107
|
+
# @param options [Hash] Additional options for the console
|
108
|
+
# @return [void]
|
109
|
+
def dbconsole(config, options = {})
|
110
|
+
db_config = config.configuration_hash
|
111
|
+
args = []
|
112
|
+
args << db_config[:database] if db_config[:database] && !MEMORY_MODE_KEYS.include?(db_config[:database])
|
113
|
+
|
114
|
+
find_cmd_and_exec('duckdb', *args)
|
115
|
+
end
|
116
|
+
|
117
|
+
# Checks if the DuckDB database file exists
|
118
|
+
# @param config [Hash] Database configuration containing database path
|
119
|
+
# @return [Boolean] true if database exists or is in-memory, false otherwise
|
120
|
+
def database_exists?(config)
|
121
|
+
# Logic to check if database exists
|
122
|
+
database_path = config[:database]
|
123
|
+
return true if MEMORY_MODE_KEYS.include?(database_path)
|
124
|
+
|
125
|
+
File.exist?(database_path.to_s)
|
126
|
+
end
|
127
|
+
end
|
128
|
+
|
129
|
+
# Indicates whether the adapter supports INSERT...RETURNING syntax
|
130
|
+
# @return [Boolean] always returns true for DuckDB
|
131
|
+
def use_insert_returning?
|
132
|
+
true
|
133
|
+
end
|
134
|
+
|
135
|
+
# Indicates whether the adapter supports INSERT RETURNING for Rails 8
|
136
|
+
# @return [Boolean] always returns true for DuckDB
|
137
|
+
def supports_insert_returning?
|
138
|
+
true
|
139
|
+
end
|
140
|
+
|
141
|
+
# Indicates whether the adapter supports INSERT ON DUPLICATE SKIP syntax
|
142
|
+
# @return [Boolean] always returns false for DuckDB
|
143
|
+
def supports_insert_on_duplicate_skip?
|
144
|
+
false
|
145
|
+
end
|
146
|
+
|
147
|
+
# Determines if a column value should be returned after insert
|
148
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] The column to check
|
149
|
+
# @return [Boolean] true if column has default function or parent method returns true
|
150
|
+
def return_value_after_insert?(column)
|
151
|
+
column.default_function.present? || super
|
152
|
+
end
|
153
|
+
|
154
|
+
# Indicates whether the adapter supports INSERT ON DUPLICATE UPDATE syntax
|
155
|
+
# @return [Boolean] always returns false for DuckDB
|
156
|
+
def supports_insert_on_duplicate_update?
|
157
|
+
false
|
158
|
+
end
|
159
|
+
|
160
|
+
# Determines if primary key should be prefetched before insert
|
161
|
+
# @param _table_name [String] The table name (unused)
|
162
|
+
# @return [Boolean] always returns false to exclude auto-increment columns from INSERT
|
163
|
+
def prefetch_primary_key?(_table_name)
|
164
|
+
false
|
165
|
+
end
|
166
|
+
|
167
|
+
# Returns the sequence name for a serial column
|
168
|
+
# @param table [String] The table name
|
169
|
+
# @param column [String] The column name
|
170
|
+
# @return [nil] always returns nil as DuckDB doesn't use named sequences
|
171
|
+
def serial_sequence(table, column)
|
172
|
+
# Return sequence name if using sequences, nil otherwise
|
173
|
+
nil
|
174
|
+
end
|
175
|
+
|
176
|
+
# Returns the primary key columns for a table
|
177
|
+
# @param table_name [String] The name of the table
|
178
|
+
# @return [Array<String>] Array of primary key column names
|
179
|
+
# @raise [ArgumentError] if table_name is blank
|
180
|
+
def primary_keys(table_name) # :nodoc:
|
181
|
+
raise ArgumentError, 'table_name cannot be blank' unless table_name.present?
|
182
|
+
|
183
|
+
results = execute("PRAGMA table_info(#{quote(table_name.to_s)})", 'SCHEMA')
|
184
|
+
results.filter_map do |result|
|
185
|
+
_cid, name, _type, _notnull, _dflt_value, pk = result
|
186
|
+
|
187
|
+
# pk can be true, false, 1, 0, or nil in DuckDB PRAGMA table_info
|
188
|
+
# true or 1 means it's a primary key, false, 0, or nil means it's not
|
189
|
+
pk_value = pk == true ? 1 : pk
|
190
|
+
[pk_value, name] if [true, 1].include?(pk)
|
191
|
+
end.sort_by(&:first).map(&:last)
|
192
|
+
end
|
193
|
+
|
194
|
+
# Returns the native database types supported by DuckDB
|
195
|
+
# @return [Hash] Hash mapping ActiveRecord types to DuckDB native types
|
196
|
+
def native_database_types
|
197
|
+
NATIVE_DATABASE_TYPES
|
198
|
+
end
|
199
|
+
|
200
|
+
# Returns column definitions for a table using PRAGMA table_info
|
201
|
+
# @param table_name [String] The name of the table
|
202
|
+
# @return [Array<Array>] Array of column definition arrays
|
203
|
+
def column_definitions(table_name)
|
204
|
+
# Use PRAGMA table_info which gives us more accurate type information
|
205
|
+
# including primary key detection that information_schema might miss
|
206
|
+
pragma_results = execute("PRAGMA table_info(#{quote(table_name.to_s)})", 'SCHEMA')
|
207
|
+
pragma_results.map do |row|
|
208
|
+
_cid, column_name, data_type, not_null, column_default, pk = row
|
209
|
+
|
210
|
+
# Format the type properly - DuckDB PRAGMA gives us the actual type
|
211
|
+
# Preserve VARCHAR limits and other type information
|
212
|
+
formatted_type = case data_type.to_s.upcase
|
213
|
+
when /^BIGINT$/i
|
214
|
+
'BIGINT'
|
215
|
+
when /^INTEGER$/i
|
216
|
+
'INTEGER'
|
217
|
+
when /^VARCHAR$/i, /^VARCHAR\(\d+\)$/i
|
218
|
+
data_type.to_s # Preserve the full VARCHAR(n) format
|
219
|
+
when /^DECIMAL\(\d+,\d+\)$/i
|
220
|
+
data_type.to_s # Preserve DECIMAL(p,s) format
|
221
|
+
when /^TIMESTAMP$/i
|
222
|
+
'TIMESTAMP'
|
223
|
+
when /^BOOLEAN$/i
|
224
|
+
'BOOLEAN'
|
225
|
+
when /^UUID$/i
|
226
|
+
'UUID'
|
227
|
+
when /^BLOB$/i
|
228
|
+
'BLOB'
|
229
|
+
when /^DATE$/i
|
230
|
+
'DATE'
|
231
|
+
when /^TIME$/i
|
232
|
+
'TIME'
|
233
|
+
when /^REAL$/i, /^DOUBLE$/i
|
234
|
+
data_type.to_s
|
235
|
+
else
|
236
|
+
data_type.to_s
|
237
|
+
end
|
238
|
+
|
239
|
+
# Convert PRAGMA results to match information_schema format
|
240
|
+
[
|
241
|
+
column_name, # column_name
|
242
|
+
formatted_type, # formatted_type
|
243
|
+
column_default, # column_default
|
244
|
+
not_null == 1, # not_null (true if NOT NULL constraint)
|
245
|
+
nil, # type_id
|
246
|
+
nil, # type_modifier
|
247
|
+
nil, # collation_name
|
248
|
+
nil, # comment
|
249
|
+
nil, # identity
|
250
|
+
nil, # generated
|
251
|
+
pk == 1 # primary_key flag (true if primary key)
|
252
|
+
]
|
253
|
+
end
|
254
|
+
end
|
255
|
+
|
256
|
+
# Indicates whether the adapter supports a specific primary key type
|
257
|
+
# Support Rails id: :uuid convention
|
258
|
+
# @param type [Symbol] The primary key type to check
|
259
|
+
# @return [Boolean] true if the type is supported, false otherwise
|
260
|
+
def supports_primary_key_type?(type)
|
261
|
+
case type
|
262
|
+
when :uuid, :string, :integer, :bigint, :primary_key
|
263
|
+
true
|
264
|
+
else
|
265
|
+
false
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
# Generates SQL for getting the next sequence value
|
270
|
+
# @param sequence_name [String] The name of the sequence
|
271
|
+
# @return [String] SQL expression for next sequence value
|
272
|
+
def next_sequence_value(sequence_name)
|
273
|
+
"nextval('#{sequence_name}')"
|
274
|
+
end
|
275
|
+
|
276
|
+
# Generates default sequence name following PostgreSQL/Oracle conventions
|
277
|
+
# @param table_name [String] The name of the table
|
278
|
+
# @param column_name [String] The name of the column (defaults to 'id')
|
279
|
+
# @return [String] The generated sequence name
|
280
|
+
def default_sequence_name(table_name, column_name = 'id')
|
281
|
+
"#{table_name}_#{column_name}_seq"
|
282
|
+
end
|
283
|
+
|
284
|
+
# Configures the primary key type at the class level
|
285
|
+
# @param type [Symbol] The primary key type to set
|
286
|
+
# @return [Symbol] The configured primary key type
|
287
|
+
def self.configure_primary_key_type(type)
|
288
|
+
self.primary_key_type = type
|
289
|
+
end
|
290
|
+
|
291
|
+
# Returns the primary key column name for a table
|
292
|
+
# @param table_name [String] The name of the table
|
293
|
+
# @return [String, nil] The primary key column name, or nil if no single primary key
|
294
|
+
def primary_key(table_name)
|
295
|
+
pk_columns = primary_keys(table_name)
|
296
|
+
pk_columns.size == 1 ? pk_columns.first : nil
|
297
|
+
end
|
298
|
+
|
299
|
+
# Override to prevent Rails schema dumper from detecting sequence defaults as table defaults
|
300
|
+
# @param table_name [String, Symbol] The name of the table
|
301
|
+
# @return [nil] Always returns nil to prevent table-level defaults
|
302
|
+
def primary_key_definition(table_name)
|
303
|
+
# Always return nil to prevent any table-level defaults from sequence columns
|
304
|
+
nil
|
305
|
+
end
|
306
|
+
|
307
|
+
# Override to ensure sequence defaults never appear at table level
|
308
|
+
# @param table_name [String, Symbol] The name of the table
|
309
|
+
# @return [nil] Always returns nil as defaults belong to columns
|
310
|
+
def table_default_value(table_name)
|
311
|
+
# Never return defaults at table level - they belong to columns
|
312
|
+
nil
|
313
|
+
end
|
314
|
+
|
315
|
+
# Returns default value for table in schema dumping
|
316
|
+
# @param table_name [String] The name of the table
|
317
|
+
# @return [nil] Always returns nil to prevent sequence defaults at table level
|
318
|
+
def default_value_for_table(table_name)
|
319
|
+
nil
|
320
|
+
end
|
321
|
+
|
322
|
+
# Maps DuckDB column types to ActiveRecord schema types
|
323
|
+
# Override schema dumping to fix type detection
|
324
|
+
# @param column [ActiveRecord::ConnectionAdapters::Column] The column object
|
325
|
+
# @return [Symbol] The Rails schema type for the column
|
326
|
+
def schema_type(column)
|
327
|
+
case column.sql_type
|
328
|
+
when /^BIGINT$/i
|
329
|
+
:bigint
|
330
|
+
when /^INTEGER$/i
|
331
|
+
:integer
|
332
|
+
when /^VARCHAR$/i, /^VARCHAR\(\d+\)$/i
|
333
|
+
:string
|
334
|
+
when /^TIMESTAMP$/i
|
335
|
+
:datetime
|
336
|
+
when /^BOOLEAN$/i
|
337
|
+
:boolean
|
338
|
+
when /^UUID$/i
|
339
|
+
:uuid
|
340
|
+
else
|
341
|
+
super
|
342
|
+
end
|
343
|
+
end
|
344
|
+
|
345
|
+
# Returns indexes for a table or all tables
|
346
|
+
# @param table_name [String, nil] The table name, or nil for all tables
|
347
|
+
# @return [Array] Array of index definitions
|
348
|
+
def indexes(table_name = nil)
|
349
|
+
if table_name
|
350
|
+
# Delegate to the schema statements implementation
|
351
|
+
super
|
352
|
+
else
|
353
|
+
# This shouldn't happen in normal schema dumping, but handle gracefully
|
354
|
+
[]
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
# Determines the primary key type for schema dumping
|
359
|
+
# @param table_name [String] The name of the table
|
360
|
+
# @return [Symbol, nil] The primary key type symbol, or nil if no primary key
|
361
|
+
def primary_key_type_for_schema_dump(table_name)
|
362
|
+
pk_column = primary_key(table_name)
|
363
|
+
return nil unless pk_column
|
364
|
+
|
365
|
+
# Get the actual column definition
|
366
|
+
col_def = columns(table_name).find { |c| c.name == pk_column }
|
367
|
+
return nil unless col_def
|
368
|
+
|
369
|
+
case col_def.sql_type.to_s.upcase
|
370
|
+
when 'BIGINT'
|
371
|
+
:bigint
|
372
|
+
when 'INTEGER'
|
373
|
+
:integer
|
374
|
+
when 'UUID'
|
375
|
+
:uuid
|
376
|
+
when /^VARCHAR/
|
377
|
+
:string
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
# Returns table options for schema dumping
|
382
|
+
# @param table_name [String] The name of the table
|
383
|
+
# @return [Hash] Hash of table options for schema dumping
|
384
|
+
def table_options(table_name)
|
385
|
+
options = {}
|
386
|
+
|
387
|
+
# Check if primary key has sequence default
|
388
|
+
pk_name = primary_key(table_name)
|
389
|
+
pk_column = pk_name ? columns(table_name).find { |c| c.name == pk_name } : nil
|
390
|
+
has_sequence_default = pk_column&.default_function&.include?('nextval(')
|
391
|
+
|
392
|
+
pk_type = primary_key_type_for_schema_dump(table_name)
|
393
|
+
if has_sequence_default
|
394
|
+
# Force explicit primary key inclusion when sequence is involved
|
395
|
+
# This prevents Rails from putting sequence default at table level
|
396
|
+
options[:id] = pk_type || :bigint
|
397
|
+
elsif pk_type && pk_type != :bigint
|
398
|
+
# Set the correct primary key type only when different from default
|
399
|
+
options[:id] = pk_type # Rails 5+ default is bigint
|
400
|
+
end
|
401
|
+
|
402
|
+
# NEVER include defaults at table level - sequence defaults belong to columns
|
403
|
+
options
|
404
|
+
end
|
405
|
+
|
406
|
+
# Check if a type is valid for schema dumping
|
407
|
+
# @param type [Symbol, String] The type to validate
|
408
|
+
# @return [Boolean] true if type is valid for DuckDB schema dumping
|
409
|
+
def valid_type?(type)
|
410
|
+
case type.to_s.to_sym
|
411
|
+
when :string, :text, :integer, :bigint, :float, :decimal, :datetime, :timestamp,
|
412
|
+
:time, :date, :binary, :boolean, :uuid, :interval, :bit,
|
413
|
+
:hugeint, :tinyint, :smallint, :utinyint, :usmallint, :uinteger, :ubigint, :uhugeint,
|
414
|
+
:varint, :blob, :list, :struct, :map, :enum, :union, :real, :double, :numeric
|
415
|
+
true
|
416
|
+
else
|
417
|
+
false
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
private
|
422
|
+
|
423
|
+
# Establishes the actual connection to the DuckDB database
|
424
|
+
# @return [void]
|
425
|
+
def connect
|
426
|
+
database = @config[:database] || :memory
|
427
|
+
db = if MEMORY_MODE_KEYS.include?(database)
|
428
|
+
DuckDB::Database.open
|
429
|
+
else
|
430
|
+
DuckDB::Database.open(database)
|
431
|
+
end
|
432
|
+
@raw_connection = db.connect
|
433
|
+
end
|
434
|
+
end
|
435
|
+
end
|
436
|
+
end
|
@@ -0,0 +1,170 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'active_record/tasks/database_tasks'
|
4
|
+
require 'duckdb'
|
5
|
+
|
6
|
+
module ActiveRecord
|
7
|
+
module Tasks # :nodoc:
|
8
|
+
# Database tasks implementation for DuckDB adapter
|
9
|
+
class DuckdbDatabaseTasks # :nodoc:
|
10
|
+
# Keys that indicate in-memory database mode
|
11
|
+
MEMORY_MODE_KEYS = [:memory, 'memory', ':memory:', ':memory'].freeze
|
12
|
+
TRUE_VALUES = [true, 'true', 'TRUE', 1, '1'].freeze
|
13
|
+
|
14
|
+
# Indicates whether this adapter uses database configurations
|
15
|
+
# @return [Boolean] always returns true
|
16
|
+
def self.using_database_configurations?
|
17
|
+
true
|
18
|
+
end
|
19
|
+
|
20
|
+
# Initializes a new DuckDB database tasks instance
|
21
|
+
# @param db_config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] Database configuration
|
22
|
+
# @param root_path [String] Root path for the Rails application
|
23
|
+
def initialize(db_config, root_path = ActiveRecord::Tasks::DatabaseTasks.root)
|
24
|
+
@db_config = db_config
|
25
|
+
@configuration_hash = db_config.configuration_hash
|
26
|
+
@root_path = root_path
|
27
|
+
end
|
28
|
+
|
29
|
+
# Creates a new DuckDB database file or skips if in-memory mode
|
30
|
+
# @return [void]
|
31
|
+
# @raise [StandardError] if database creation fails
|
32
|
+
def create
|
33
|
+
# For DuckDB, creating a database means creating the file (if not in-memory)
|
34
|
+
database_path = @configuration_hash[:database]
|
35
|
+
return if MEMORY_MODE_KEYS.include?(database_path)
|
36
|
+
|
37
|
+
# Ensure the directory exists
|
38
|
+
FileUtils.mkdir_p(File.dirname(database_path)) if database_path.include?('/')
|
39
|
+
|
40
|
+
# Create the database file by opening a connection
|
41
|
+
DuckDB::Database.open(database_path).connect.close
|
42
|
+
rescue StandardError => e
|
43
|
+
warn "Couldn't create '#{database_path}' database. Please check your configuration."
|
44
|
+
warn "Error: #{e.message}"
|
45
|
+
raise
|
46
|
+
end
|
47
|
+
|
48
|
+
# Drops the DuckDB database by removing the database file
|
49
|
+
# @return [void]
|
50
|
+
# @raise [StandardError] if database drop fails
|
51
|
+
def drop
|
52
|
+
db_path = @configuration_hash[:database]
|
53
|
+
return if MEMORY_MODE_KEYS.include?(db_path)
|
54
|
+
|
55
|
+
db_file_path = File.absolute_path?(db_path) ? db_path : File.join(root_path, db_path)
|
56
|
+
FileUtils.rm_f(db_file_path)
|
57
|
+
rescue StandardError => e
|
58
|
+
warn "Couldn't drop database '#{db_path}'"
|
59
|
+
warn "Error: #{e.message}"
|
60
|
+
raise
|
61
|
+
end
|
62
|
+
|
63
|
+
# Purges the database by dropping and recreating it
|
64
|
+
# @return [void]
|
65
|
+
def purge
|
66
|
+
drop
|
67
|
+
create
|
68
|
+
end
|
69
|
+
|
70
|
+
# Returns the character set used by DuckDB
|
71
|
+
# @return [String] always returns 'UTF-8'
|
72
|
+
def charset
|
73
|
+
'UTF-8'
|
74
|
+
end
|
75
|
+
|
76
|
+
# Returns the collation used by DuckDB
|
77
|
+
# @return [nil] always returns nil as DuckDB doesn't use explicit collations
|
78
|
+
def collation
|
79
|
+
nil
|
80
|
+
end
|
81
|
+
|
82
|
+
# Dumps the database structure to a SQL file
|
83
|
+
# @param filename [String] The filename to write the structure dump to
|
84
|
+
# @param extra_flags [String, nil] Additional flags for the dump (unused)
|
85
|
+
# @return [void]
|
86
|
+
def structure_dump(filename, extra_flags = nil)
|
87
|
+
# Export the database schema
|
88
|
+
establish_connection
|
89
|
+
|
90
|
+
File.open(filename, 'w') do |file|
|
91
|
+
# Get all tables using DuckDB's information schema
|
92
|
+
tables_sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main' AND table_type = 'BASE TABLE'"
|
93
|
+
tables = connection.query(tables_sql)
|
94
|
+
|
95
|
+
tables.each do |table_row|
|
96
|
+
table_name = table_row[0]
|
97
|
+
next if %w[ar_internal_metadata schema_migrations].include?(table_name)
|
98
|
+
|
99
|
+
# Get table schema using PRAGMA (DuckDB supports SQLite-compatible PRAGMA)
|
100
|
+
table_info = connection.query("PRAGMA table_info('#{table_name}')")
|
101
|
+
|
102
|
+
# Build CREATE TABLE statement
|
103
|
+
# [[0, "id", "BIGINT", true, nil, true], [1, "name", "VARCHAR", false, 'default_value', false], [2, "email", "VARCHAR", false, nil, false], [3, "created_at", "TIMESTAMP", true, nil, false], [4, "updated_at", "TIMESTAMP", true, nil, false]]
|
104
|
+
columns = table_info.map do |col|
|
105
|
+
col_def = "#{col[1]} #{col[2]}"
|
106
|
+
col_def += ' NOT NULL' if TRUE_VALUES.include?(col[3])
|
107
|
+
col_def += " DEFAULT #{col[4]}" if col[4]
|
108
|
+
col_def += ' PRIMARY KEY' if TRUE_VALUES.include?(col[5])
|
109
|
+
col_def
|
110
|
+
end
|
111
|
+
|
112
|
+
file.puts "CREATE TABLE #{table_name} ("
|
113
|
+
file.puts " #{columns.join(",\n ")}"
|
114
|
+
file.puts ');'
|
115
|
+
file.puts
|
116
|
+
end
|
117
|
+
|
118
|
+
# Get list of sequences used for the table
|
119
|
+
sequences = connection.query('SELECT sequencename, start_value, min_value, max_value, increment_by, cycle FROM pg_catalog.pg_sequences').to_a
|
120
|
+
|
121
|
+
# Build CREATE SEQUENCE statements
|
122
|
+
sequences.each do |seq|
|
123
|
+
seq_def = "CREATE SEQUENCE #{seq[0]}"
|
124
|
+
seq_def += " START WITH #{seq[1]}" if seq[1] && seq[1] != 1
|
125
|
+
seq_def += " INCREMENT BY #{seq[4]}" if seq[4] && seq[4] != 1
|
126
|
+
seq_def += " MINVALUE #{seq[2]}" if seq[2] != -9_223_372_036_854_775_808
|
127
|
+
seq_def += " MAXVALUE #{seq[3]}" if seq[3] != 9_223_372_036_854_775_807
|
128
|
+
seq_def += ' CYCLE' if seq[5]
|
129
|
+
seq_def += ';'
|
130
|
+
file.puts seq_def
|
131
|
+
end
|
132
|
+
end
|
133
|
+
end
|
134
|
+
|
135
|
+
# Loads database structure from a SQL file
|
136
|
+
# @param filename [String] The filename to load the structure from
|
137
|
+
# @param extra_flags [String, nil] Additional flags for the load (unused)
|
138
|
+
# @return [void]
|
139
|
+
# @raise [LoadError] if the schema file does not exist
|
140
|
+
def structure_load(filename, extra_flags = nil)
|
141
|
+
establish_connection
|
142
|
+
raise(LoadError, 'Database scheme file does not exist') unless File.exist?(filename)
|
143
|
+
|
144
|
+
sql = File.read(filename)
|
145
|
+
connection.query(sql)
|
146
|
+
end
|
147
|
+
|
148
|
+
private
|
149
|
+
|
150
|
+
attr_reader :configuration_hash, :db_config, :root_path
|
151
|
+
|
152
|
+
# Gets a database connection from the connection pool
|
153
|
+
# @return [ActiveRecord::ConnectionAdapters::AbstractAdapter] The database connection
|
154
|
+
def connection
|
155
|
+
if ActiveRecord::Base.connection_pool
|
156
|
+
ActiveRecord::Base.connection_pool.checkout
|
157
|
+
else
|
158
|
+
ActiveRecord::Base.lease_connection
|
159
|
+
end
|
160
|
+
end
|
161
|
+
|
162
|
+
# Establishes a connection to the database
|
163
|
+
# @param config [ActiveRecord::DatabaseConfigurations::DatabaseConfig] Database configuration to use
|
164
|
+
# @return [void]
|
165
|
+
def establish_connection(config = db_config)
|
166
|
+
ActiveRecord::Base.establish_connection(config)
|
167
|
+
end
|
168
|
+
end
|
169
|
+
end
|
170
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ActiveRecord adapter for DuckDB database integration
|
4
|
+
module Activerecord
|
5
|
+
# DuckDB specific functionality and constants
|
6
|
+
module Duckdb
|
7
|
+
# Current version of the activerecord-duckdb gem
|
8
|
+
# @return [String] the version string in semantic version format
|
9
|
+
VERSION = '0.1.0'
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Main entry point for the activerecord-duckdb gem
|
4
|
+
# Loads the DuckDB adapter and registers it with ActiveRecord
|
5
|
+
# Provides Rails integration when Rails is present
|
6
|
+
|
7
|
+
# Load the adapter regardless of Rails presence
|
8
|
+
require 'active_record/connection_adapters/duckdb_adapter'
|
9
|
+
|
10
|
+
# Register the DuckDB adapter with ActiveRecord
|
11
|
+
# This works for both Rails and non-Rails environments
|
12
|
+
# @return [void]
|
13
|
+
ActiveRecord::ConnectionAdapters.register(
|
14
|
+
'duckdb',
|
15
|
+
'ActiveRecord::ConnectionAdapters::DuckdbAdapter',
|
16
|
+
'active_record/connection_adapters/duckdb_adapter'
|
17
|
+
)
|
18
|
+
|
19
|
+
if defined?(Rails)
|
20
|
+
module ActiveRecord
|
21
|
+
module ConnectionAdapters
|
22
|
+
# Rails integration for the DuckDB adapter
|
23
|
+
# Provides rake tasks and ActiveRecord integration when Rails is present
|
24
|
+
class DuckdbRailtie < ::Rails::Railtie
|
25
|
+
# Registers DuckDB database tasks with Rails
|
26
|
+
# @return [void]
|
27
|
+
rake_tasks do
|
28
|
+
require 'active_record/tasks/duckdb_database_tasks'
|
29
|
+
end
|
30
|
+
|
31
|
+
# Sets up DuckDB adapter integration when ActiveRecord loads
|
32
|
+
# @return [void]
|
33
|
+
ActiveSupport.on_load(:active_record) do
|
34
|
+
# Register the database tasks - try multiple approaches for compatibility
|
35
|
+
if ActiveRecord::Tasks::DatabaseTasks.respond_to?(:register_task)
|
36
|
+
ActiveRecord::Tasks::DatabaseTasks.register_task(
|
37
|
+
'duckdb',
|
38
|
+
'ActiveRecord::Tasks::DuckdbDatabaseTasks'
|
39
|
+
)
|
40
|
+
else
|
41
|
+
# Fallback for older Rails versions
|
42
|
+
ActiveRecord::Tasks::DatabaseTasks.module_eval do
|
43
|
+
def self.class_for_adapter(adapter)
|
44
|
+
case adapter
|
45
|
+
when 'duckdb'
|
46
|
+
ActiveRecord::Tasks::DuckdbDatabaseTasks
|
47
|
+
else
|
48
|
+
super
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
else
|
58
|
+
# Non-Rails environment - require the tasks after adapter registration
|
59
|
+
require 'active_record/tasks/duckdb_database_tasks'
|
60
|
+
end
|
data/tmp/.gitkeep
ADDED
File without changes
|