sequel-duckdb 0.3.0 → 0.4.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/CHANGELOG.md +174 -2
- data/README.md +121 -0
- data/lib/sequel/adapters/duckdb.rb +111 -14
- data/lib/sequel/adapters/shared/duckdb.rb +286 -149
- data/lib/sequel/duckdb/helpers/copier.rb +30 -7
- data/lib/sequel/duckdb/helpers/pathifier.rb +1 -1
- data/lib/sequel/duckdb/instance_registry.rb +114 -0
- data/lib/sequel/duckdb/sql_time_tz.rb +31 -0
- data/lib/sequel/duckdb/version.rb +1 -1
- data/lib/sequel/extensions/duckdb_array.rb +149 -0
- data/lib/sequel/extensions/duckdb_json.rb +130 -0
- data/lib/sequel/extensions/duckdb_map.rb +93 -0
- data/lib/sequel/extensions/duckdb_ops.rb +219 -0
- data/lib/sequel/extensions/duckdb_struct.rb +92 -0
- metadata +26 -4
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "../../../sequel/duckdb"
|
|
4
4
|
require_relative "../../../sequel/duckdb/helpers/pathifier"
|
|
5
5
|
require_relative "../../../sequel/duckdb/helpers/copier"
|
|
6
|
+
require_relative "../../../sequel/duckdb/sql_time_tz"
|
|
6
7
|
|
|
7
8
|
# Sequel is the database toolkit for Ruby
|
|
8
9
|
module Sequel
|
|
@@ -56,7 +57,7 @@ module Sequel
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Whether to quote identifiers by default for this database
|
|
59
|
-
def quote_identifiers_default
|
|
60
|
+
def quote_identifiers_default
|
|
60
61
|
true
|
|
61
62
|
end
|
|
62
63
|
|
|
@@ -102,11 +103,11 @@ module Sequel
|
|
|
102
103
|
|
|
103
104
|
sql = if schema_ref.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
104
105
|
"SELECT table_name FROM information_schema.tables " \
|
|
105
|
-
"WHERE table_catalog =
|
|
106
|
-
|
|
106
|
+
"WHERE table_catalog = #{literal(schema_ref.table.to_s)} AND table_schema = #{literal(schema_ref.column.to_s)} AND table_type = 'BASE TABLE'"
|
|
107
|
+
else
|
|
107
108
|
"SELECT table_name FROM information_schema.tables " \
|
|
108
|
-
"WHERE table_schema =
|
|
109
|
-
|
|
109
|
+
"WHERE table_schema = #{literal(schema_ref.to_s)} AND table_type = 'BASE TABLE'"
|
|
110
|
+
end
|
|
110
111
|
|
|
111
112
|
tables = []
|
|
112
113
|
execute(sql) do |row|
|
|
@@ -122,10 +123,7 @@ module Sequel
|
|
|
122
123
|
# @param opts [Hash] Options for schema parsing
|
|
123
124
|
# @return [Array<Array>] Array of [column_name, column_info] pairs
|
|
124
125
|
def schema_parse_table(table_name, opts = {})
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
# First check if table exists
|
|
128
|
-
raise Sequel::DatabaseError, "Table '#{table_name}' does not exist" unless table_exists?(table_name, opts)
|
|
126
|
+
resolved = resolve_table!(table_name, opts)
|
|
129
127
|
|
|
130
128
|
# Use information_schema.columns for detailed column information
|
|
131
129
|
sql = <<~SQL
|
|
@@ -139,7 +137,7 @@ module Sequel
|
|
|
139
137
|
numeric_precision,
|
|
140
138
|
numeric_scale
|
|
141
139
|
FROM information_schema.columns
|
|
142
|
-
WHERE
|
|
140
|
+
WHERE #{resolved_table_filter(resolved, catalog: :table_catalog, schema: :table_schema, table: :table_name)}
|
|
143
141
|
ORDER BY ordinal_position
|
|
144
142
|
SQL
|
|
145
143
|
|
|
@@ -171,11 +169,11 @@ module Sequel
|
|
|
171
169
|
column_info[:precision] = row[:numeric_precision] if row[:numeric_precision]
|
|
172
170
|
column_info[:scale] = row[:numeric_scale] if row[:numeric_scale]
|
|
173
171
|
|
|
174
|
-
columns << [column_name, column_info]
|
|
172
|
+
columns << [ column_name, column_info ]
|
|
175
173
|
end
|
|
176
174
|
|
|
177
175
|
# Update primary key information
|
|
178
|
-
update_primary_key_info(
|
|
176
|
+
update_primary_key_info(resolved, columns)
|
|
179
177
|
|
|
180
178
|
columns
|
|
181
179
|
end
|
|
@@ -186,10 +184,7 @@ module Sequel
|
|
|
186
184
|
# @param opts [Hash] Options for index parsing
|
|
187
185
|
# @return [Hash] Hash of index_name => index_info
|
|
188
186
|
def schema_parse_indexes(table_name, opts = {})
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
# First check if table exists
|
|
192
|
-
raise Sequel::DatabaseError, "Table '#{table_name}' does not exist" unless table_exists?(table_name, opts)
|
|
187
|
+
resolved = resolve_table!(table_name, opts)
|
|
193
188
|
|
|
194
189
|
# Use duckdb_indexes() function to get index information
|
|
195
190
|
sql = <<~SQL
|
|
@@ -200,7 +195,7 @@ module Sequel
|
|
|
200
195
|
expressions,
|
|
201
196
|
sql
|
|
202
197
|
FROM duckdb_indexes()
|
|
203
|
-
WHERE
|
|
198
|
+
WHERE #{resolved_table_filter(resolved, catalog: :database_name, schema: :schema_name, table: :table_name)}
|
|
204
199
|
SQL
|
|
205
200
|
|
|
206
201
|
indexes = {}
|
|
@@ -249,13 +244,11 @@ module Sequel
|
|
|
249
244
|
|
|
250
245
|
# Format value appropriately for SQL
|
|
251
246
|
formatted_value = case value
|
|
252
|
-
|
|
253
|
-
"'#{value.gsub("'", "''")}'" # Escape single quotes
|
|
254
|
-
when TrueClass, FalseClass, Numeric
|
|
247
|
+
when TrueClass, FalseClass, Numeric
|
|
255
248
|
value.to_s
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
249
|
+
else
|
|
250
|
+
literal(value.to_s)
|
|
251
|
+
end
|
|
259
252
|
|
|
260
253
|
# Execute PRAGMA statement
|
|
261
254
|
pragma_sql = "PRAGMA #{pragma_key} = #{formatted_value}"
|
|
@@ -299,28 +292,21 @@ module Sequel
|
|
|
299
292
|
|
|
300
293
|
# Check if table exists
|
|
301
294
|
#
|
|
302
|
-
#
|
|
303
|
-
#
|
|
295
|
+
# DuckDB resolves the name itself, as it does for any query, so every Sequel
|
|
296
|
+
# identifier form, the search_path, temporary tables and views all work. The query
|
|
297
|
+
# reads no rows. A missing table is a catalog error, which does not abort an open
|
|
298
|
+
# DuckDB transaction, so no savepoint is needed.
|
|
299
|
+
#
|
|
300
|
+
# @param table_name [Symbol, String, Sequel::SQL::Identifier, Sequel::SQL::QualifiedIdentifier] Name of the table
|
|
301
|
+
# @param opts [Hash] Options (:schema qualifies an unqualified name)
|
|
304
302
|
# @return [Boolean] true if table exists
|
|
305
303
|
def table_exists?(table_name, opts = {})
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
table_name = table_name.to_s
|
|
313
|
-
end
|
|
314
|
-
|
|
315
|
-
sql = "SELECT 1 FROM information_schema.tables " \
|
|
316
|
-
"WHERE table_schema = '#{schema_name}' AND table_name = '#{table_name}' LIMIT 1"
|
|
317
|
-
|
|
318
|
-
result = nil
|
|
319
|
-
execute(sql) do |_row|
|
|
320
|
-
result = true
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
!!result
|
|
304
|
+
from(qualify_with_schema_option(table_name, opts)).where(false).select(Sequel::NULL).all
|
|
305
|
+
true
|
|
306
|
+
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
|
|
307
|
+
raise
|
|
308
|
+
rescue Sequel::DatabaseError
|
|
309
|
+
false
|
|
324
310
|
end
|
|
325
311
|
|
|
326
312
|
# Get list of tables
|
|
@@ -340,11 +326,11 @@ module Sequel
|
|
|
340
326
|
|
|
341
327
|
sql = if schema_ref.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
342
328
|
"SELECT table_name FROM information_schema.tables " \
|
|
343
|
-
"WHERE table_catalog =
|
|
344
|
-
|
|
329
|
+
"WHERE table_catalog = #{literal(schema_ref.table.to_s)} AND table_schema = #{literal(schema_ref.column.to_s)} AND table_type = 'VIEW'"
|
|
330
|
+
else
|
|
345
331
|
"SELECT table_name FROM information_schema.tables " \
|
|
346
|
-
"WHERE table_schema =
|
|
347
|
-
|
|
332
|
+
"WHERE table_schema = #{literal(schema_ref.to_s)} AND table_type = 'VIEW'"
|
|
333
|
+
end
|
|
348
334
|
|
|
349
335
|
views = []
|
|
350
336
|
execute(sql) do |row|
|
|
@@ -360,9 +346,9 @@ module Sequel
|
|
|
360
346
|
# @param opts [Hash] Options
|
|
361
347
|
# @return [Array<Array>] Schema information
|
|
362
348
|
def schema(table_name, opts = {})
|
|
363
|
-
#
|
|
349
|
+
# A qualified identifier (e.g., Sequel[:schema][:table]) is parsed as given
|
|
364
350
|
if table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
365
|
-
|
|
351
|
+
parse_name = table_name
|
|
366
352
|
actual_table_name = table_name.column.to_s.to_sym
|
|
367
353
|
elsif table_name.is_a?(Sequel::Dataset)
|
|
368
354
|
# Handle case where Sequel passes a Dataset object instead of table name
|
|
@@ -381,16 +367,7 @@ module Sequel
|
|
|
381
367
|
actual_table_name = table_name
|
|
382
368
|
end
|
|
383
369
|
|
|
384
|
-
|
|
385
|
-
schema_info = schema_parse_table(actual_table_name, opts)
|
|
386
|
-
@schema_cache ||= {}
|
|
387
|
-
@schema_cache[actual_table_name] = {}
|
|
388
|
-
|
|
389
|
-
schema_info.each do |column_name, column_info|
|
|
390
|
-
@schema_cache[actual_table_name][column_name] = column_info
|
|
391
|
-
end
|
|
392
|
-
|
|
393
|
-
schema_info
|
|
370
|
+
schema_parse_table(parse_name || actual_table_name, opts)
|
|
394
371
|
end
|
|
395
372
|
|
|
396
373
|
# Get index information for a table
|
|
@@ -404,17 +381,37 @@ module Sequel
|
|
|
404
381
|
|
|
405
382
|
private
|
|
406
383
|
|
|
384
|
+
# A value assigned to a TIMETZ model column becomes a SQLTimeTZ, which keeps the UTC
|
|
385
|
+
# offset. A string without an offset gets the local offset.
|
|
386
|
+
def typecast_value_time_timezone(value)
|
|
387
|
+
case value
|
|
388
|
+
when SQLTimeTZ
|
|
389
|
+
value
|
|
390
|
+
when Time
|
|
391
|
+
SQLTimeTZ.from_time(value)
|
|
392
|
+
when DateTime
|
|
393
|
+
SQLTimeTZ.from_time(value.to_time)
|
|
394
|
+
when String
|
|
395
|
+
SQLTimeTZ.from_time(Time.parse(typecast_check_string_length(value, 100)))
|
|
396
|
+
else
|
|
397
|
+
raise Sequel::InvalidValue, "invalid value for TIMETZ: #{value.inspect}"
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
407
401
|
# Map DuckDB data types to Sequel types
|
|
408
402
|
#
|
|
409
403
|
# @param duckdb_type [String] DuckDB data type
|
|
410
404
|
# @return [Symbol] Sequel type symbol
|
|
405
|
+
# information_schema.columns reports a REAL column as FLOAT, a TIMESTAMPTZ column as
|
|
406
|
+
# TIMESTAMP WITH TIME ZONE, and a VARINT column as BIGNUM. An unsigned type maps by
|
|
407
|
+
# the signed type that holds all its values.
|
|
411
408
|
def map_duckdb_type_to_sequel(duckdb_type)
|
|
412
409
|
case duckdb_type.upcase
|
|
413
|
-
when "INTEGER", "INT", "INT4", "SMALLINT", "INT2", "TINYINT", "INT1"
|
|
410
|
+
when "INTEGER", "INT", "INT4", "SMALLINT", "INT2", "TINYINT", "INT1", "USMALLINT", "UTINYINT"
|
|
414
411
|
:integer
|
|
415
|
-
when "BIGINT", "INT8"
|
|
412
|
+
when "BIGINT", "INT8", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT", "BIGNUM", "VARINT"
|
|
416
413
|
:bigint
|
|
417
|
-
when "REAL", "FLOAT4", "DOUBLE", "FLOAT8"
|
|
414
|
+
when "FLOAT", "REAL", "FLOAT4", "DOUBLE", "FLOAT8"
|
|
418
415
|
:float
|
|
419
416
|
when /^DECIMAL/, /^NUMERIC/
|
|
420
417
|
:decimal
|
|
@@ -422,10 +419,15 @@ module Sequel
|
|
|
422
419
|
:boolean
|
|
423
420
|
when "DATE"
|
|
424
421
|
:date
|
|
425
|
-
when "TIMESTAMP", "DATETIME"
|
|
422
|
+
when "TIMESTAMP", "DATETIME", "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ",
|
|
423
|
+
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_US", "TIMESTAMP_NS"
|
|
426
424
|
:datetime
|
|
427
|
-
when "TIME"
|
|
425
|
+
when "TIME", "TIME_NS"
|
|
428
426
|
:time
|
|
427
|
+
when "TIME WITH TIME ZONE", "TIMETZ"
|
|
428
|
+
:time_timezone
|
|
429
|
+
when "INTERVAL"
|
|
430
|
+
:interval
|
|
429
431
|
when "BLOB", "BYTEA"
|
|
430
432
|
:blob
|
|
431
433
|
when "UUID"
|
|
@@ -460,25 +462,103 @@ module Sequel
|
|
|
460
462
|
end
|
|
461
463
|
end
|
|
462
464
|
|
|
465
|
+
# Find the table or view that a name refers to, as DuckDB resolves it in a query.
|
|
466
|
+
# The information_schema and duckdb_indexes() queries need the exact catalog, schema
|
|
467
|
+
# and table, and DuckDB has no function that returns them. So the candidates are the
|
|
468
|
+
# tables and views with that name, and the one whose EXPLAIN plan equals the plan for
|
|
469
|
+
# the name is the match. Two candidates have equal plans only when a view is
|
|
470
|
+
# SELECT * FROM the other candidate. DuckDB's search order then decides.
|
|
471
|
+
#
|
|
472
|
+
# @param table_name [Symbol, String, Sequel::SQL::Identifier, Sequel::SQL::QualifiedIdentifier] Name of the table
|
|
473
|
+
# @param opts [Hash] Options (:schema qualifies an unqualified name)
|
|
474
|
+
# @return [Hash, nil] :catalog, :schema and :table, or nil if the name does not resolve
|
|
475
|
+
def resolve_table(table_name, opts = {})
|
|
476
|
+
table_name = qualify_with_schema_option(table_name, opts)
|
|
477
|
+
target = explain_plan(table_name)
|
|
478
|
+
return unless target
|
|
479
|
+
|
|
480
|
+
candidates = fetch("SELECT table_catalog, table_schema, table_name FROM information_schema.tables " \
|
|
481
|
+
"WHERE lower(table_name) = lower(?)", bare_table_name(table_name)).all
|
|
482
|
+
matches = candidates.select do |c|
|
|
483
|
+
explain_plan(qualified_name(c[:table_catalog], c[:table_schema], c[:table_name])) == target
|
|
484
|
+
end
|
|
485
|
+
match = matches.size > 1 ? first_in_search_order(matches) : matches.first
|
|
486
|
+
match && { catalog: match[:table_catalog], schema: match[:table_schema], table: match[:table_name] }
|
|
487
|
+
end
|
|
488
|
+
|
|
489
|
+
# resolve_table, but a name that does not resolve raises.
|
|
490
|
+
def resolve_table!(table_name, opts = {})
|
|
491
|
+
resolve_table(table_name, opts) ||
|
|
492
|
+
raise(Sequel::DatabaseError, "Table #{literal(qualify_with_schema_option(table_name, opts))} does not exist")
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
def qualify_with_schema_option(table_name, opts)
|
|
496
|
+
return table_name unless opts[:schema] && !table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
497
|
+
|
|
498
|
+
Sequel.qualify(opts[:schema], table_name)
|
|
499
|
+
end
|
|
500
|
+
|
|
501
|
+
def bare_table_name(table_name)
|
|
502
|
+
case table_name
|
|
503
|
+
when Sequel::SQL::QualifiedIdentifier then bare_table_name(table_name.column)
|
|
504
|
+
when Sequel::SQL::Identifier then table_name.value.to_s
|
|
505
|
+
else table_name.to_s
|
|
506
|
+
end
|
|
507
|
+
end
|
|
508
|
+
|
|
509
|
+
def qualified_name(catalog, schema, table)
|
|
510
|
+
Sequel::SQL::QualifiedIdentifier.new(
|
|
511
|
+
Sequel::SQL::QualifiedIdentifier.new(Sequel.identifier(catalog), Sequel.identifier(schema)),
|
|
512
|
+
Sequel.identifier(table)
|
|
513
|
+
)
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# The EXPLAIN plan of SELECT * FROM the name, or nil if DuckDB cannot bind the name.
|
|
517
|
+
# A bind error does not abort an open DuckDB transaction.
|
|
518
|
+
def explain_plan(table_name)
|
|
519
|
+
plan = []
|
|
520
|
+
execute("EXPLAIN (FORMAT JSON) #{from(table_name).sql}") { |row| plan << row }
|
|
521
|
+
plan
|
|
522
|
+
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
|
|
523
|
+
raise
|
|
524
|
+
rescue Sequel::DatabaseError
|
|
525
|
+
nil
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# DuckDB looks in the temp catalog first, then in the search_path schemas, then in the
|
|
529
|
+
# default catalog.
|
|
530
|
+
def first_in_search_order(candidates)
|
|
531
|
+
schemas = get(Sequel.function(:current_schemas, false))
|
|
532
|
+
database = get(Sequel.function(:current_database))
|
|
533
|
+
candidates.min_by do |c|
|
|
534
|
+
[ c[:table_catalog] == "temp" ? 0 : 1,
|
|
535
|
+
schemas.index(c[:table_schema]) || schemas.size,
|
|
536
|
+
c[:table_catalog] == database ? 0 : 1 ]
|
|
537
|
+
end
|
|
538
|
+
end
|
|
539
|
+
|
|
540
|
+
# The SQL condition that selects the resolved table in a catalog query, given the
|
|
541
|
+
# names of that query's catalog, schema and table columns.
|
|
542
|
+
def resolved_table_filter(resolved, catalog:, schema:, table:)
|
|
543
|
+
literal(Sequel.&({ catalog => resolved[:catalog] }, { schema => resolved[:schema] }, { table => resolved[:table] }))
|
|
544
|
+
end
|
|
545
|
+
|
|
463
546
|
# Update primary key information in column schema
|
|
464
547
|
#
|
|
465
|
-
# @param
|
|
548
|
+
# @param resolved [Hash] The table, from resolve_table
|
|
466
549
|
# @param columns [Array] Array of column information
|
|
467
|
-
|
|
468
|
-
def update_primary_key_info(table_name, columns, opts = {})
|
|
469
|
-
schema_name = opts[:schema] || "main"
|
|
470
|
-
|
|
550
|
+
def update_primary_key_info(resolved, columns)
|
|
471
551
|
# Query for primary key constraints
|
|
472
552
|
sql = <<~SQL
|
|
473
553
|
SELECT column_name
|
|
474
554
|
FROM information_schema.table_constraints tc
|
|
475
555
|
JOIN information_schema.key_column_usage kcu
|
|
476
556
|
ON tc.constraint_name = kcu.constraint_name
|
|
557
|
+
AND tc.table_catalog = kcu.table_catalog
|
|
477
558
|
AND tc.table_schema = kcu.table_schema
|
|
478
559
|
AND tc.table_name = kcu.table_name
|
|
479
560
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
480
|
-
AND tc
|
|
481
|
-
AND tc.table_name = '#{table_name}'
|
|
561
|
+
AND #{resolved_table_filter(resolved, catalog: Sequel[:tc][:table_catalog], schema: Sequel[:tc][:table_schema], table: Sequel[:tc][:table_name])}
|
|
482
562
|
SQL
|
|
483
563
|
|
|
484
564
|
primary_key_columns = []
|
|
@@ -523,6 +603,16 @@ module Sequel
|
|
|
523
603
|
true
|
|
524
604
|
end
|
|
525
605
|
|
|
606
|
+
# create_table? and drop_table? emit IF NOT EXISTS / IF EXISTS instead of
|
|
607
|
+
# querying information_schema first.
|
|
608
|
+
def supports_create_table_if_not_exists?
|
|
609
|
+
true
|
|
610
|
+
end
|
|
611
|
+
|
|
612
|
+
def supports_drop_table_if_exists?
|
|
613
|
+
true
|
|
614
|
+
end
|
|
615
|
+
|
|
526
616
|
# DuckDB-specific schema generation methods
|
|
527
617
|
|
|
528
618
|
# Generate SQL for primary key column
|
|
@@ -667,14 +757,16 @@ module Sequel
|
|
|
667
757
|
|
|
668
758
|
# Remove all cached schema information
|
|
669
759
|
#
|
|
670
|
-
# Called after schema modifications to ensure cache consistency
|
|
760
|
+
# Called after schema modifications to ensure cache consistency. It empties the
|
|
761
|
+
# caches in place, because a frozen Database cannot replace them.
|
|
671
762
|
#
|
|
672
763
|
# @return [void]
|
|
673
764
|
def remove_all_cached_schemas
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
765
|
+
Sequel.synchronize do
|
|
766
|
+
@schemas&.clear
|
|
767
|
+
@primary_keys.clear
|
|
768
|
+
@primary_key_sequences.clear
|
|
769
|
+
end
|
|
678
770
|
end
|
|
679
771
|
|
|
680
772
|
# List all schemas in the database
|
|
@@ -685,7 +777,7 @@ module Sequel
|
|
|
685
777
|
#
|
|
686
778
|
def schemas(opts = OPTS)
|
|
687
779
|
sql = "SELECT schema_name FROM information_schema.schemata"
|
|
688
|
-
sql += " WHERE catalog_name =
|
|
780
|
+
sql += " WHERE catalog_name = #{literal(opts[:catalog].to_s)}" if opts[:catalog]
|
|
689
781
|
|
|
690
782
|
schemas = []
|
|
691
783
|
execute(sql) do |row|
|
|
@@ -702,7 +794,7 @@ module Sequel
|
|
|
702
794
|
# @return [Boolean] true if schema exists
|
|
703
795
|
#
|
|
704
796
|
def schema_exists?(name, _opts = OPTS)
|
|
705
|
-
sql = "SELECT 1 FROM information_schema.schemata WHERE schema_name =
|
|
797
|
+
sql = "SELECT 1 FROM information_schema.schemata WHERE schema_name = #{literal(name.to_s)} LIMIT 1"
|
|
706
798
|
|
|
707
799
|
result = nil
|
|
708
800
|
execute(sql) do |_row|
|
|
@@ -754,13 +846,13 @@ module Sequel
|
|
|
754
846
|
read_something_sql(some_paths, options).then do |read_stmt|
|
|
755
847
|
from(read_stmt)
|
|
756
848
|
end.sql
|
|
757
|
-
|
|
849
|
+
elsif source.is_a?(Dataset)
|
|
758
850
|
source.sql
|
|
759
|
-
|
|
851
|
+
elsif source.is_a?(String)
|
|
760
852
|
source
|
|
761
|
-
|
|
853
|
+
else
|
|
762
854
|
raise Sequel::Error, "Unsupported source type: #{source.class}"
|
|
763
|
-
|
|
855
|
+
end
|
|
764
856
|
|
|
765
857
|
sql = String.new
|
|
766
858
|
sql << "#{create_view_prefix_sql(name, options)} AS #{source}"
|
|
@@ -772,9 +864,13 @@ module Sequel
|
|
|
772
864
|
sql
|
|
773
865
|
end
|
|
774
866
|
|
|
867
|
+
# Runs a DuckDB COPY that writes +source+ (a Dataset or a SQL string) to
|
|
868
|
+
# +path+. For a Dataset this is the SQL from Dataset#copy_to_sql.
|
|
775
869
|
def copy_to(source, path, options = Sequel::OPTS)
|
|
776
|
-
Sequel::
|
|
777
|
-
run(
|
|
870
|
+
if source.is_a?(Sequel::Dataset)
|
|
871
|
+
run(source.copy_to_sql(path, **options))
|
|
872
|
+
else
|
|
873
|
+
run(Sequel::DuckDB::Helpers::Copier.new(source, path, options).to_sql)
|
|
778
874
|
end
|
|
779
875
|
end
|
|
780
876
|
|
|
@@ -783,54 +879,6 @@ module Sequel
|
|
|
783
879
|
def read_something_sql(paths, options = OPTS)
|
|
784
880
|
Sequel::DuckDB::Helpers::Pathifier.new(paths, options).to_sql
|
|
785
881
|
end
|
|
786
|
-
|
|
787
|
-
# Type conversion methods for DuckDB-specific handling
|
|
788
|
-
|
|
789
|
-
# Convert DuckDB TIME values to Ruby time-only objects
|
|
790
|
-
# DuckDB TIME columns should only contain time-of-day information
|
|
791
|
-
def typecast_value_time(value)
|
|
792
|
-
case value
|
|
793
|
-
when Time
|
|
794
|
-
# Extract only the time portion, discarding date information
|
|
795
|
-
# Create a new Time object with today's date but the original time
|
|
796
|
-
Time.local(1970, 1, 1, value.hour, value.min, value.sec, value.usec)
|
|
797
|
-
when String
|
|
798
|
-
# Parse time string and create time-only object
|
|
799
|
-
if value =~ /\A(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))?\z/
|
|
800
|
-
hour = ::Regexp.last_match(1).to_i
|
|
801
|
-
min = ::Regexp.last_match(2).to_i
|
|
802
|
-
sec = ::Regexp.last_match(3).to_i
|
|
803
|
-
usec = (::Regexp.last_match(4) || "0").ljust(6, "0").to_i
|
|
804
|
-
Time.local(1970, 1, 1, hour, min, sec, usec)
|
|
805
|
-
else
|
|
806
|
-
# Fallback: parse as time and extract time portion
|
|
807
|
-
parsed = Time.parse(value.to_s)
|
|
808
|
-
Time.local(1970, 1, 1, parsed.hour, parsed.min, parsed.sec, parsed.usec)
|
|
809
|
-
end
|
|
810
|
-
else
|
|
811
|
-
value
|
|
812
|
-
end
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
# Override the default type conversion to use our custom TIME handling
|
|
816
|
-
# This method needs to be public for Sequel models to access it
|
|
817
|
-
public
|
|
818
|
-
|
|
819
|
-
def typecast_value(column, value)
|
|
820
|
-
return value if value.nil?
|
|
821
|
-
|
|
822
|
-
# Get column schema information to determine the correct type
|
|
823
|
-
if @schema_cache && @schema_cache[column]
|
|
824
|
-
column_type = @schema_cache[column][:type]
|
|
825
|
-
case column_type
|
|
826
|
-
when :time
|
|
827
|
-
return typecast_value_time(value)
|
|
828
|
-
end
|
|
829
|
-
end
|
|
830
|
-
|
|
831
|
-
# Fall back to default Sequel type conversion
|
|
832
|
-
super
|
|
833
|
-
end
|
|
834
882
|
end
|
|
835
883
|
|
|
836
884
|
# DatasetMethods module provides shared dataset functionality for DuckDB adapter
|
|
@@ -862,8 +910,24 @@ module Sequel
|
|
|
862
910
|
seconds: "SECOND"
|
|
863
911
|
}.freeze
|
|
864
912
|
|
|
865
|
-
#
|
|
866
|
-
|
|
913
|
+
# Units that date_add_sql_append emits as a subtraction, and units it emits with the
|
|
914
|
+
# interval first (see the comment there)
|
|
915
|
+
MONTH_BASED_UNITS = %w[YEAR MONTH].freeze
|
|
916
|
+
TIME_BASED_UNITS = %w[HOUR MINUTE SECOND].freeze
|
|
917
|
+
|
|
918
|
+
# Clause order of each statement. A SELECT can be a VALUES list, and every
|
|
919
|
+
# statement can start with a WITH clause.
|
|
920
|
+
Dataset.def_sql_method(self, :select, [ [ "if opts[:values]", %w[values compounds order limit] ], [ "else", %w[with select distinct columns from join where group having window compounds order limit lock] ] ])
|
|
921
|
+
Dataset.def_sql_method(self, :insert, %w[with insert into columns values])
|
|
922
|
+
Dataset.def_sql_method(self, :update, %w[with update table set where])
|
|
923
|
+
Dataset.def_sql_method(self, :delete, %w[with delete from where])
|
|
924
|
+
|
|
925
|
+
# Returns the DuckDB COPY statement that writes this dataset to +path+,
|
|
926
|
+
# without running it. Options are DuckDB COPY options, for example
|
|
927
|
+
# format: :parquet, partition_by: [:a, :b], compression: :zstd.
|
|
928
|
+
def copy_to_sql(path, **options)
|
|
929
|
+
Sequel::DuckDB::Helpers::Copier.new(self, path, options).to_sql
|
|
930
|
+
end
|
|
867
931
|
|
|
868
932
|
private
|
|
869
933
|
|
|
@@ -904,7 +968,21 @@ module Sequel
|
|
|
904
968
|
true
|
|
905
969
|
end
|
|
906
970
|
|
|
907
|
-
|
|
971
|
+
# DuckDB supports the FILTER clause for aggregate functions natively.
|
|
972
|
+
# Without this, Sequel emulates FILTER with CASE, which gives wrong
|
|
973
|
+
# results for order-sensitive aggregates such as first(x ORDER BY ...).
|
|
974
|
+
def supports_filtered_aggregates?
|
|
975
|
+
true
|
|
976
|
+
end
|
|
977
|
+
|
|
978
|
+
# DuckDB supports IS DISTINCT FROM natively. Sequel's is_distinct_from
|
|
979
|
+
# extension uses this flag; without it the extension emits a CASE.
|
|
980
|
+
def supports_is_distinct_from?
|
|
981
|
+
true
|
|
982
|
+
end
|
|
983
|
+
|
|
984
|
+
# DuckDB runs a WITH clause before SELECT, INSERT, UPDATE and DELETE.
|
|
985
|
+
def supports_cte?(_type = :select)
|
|
908
986
|
true
|
|
909
987
|
end
|
|
910
988
|
|
|
@@ -926,6 +1004,48 @@ module Sequel
|
|
|
926
1004
|
true
|
|
927
1005
|
end
|
|
928
1006
|
|
|
1007
|
+
# Features that Sequel refuses, or leaves out of the SQL, without these flags.
|
|
1008
|
+
# test/capability_flags_test.rb runs each one against a real DuckDB.
|
|
1009
|
+
# supports_cte_in_subqueries? is left undeclared on purpose: declaring it stops
|
|
1010
|
+
# Sequel from moving a subquery's CTE to the outer query, which changes the SQL
|
|
1011
|
+
# that existing callers get.
|
|
1012
|
+
def supports_distinct_on?
|
|
1013
|
+
true
|
|
1014
|
+
end
|
|
1015
|
+
|
|
1016
|
+
def supports_ordered_distinct_on?
|
|
1017
|
+
true
|
|
1018
|
+
end
|
|
1019
|
+
|
|
1020
|
+
def supports_group_rollup?
|
|
1021
|
+
true
|
|
1022
|
+
end
|
|
1023
|
+
|
|
1024
|
+
def supports_group_cube?
|
|
1025
|
+
true
|
|
1026
|
+
end
|
|
1027
|
+
|
|
1028
|
+
def supports_grouping_sets?
|
|
1029
|
+
true
|
|
1030
|
+
end
|
|
1031
|
+
|
|
1032
|
+
def supports_lateral_subqueries?
|
|
1033
|
+
true
|
|
1034
|
+
end
|
|
1035
|
+
|
|
1036
|
+
def supports_merge?
|
|
1037
|
+
true
|
|
1038
|
+
end
|
|
1039
|
+
|
|
1040
|
+
def supports_window_clause?
|
|
1041
|
+
true
|
|
1042
|
+
end
|
|
1043
|
+
|
|
1044
|
+
# complex_expression_sql_append writes all four regexp operators.
|
|
1045
|
+
def supports_regexp?
|
|
1046
|
+
true
|
|
1047
|
+
end
|
|
1048
|
+
|
|
929
1049
|
# DuckDB requires WITH RECURSIVE if any CTE is recursive
|
|
930
1050
|
# This follows the same pattern as PostgreSQL
|
|
931
1051
|
def select_with_sql_base
|
|
@@ -995,6 +1115,10 @@ module Sequel
|
|
|
995
1115
|
sql << ", "
|
|
996
1116
|
literal_append(sql, args.last)
|
|
997
1117
|
sql << ", 'i'))"
|
|
1118
|
+
when :"!~", :"!~*"
|
|
1119
|
+
# Not Sequel's default: DuckDB's !~ is a full match, so !~ 'bc' is true for 'abc'.
|
|
1120
|
+
sql << "NOT "
|
|
1121
|
+
complex_expression_sql_append(sql, operator == :"!~" ? :~ : :"~*", args)
|
|
998
1122
|
else
|
|
999
1123
|
super
|
|
1000
1124
|
end
|
|
@@ -1045,16 +1169,12 @@ module Sequel
|
|
|
1045
1169
|
# Only handles cases that differ from Sequel's default behavior
|
|
1046
1170
|
def literal_append(sql, value)
|
|
1047
1171
|
case value
|
|
1048
|
-
when
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
# Use our custom datetime formatting for consistency
|
|
1055
|
-
literal_datetime_append(sql, value)
|
|
1056
|
-
end
|
|
1057
|
-
when DateTime
|
|
1172
|
+
when SQLTimeTZ
|
|
1173
|
+
sql << "'#{value.strftime("%H:%M:%S.%6N%:z")}'"
|
|
1174
|
+
when Sequel::SQLTime
|
|
1175
|
+
# A time of day: Sequel writes it as 'HH:MM:SS.ffffff'
|
|
1176
|
+
super
|
|
1177
|
+
when Time, DateTime
|
|
1058
1178
|
# Use our custom datetime formatting for consistency
|
|
1059
1179
|
literal_datetime_append(sql, value)
|
|
1060
1180
|
when String
|
|
@@ -1100,9 +1220,22 @@ module Sequel
|
|
|
1100
1220
|
sql_unit = DUCKDB_DURATION_UNITS[unit]
|
|
1101
1221
|
next unless sql_unit
|
|
1102
1222
|
|
|
1103
|
-
#
|
|
1223
|
+
# DuckDB judges INTERVAL 1 MONTH, INTERVAL 30 DAY and INTERVAL 720 HOUR equal, and
|
|
1224
|
+
# its optimizer then merges d + INTERVAL 30 DAY with d + INTERVAL 1 MONTH (or 1 DAY
|
|
1225
|
+
# with 24 HOUR) into one expression, so both return the same value. Each kind of
|
|
1226
|
+
# unit gets its own expression form, so two kinds never compare equal:
|
|
1227
|
+
# month-based: d - INTERVAL (-n) MONTH
|
|
1228
|
+
# days: d + INTERVAL n DAY
|
|
1229
|
+
# time-based: INTERVAL n HOUR + d
|
|
1230
|
+
# A subtraction for time units would merge them with months.
|
|
1104
1231
|
interval = build_interval_literal(value, sql_unit)
|
|
1105
|
-
result =
|
|
1232
|
+
result = if MONTH_BASED_UNITS.include?(sql_unit)
|
|
1233
|
+
Sequel.-(result, build_interval_literal(negate_interval_value(value), sql_unit))
|
|
1234
|
+
elsif TIME_BASED_UNITS.include?(sql_unit)
|
|
1235
|
+
Sequel.+(interval, result)
|
|
1236
|
+
else
|
|
1237
|
+
Sequel.+(result, interval)
|
|
1238
|
+
end
|
|
1106
1239
|
end
|
|
1107
1240
|
|
|
1108
1241
|
# Apply cast if specified or default to Time (TIMESTAMP)
|
|
@@ -1114,6 +1247,10 @@ module Sequel
|
|
|
1114
1247
|
|
|
1115
1248
|
private
|
|
1116
1249
|
|
|
1250
|
+
def negate_interval_value(value)
|
|
1251
|
+
value.is_a?(Numeric) ? -value : Sequel.*(value, -1)
|
|
1252
|
+
end
|
|
1253
|
+
|
|
1117
1254
|
def build_interval_literal(value, unit)
|
|
1118
1255
|
# If value is numeric, use direct syntax
|
|
1119
1256
|
# If value is expression, wrap in parentheses
|
|
@@ -1123,12 +1260,12 @@ module Sequel
|
|
|
1123
1260
|
if value.negative?
|
|
1124
1261
|
Sequel.lit("INTERVAL (#{value}) #{unit}")
|
|
1125
1262
|
else
|
|
1126
|
-
Sequel.lit(["INTERVAL ", " #{unit}"], value)
|
|
1263
|
+
Sequel.lit([ "INTERVAL ", " #{unit}" ], value)
|
|
1127
1264
|
end
|
|
1128
1265
|
else
|
|
1129
1266
|
# Expression: INTERVAL (column_name) HOUR
|
|
1130
1267
|
# Note: expressions already include negation from date_sub
|
|
1131
|
-
Sequel.lit(["INTERVAL (", ") #{unit}"], value)
|
|
1268
|
+
Sequel.lit([ "INTERVAL (", ") #{unit}" ], value)
|
|
1132
1269
|
end
|
|
1133
1270
|
end
|
|
1134
1271
|
end
|