sequel-duckdb 0.3.1 → 0.4.1
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 +189 -2
- data/README.md +169 -3
- data/lib/sequel/adapters/duckdb.rb +126 -13
- data/lib/sequel/adapters/shared/duckdb.rb +330 -130
- data/lib/sequel/duckdb/helpers/copier.rb +22 -14
- data/lib/sequel/duckdb/helpers/pathifier.rb +1 -1
- data/lib/sequel/duckdb/helpers/sql_options.rb +44 -0
- 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 +15 -7
|
@@ -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
|
|
@@ -102,10 +103,10 @@ 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
|
+
"WHERE table_catalog = #{literal(schema_ref.table.to_s)} AND table_schema = #{literal(schema_ref.column.to_s)} AND table_type = 'BASE TABLE'"
|
|
106
107
|
else
|
|
107
108
|
"SELECT table_name FROM information_schema.tables " \
|
|
108
|
-
"WHERE table_schema =
|
|
109
|
+
"WHERE table_schema = #{literal(schema_ref.to_s)} AND table_type = 'BASE TABLE'"
|
|
109
110
|
end
|
|
110
111
|
|
|
111
112
|
tables = []
|
|
@@ -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
|
|
|
@@ -175,7 +173,7 @@ module Sequel
|
|
|
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,12 +244,10 @@ module Sequel
|
|
|
249
244
|
|
|
250
245
|
# Format value appropriately for SQL
|
|
251
246
|
formatted_value = case value
|
|
252
|
-
when String
|
|
253
|
-
"'#{value.gsub("'", "''")}'" # Escape single quotes
|
|
254
247
|
when TrueClass, FalseClass, Numeric
|
|
255
248
|
value.to_s
|
|
256
249
|
else
|
|
257
|
-
|
|
250
|
+
literal(value.to_s)
|
|
258
251
|
end
|
|
259
252
|
|
|
260
253
|
# Execute PRAGMA statement
|
|
@@ -299,28 +292,23 @@ 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. It must not abort an open
|
|
298
|
+
# transaction, and DuckDB has no savepoints, so see keep_transaction_on_bind_error.
|
|
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
|
-
|
|
304
|
+
keep_transaction_on_bind_error do
|
|
305
|
+
from(qualify_with_schema_option(table_name, opts)).where(false).select(Sequel::NULL).all
|
|
306
|
+
true
|
|
307
|
+
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
|
|
308
|
+
raise
|
|
309
|
+
rescue Sequel::DatabaseError
|
|
310
|
+
false
|
|
313
311
|
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
|
|
324
312
|
end
|
|
325
313
|
|
|
326
314
|
# Get list of tables
|
|
@@ -340,10 +328,10 @@ module Sequel
|
|
|
340
328
|
|
|
341
329
|
sql = if schema_ref.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
342
330
|
"SELECT table_name FROM information_schema.tables " \
|
|
343
|
-
"WHERE table_catalog =
|
|
331
|
+
"WHERE table_catalog = #{literal(schema_ref.table.to_s)} AND table_schema = #{literal(schema_ref.column.to_s)} AND table_type = 'VIEW'"
|
|
344
332
|
else
|
|
345
333
|
"SELECT table_name FROM information_schema.tables " \
|
|
346
|
-
"WHERE table_schema =
|
|
334
|
+
"WHERE table_schema = #{literal(schema_ref.to_s)} AND table_type = 'VIEW'"
|
|
347
335
|
end
|
|
348
336
|
|
|
349
337
|
views = []
|
|
@@ -360,9 +348,9 @@ module Sequel
|
|
|
360
348
|
# @param opts [Hash] Options
|
|
361
349
|
# @return [Array<Array>] Schema information
|
|
362
350
|
def schema(table_name, opts = {})
|
|
363
|
-
#
|
|
351
|
+
# A qualified identifier (e.g., Sequel[:schema][:table]) is parsed as given
|
|
364
352
|
if table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
365
|
-
|
|
353
|
+
parse_name = table_name
|
|
366
354
|
actual_table_name = table_name.column.to_s.to_sym
|
|
367
355
|
elsif table_name.is_a?(Sequel::Dataset)
|
|
368
356
|
# Handle case where Sequel passes a Dataset object instead of table name
|
|
@@ -381,16 +369,7 @@ module Sequel
|
|
|
381
369
|
actual_table_name = table_name
|
|
382
370
|
end
|
|
383
371
|
|
|
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
|
|
372
|
+
schema_parse_table(parse_name || actual_table_name, opts)
|
|
394
373
|
end
|
|
395
374
|
|
|
396
375
|
# Get index information for a table
|
|
@@ -404,17 +383,37 @@ module Sequel
|
|
|
404
383
|
|
|
405
384
|
private
|
|
406
385
|
|
|
386
|
+
# A value assigned to a TIMETZ model column becomes a SQLTimeTZ, which keeps the UTC
|
|
387
|
+
# offset. A string without an offset gets the local offset.
|
|
388
|
+
def typecast_value_time_timezone(value)
|
|
389
|
+
case value
|
|
390
|
+
when SQLTimeTZ
|
|
391
|
+
value
|
|
392
|
+
when Time
|
|
393
|
+
SQLTimeTZ.from_time(value)
|
|
394
|
+
when DateTime
|
|
395
|
+
SQLTimeTZ.from_time(value.to_time)
|
|
396
|
+
when String
|
|
397
|
+
SQLTimeTZ.from_time(Time.parse(typecast_check_string_length(value, 100)))
|
|
398
|
+
else
|
|
399
|
+
raise Sequel::InvalidValue, "invalid value for TIMETZ: #{value.inspect}"
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
|
|
407
403
|
# Map DuckDB data types to Sequel types
|
|
408
404
|
#
|
|
409
405
|
# @param duckdb_type [String] DuckDB data type
|
|
410
406
|
# @return [Symbol] Sequel type symbol
|
|
407
|
+
# information_schema.columns reports a REAL column as FLOAT, a TIMESTAMPTZ column as
|
|
408
|
+
# TIMESTAMP WITH TIME ZONE, and a VARINT column as BIGNUM. An unsigned type maps by
|
|
409
|
+
# the signed type that holds all its values.
|
|
411
410
|
def map_duckdb_type_to_sequel(duckdb_type)
|
|
412
411
|
case duckdb_type.upcase
|
|
413
|
-
when "INTEGER", "INT", "INT4", "SMALLINT", "INT2", "TINYINT", "INT1"
|
|
412
|
+
when "INTEGER", "INT", "INT4", "SMALLINT", "INT2", "TINYINT", "INT1", "USMALLINT", "UTINYINT"
|
|
414
413
|
:integer
|
|
415
|
-
when "BIGINT", "INT8"
|
|
414
|
+
when "BIGINT", "INT8", "UINTEGER", "UBIGINT", "HUGEINT", "UHUGEINT", "BIGNUM", "VARINT"
|
|
416
415
|
:bigint
|
|
417
|
-
when "REAL", "FLOAT4", "DOUBLE", "FLOAT8"
|
|
416
|
+
when "FLOAT", "REAL", "FLOAT4", "DOUBLE", "FLOAT8"
|
|
418
417
|
:float
|
|
419
418
|
when /^DECIMAL/, /^NUMERIC/
|
|
420
419
|
:decimal
|
|
@@ -422,10 +421,15 @@ module Sequel
|
|
|
422
421
|
:boolean
|
|
423
422
|
when "DATE"
|
|
424
423
|
:date
|
|
425
|
-
when "TIMESTAMP", "DATETIME"
|
|
424
|
+
when "TIMESTAMP", "DATETIME", "TIMESTAMP WITH TIME ZONE", "TIMESTAMPTZ",
|
|
425
|
+
"TIMESTAMP_S", "TIMESTAMP_MS", "TIMESTAMP_US", "TIMESTAMP_NS"
|
|
426
426
|
:datetime
|
|
427
|
-
when "TIME"
|
|
427
|
+
when "TIME", "TIME_NS"
|
|
428
428
|
:time
|
|
429
|
+
when "TIME WITH TIME ZONE", "TIMETZ"
|
|
430
|
+
:time_timezone
|
|
431
|
+
when "INTERVAL"
|
|
432
|
+
:interval
|
|
429
433
|
when "BLOB", "BYTEA"
|
|
430
434
|
:blob
|
|
431
435
|
when "UUID"
|
|
@@ -460,25 +464,131 @@ module Sequel
|
|
|
460
464
|
end
|
|
461
465
|
end
|
|
462
466
|
|
|
467
|
+
# Find the table or view that a name refers to, as DuckDB resolves it in a query.
|
|
468
|
+
# The information_schema and duckdb_indexes() queries need the exact catalog, schema
|
|
469
|
+
# and table, and DuckDB has no function that returns them. So the candidates are the
|
|
470
|
+
# tables and views with that name, and the one whose EXPLAIN plan equals the plan for
|
|
471
|
+
# the name is the match. Two candidates have equal plans only when a view is
|
|
472
|
+
# SELECT * FROM the other candidate. DuckDB's search order then decides.
|
|
473
|
+
#
|
|
474
|
+
# @param table_name [Symbol, String, Sequel::SQL::Identifier, Sequel::SQL::QualifiedIdentifier] Name of the table
|
|
475
|
+
# @param opts [Hash] Options (:schema qualifies an unqualified name)
|
|
476
|
+
# @return [Hash, nil] :catalog, :schema and :table, or nil if the name does not resolve
|
|
477
|
+
def resolve_table(table_name, opts = {})
|
|
478
|
+
table_name = qualify_with_schema_option(table_name, opts)
|
|
479
|
+
keep_transaction_on_bind_error do
|
|
480
|
+
target = explain_plan(table_name)
|
|
481
|
+
next unless target
|
|
482
|
+
|
|
483
|
+
candidates = fetch("SELECT table_catalog, table_schema, table_name FROM information_schema.tables " \
|
|
484
|
+
"WHERE lower(table_name) = lower(?)", bare_table_name(table_name)).all
|
|
485
|
+
matches = candidates.select do |c|
|
|
486
|
+
explain_plan(qualified_name(c[:table_catalog], c[:table_schema], c[:table_name])) == target
|
|
487
|
+
end
|
|
488
|
+
match = matches.size > 1 ? first_in_search_order(matches) : matches.first
|
|
489
|
+
match && { catalog: match[:table_catalog], schema: match[:table_schema], table: match[:table_name] }
|
|
490
|
+
end
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# resolve_table, but a name that does not resolve raises.
|
|
494
|
+
def resolve_table!(table_name, opts = {})
|
|
495
|
+
resolve_table(table_name, opts) ||
|
|
496
|
+
raise(Sequel::DatabaseError, "Table #{literal(qualify_with_schema_option(table_name, opts))} does not exist")
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def qualify_with_schema_option(table_name, opts)
|
|
500
|
+
return table_name unless opts[:schema] && !table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
|
|
501
|
+
|
|
502
|
+
Sequel.qualify(opts[:schema], table_name)
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def bare_table_name(table_name)
|
|
506
|
+
case table_name
|
|
507
|
+
when Sequel::SQL::QualifiedIdentifier then bare_table_name(table_name.column)
|
|
508
|
+
when Sequel::SQL::Identifier then table_name.value.to_s
|
|
509
|
+
else table_name.to_s
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
def qualified_name(catalog, schema, table)
|
|
514
|
+
Sequel::SQL::QualifiedIdentifier.new(
|
|
515
|
+
Sequel::SQL::QualifiedIdentifier.new(Sequel.identifier(catalog), Sequel.identifier(schema)),
|
|
516
|
+
Sequel.identifier(table)
|
|
517
|
+
)
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
# The EXPLAIN plan of SELECT * FROM the name, or nil if DuckDB cannot bind the name.
|
|
521
|
+
# Call it inside keep_transaction_on_bind_error.
|
|
522
|
+
def explain_plan(table_name)
|
|
523
|
+
plan = []
|
|
524
|
+
execute("EXPLAIN (FORMAT JSON) #{from(table_name).sql}") { |row| plan << row }
|
|
525
|
+
plan
|
|
526
|
+
rescue Sequel::DatabaseConnectionError, Sequel::DatabaseDisconnectError
|
|
527
|
+
raise
|
|
528
|
+
rescue Sequel::DatabaseError
|
|
529
|
+
nil
|
|
530
|
+
end
|
|
531
|
+
|
|
532
|
+
# Runs the block so that a binder or catalog error in it, such as a missing table,
|
|
533
|
+
# does not abort an open transaction.
|
|
534
|
+
#
|
|
535
|
+
# DuckDB 1.x never aborts a transaction on those errors. DuckDB 2.x aborts it by
|
|
536
|
+
# default, and its current_transaction_invalidation_policy setting turns that off
|
|
537
|
+
# for the open transaction only. DuckDB 1.x does not know the value, and a failed
|
|
538
|
+
# SET aborts the transaction, so the setting is used only where
|
|
539
|
+
# bind_errors_abort_transactions? is true. The block must handle its own errors:
|
|
540
|
+
# if it raises, the policy is not restored, and the transaction ends anyway.
|
|
541
|
+
def keep_transaction_on_bind_error
|
|
542
|
+
return yield unless bind_errors_abort_transactions? && in_transaction?
|
|
543
|
+
|
|
544
|
+
setting = "current_transaction_invalidation_policy"
|
|
545
|
+
previous = get(Sequel.function(:current_setting, setting))
|
|
546
|
+
run("SET #{setting} = 'SYNTACTIC_ERRORS_DO_NOT_INVALIDATE'")
|
|
547
|
+
result = yield
|
|
548
|
+
run("SET #{setting} = #{literal(previous)}")
|
|
549
|
+
result
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
# True when a binder or catalog error aborts an open transaction. The native adapter
|
|
553
|
+
# answers from the loaded DuckDB library. Other adapters run no queries, so false.
|
|
554
|
+
def bind_errors_abort_transactions?
|
|
555
|
+
false
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
# DuckDB looks in the temp catalog first, then in the search_path schemas, then in the
|
|
559
|
+
# default catalog.
|
|
560
|
+
def first_in_search_order(candidates)
|
|
561
|
+
schemas = get(Sequel.function(:current_schemas, false))
|
|
562
|
+
database = get(Sequel.function(:current_database))
|
|
563
|
+
candidates.min_by do |c|
|
|
564
|
+
[ c[:table_catalog] == "temp" ? 0 : 1,
|
|
565
|
+
schemas.index(c[:table_schema]) || schemas.size,
|
|
566
|
+
c[:table_catalog] == database ? 0 : 1 ]
|
|
567
|
+
end
|
|
568
|
+
end
|
|
569
|
+
|
|
570
|
+
# The SQL condition that selects the resolved table in a catalog query, given the
|
|
571
|
+
# names of that query's catalog, schema and table columns.
|
|
572
|
+
def resolved_table_filter(resolved, catalog:, schema:, table:)
|
|
573
|
+
literal(Sequel.&({ catalog => resolved[:catalog] }, { schema => resolved[:schema] }, { table => resolved[:table] }))
|
|
574
|
+
end
|
|
575
|
+
|
|
463
576
|
# Update primary key information in column schema
|
|
464
577
|
#
|
|
465
|
-
# @param
|
|
578
|
+
# @param resolved [Hash] The table, from resolve_table
|
|
466
579
|
# @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
|
-
|
|
580
|
+
def update_primary_key_info(resolved, columns)
|
|
471
581
|
# Query for primary key constraints
|
|
472
582
|
sql = <<~SQL
|
|
473
583
|
SELECT column_name
|
|
474
584
|
FROM information_schema.table_constraints tc
|
|
475
585
|
JOIN information_schema.key_column_usage kcu
|
|
476
586
|
ON tc.constraint_name = kcu.constraint_name
|
|
587
|
+
AND tc.table_catalog = kcu.table_catalog
|
|
477
588
|
AND tc.table_schema = kcu.table_schema
|
|
478
589
|
AND tc.table_name = kcu.table_name
|
|
479
590
|
WHERE tc.constraint_type = 'PRIMARY KEY'
|
|
480
|
-
AND tc
|
|
481
|
-
AND tc.table_name = '#{table_name}'
|
|
591
|
+
AND #{resolved_table_filter(resolved, catalog: Sequel[:tc][:table_catalog], schema: Sequel[:tc][:table_schema], table: Sequel[:tc][:table_name])}
|
|
482
592
|
SQL
|
|
483
593
|
|
|
484
594
|
primary_key_columns = []
|
|
@@ -523,6 +633,16 @@ module Sequel
|
|
|
523
633
|
true
|
|
524
634
|
end
|
|
525
635
|
|
|
636
|
+
# create_table? and drop_table? emit IF NOT EXISTS / IF EXISTS instead of
|
|
637
|
+
# querying information_schema first.
|
|
638
|
+
def supports_create_table_if_not_exists?
|
|
639
|
+
true
|
|
640
|
+
end
|
|
641
|
+
|
|
642
|
+
def supports_drop_table_if_exists?
|
|
643
|
+
true
|
|
644
|
+
end
|
|
645
|
+
|
|
526
646
|
# DuckDB-specific schema generation methods
|
|
527
647
|
|
|
528
648
|
# Generate SQL for primary key column
|
|
@@ -667,14 +787,12 @@ module Sequel
|
|
|
667
787
|
|
|
668
788
|
# Remove all cached schema information
|
|
669
789
|
#
|
|
670
|
-
# Called after schema modifications to ensure cache consistency
|
|
790
|
+
# Called after schema modifications to ensure cache consistency. It empties the
|
|
791
|
+
# cache in place, because a frozen Database cannot replace it.
|
|
671
792
|
#
|
|
672
793
|
# @return [void]
|
|
673
794
|
def remove_all_cached_schemas
|
|
674
|
-
@
|
|
675
|
-
@schemas = {}
|
|
676
|
-
@primary_keys = {}
|
|
677
|
-
@primary_key_sequences = {}
|
|
795
|
+
Sequel.synchronize { @schemas&.clear }
|
|
678
796
|
end
|
|
679
797
|
|
|
680
798
|
# List all schemas in the database
|
|
@@ -685,7 +803,7 @@ module Sequel
|
|
|
685
803
|
#
|
|
686
804
|
def schemas(opts = OPTS)
|
|
687
805
|
sql = "SELECT schema_name FROM information_schema.schemata"
|
|
688
|
-
sql += " WHERE catalog_name =
|
|
806
|
+
sql += " WHERE catalog_name = #{literal(opts[:catalog].to_s)}" if opts[:catalog]
|
|
689
807
|
|
|
690
808
|
schemas = []
|
|
691
809
|
execute(sql) do |row|
|
|
@@ -702,7 +820,7 @@ module Sequel
|
|
|
702
820
|
# @return [Boolean] true if schema exists
|
|
703
821
|
#
|
|
704
822
|
def schema_exists?(name, _opts = OPTS)
|
|
705
|
-
sql = "SELECT 1 FROM information_schema.schemata WHERE schema_name =
|
|
823
|
+
sql = "SELECT 1 FROM information_schema.schemata WHERE schema_name = #{literal(name.to_s)} LIMIT 1"
|
|
706
824
|
|
|
707
825
|
result = nil
|
|
708
826
|
execute(sql) do |_row|
|
|
@@ -772,64 +890,57 @@ module Sequel
|
|
|
772
890
|
sql
|
|
773
891
|
end
|
|
774
892
|
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
893
|
+
# Runs a DuckDB ATTACH, which opens the database file at +path+ as the catalog
|
|
894
|
+
# +as+. The catalog is part of the DuckDB database, so every pool connection sees
|
|
895
|
+
# it. It lasts until Database#disconnect closes the last connection; attach again
|
|
896
|
+
# after that. See attach_sql for the options.
|
|
897
|
+
def attach(path, as:, **options)
|
|
898
|
+
run(attach_sql(path, as:, **options))
|
|
779
899
|
end
|
|
780
900
|
|
|
781
|
-
|
|
901
|
+
# The ATTACH statement that #attach runs. The alias +as+ is required: an alias
|
|
902
|
+
# that DuckDB derives from the file name can equal a schema name in the file,
|
|
903
|
+
# which makes schema.table ambiguous.
|
|
904
|
+
#
|
|
905
|
+
# @param path [String] The database file; a string literal in the SQL
|
|
906
|
+
# @param as [Symbol, String] The catalog name
|
|
907
|
+
# @param if_not_exists [Boolean] Do nothing if the catalog is already attached
|
|
908
|
+
# @param options [Hash] DuckDB ATTACH options, for example read_only: true or
|
|
909
|
+
# type: :sqlite. A true value renders the bare keyword (READ_ONLY).
|
|
910
|
+
# @return [String]
|
|
911
|
+
def attach_sql(path, as:, if_not_exists: false, **options)
|
|
912
|
+
sql = +"ATTACH "
|
|
913
|
+
sql << "IF NOT EXISTS " if if_not_exists
|
|
914
|
+
sql << "#{literal(path.to_s)} AS #{Sequel::DuckDB::Helpers::SqlOptions.quoted_identifier(as)}"
|
|
915
|
+
list = Sequel::DuckDB::Helpers::SqlOptions.list(options)
|
|
916
|
+
sql << " #{list}" unless list.empty?
|
|
917
|
+
sql
|
|
918
|
+
end
|
|
782
919
|
|
|
783
|
-
|
|
784
|
-
|
|
920
|
+
# Runs a DuckDB DETACH of the catalog +name+.
|
|
921
|
+
def detach(name, if_exists: false)
|
|
922
|
+
run(detach_sql(name, if_exists:))
|
|
785
923
|
end
|
|
786
924
|
|
|
787
|
-
#
|
|
925
|
+
# The DETACH statement that #detach runs.
|
|
926
|
+
def detach_sql(name, if_exists: false)
|
|
927
|
+
"DETACH DATABASE #{"IF EXISTS " if if_exists}#{Sequel::DuckDB::Helpers::SqlOptions.quoted_identifier(name)}"
|
|
928
|
+
end
|
|
788
929
|
|
|
789
|
-
#
|
|
790
|
-
#
|
|
791
|
-
def
|
|
792
|
-
|
|
793
|
-
|
|
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
|
|
930
|
+
# Runs a DuckDB COPY that writes +source+ (a Dataset or a SQL string) to
|
|
931
|
+
# +path+. For a Dataset this is the SQL from Dataset#copy_to_sql.
|
|
932
|
+
def copy_to(source, path, options = Sequel::OPTS)
|
|
933
|
+
if source.is_a?(Sequel::Dataset)
|
|
934
|
+
run(source.copy_to_sql(path, **options))
|
|
810
935
|
else
|
|
811
|
-
|
|
936
|
+
run(Sequel::DuckDB::Helpers::Copier.new(source, path, options).to_sql)
|
|
812
937
|
end
|
|
813
938
|
end
|
|
814
939
|
|
|
815
|
-
|
|
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
|
|
940
|
+
private
|
|
830
941
|
|
|
831
|
-
|
|
832
|
-
|
|
942
|
+
def read_something_sql(paths, options = OPTS)
|
|
943
|
+
Sequel::DuckDB::Helpers::Pathifier.new(paths, options).to_sql
|
|
833
944
|
end
|
|
834
945
|
end
|
|
835
946
|
|
|
@@ -862,8 +973,24 @@ module Sequel
|
|
|
862
973
|
seconds: "SECOND"
|
|
863
974
|
}.freeze
|
|
864
975
|
|
|
865
|
-
#
|
|
866
|
-
|
|
976
|
+
# Units that date_add_sql_append emits as a subtraction, and units it emits with the
|
|
977
|
+
# interval first (see the comment there)
|
|
978
|
+
MONTH_BASED_UNITS = %w[YEAR MONTH].freeze
|
|
979
|
+
TIME_BASED_UNITS = %w[HOUR MINUTE SECOND].freeze
|
|
980
|
+
|
|
981
|
+
# Clause order of each statement. A SELECT can be a VALUES list, and every
|
|
982
|
+
# statement can start with a WITH clause.
|
|
983
|
+
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] ] ])
|
|
984
|
+
Dataset.def_sql_method(self, :insert, %w[with insert into columns values])
|
|
985
|
+
Dataset.def_sql_method(self, :update, %w[with update table set where])
|
|
986
|
+
Dataset.def_sql_method(self, :delete, %w[with delete from where])
|
|
987
|
+
|
|
988
|
+
# Returns the DuckDB COPY statement that writes this dataset to +path+,
|
|
989
|
+
# without running it. Options are DuckDB COPY options, for example
|
|
990
|
+
# format: :parquet, partition_by: [:a, :b], compression: :zstd.
|
|
991
|
+
def copy_to_sql(path, **options)
|
|
992
|
+
Sequel::DuckDB::Helpers::Copier.new(self, path, options).to_sql
|
|
993
|
+
end
|
|
867
994
|
|
|
868
995
|
private
|
|
869
996
|
|
|
@@ -904,7 +1031,21 @@ module Sequel
|
|
|
904
1031
|
true
|
|
905
1032
|
end
|
|
906
1033
|
|
|
907
|
-
|
|
1034
|
+
# DuckDB supports the FILTER clause for aggregate functions natively.
|
|
1035
|
+
# Without this, Sequel emulates FILTER with CASE, which gives wrong
|
|
1036
|
+
# results for order-sensitive aggregates such as first(x ORDER BY ...).
|
|
1037
|
+
def supports_filtered_aggregates?
|
|
1038
|
+
true
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
# DuckDB supports IS DISTINCT FROM natively. Sequel's is_distinct_from
|
|
1042
|
+
# extension uses this flag; without it the extension emits a CASE.
|
|
1043
|
+
def supports_is_distinct_from?
|
|
1044
|
+
true
|
|
1045
|
+
end
|
|
1046
|
+
|
|
1047
|
+
# DuckDB runs a WITH clause before SELECT, INSERT, UPDATE and DELETE.
|
|
1048
|
+
def supports_cte?(_type = :select)
|
|
908
1049
|
true
|
|
909
1050
|
end
|
|
910
1051
|
|
|
@@ -926,6 +1067,48 @@ module Sequel
|
|
|
926
1067
|
true
|
|
927
1068
|
end
|
|
928
1069
|
|
|
1070
|
+
# Features that Sequel refuses, or leaves out of the SQL, without these flags.
|
|
1071
|
+
# test/capability_flags_test.rb runs each one against a real DuckDB.
|
|
1072
|
+
# supports_cte_in_subqueries? is left undeclared on purpose: declaring it stops
|
|
1073
|
+
# Sequel from moving a subquery's CTE to the outer query, which changes the SQL
|
|
1074
|
+
# that existing callers get.
|
|
1075
|
+
def supports_distinct_on?
|
|
1076
|
+
true
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
def supports_ordered_distinct_on?
|
|
1080
|
+
true
|
|
1081
|
+
end
|
|
1082
|
+
|
|
1083
|
+
def supports_group_rollup?
|
|
1084
|
+
true
|
|
1085
|
+
end
|
|
1086
|
+
|
|
1087
|
+
def supports_group_cube?
|
|
1088
|
+
true
|
|
1089
|
+
end
|
|
1090
|
+
|
|
1091
|
+
def supports_grouping_sets?
|
|
1092
|
+
true
|
|
1093
|
+
end
|
|
1094
|
+
|
|
1095
|
+
def supports_lateral_subqueries?
|
|
1096
|
+
true
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
def supports_merge?
|
|
1100
|
+
true
|
|
1101
|
+
end
|
|
1102
|
+
|
|
1103
|
+
def supports_window_clause?
|
|
1104
|
+
true
|
|
1105
|
+
end
|
|
1106
|
+
|
|
1107
|
+
# complex_expression_sql_append writes all four regexp operators.
|
|
1108
|
+
def supports_regexp?
|
|
1109
|
+
true
|
|
1110
|
+
end
|
|
1111
|
+
|
|
929
1112
|
# DuckDB requires WITH RECURSIVE if any CTE is recursive
|
|
930
1113
|
# This follows the same pattern as PostgreSQL
|
|
931
1114
|
def select_with_sql_base
|
|
@@ -995,6 +1178,10 @@ module Sequel
|
|
|
995
1178
|
sql << ", "
|
|
996
1179
|
literal_append(sql, args.last)
|
|
997
1180
|
sql << ", 'i'))"
|
|
1181
|
+
when :"!~", :"!~*"
|
|
1182
|
+
# Not Sequel's default: DuckDB's !~ is a full match, so !~ 'bc' is true for 'abc'.
|
|
1183
|
+
sql << "NOT "
|
|
1184
|
+
complex_expression_sql_append(sql, operator == :"!~" ? :~ : :"~*", args)
|
|
998
1185
|
else
|
|
999
1186
|
super
|
|
1000
1187
|
end
|
|
@@ -1045,16 +1232,12 @@ module Sequel
|
|
|
1045
1232
|
# Only handles cases that differ from Sequel's default behavior
|
|
1046
1233
|
def literal_append(sql, value)
|
|
1047
1234
|
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
|
|
1235
|
+
when SQLTimeTZ
|
|
1236
|
+
sql << "'#{value.strftime("%H:%M:%S.%6N%:z")}'"
|
|
1237
|
+
when Sequel::SQLTime
|
|
1238
|
+
# A time of day: Sequel writes it as 'HH:MM:SS.ffffff'
|
|
1239
|
+
super
|
|
1240
|
+
when Time, DateTime
|
|
1058
1241
|
# Use our custom datetime formatting for consistency
|
|
1059
1242
|
literal_datetime_append(sql, value)
|
|
1060
1243
|
when String
|
|
@@ -1100,9 +1283,22 @@ module Sequel
|
|
|
1100
1283
|
sql_unit = DUCKDB_DURATION_UNITS[unit]
|
|
1101
1284
|
next unless sql_unit
|
|
1102
1285
|
|
|
1103
|
-
#
|
|
1286
|
+
# DuckDB judges INTERVAL 1 MONTH, INTERVAL 30 DAY and INTERVAL 720 HOUR equal, and
|
|
1287
|
+
# its optimizer then merges d + INTERVAL 30 DAY with d + INTERVAL 1 MONTH (or 1 DAY
|
|
1288
|
+
# with 24 HOUR) into one expression, so both return the same value. Each kind of
|
|
1289
|
+
# unit gets its own expression form, so two kinds never compare equal:
|
|
1290
|
+
# month-based: d - INTERVAL (-n) MONTH
|
|
1291
|
+
# days: d + INTERVAL n DAY
|
|
1292
|
+
# time-based: INTERVAL n HOUR + d
|
|
1293
|
+
# A subtraction for time units would merge them with months.
|
|
1104
1294
|
interval = build_interval_literal(value, sql_unit)
|
|
1105
|
-
result =
|
|
1295
|
+
result = if MONTH_BASED_UNITS.include?(sql_unit)
|
|
1296
|
+
Sequel.-(result, build_interval_literal(negate_interval_value(value), sql_unit))
|
|
1297
|
+
elsif TIME_BASED_UNITS.include?(sql_unit)
|
|
1298
|
+
Sequel.+(interval, result)
|
|
1299
|
+
else
|
|
1300
|
+
Sequel.+(result, interval)
|
|
1301
|
+
end
|
|
1106
1302
|
end
|
|
1107
1303
|
|
|
1108
1304
|
# Apply cast if specified or default to Time (TIMESTAMP)
|
|
@@ -1114,6 +1310,10 @@ module Sequel
|
|
|
1114
1310
|
|
|
1115
1311
|
private
|
|
1116
1312
|
|
|
1313
|
+
def negate_interval_value(value)
|
|
1314
|
+
value.is_a?(Numeric) ? -value : Sequel.*(value, -1)
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1117
1317
|
def build_interval_literal(value, unit)
|
|
1118
1318
|
# If value is numeric, use direct syntax
|
|
1119
1319
|
# If value is expression, wrap in parentheses
|