Module: Sequel::DuckDB::DatabaseMethods

Included in:
Database
Defined in:
lib/sequel/adapters/shared/duckdb.rb

Overview

DatabaseMethods module provides shared database functionality for DuckDB adapter

This module is included by the main Database class to provide connection management, schema introspection, and SQL execution capabilities. It implements the core database operations required by Sequel's adapter interface.

Key responsibilities:

  • Connection management (connect, disconnect, validation)
  • SQL execution with proper error handling and logging
  • Schema introspection (tables, columns, indexes, constraints)
  • Transaction support with commit/rollback capabilities
  • Data type mapping between Ruby and DuckDB types
  • Performance optimizations for analytical workloads

Examples:

Connection management

db = Sequel.connect('duckdb:///path/to/database.duckdb')
db.test_connection  # => true
db.disconnect

Schema introspection

db.tables                    # => [:users, :products, :orders]
db.schema(:users)            # => [[:id, {...}], [:name, {...}]]
db.indexes(:users)           # => {:users_email_index => {...}}
db.table_exists?(:users)     # => true

SQL execution

db.execute("SELECT COUNT(*) FROM users")
db.execute("INSERT INTO users (name) VALUES (?)", ["John"])

Transactions

db.transaction do
  db[:users].insert(name: 'Alice')
  db[:orders].insert(user_id: db[:users].max(:id), total: 100)
end

See Also:

Since:

  • 0.1.0

Instance Method Summary collapse

Instance Method Details

#analyze_query(sql) ⇒ Hash

Get detailed query analysis information

Parameters:

  • sql (String)

    SQL statement to analyze

Returns:

  • (Hash)

    Analysis information including plan, timing estimates, etc.

Since:

  • 0.1.0



1098
1099
1100
1101
1102
1103
1104
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1098

def analyze_query(sql)
  {
    plan: query_plan(sql),
    explain_output: explain_query(sql),
    supports_explain: supports_explain?
  }
end

#auto_increment_column_sql(column, _opts) ⇒ String (private)

Generate SQL for auto-incrementing column DuckDB doesn't support AUTOINCREMENT, use sequences instead

Parameters:

  • column (Symbol)

    Column name

  • _opts (Hash)

    Column options

Returns:

  • (String)

    SQL for auto-incrementing column

Since:

  • 0.1.0



850
851
852
853
854
855
856
# File 'lib/sequel/adapters/shared/duckdb.rb', line 850

def auto_increment_column_sql(column, _opts)
  # DuckDB uses sequences for auto-increment, but for primary keys
  # we can just use INTEGER PRIMARY KEY without AUTOINCREMENT
  col_sql = String.new
  quote_identifier_append(col_sql, column)
  "#{col_sql} INTEGER PRIMARY KEY"
end

#auto_increment_sqlObject (private)

Override to prevent AUTOINCREMENT from being added

Since:

  • 0.1.0



840
841
842
# File 'lib/sequel/adapters/shared/duckdb.rb', line 840

def auto_increment_sql
  ""
end

#begin_transaction(conn, opts = {}) ⇒ void

This method returns an undefined value.

Begin a transaction manually Sequel calls this with (conn, opts) arguments

Parameters:

  • conn (::DuckDB::Connection)

    Database connection

  • opts (Hash) (defaults to: {})

    Transaction options

Since:

  • 0.1.0



692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
# File 'lib/sequel/adapters/shared/duckdb.rb', line 692

def begin_transaction(conn, opts = {})
  if opts[:isolation]
    isolation_sql = case opts[:isolation]
                    when :read_uncommitted
                      "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED"
                    when :read_committed
                      "SET TRANSACTION ISOLATION LEVEL READ COMMITTED"
                    else
                      raise Sequel::DatabaseError, "Unsupported isolation level: #{opts[:isolation]}"
                    end
    conn.query(isolation_sql)
  end

  conn.query("BEGIN TRANSACTION")
end

#commit_transaction(conn, _opts = {}) ⇒ void

This method returns an undefined value.

Commit the current transaction manually Sequel calls this with (conn, opts) arguments

Parameters:

  • conn (::DuckDB::Connection)

    Database connection

  • _opts (Hash) (defaults to: {})

    Options

Since:

  • 0.1.0



714
715
716
# File 'lib/sequel/adapters/shared/duckdb.rb', line 714

def commit_transaction(conn, _opts = {})
  conn.query("COMMIT")
end

#configure_columnar_optimizationObject

Configure DuckDB for columnar storage optimization

Since:

  • 0.1.0



1155
1156
1157
1158
1159
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1155

def configure_columnar_optimization
  set_config_value("enable_optimizer", true)
  set_config_value("enable_profiling", false)
  set_config_value("enable_progress_bar", false)
end

#configure_duckdb(options = {}) ⇒ void

This method returns an undefined value.

Configure multiple DuckDB settings at once

This method allows batch configuration of multiple DuckDB PRAGMA settings in a single method call. It's a convenience wrapper around multiple set_pragma calls.

Examples:

Configure multiple settings

db.configure_duckdb(
  memory_limit: "2GB",
  threads: 8,
  enable_progress_bar: true,
  default_order: "ASC"
)

Configure with string keys

db.configure_duckdb(
  "memory_limit" => "1GB",
  "threads" => 4
)

Parameters:

  • options (Hash) (defaults to: {})

    Hash of pragma_name => value pairs

Raises:

  • (Sequel::DatabaseError)

    If any pragma setting fails

See Also:

Since:

  • 0.1.0



443
444
445
446
447
448
449
450
# File 'lib/sequel/adapters/shared/duckdb.rb', line 443

def configure_duckdb(options = {})
  return if options.empty?

  # Apply each configuration option
  options.each do |key, value|
    set_pragma(key, value)
  end
end

#configure_memory_optimization(memory_limit = "1GB") ⇒ Object

Configure DuckDB for memory-efficient operations

Parameters:

  • memory_limit (String) (defaults to: "1GB")

    Memory limit (e.g., "1GB", "512MB")

Since:

  • 0.1.0



1149
1150
1151
1152
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1149

def configure_memory_optimization(memory_limit = "1GB")
  set_config_value("memory_limit", "'#{memory_limit}'")
  set_config_value("temp_directory", "'/tmp'")
end

#configure_parallel_execution(thread_count = nil) ⇒ Object

Configure DuckDB for optimal parallel execution

Parameters:

  • thread_count (Integer) (defaults to: nil)

    Number of threads to use

Since:

  • 0.1.0



1138
1139
1140
1141
1142
1143
1144
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1138

def configure_parallel_execution(thread_count = nil)
  thread_count ||= [4, cpu_count].min

  set_config_value("threads", thread_count)
  set_config_value("enable_optimizer", true)
  set_config_value("enable_profiling", false) # Disable for performance
end

#cpu_countObject (private)

Get CPU count for parallel execution configuration

Since:

  • 0.1.0



1164
1165
1166
1167
1168
1169
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1164

def cpu_count
  require "etc"
  Etc.nprocessors
rescue StandardError
  4 # Default fallback
end

#database_error_classesArray<Class> (private)

Get database error classes that should be caught and converted to Sequel exceptions

Returns:

  • (Array<Class>)

    Array of DuckDB error classes

Since:

  • 0.1.0



138
139
140
# File 'lib/sequel/adapters/shared/duckdb.rb', line 138

def database_error_classes
  [::DuckDB::Error]
end

#database_exception_class(exception, _opts) ⇒ Class (private)

Map DuckDB errors to appropriate Sequel exception types (Requirements 8.1, 8.2, 8.3, 8.7)

Parameters:

  • exception (::DuckDB::Error)

    The DuckDB exception

  • _opts (Hash)

    Additional options

Returns:

  • (Class)

    Sequel exception class to use

Since:

  • 0.1.0



165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
# File 'lib/sequel/adapters/shared/duckdb.rb', line 165

def database_exception_class(exception, _opts)
  message = exception.message.to_s

  # Map specific DuckDB error patterns to appropriate Sequel exceptions
  case message
  when /connection/i, /database.*not.*found/i, /cannot.*open/i
    # Connection-related errors (Requirement 8.1)
    Sequel::DatabaseConnectionError
  when /violates.*not.*null/i, /not.*null.*constraint/i, /null.*value.*not.*allowed/i
    # NOT NULL constraint violations (Requirement 8.3) - moved up for priority
    Sequel::NotNullConstraintViolation
  when /unique.*constraint/i, /duplicate.*key/i, /already.*exists/i,
     /primary.*key.*constraint/i, /duplicate.*primary.*key/i
    # UNIQUE and PRIMARY KEY constraint violations (Requirement 8.3)
    # Primary key violations are a type of unique constraint
    Sequel::UniqueConstraintViolation
  when /foreign.*key.*constraint/i, /violates.*foreign.*key/i
    # Foreign key constraint violations (Requirement 8.3)
    Sequel::ForeignKeyConstraintViolation
  when /check.*constraint/i, /violates.*check/i
    # CHECK constraint violations (Requirement 8.3)
    Sequel::CheckConstraintViolation
  when /constraint.*violation/i, /violates.*constraint/i
    # Generic constraint violations (Requirement 8.3) - moved to end for lower priority
    Sequel::ConstraintViolation
  else
    # when /syntax.*error/i, /parse.*error/i, /unexpected.*token/i,
    #      /table.*does.*not.*exist/i, /relation.*does.*not.*exist/i,
    #      /no.*such.*table/i, /column.*does.*not.*exist/i,
    #      /no.*such.*column/i, /unknown.*column/i,
    #      /referenced.*column.*not.*found/i,
    #      /does.*not.*have.*a.*column/i, /schema.*does.*not.*exist/i,
    #      /no.*such.*schema/i, /function.*does.*not.*exist/i,
    #      /no.*such.*function/i, /unknown.*function/i, /type.*error/i,
    #      /cannot.*cast/i, /invalid.*type/i, /permission.*denied/i,
    #      /access.*denied/i, /insufficient.*privileges/i
    # Various database errors (Requirements 8.2, 8.7):
    # - SQL syntax errors
    # - Table/column/schema/function not found errors
    # - Type conversion errors
    # - Permission/access errors
    Sequel::DatabaseError
  end
end

#database_exception_message(exception, opts) ⇒ String (private)

Enhanced error message formatting for better debugging (Requirements 8.2, 8.7)

Parameters:

  • exception (::DuckDB::Error)

    The DuckDB exception

  • opts (Hash)

    Additional options including SQL and parameters

Returns:

  • (String)

    Enhanced error message

Since:

  • 0.1.0



215
216
217
218
219
220
221
222
223
224
225
# File 'lib/sequel/adapters/shared/duckdb.rb', line 215

def database_exception_message(exception, opts)
  message = "DuckDB error: #{exception.message}"

  # Add SQL context if available for better debugging
  message += " -- SQL: #{opts[:sql]}" if opts[:sql]

  # Add parameter context if available
  message += " -- Parameters: #{opts[:params].inspect}" if opts[:params] && !opts[:params].empty?

  message
end

#database_exception_sqlstate(_exception, _opts) ⇒ String? (private)

Extract SQL state from DuckDB exception if available

Parameters:

  • _exception (::DuckDB::Error)

    The DuckDB exception

  • _opts (Hash)

    Additional options

Returns:

  • (String, nil)

    SQL state code or nil if not available

Since:

  • 0.1.0



147
148
149
150
151
# File 'lib/sequel/adapters/shared/duckdb.rb', line 147

def database_exception_sqlstate(_exception, _opts)
  # DuckDB errors may not always have SQL state codes
  # This can be enhanced when more detailed error information is available
  nil
end

#database_exception_use_sqlstates?Boolean (private)

Whether to use SQL states for exception handling

Returns:

  • (Boolean)

    true if SQL states should be used

Since:

  • 0.1.0



156
157
158
# File 'lib/sequel/adapters/shared/duckdb.rb', line 156

def database_exception_use_sqlstates?
  false
end

#database_typeObject

DuckDB uses the :duckdb database type.

Since:

  • 0.1.0



47
48
49
# File 'lib/sequel/adapters/shared/duckdb.rb', line 47

def database_type
  :duckdb
end

#execute(sql, opts = {}, &block) ⇒ Object

Execute SQL statement

Parameters:

  • sql (String)

    SQL statement to execute

  • opts (Hash, Array) (defaults to: {})

    Options for execution or parameters array

Returns:

  • (Object)

    Result of execution

Since:

  • 0.1.0



75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
# File 'lib/sequel/adapters/shared/duckdb.rb', line 75

def execute(sql, opts = {}, &block)
  # Handle both old-style (sql, opts) and new-style (sql, params) calls
  if opts.is_a?(Array)
    params = opts
    opts = {}
  elsif opts.is_a?(Hash)
    params = opts[:params] || []
  else
    # Handle other types (like strings) by treating as empty params
    params = []
    opts = {}
  end

  synchronize(opts[:server]) do |conn|
    result = execute_statement(conn, sql, params, opts, &block)

    # For UPDATE/DELETE operations without a block, return the number of affected rows
    # This is what Sequel models expect
    if !block && result.is_a?(::DuckDB::Result) \
      && (sql.strip.upcase.start_with?("UPDATE ") \
      || sql.strip.upcase.start_with?("DELETE "))
      return result.rows_changed
    end

    return result
  end
end

#execute_insert(sql, opts = {}) ⇒ Object

Execute INSERT statement

Parameters:

  • sql (String)

    INSERT SQL statement

  • opts (Hash) (defaults to: {})

    Options for execution

Returns:

  • (Object)

    Result of execution

Since:

  • 0.1.0



108
109
110
111
112
113
114
# File 'lib/sequel/adapters/shared/duckdb.rb', line 108

def execute_insert(sql, opts = {})
  execute(sql, opts)
  # For INSERT statements, we should return the inserted ID if possible
  # Since DuckDB doesn't support AUTOINCREMENT, we'll return nil for now
  # This matches the behavior expected by Sequel
  nil
end

#execute_statement(conn, sql, params = [], _opts = {}) ⇒ Object (private)

Execute SQL statement against DuckDB connection

Parameters:

  • conn (::DuckDB::Connection)

    Database connection (already connected)

  • sql (String)

    SQL statement to execute

  • params (Array) (defaults to: [])

    Parameters for prepared statement

  • _opts (Hash) (defaults to: {})

    Options for execution

Returns:

  • (Object)

    Result of execution

Since:

  • 0.1.0



906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
# File 'lib/sequel/adapters/shared/duckdb.rb', line 906

def execute_statement(conn, sql, params = [], _opts = {})
  # Log the SQL query with timing information (Requirements 8.4, 8.5)
  start_time = Time.now

  begin
    # Log the SQL query before execution
    log_sql_query(sql, params)

    # Handle parameterized queries
    if params && !params.empty?
      # Prepare statement with ? placeholders
      stmt = conn.prepare(sql)

      # Bind parameters using 1-based indexing
      params.each_with_index do |param, index|
        stmt.bind(index + 1, param)
      end

      # Execute the prepared statement
      result = stmt.execute
    else
      # Execute directly without parameters
      result = conn.query(sql)
    end

    # Log timing information for the operation
    end_time = Time.now
    execution_time = end_time - start_time
    log_sql_timing(sql, execution_time)

    if block_given?
      # Get column names from the result
      columns = result.columns

      # Iterate through each row
      result.each do |row_array|
        # Convert array to hash with column names as keys
        row_hash = {}
        columns.each_with_index do |column, index|
          # DuckDB::Column objects have a name method
          column_name = column.respond_to?(:name) ? column.name : column.to_s
          row_hash[column_name.to_sym] = row_array[index]
        end
        yield row_hash
      end
    else
      result
    end
  rescue ::DuckDB::Error => e
    # Log the error for debugging (Requirement 8.6)
    end_time = Time.now
    execution_time = end_time - start_time
    log_sql_error(sql, params, e, execution_time)

    # Use enhanced error mapping for better exception categorization (Requirements 8.1, 8.2, 8.3, 8.7)
    error_opts = { sql: sql, params: params }
    exception_class = database_exception_class(e, error_opts)
    enhanced_message = database_exception_message(e, error_opts)

    raise exception_class, enhanced_message
  rescue StandardError => e
    # Log unexpected errors
    end_time = Time.now
    execution_time = end_time - start_time
    log_sql_error(sql, params, e, execution_time)
    raise e
  end
end

#execute_update(sql, opts = {}) ⇒ Object

Execute UPDATE statement

Parameters:

  • sql (String)

    UPDATE SQL statement

  • opts (Hash) (defaults to: {})

    Options for execution

Returns:

  • (Object)

    Result of execution

Since:

  • 0.1.0



121
122
123
124
125
126
127
128
129
130
131
# File 'lib/sequel/adapters/shared/duckdb.rb', line 121

def execute_update(sql, opts = {})
  result = execute(sql, opts)
  # For UPDATE/DELETE statements, return the number of affected rows
  # DuckDB::Result has a rows_changed method for affected row count
  if result.respond_to?(:rows_changed)
    result.rows_changed
  else
    # Fallback: try to get row count from result
    result.is_a?(Integer) ? result : 0
  end
end

#explain_query(sql) ⇒ Array<Hash>

EXPLAIN functionality access for query plans (Requirement 9.6)

Parameters:

  • sql (String)

    SQL query to explain

Returns:

  • (Array<Hash>)

    Query plan information

Since:

  • 0.1.0



1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1061

def explain_query(sql)
  explain_sql = "EXPLAIN #{sql}"
  plan_rows = []

  execute(explain_sql) do |row|
    plan_rows << row
  end

  plan_rows
end

#folds_unquoted_identifiers_to_uppercase?Boolean (private)

DuckDB doesn't fold unquoted identifiers to uppercase

Returns:

  • (Boolean)

Since:

  • 0.1.0



64
65
66
# File 'lib/sequel/adapters/shared/duckdb.rb', line 64

def folds_unquoted_identifiers_to_uppercase?
  false
end

#get_config_value(key) ⇒ Object

Get DuckDB configuration value

Parameters:

  • key (String)

    Configuration key

Returns:

  • (Object)

    Configuration value

Since:

  • 0.1.0



1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1123

def get_config_value(key)
  result = nil
  synchronize do |conn|
    # Use PRAGMA to get configuration values
    conn.query("PRAGMA #{key}") do |row|
      result = row.values.first
      break
    end
  end
  result
end

#handle_constraint_violation(exception, opts = {}) ⇒ Exception (private)

Handle constraint violation errors with specific categorization (Requirement 8.3)

Parameters:

  • exception (::DuckDB::Error)

    The DuckDB exception

  • opts (Hash) (defaults to: {})

    Additional options

Returns:

  • (Exception)

    Appropriate Sequel constraint exception

Since:

  • 0.1.0



232
233
234
235
236
237
238
# File 'lib/sequel/adapters/shared/duckdb.rb', line 232

def handle_constraint_violation(exception, opts = {})
  message = database_exception_message(exception, opts)
  exception_class = database_exception_class(exception, opts)

  # Create the appropriate exception with enhanced message
  exception_class.new(message)
end

#in_transaction?Boolean

Check if currently in a transaction

Returns:

  • (Boolean)

    true if in a transaction

Since:

  • 0.1.0



680
681
682
683
684
# File 'lib/sequel/adapters/shared/duckdb.rb', line 680

def in_transaction?
  # Use Sequel's built-in transaction tracking
  # Sequel tracks transaction state internally
  @transactions && !@transactions.empty?
end

#indexes(table_name, opts = {}) ⇒ Hash

Get index information for a table

Parameters:

  • table_name (Symbol, String)

    Name of the table

  • opts (Hash) (defaults to: {})

    Options

Returns:

  • (Hash)

    Index information

Since:

  • 0.1.0



520
521
522
# File 'lib/sequel/adapters/shared/duckdb.rb', line 520

def indexes(table_name, opts = {})
  schema_parse_indexes(table_name, opts)
end

#isolation_transaction(opts = {}) ⇒ Object (private)

Handle transactions with specific isolation levels

Parameters:

  • opts (Hash) (defaults to: {})

    Transaction options including :isolation

Returns:

  • (Object)

    Result of the transaction block

Since:

  • 0.1.0



787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
# File 'lib/sequel/adapters/shared/duckdb.rb', line 787

def isolation_transaction(opts = {})
  synchronize(opts[:server]) do |conn|
    # Set isolation level before beginning transaction
    isolation_sql = case opts[:isolation]
                    when :read_uncommitted
                      "SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED"
                    when :read_committed
                      "SET TRANSACTION ISOLATION LEVEL READ COMMITTED"
                    else
                      raise Sequel::DatabaseError, "Unsupported isolation level: #{opts[:isolation]}"
                    end

    conn.query(isolation_sql)
    conn.query("BEGIN TRANSACTION")

    # Execute the block
    result = yield

    # Commit on success
    conn.query("COMMIT")

    result
  rescue Sequel::Rollback
    # Rollback on explicit rollback
    conn.query("ROLLBACK")
    nil
  rescue StandardError => e
    # Rollback on any other exception
    begin
      conn.query("ROLLBACK")
    rescue ::DuckDB::Error
      # Ignore errors during rollback cleanup
    end
    raise e
  end
end

#log_connection_info?Boolean (private)

Check if connection info should be logged

Returns:

  • (Boolean)

    true if logging is enabled

Since:

  • 0.1.0



1029
1030
1031
1032
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1029

def log_connection_info?
  # Use Sequel's built-in logging mechanism
  !loggers.empty?
end

#log_error(message) ⇒ Object (private)

Log error message using Sequel's logging system

Parameters:

  • message (String)

    Message to log

Since:

  • 0.1.0



1051
1052
1053
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1051

def log_error(message)
  log_connection_yield("ERROR: #{message}", nil) { nil }
end

#log_info(message) ⇒ Object (private)

Log info message using Sequel's logging system

Parameters:

  • message (String)

    Message to log

Since:

  • 0.1.0



1037
1038
1039
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1037

def log_info(message)
  log_connection_yield(message, nil) { nil }
end

#log_sql_error(sql, params, error, execution_time) ⇒ Object (private)

Log SQL query errors (Requirement 8.6)

Parameters:

  • sql (String)

    SQL statement that failed

  • params (Array)

    Parameters for the query

  • error (Exception)

    The error that occurred

  • execution_time (Float)

    Time taken before error

Since:

  • 0.1.0



1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1014

def log_sql_error(sql, params, error, execution_time)
  return unless log_connection_info?

  time_ms = (execution_time * 1000).round(2)

  if params && !params.empty?
    log_error("SQL Error after #{time_ms}ms: #{error.message} -- SQL: #{sql} -- Parameters: #{params.inspect}")
  else
    log_error("SQL Error after #{time_ms}ms: #{error.message} -- SQL: #{sql}")
  end
end

#log_sql_query(sql, params = []) ⇒ Object (private)

Log SQL query execution (Requirement 8.4)

Parameters:

  • sql (String)

    SQL statement

  • params (Array) (defaults to: [])

    Parameters for the query

Since:

  • 0.1.0



979
980
981
982
983
984
985
986
987
988
989
# File 'lib/sequel/adapters/shared/duckdb.rb', line 979

def log_sql_query(sql, params = [])
  return unless log_connection_info?

  if params && !params.empty?
    # Log parameterized query with parameters
    log_info("SQL Query: #{sql} -- Parameters: #{params.inspect}")
  else
    # Log simple query
    log_info("SQL Query: #{sql}")
  end
end

#log_sql_timing(sql, execution_time) ⇒ Object (private)

Log SQL query timing information (Requirement 8.5)

Parameters:

  • sql (String)

    SQL statement

  • execution_time (Float)

    Time taken to execute in seconds

Since:

  • 0.1.0



995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
# File 'lib/sequel/adapters/shared/duckdb.rb', line 995

def log_sql_timing(sql, execution_time)
  return unless log_connection_info?

  # Log timing information, highlighting slow operations
  time_ms = (execution_time * 1000).round(2)

  if execution_time > 1.0 # Log slow operations (> 1 second) as warnings
    log_warn("SLOW SQL Query (#{time_ms}ms): #{sql}")
  else
    log_info("SQL Query completed in #{time_ms}ms")
  end
end

#log_warn(message) ⇒ Object (private)

Log warning message using Sequel's logging system

Parameters:

  • message (String)

    Message to log

Since:

  • 0.1.0



1044
1045
1046
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1044

def log_warn(message)
  log_connection_yield("WARNING: #{message}", nil) { nil }
end

#map_duckdb_type_to_sequel(duckdb_type) ⇒ Symbol (private)

Map DuckDB data types to Sequel types

Parameters:

  • duckdb_type (String)

    DuckDB data type

Returns:

  • (Symbol)

    Sequel type symbol

Since:

  • 0.1.0



530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
# File 'lib/sequel/adapters/shared/duckdb.rb', line 530

def map_duckdb_type_to_sequel(duckdb_type)
  case duckdb_type.upcase
  when "INTEGER", "INT", "INT4", "SMALLINT", "INT2", "TINYINT", "INT1"
    :integer
  when "BIGINT", "INT8"
    :bigint
  when "REAL", "FLOAT4", "DOUBLE", "FLOAT8"
    :float
  when /^DECIMAL/, /^NUMERIC/
    :decimal
  when "BOOLEAN", "BOOL"
    :boolean
  when "DATE"
    :date
  when "TIMESTAMP", "DATETIME"
    :datetime
  when "TIME"
    :time
  when "BLOB", "BYTEA"
    :blob
  when "UUID"
    :uuid
  else
    # when "VARCHAR", "TEXT", "STRING"
    :string # Default fallback
  end
end

#parse_default_value(default_str) ⇒ Object? (private)

Parse default value from DuckDB format

Parameters:

  • default_str (String, nil)

    Default value string from DuckDB

Returns:

  • (Object, nil)

    Parsed default value

Since:

  • 0.1.0



562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
# File 'lib/sequel/adapters/shared/duckdb.rb', line 562

def parse_default_value(default_str)
  return nil if default_str.nil? || default_str.empty?

  # Handle common DuckDB default formats
  case default_str
  when /^CAST\('(.+)' AS BOOLEAN\)$/
    ::Regexp.last_match(1) == "t"
  when /^'(.+)'$/
    ::Regexp.last_match(1) # String literal
  when /^\d+$/
    default_str.to_i  # Integer literal
  when /^\d+\.\d+$/
    default_str.to_f  # Float literal
  when "NULL"
    nil
  else
    default_str # Return as-is for complex expressions
  end
end

#parse_index_columns(expressions_str) ⇒ Array<Symbol> (private)

Parse index column expressions from DuckDB format

Parameters:

  • expressions_str (String)

    JSON array string of column expressions

Returns:

  • (Array<Symbol>)

    Array of column names

Since:

  • 0.1.0



621
622
623
624
625
626
627
628
# File 'lib/sequel/adapters/shared/duckdb.rb', line 621

def parse_index_columns(expressions_str)
  return [] if expressions_str.nil? || expressions_str.empty?

  # DuckDB returns expressions as JSON array like "[column_name]" or "['\"column_name\"']"
  # Remove brackets and quotes, split by comma
  cleaned = expressions_str.gsub(/^\[|\]$/, "").gsub(/['"]/, "")
  cleaned.split(",").map(&:strip).map(&:to_sym)
end

#primary_key_column_sql(column, _opts) ⇒ String (private)

Generate SQL for primary key column

Parameters:

  • column (Symbol)

    Column name

  • _opts (Hash)

    Column options

Returns:

  • (String)

    SQL for primary key column

Since:

  • 0.1.0



831
832
833
834
835
836
837
# File 'lib/sequel/adapters/shared/duckdb.rb', line 831

def primary_key_column_sql(column, _opts)
  # DuckDB doesn't support AUTOINCREMENT, so we just use INTEGER PRIMARY KEY
  col_sql = String.new
  quote_identifier_append(col_sql, column)
  "#{col_sql} INTEGER PRIMARY KEY"
  # Don't add AUTOINCREMENT for DuckDB
end

#query_plan(sql) ⇒ String

Get query plan for a SQL statement

Parameters:

  • sql (String)

    SQL statement to analyze

Returns:

  • (String)

    Query plan as string

Since:

  • 0.1.0



1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1076

def query_plan(sql)
  plan_rows = explain_query(sql)

  if plan_rows.empty?
    "No query plan available"
  else
    # Format the plan rows into a readable string
    plan_rows.map { |row| row.values.join(" | ") }.join("\n")
  end
end

#quote_identifiers_defaultObject

Whether to quote identifiers by default for this database

Since:

  • 0.1.0



57
58
59
# File 'lib/sequel/adapters/shared/duckdb.rb', line 57

def quote_identifiers_default # rubocop:disable Naming/PredicateMethod
  true
end

#rollback_transaction(conn, _opts = {}) ⇒ void

This method returns an undefined value.

Rollback the current transaction manually Sequel calls this with (conn, opts) arguments

Parameters:

  • conn (::DuckDB::Connection)

    Database connection

  • _opts (Hash) (defaults to: {})

    Options

Since:

  • 0.1.0



724
725
726
# File 'lib/sequel/adapters/shared/duckdb.rb', line 724

def rollback_transaction(conn, _opts = {})
  conn.query("ROLLBACK")
end

#savepoint_transaction(opts = {}) ⇒ Object (private)

Handle savepoint-based nested transactions

Parameters:

  • opts (Hash) (defaults to: {})

    Transaction options

Returns:

  • (Object)

    Result of the transaction block

Since:

  • 0.1.0



751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
# File 'lib/sequel/adapters/shared/duckdb.rb', line 751

def savepoint_transaction(opts = {})
  # Generate a unique savepoint name
  savepoint_name = "sp_#{Time.now.to_f.to_s.gsub(".", "_")}"

  synchronize(opts[:server]) do |conn|
    # Create savepoint
    conn.query("SAVEPOINT #{savepoint_name}")

    # Execute the block
    result = yield

    # Release savepoint on success
    conn.query("RELEASE SAVEPOINT #{savepoint_name}")

    result
  rescue Sequel::Rollback
    # Rollback to savepoint on explicit rollback
    conn.query("ROLLBACK TO SAVEPOINT #{savepoint_name}")
    conn.query("RELEASE SAVEPOINT #{savepoint_name}")
    nil
  rescue StandardError => e
    # Rollback to savepoint on any other exception
    begin
      conn.query("ROLLBACK TO SAVEPOINT #{savepoint_name}")
      conn.query("RELEASE SAVEPOINT #{savepoint_name}")
    rescue ::DuckDB::Error
      # Ignore errors during rollback cleanup
    end
    raise e
  end
end

#schema(table_name, opts = {}) ⇒ Array<Array>

Get schema information for a table

Parameters:

  • table_name (Symbol, String, Dataset)

    Name of the table or dataset

  • opts (Hash) (defaults to: {})

    Options

Returns:

  • (Array<Array>)

    Schema information

Since:

  • 0.1.0



483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
# File 'lib/sequel/adapters/shared/duckdb.rb', line 483

def schema(table_name, opts = {})
  # Handle case where Sequel passes a Dataset object instead of table name
  if table_name.is_a?(Sequel::Dataset)
    # Extract table name from dataset
    if table_name.opts[:from]&.first
      actual_table_name = table_name.opts[:from].first
      # Handle case where table name is wrapped in an identifier
      actual_table_name = actual_table_name.value if actual_table_name.respond_to?(:value)
    else
      # Fallback: try to extract from SQL
      sql = table_name.sql
      raise Sequel::Error, "Cannot determine table name from dataset: #{table_name}" unless sql =~ /FROM\s+(\w+)/i

      actual_table_name = ::Regexp.last_match(1).to_sym

    end
  else
    actual_table_name = table_name
  end

  # Cache schema information for type conversion
  schema_info = schema_parse_table(actual_table_name, opts)
  @schema_cache ||= {}
  @schema_cache[actual_table_name] = {}

  schema_info.each do |column_name, column_info|
    @schema_cache[actual_table_name][column_name] = column_info
  end

  schema_info
end

#schema_parse_indexes(table_name, opts = {}) ⇒ Hash (private)

Parse index information for a table

Parameters:

  • table_name (Symbol, String)

    Name of the table

  • opts (Hash) (defaults to: {})

    Options for index parsing

Returns:

  • (Hash)

    Hash of index_name => index_info

Raises:

  • (Sequel::DatabaseError)

Since:

  • 0.1.0



328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
# File 'lib/sequel/adapters/shared/duckdb.rb', line 328

def schema_parse_indexes(table_name, opts = {})
  schema_name = opts[:schema] || "main"

  # First check if table exists
  raise Sequel::DatabaseError, "Table '#{table_name}' does not exist" unless table_exists?(table_name, opts)

  # Use duckdb_indexes() function to get index information
  sql = <<~SQL
    SELECT
      index_name,
      is_unique,
      is_primary,
      expressions,
      sql
    FROM duckdb_indexes()
    WHERE schema_name = ? AND table_name = ?
  SQL

  indexes = {}
  execute(sql, [schema_name, table_name.to_s]) do |row|
    index_name = row[:index_name].to_sym

    # Parse column expressions - DuckDB returns them as JSON array strings
    columns = parse_index_columns(row[:expressions])

    index_info = {
      columns: columns,
      unique: row[:is_unique],
      primary: row[:is_primary]
    }

    indexes[index_name] = index_info
  end

  indexes
end

#schema_parse_table(table_name, opts = {}) ⇒ Array<Array> (private)

Parse table schema information

Parameters:

  • table_name (Symbol, String)

    Name of the table

  • opts (Hash) (defaults to: {})

    Options for schema parsing

Returns:

  • (Array<Array>)

    Array of [column_name, column_info] pairs

Raises:

  • (Sequel::DatabaseError)

Since:

  • 0.1.0



264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
# File 'lib/sequel/adapters/shared/duckdb.rb', line 264

def schema_parse_table(table_name, opts = {})
  schema_name = opts[:schema] || "main"

  # First check if table exists
  raise Sequel::DatabaseError, "Table '#{table_name}' does not exist" unless table_exists?(table_name, opts)

  # Use information_schema.columns for detailed column information
  sql = <<~SQL
    SELECT
      column_name,
      ordinal_position,
      column_default,
      is_nullable,
      data_type,
      character_maximum_length,
      numeric_precision,
      numeric_scale
    FROM information_schema.columns
    WHERE table_schema = ? AND table_name = ?
    ORDER BY ordinal_position
  SQL

  columns = []
  execute(sql, [schema_name, table_name.to_s]) do |row|
    column_name = row[:column_name].to_sym

    # Map DuckDB types to Sequel types
    sequel_type = map_duckdb_type_to_sequel(row[:data_type])

    # Parse nullable flag
    allow_null = row[:is_nullable] == "YES"

    # Parse default value
    default_value = parse_default_value(row[:column_default])

    column_info = {
      type: sequel_type,
      db_type: row[:data_type],
      allow_null: allow_null,
      default: default_value,
      primary_key: false # Will be updated below
    }

    # Add size information for string types
    column_info[:max_length] = row[:character_maximum_length] if row[:character_maximum_length]

    # Add precision/scale for numeric types
    column_info[:precision] = row[:numeric_precision] if row[:numeric_precision]
    column_info[:scale] = row[:numeric_scale] if row[:numeric_scale]

    columns << [column_name, column_info]
  end

  # Update primary key information
  update_primary_key_info(table_name, columns, opts)

  columns
end

#schema_parse_tables(opts = {}) ⇒ Array<Symbol> (private)

Parse table list from database

Parameters:

  • opts (Hash) (defaults to: {})

    Options for table parsing

Returns:

  • (Array<Symbol>)

    Array of table names as symbols

Since:

  • 0.1.0



246
247
248
249
250
251
252
253
254
255
256
257
# File 'lib/sequel/adapters/shared/duckdb.rb', line 246

def schema_parse_tables(opts = {})
  schema_name = opts[:schema] || "main"

  sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_type = 'BASE TABLE'"

  tables = []
  execute(sql, [schema_name]) do |row|
    tables << row[:table_name].to_sym
  end

  tables
end

#set_config_value(key, value) ⇒ Object

Set DuckDB configuration value

Parameters:

  • key (String)

    Configuration key

  • value (Object)

    Configuration value

Since:

  • 0.1.0



1112
1113
1114
1115
1116
1117
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1112

def set_config_value(key, value)
  synchronize do |conn|
    # Use PRAGMA for DuckDB configuration
    conn.query("PRAGMA #{key} = #{value}")
  end
end

#set_pragma(key, value) ⇒ void

This method returns an undefined value.

Set a DuckDB PRAGMA setting

This method provides a user-friendly wrapper around DuckDB's PRAGMA statements. PRAGMA statements are used to configure various DuckDB settings and behaviors.

Examples:

Set memory limit

db.set_pragma("memory_limit", "2GB")
db.set_pragma(:memory_limit, "1GB")

Set thread count

db.set_pragma("threads", 4)

Enable/disable features

db.set_pragma("enable_progress_bar", true)
db.set_pragma("enable_profiling", false)

Parameters:

  • key (String, Symbol)

    The pragma setting name

  • value (Object)

    The value to set (will be converted to appropriate format)

Raises:

  • (Sequel::DatabaseError)

    If the pragma setting is invalid or fails

See Also:

Since:

  • 0.1.0



393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
# File 'lib/sequel/adapters/shared/duckdb.rb', line 393

def set_pragma(key, value)
  # Convert key to string for consistency
  pragma_key = key.to_s

  # Format value appropriately for SQL
  formatted_value = case value
                    when String
                      "'#{value.gsub("'", "''")}'" # Escape single quotes
                    when TrueClass, FalseClass, Numeric
                      value.to_s
                    else
                      "'#{value}'"
                    end

  # Execute PRAGMA statement
  pragma_sql = "PRAGMA #{pragma_key} = #{formatted_value}"

  begin
    execute(pragma_sql)
  rescue StandardError => e
    raise Sequel::DatabaseError, "Failed to set pragma #{pragma_key}: #{e.message}"
  end
end

#supports_autocommit_control?Boolean

Check if DuckDB supports autocommit control

Returns:

  • (Boolean)

    true if autocommit can be controlled

Since:

  • 0.1.0



664
665
666
667
# File 'lib/sequel/adapters/shared/duckdb.rb', line 664

def supports_autocommit_control?
  # DuckDB has autocommit behavior but limited control over it
  false
end

#supports_autocommit_disable?Boolean

Check if DuckDB supports disabling autocommit

Returns:

  • (Boolean)

    true if autocommit can be disabled

Since:

  • 0.1.0



672
673
674
675
# File 'lib/sequel/adapters/shared/duckdb.rb', line 672

def supports_autocommit_disable?
  # DuckDB doesn't support disabling autocommit mode
  false
end

#supports_autoincrement?Boolean

DuckDB doesn't support AUTOINCREMENT

Returns:

  • (Boolean)

Since:

  • 0.1.0



52
53
54
# File 'lib/sequel/adapters/shared/duckdb.rb', line 52

def supports_autoincrement?
  false
end

#supports_explain?Boolean

Check if EXPLAIN functionality is supported

Returns:

  • (Boolean)

    true if EXPLAIN is supported

Since:

  • 0.1.0



1090
1091
1092
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1090

def supports_explain?
  true # DuckDB supports EXPLAIN
end

#supports_manual_transaction_control?Boolean

Check if DuckDB supports manual transaction control

Returns:

  • (Boolean)

    true if manual transaction control is supported

Since:

  • 0.1.0



656
657
658
659
# File 'lib/sequel/adapters/shared/duckdb.rb', line 656

def supports_manual_transaction_control?
  # DuckDB supports BEGIN, COMMIT, and ROLLBACK statements
  true
end

#supports_savepoints?Boolean

Check if DuckDB supports savepoints for nested transactions

Returns:

  • (Boolean)

    true if savepoints are supported

Since:

  • 0.1.0



637
638
639
640
641
# File 'lib/sequel/adapters/shared/duckdb.rb', line 637

def supports_savepoints?
  # DuckDB does not currently support SAVEPOINT/ROLLBACK TO SAVEPOINT syntax
  # Nested transactions are handled by Sequel's default behavior
  false
end

#supports_transaction_isolation_level?(_level) ⇒ Boolean

Check if DuckDB supports the specified transaction isolation level

Parameters:

  • _level (Symbol)

    Isolation level (:read_uncommitted, :read_committed, :repeatable_read, :serializable)

Returns:

  • (Boolean)

    true if the isolation level is supported

Since:

  • 0.1.0



647
648
649
650
651
# File 'lib/sequel/adapters/shared/duckdb.rb', line 647

def supports_transaction_isolation_level?(_level)
  # DuckDB does not currently support setting transaction isolation levels
  # It uses a default isolation level similar to READ_COMMITTED
  false
end

#table_exists?(table_name, opts = {}) ⇒ Boolean

Check if table exists

Parameters:

  • table_name (Symbol, String)

    Name of the table

  • opts (Hash) (defaults to: {})

    Options

Returns:

  • (Boolean)

    true if table exists

Since:

  • 0.1.0



457
458
459
460
461
462
463
464
465
466
467
468
# File 'lib/sequel/adapters/shared/duckdb.rb', line 457

def table_exists?(table_name, opts = {})
  schema_name = opts[:schema] || "main"

  sql = "SELECT 1 FROM information_schema.tables WHERE table_schema = ? AND table_name = ? LIMIT 1"

  result = nil
  execute(sql, [schema_name, table_name.to_s]) do |_row|
    result = true
  end

  !!result
end

#tables(opts = {}) ⇒ Array<Symbol>

Get list of tables

Parameters:

  • opts (Hash) (defaults to: {})

    Options

Returns:

  • (Array<Symbol>)

    Array of table names

Since:

  • 0.1.0



474
475
476
# File 'lib/sequel/adapters/shared/duckdb.rb', line 474

def tables(opts = {})
  schema_parse_tables(opts)
end

#transaction(opts = {}) ⇒ Object

Override Sequel's transaction method to support advanced features

Since:

  • 0.1.0



729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
# File 'lib/sequel/adapters/shared/duckdb.rb', line 729

def transaction(opts = {}, &)
  # Handle savepoint transactions (nested transactions)
  return savepoint_transaction(opts, &) if opts[:savepoint] && supports_savepoints?

  # Handle isolation level setting
  if opts[:isolation] && supports_transaction_isolation_level?(opts[:isolation])
    return isolation_transaction(
      opts,
      &
    )
  end

  # Fall back to standard Sequel transaction handling
  super
end

#type_literal(opts) ⇒ String (private)

Map Ruby types to DuckDB types

Parameters:

  • opts (Hash)

    Column options

Returns:

  • (String)

    DuckDB type

Since:

  • 0.1.0



862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
# File 'lib/sequel/adapters/shared/duckdb.rb', line 862

def type_literal(opts)
  case opts[:type]
  when :primary_key, :integer
    "INTEGER"
  when :string, :text
    if opts[:size]
      "VARCHAR(#{opts[:size]})"
    else
      "VARCHAR"
    end
  when :bigint
    "BIGINT"
  when :float, :real
    "REAL"
  when :double
    "DOUBLE"
  when :decimal, :numeric
    if opts[:size]
      "DECIMAL(#{Array(opts[:size]).join(",")})"
    else
      "DECIMAL"
    end
  when :boolean
    "BOOLEAN"
  when :date
    "DATE"
  when :datetime, :timestamp
    "TIMESTAMP"
  when :time
    "TIME"
  when :blob, :binary
    "BLOB"
  else
    super
  end
end

#typecast_value(column, value) ⇒ Object

Since:

  • 0.1.0



1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1203

def typecast_value(column, value)
  return value if value.nil?

  # Get column schema information to determine the correct type
  if @schema_cache && @schema_cache[column]
    column_type = @schema_cache[column][:type]
    case column_type
    when :time
      return typecast_value_time(value)
    end
  end

  # Fall back to default Sequel type conversion
  super
end

#typecast_value_time(value) ⇒ Object (private)

Convert DuckDB TIME values to Ruby time-only objects DuckDB TIME columns should only contain time-of-day information

Since:

  • 0.1.0



1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
# File 'lib/sequel/adapters/shared/duckdb.rb', line 1175

def typecast_value_time(value)
  case value
  when Time
    # Extract only the time portion, discarding date information
    # Create a new Time object with today's date but the original time
    Time.local(1970, 1, 1, value.hour, value.min, value.sec, value.usec)
  when String
    # Parse time string and create time-only object
    if value =~ /\A(\d{1,2}):(\d{2}):(\d{2})(?:\.(\d+))?\z/
      hour = ::Regexp.last_match(1).to_i
      min = ::Regexp.last_match(2).to_i
      sec = ::Regexp.last_match(3).to_i
      usec = (::Regexp.last_match(4) || "0").ljust(6, "0").to_i
      Time.local(1970, 1, 1, hour, min, sec, usec)
    else
      # Fallback: parse as time and extract time portion
      parsed = Time.parse(value.to_s)
      Time.local(1970, 1, 1, parsed.hour, parsed.min, parsed.sec, parsed.usec)
    end
  else
    value
  end
end

#update_primary_key_info(table_name, columns, opts = {}) ⇒ Object (private)

Update primary key information in column schema

Parameters:

  • table_name (Symbol, String)

    Table name

  • columns (Array)

    Array of column information

  • opts (Hash) (defaults to: {})

    Options

Since:

  • 0.1.0



587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
# File 'lib/sequel/adapters/shared/duckdb.rb', line 587

def update_primary_key_info(table_name, columns, opts = {})
  schema_name = opts[:schema] || "main"

  # Query for primary key constraints
  sql = <<~SQL
    SELECT column_name
    FROM information_schema.table_constraints tc
    JOIN information_schema.key_column_usage kcu
      ON tc.constraint_name = kcu.constraint_name
      AND tc.table_schema = kcu.table_schema
      AND tc.table_name = kcu.table_name
    WHERE tc.constraint_type = 'PRIMARY KEY'
      AND tc.table_schema = ?
      AND tc.table_name = ?
  SQL

  primary_key_columns = []
  execute(sql, [schema_name, table_name.to_s]) do |row|
    primary_key_columns << row[:column_name].to_sym
  end

  # Update primary key flag for matching columns
  columns.each do |column_name, column_info|
    if primary_key_columns.include?(column_name)
      column_info[:primary_key] = true
      column_info[:allow_null] = false # Primary keys cannot be null
    end
  end
end