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.
@@ -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 # rubocop:disable Naming/PredicateMethod
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 = '#{schema_ref.table}' AND table_schema = '#{schema_ref.column}' AND table_type = 'BASE TABLE'"
106
- else
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 = '#{schema_ref}' AND table_type = 'BASE TABLE'"
109
- end
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
- 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
 
@@ -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(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,13 +244,11 @@ 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
- when TrueClass, FalseClass, Numeric
247
+ when TrueClass, FalseClass, Numeric
255
248
  value.to_s
256
- else
257
- "'#{value}'"
258
- end
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
- # @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, 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
- # 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
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 = '#{schema_ref.table}' AND table_schema = '#{schema_ref.column}' AND table_type = 'VIEW'"
344
- else
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 = '#{schema_ref}' AND table_type = 'VIEW'"
347
- end
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
- # Handle qualified identifiers (e.g., Sequel[:schema][:table])
349
+ # A qualified identifier (e.g., Sequel[:schema][:table]) is parsed as given
364
350
  if table_name.is_a?(Sequel::SQL::QualifiedIdentifier)
365
- opts = opts.merge(schema: table_name.table.to_s)
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
- # 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
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 table_name [Symbol, String] Table name
548
+ # @param resolved [Hash] The table, from resolve_table
466
549
  # @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
-
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.table_schema = '#{schema_name}'
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
- @schema_cache = {}
675
- @schemas = {}
676
- @primary_keys = {}
677
- @primary_key_sequences = {}
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 = '#{opts[:catalog]}'" if opts[:catalog]
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 = '#{name}' LIMIT 1"
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
- elsif source.is_a?(Dataset)
849
+ elsif source.is_a?(Dataset)
758
850
  source.sql
759
- elsif source.is_a?(String)
851
+ elsif source.is_a?(String)
760
852
  source
761
- else
853
+ else
762
854
  raise Sequel::Error, "Unsupported source type: #{source.class}"
763
- end
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::DuckDB::Helpers::Copier.new(source, path, options).then do |copier|
777
- run(copier.to_sql)
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
- # 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]]])
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
- def supports_cte?
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 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
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
- # Create interval addition
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 = Sequel.+(result, interval)
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