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.
@@ -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 = '#{schema_ref.table}' AND table_schema = '#{schema_ref.column}' AND table_type = 'BASE TABLE'"
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 = '#{schema_ref}' AND table_type = 'BASE TABLE'"
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
- schema_name = opts[:schema] || "main"
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 table_schema = '#{schema_name}' AND table_name = '#{table_name}'
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(table_name, columns, opts)
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
- schema_name = opts[:schema] || "main"
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 schema_name = '#{schema_name}' AND table_name = '#{table_name}'
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
- "'#{value}'"
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
- # @param table_name [Symbol, String, Sequel::SQL::QualifiedIdentifier] Name of the table
303
- # @param opts [Hash] Options
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
- # Handle qualified identifiers (e.g., Sequel[:schema][:table])
307
- if table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
308
- schema_name = table_name.table.to_s
309
- table_name = table_name.column.to_s
310
- else
311
- schema_name = opts[:schema] || "main"
312
- table_name = table_name.to_s
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 = '#{schema_ref.table}' AND table_schema = '#{schema_ref.column}' AND table_type = 'VIEW'"
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 = '#{schema_ref}' AND table_type = 'VIEW'"
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
- # Handle qualified identifiers (e.g., Sequel[:schema][:table])
351
+ # A qualified identifier (e.g., Sequel[:schema][:table]) is parsed as given
364
352
  if table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
365
- opts = opts.merge(schema: table_name.table.to_s)
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
- # Cache schema information for type conversion
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 table_name [Symbol, String] Table name
578
+ # @param resolved [Hash] The table, from resolve_table
466
579
  # @param columns [Array] Array of column information
467
- # @param opts [Hash] Options
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.table_schema = '#{schema_name}'
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
- @schema_cache = {}
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 = '#{opts[:catalog]}'" if opts[:catalog]
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 = '#{name}' LIMIT 1"
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
- def copy_to(source, path, options = Sequel::OPTS)
776
- Sequel::DuckDB::Helpers::Copier.new(source, path, options).then do |copier|
777
- run(copier.to_sql)
778
- end
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
- private
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
- def read_something_sql(paths, options = OPTS)
784
- Sequel::DuckDB::Helpers::Pathifier.new(paths, options).to_sql
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
- # Type conversion methods for DuckDB-specific handling
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
- # 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
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
- value
936
+ run(Sequel::DuckDB::Helpers::Copier.new(source, path, options).to_sql)
812
937
  end
813
938
  end
814
939
 
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
940
+ private
830
941
 
831
- # Fall back to default Sequel type conversion
832
- super
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
- # Override select SQL clause order to support VALUES
866
- 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 compounds order limit lock] ] ])
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
- def supports_cte?
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 Time
1049
- # Special handling for time-only values (year 1970 indicates time-only)
1050
- if value.year == 1970 && value.month == 1 && value.day == 1
1051
- # This is a time-only value, use TIME format
1052
- sql << "'#{value.strftime("%H:%M:%S")}'"
1053
- else
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
- # Create interval addition
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 = Sequel.+(result, interval)
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