diff --git a/lib/active_record/connection_adapters/duckdb/database_statements.rb b/lib/active_record/connection_adapters/duckdb/database_statements.rb index be95a13..21228a0 100644 --- a/lib/active_record/connection_adapters/duckdb/database_statements.rb +++ b/lib/active_record/connection_adapters/duckdb/database_statements.rb @@ -3,40 +3,289 @@ module ActiveRecord module ConnectionAdapters module Duckdb - module DatabaseStatements # :nodoc: - def write_query?(sql) # :nodoc: - false + module DatabaseStatements + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] sql SQL to execute + # @param [String, nil] name Query name for logging + # @param [Boolean] allow_retry Whether to allow retry on failure + # @return [Object] Query result + def execute(sql, name = nil, allow_retry: false) + internal_execute(sql, name, allow_retry: allow_retry) + end + + # @note internal execution wrapper for DuckDB + # @param [String] sql SQL to execute + # @param [String] name Query name for logging + # @param [Array] binds Bind parameters + # @param [Boolean] prepare Whether to prepare statement + # @param [Boolean] async Whether to execute asynchronously + # @param [Boolean] allow_retry Whether to allow retry on failure + # @param [Boolean] materialize_transactions Whether to materialize transactions + # @return [Object] Query result + def internal_execute(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, &block) + raw_execute(sql, name, binds, prepare: prepare, async: async, allow_retry: allow_retry, materialize_transactions: materialize_transactions, &block) end - def execute(sql, name = nil) # :nodoc: - sql = transform_query(sql) + # @override + # @note Implements AbstractAdapter interface method - These methods need to return integers for update_all and delete_all + # @param [Object] arel Arel object or SQL string + # @param [String, nil] name Query name for logging + # @param [Array] binds Bind parameters + # @return [Integer] Number of affected rows + def update(arel, name = nil, binds = []) + sql, binds = to_sql_and_binds(arel, binds) + result = internal_execute(sql, name, binds) + extract_row_count(result, sql) + end + + # @override + # @note Implements AbstractAdapter interface method - These methods need to return integers for update_all and delete_all + # @param [Object] arel Arel object or SQL string + # @param [String, nil] name Query name for logging + # @param [Array] binds Bind parameters + # @return [Integer] Number of affected rows + def delete(arel, name = nil, binds = []) + sql, binds = to_sql_and_binds(arel, binds) + result = internal_execute(sql, name, binds) + extract_row_count(result, sql) + end - log(sql, name) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - @connection.query(sql) + # @override + # @note Implements AbstractAdapter interface method + # @param [String] sql SQL to execute + # @param [String] name Query name for logging + # @param [Array] binds Bind parameters + # @param [Boolean] prepare Whether to prepare statement + # @param [Boolean] async Whether to execute asynchronously + # @param [Boolean] allow_retry Whether to allow retry on failure + # @param [Boolean] materialize_transactions Whether to materialize transactions + # @return [ActiveRecord::Result] Query result as ActiveRecord::Result + def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true) + result = internal_execute(sql, name, binds, prepare: prepare, async: async, allow_retry: allow_retry, materialize_transactions: materialize_transactions) + + # Convert DuckDB result to ActiveRecord::Result + raw_cols = result.columns || [] + cols = raw_cols.map { |col| col.respond_to?(:name) ? col.name : col.to_s } + rows = result.to_a || [] + + ActiveRecord::Result.new(cols, rows) + end + + # @note raw execution for DuckDB + # @param [String] sql SQL to execute + # @param [String, nil] name Query name for logging + # @param [Array] binds Bind parameters + # @param [Boolean] prepare Whether to prepare statement + # @param [Boolean] async Whether to execute asynchronously + # @param [Boolean] allow_retry Whether to allow retry on failure + # @param [Boolean] materialize_transactions Whether to materialize transactions + # @param [Boolean] batch Whether to execute in batch mode + # @return [Object] Query result + def raw_execute(sql, name = nil, binds = [], prepare: false, async: false, allow_retry: false, materialize_transactions: true, batch: false) + type_casted_binds = type_casted_binds(binds) + log(sql, name, binds, type_casted_binds, async: async) do |notification_payload| + with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn| + perform_query(conn, sql, binds, type_casted_binds, prepare: prepare, notification_payload: notification_payload, batch: batch) end end end - def exec_query(sql, name = nil, binds = [], prepare: false, async: false) # :nodoc: - result = execute_and_clear(sql, name, binds, prepare: prepare, async: async) + # @note DuckDB-specific query execution + # @param [Object] raw_connection Raw database connection + # @param [String] sql SQL to execute + # @param [Array] binds Bind parameters + # @param [Array] type_casted_binds Type-casted bind parameters + # @param [Boolean] prepare Whether to prepare statement + # @param [Object] notification_payload Notification payload for logging + # @param [Boolean] batch Whether to execute in batch mode + # @return [Object] Query result + def perform_query(raw_connection, sql, binds, type_casted_binds, prepare:, notification_payload:, batch: false) + # Use DuckDB's native parameter binding - clean and secure + bind_values = extract_bind_values(type_casted_binds, binds) + + if bind_values&.any? + @raw_connection.query(sql, *bind_values) + else + @raw_connection.query(sql) + end + end - # TODO: https://github.com/suketa/ruby-duckdb/issues/168 - # build_result(columns: result.columns, rows: result.to_a) - if result.to_a.first&.size == 1 - build_result(columns: ['count'], rows: result.to_a) - elsif result.to_a.first&.size == 2 - build_result(columns: ['id', 'name'], rows: result.to_a) + # @override + # @note Implements AbstractAdapter interface method + # @param [String] sql SQL to execute + # @param [String, nil] name Query name for logging + # @return [Object] Query result + def query(sql, name = nil) + result = internal_execute(sql, name) + result + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] sql SQL to explain + # @return [String] Pretty-printed explanation + def explain(sql) + result = internal_exec_query(sql, "EXPLAIN") + Duckdb::ExplainPrettyPrinter.new.pp(result) + end + + # @override + # @note Implements AbstractAdapter interface method - Executes an INSERT statement and returns the ID of the newly inserted record + # @param [String] sql INSERT SQL to execute + # @param [String, nil] name Query name for logging + # @param [Array] binds Bind parameters + # @param [String, nil] pk Primary key column name + # @param [String, nil] sequence_name Sequence name for auto-increment + # @param [String, nil] returning RETURNING clause + # @return [ActiveRecord::Result] Result containing inserted ID + def exec_insert(sql, name = nil, binds = [], pk = nil, sequence_name = nil, returning: nil) + if pk && supports_insert_returning? + # Use INSERT...RETURNING to get the inserted ID + returning_sql = sql.sub(/\bINSERT\b/i, "INSERT").concat(" RETURNING #{quote_column_name(pk)}") + internal_exec_query(returning_sql, name, binds) else - build_result(columns: ['id', 'author', 'title', 'body', 'count'], rows: result.to_a) + # Regular insert - return result from internal_execute + internal_execute(sql, name, binds) + # Return an empty result since we don't have the ID + ActiveRecord::Result.new([], []) end end - def exec_delete(sql, name = nil, binds = []) # :nodoc: - result = execute_and_clear(sql, name, binds) - result.rows_changed + private + + # @note extract row count from DuckDB result + # @param [Object] result Query result + # @param [String] sql Original SQL query + # @return [Integer] Number of affected rows + def extract_row_count(result, sql) + if result.respond_to?(:to_a) + rows = result.to_a + if rows.length == 1 && rows[0].length == 1 + count = rows[0][0] + return count.is_a?(Integer) ? count : count.to_i + end + end + 0 end - alias :exec_update :exec_delete + + # @note convert Arel to SQL string + # @param [Object] arel Arel object or SQL string + # @param [Array] binds Bind parameters (unused) + # @return [String] SQL string + def to_sql(arel, binds = []) + if arel.respond_to?(:to_sql) + arel.to_sql + else + arel.to_s + end + end + + # @note Convert Arel to SQL and extract bind parameters + # @param [Object] arel_or_sql_string Arel object or SQL string + # @param [Array] binds Bind parameters + # @param [Array] args Additional arguments + # @return [Array] Array containing SQL string and bind parameters + def to_sql_and_binds(arel_or_sql_string, binds = [], *args) + # For simple cases, delegate to parent implementation if it exists + if defined?(super) + begin + return super(arel_or_sql_string, binds, *args) + rescue NoMethodError + # Fall through to our implementation + end + end + + # Our simplified implementation for basic cases + if arel_or_sql_string.respond_to?(:ast) + # For Arel objects, visit the AST to get SQL and collect binds + visitor = arel_visitor + collector = Arel::Collectors::SQLString.new + visitor.accept(arel_or_sql_string.ast, collector) + sql = collector.value + + # Extract binds from the visitor if it collected them + visitor_binds = if visitor.respond_to?(:binds) + visitor.binds + else + [] + end + + result = [sql, binds + visitor_binds] + # Add any additional args back to maintain signature compatibility + args.each { |arg| result << arg } + result + elsif arel_or_sql_string.respond_to?(:to_sql) + # For objects with to_sql method, use it directly + result = [arel_or_sql_string.to_sql, binds] + args.each { |arg| result << arg } + result + else + # For plain strings, return as-is + result = [arel_or_sql_string.to_s, binds] + args.each { |arg| result << arg } + result + end + end + + # @note get Arel visitor for SQL generation + # @return [Object] Arel visitor instance + def arel_visitor + connection_pool.get_schema_cache(connection).arel_visitor + rescue + # Fallback for older ActiveRecord versions or if schema cache is not available + Arel::Visitors::ToSql.new(self) + end + + # @override + # @note Implements AbstractAdapter interface method - ActiveRecord calls this method to get properly type-cast bind parameters + # @param [Array] binds Array of bind parameters + # @return [Array] Array of type-cast values + def type_casted_binds(binds) + if binds.empty? + [] + else + binds.map do |attr| + if attr.respond_to?(:value_for_database) + value = attr.value_for_database + # Handle ActiveRecord timestamp value objects that DuckDB doesn't understand + if value.class.name == 'ActiveRecord::Type::Time::Value' + # Convert to a proper Time object that DuckDB can handle + Time.parse(value.to_s) + else + value + end + else + type_cast(attr) + end + end + end + end + + # @note extract bind values for DuckDB parameter binding + # @param [Array] type_casted_binds Type-casted bind parameters + # @param [Array] binds Original bind parameters + # @return [Array, nil] Array of bind values or nil if none + def extract_bind_values(type_casted_binds, binds) + # Prefer type_casted_binds as they are pre-processed by ActiveRecord + return type_casted_binds if type_casted_binds&.any? + + # Extract values from bind objects if no type_casted_binds + return nil unless binds&.any? + + binds.map do |bind| + case bind + when Array + # [column, value] format + bind[1] + else + # Extract value from attribute objects or use direct value + bind.respond_to?(:value) ? bind.value : bind + end + end + end + end end end diff --git a/lib/active_record/connection_adapters/duckdb/explain_pretty_printer.rb b/lib/active_record/connection_adapters/duckdb/explain_pretty_printer.rb new file mode 100644 index 0000000..da0c043 --- /dev/null +++ b/lib/active_record/connection_adapters/duckdb/explain_pretty_printer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module Duckdb + class ExplainPrettyPrinter # :nodoc: + # @note Pretty prints the result of an EXPLAIN QUERY PLAN in a way that resembles the output of the SQLite shell + # @example Output format + # 0|0|0|SEARCH TABLE users USING INTEGER PRIMARY KEY (rowid=?) (~1 rows) + # 0|1|1|SCAN TABLE posts (~100000 rows) + # @param [ActiveRecord::Result] result Query result containing explain output + # @return [String] Pretty-printed explanation with newlines + def pp(result) + result.rows.map do |row| + row.join("|") + end.join("\n") + "\n" + end + end + end + end +end diff --git a/lib/active_record/connection_adapters/duckdb/quoting.rb b/lib/active_record/connection_adapters/duckdb/quoting.rb new file mode 100644 index 0000000..01b6023 --- /dev/null +++ b/lib/active_record/connection_adapters/duckdb/quoting.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionAdapters + module Duckdb + module Quoting # :nodoc: + extend ActiveSupport::Concern + + QUOTED_COLUMN_NAMES = Concurrent::Map.new # :nodoc: + QUOTED_TABLE_NAMES = Concurrent::Map.new # :nodoc: + + module ClassMethods # :nodoc: + # @note regex pattern for column name matching + # @return [Regexp] Regular expression for matching column names + def column_name_matcher + / + \A + ( + (?: + # "table_name"."column_name" | function(one or no argument) + ((?:\w+\.|"\w+"\.)?(?:\w+|"\w+") | \w+\((?:|\g<2>)\)) + ) + (?:(?:\s+AS)?\s+(?:\w+|"\w+"))? + ) + (?:\s*,\s*\g<1>)* + \z + /ix + end + + # @note regex pattern for column name with order matching + # @return [Regexp] Regular expression for matching column names with order + def column_name_with_order_matcher + / + \A + ( + (?: + # "table_name"."column_name" | function(one or no argument) + ((?:\w+\.|"\w+"\.)?(?:\w+|"\w+") | \w+\((?:|\g<2>)\)) + ) + (?:\s+COLLATE\s+(?:\w+|"\w+"))? + (?:\s+ASC|\s+DESC)? + ) + (?:\s*,\s*\g<1>)* + \z + /ix + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String, Symbol] name Column name to quote + # @return [String] Quoted column name + def quote_column_name(name) + QUOTED_COLUMN_NAMES[name] ||= %Q("#{name.to_s.gsub('"', '""')}").freeze + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String, Symbol] name Table name to quote + # @return [String] Quoted table name + def quote_table_name(name) + QUOTED_TABLE_NAMES[name] ||= %Q("#{name.to_s.gsub('"', '""').gsub(".", "\".\"")}").freeze + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] s String to quote + # @return [String] Quoted string with escaped single quotes + def quote_string(s) + s.gsub("'", "''") # Escape single quotes by doubling them + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table Table name (unused) + # @param [String] attr Attribute name + # @return [String] Quoted column name + def quote_table_name_for_assignment(table, attr) + quote_column_name(attr) + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [Time] value Time value to quote + # @return [String] Quoted time string + def quoted_time(value) + value = value.change(year: 2000, month: 1, day: 1) + quoted_date(value).sub(/\A\d\d\d\d-\d\d-\d\d /, "2000-01-01 ") + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] value Binary value to quote + # @return [String] Quoted binary string in hex format + def quoted_binary(value) + "x'#{value.hex}'" + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [String] Quoted true value for DuckDB + def quoted_true + "1" + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Integer] Unquoted true value for DuckDB + def unquoted_true + 1 + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [String] Quoted false value for DuckDB + def quoted_false + "0" + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Integer] Unquoted false value for DuckDB + def unquoted_false + 0 + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [Object] value Default value to quote + # @param [ActiveRecord::ConnectionAdapters::Column] column Column object + # @return [String] Quoted default expression + def quote_default_expression(value, column) # :nodoc: + if value.is_a?(Proc) + value = value.call + # Don't wrap nextval() calls in extra parentheses + value + elsif value.is_a?(String) && value.match?(/\Anextval\(/i) + # Handle nextval function calls for sequences - don't quote them + value + else + super + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [Object] value Value to type cast + # @return [Object] Type-cast value + def type_cast(value) # :nodoc: + case value + when BigDecimal, Rational + value.to_f + when String + if value.encoding == Encoding::ASCII_8BIT + super(value.encode(Encoding::UTF_8)) + else + super + end + else + super + end + end + end + end + end +end diff --git a/lib/active_record/connection_adapters/duckdb/schema_statements.rb b/lib/active_record/connection_adapters/duckdb/schema_statements.rb index e694b8f..4d5a90f 100644 --- a/lib/active_record/connection_adapters/duckdb/schema_statements.rb +++ b/lib/active_record/connection_adapters/duckdb/schema_statements.rb @@ -1,37 +1,194 @@ # frozen_string_literal: true -require 'debug' module ActiveRecord module ConnectionAdapters module Duckdb module SchemaStatements # :nodoc: - private - def new_column_from_field(table_name, field) - _cid, name, type, notnull, _dflt_value, _pk = field - - Column.new( - name, - nil, # default value - fetch_type_metadata(type), - !notnull, - nil, # default function - ) + + # @override + # @note Implements AbstractAdapter interface method - Returns an array of indexes for the given table + # @param [String] table_name Name of the table + # @return [Array] Array of index objects (currently empty) + def indexes(table_name) + # DuckDB uses duckdb_indexes() function for index information + # Since we may not have access to the duckdb_indexes() function in all contexts, + # we'll return an empty array for now + # TODO: Implement proper index querying when DuckDB Ruby driver supports it + [] + end + + # @override + # @note Implements AbstractAdapter interface method - Checks to see if the data source +name+ exists on the database + # @example + # data_source_exists?(:ebooks) + # @param [String, Symbol] name Name of the data source + # @return [Boolean] true if data source exists, false otherwise + def data_source_exists?(name) + return false unless name.present? + data_sources.include?(name.to_s) + end + + # @note generates SQL for data source queries + # @param [String, nil] name Data source name + # @param [String, nil] type Data source type + # @return [String] SQL query string + def data_source_sql(name = nil, type: nil) + scope = quoted_scope(name, type: type) + + sql = +"SELECT table_name FROM information_schema.tables" + sql << " WHERE table_schema = #{scope[:schema]}" + if scope[:type] || scope[:name] + conditions = [] + conditions << "table_type = #{scope[:type]}" if scope[:type] + conditions << "table_name = #{scope[:name]}" if scope[:name] + sql << " AND #{conditions.join(" AND ")}" end + sql + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table_name Name of the table + # @return [Boolean] true if table exists, false otherwise + def table_exists?(table_name) + return false unless table_name.present? + + sql = "SELECT COUNT(*) FROM information_schema.tables WHERE table_name = #{quote(table_name.to_s)} AND table_schema = 'main'" + query_value(sql, "SCHEMA") > 0 + end - def data_source_sql(name = nil, type: nil) - scope = quoted_scope(name, type: type) + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table_name Name of the table to create + # @param [Symbol, String, Boolean] id Primary key configuration + # @param [String, nil] primary_key Primary key column name + # @param [Boolean, nil] force Whether to drop existing table + # @param [Hash] options Additional table options + # @return [ActiveRecord::ConnectionAdapters::TableDefinition] Table definition + def create_table(table_name, id: :primary_key, primary_key: nil, force: nil, **options) + if force + drop_table(table_name, if_exists: true, **options) + end - sql = +"SELECT table_name FROM information_schema.tables" - sql << " WHERE table_schema = #{scope[:schema]}" - if scope[:type] || scope[:name] - conditions = [] - conditions << "table_type = #{scope[:type]}" if scope[:type] - conditions << "table_name = #{scope[:name]}" if scope[:name] - sql << " AND #{conditions.join(" AND ")}" + td = create_table_definition(table_name, **options) + + # Add primary key unless explicitly disabled + if id != false + case id + when :primary_key, true + # DuckDB native auto-increment: create sequence then use it as column default + pk_name = primary_key || default_primary_key_name + + # Add primary key column with auto-increment via sequence + # This follows DuckDB's documented pattern for auto-increment primary keys + add_auto_increment_primary_key(td, table_name, pk_name) + when Symbol, String + # For other primary key types, delegate to parent behavior + td.primary_key id, primary_key, **options end - sql end + yield td if block_given? + + if supports_comments? && !supports_comments_in_create? + change_table_comment(table_name, options[:comment]) if options[:comment].present? + end + + execute schema_creation.accept(td) + td + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Array] Array of data source names + def data_sources + sql = "SELECT table_name FROM information_schema.tables WHERE table_schema = 'main'" + execute(sql).map { |row| row[0] } + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table_name Name of the table to drop + # @param [Boolean] if_exists Whether to use IF EXISTS clause + # @param [Hash] options Additional drop options + # @return [void] + def drop_table(table_name, if_exists: false, **options) + sql = +"DROP TABLE" + sql << " IF EXISTS" if if_exists + sql << " #{quote_table_name(table_name)}" + execute sql + end + + private + # @note creates new column from DuckDB field information + # @param [String] table_name Name of the table + # @param [Array] field Field information array + # @param [Object, nil] type_metadata Type metadata object + # @return [ActiveRecord::ConnectionAdapters::Column] Column object + def new_column_from_field(table_name, field, type_metadata = nil) + # DuckDB information_schema returns: column_name, data_type, is_nullable, column_default + column_name, data_type, is_nullable, column_default = field + + # For auto-increment columns, DuckDB might return internal default expressions + # that we don't want to expose as ActiveRecord column defaults + if column_default && (column_default.match?(/\Anextval\(/i) || column_default.match?(/\Aautoincrement/i)) + column_default = nil + end + + # Convert DuckDB data types to ActiveRecord types + sql_type_metadata = type_metadata || fetch_type_metadata(data_type) + + ActiveRecord::ConnectionAdapters::Column.new( + column_name, + column_default, + sql_type_metadata, + is_nullable == 'YES' + ) + end + + # @note converts DuckDB data types to ActiveRecord type metadata + # @param [String] sql_type DuckDB SQL type string + # @return [ActiveRecord::ConnectionAdapters::SqlTypeMetadata] Type metadata object + def fetch_type_metadata(sql_type) + # Convert DuckDB data types to ActiveRecord types + cast_type = case sql_type.downcase + when /^integer/i + :integer + when /^bigint/i + :bigint + when /^varchar/i, /^text/i + :string + when /^decimal/i, /^numeric/i + :decimal + when /^real/i, /^double/i, /^float/i + :float + when /^boolean/i + :boolean + when /^date$/i + :date + when /^time/i + :time + when /^timestamp/i + :datetime + when /^blob/i + :binary + when /^uuid/i + :string # DuckDB UUID as string for now + else + :string # fallback + end + + # Create type metadata + ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( + sql_type: sql_type, + type: cast_type + ) + end + + # @note creates quoted scope for SQL queries + # @param [String, nil] name Table or data source name + # @param [String, nil] type Data source type + # @return [Hash] Hash containing quoted scope elements def quoted_scope(name = nil, type: nil) schema, name = extract_schema_qualified_name(name) scope = {} @@ -41,13 +198,69 @@ def quoted_scope(name = nil, type: nil) scope end + # @note extracts schema and name from qualified name string + # @param [String, Symbol] string Qualified name string + # @return [Array] Array containing schema and name def extract_schema_qualified_name(string) schema, name = string.to_s.scan(/[^`.\s]+|`[^`]*`/) schema, name = nil, schema unless name [schema, name] end + + # @note creates table definition for create_table + # @param [String] table_name Name of the table + # @param [Hash] options Table creation options + # @return [ActiveRecord::ConnectionAdapters::TableDefinition] Table definition object + def create_table_definition(table_name, **options) + ActiveRecord::ConnectionAdapters::TableDefinition.new( + self, + table_name, + **options + ) + end + + # @note returns default primary key name + # @return [String] Default primary key column name + def default_primary_key_name + "id" + end + + # @note DuckDB doesn't support table comments yet + # @return [Boolean] false, as DuckDB doesn't support table comments + def supports_comments? + false + end + + # @note DuckDB doesn't support comments in CREATE statements + # @return [Boolean] false, as DuckDB doesn't support comments in CREATE + def supports_comments_in_create? + false + end + + # @note returns schema creation helper + # @return [ActiveRecord::ConnectionAdapters::SchemaCreation] Schema creation helper + def schema_creation + ActiveRecord::ConnectionAdapters::SchemaCreation.new(self) + end + + # @note adds auto-increment primary key using DuckDB's native sequence approach + # @param [ActiveRecord::ConnectionAdapters::TableDefinition] td Table definition + # @param [String] table_name Name of the table + # @param [String] pk_name Primary key column name + # @return [void] + def add_auto_increment_primary_key(td, table_name, pk_name) + sequence_name = "#{table_name}_#{pk_name}_seq" + + # Use DuckDB's native sequence approach - this is the official DuckDB pattern + # Create sequence first, then reference it in the column default + execute "CREATE SEQUENCE IF NOT EXISTS #{quote_table_name(sequence_name)}" + + # Add the column with nextval() as default - DuckDB's standard auto-increment pattern + td.column pk_name, :bigint, primary_key: true, default: -> { "nextval('#{sequence_name}')" } + end + + end end end end - \ No newline at end of file diff --git a/lib/active_record/connection_adapters/duckdb/tasks.rb b/lib/active_record/connection_adapters/duckdb/tasks.rb new file mode 100644 index 0000000..b1f23f8 --- /dev/null +++ b/lib/active_record/connection_adapters/duckdb/tasks.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require 'fileutils' + +module ActiveRecord + module Tasks # :nodoc: + class DuckdbDatabaseTasks # :nodoc: + # @override + # @note Implements ActiveRecord::Tasks interface method + # @return [Boolean] true if using database configurations + def self.using_database_configurations? + true + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @param [Object] db_config Database configuration object + # @param [String, nil] root Root directory path + # @return [DuckdbDatabaseTasks] New database tasks instance + def initialize(db_config, root = nil) + @db_config = db_config + @root = root || determine_root_directory + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @raise [ArgumentError] if no database file specified + # @raise [ActiveRecord::DatabaseAlreadyExists] if database already exists + # @raise [ActiveRecord::DatabaseConnectionError] if connection fails + # @return [void] + def create + database_path = db_config.respond_to?(:database) ? db_config.database : db_config[:database] + + # Handle in-memory databases + if database_path == ":memory:" + # In-memory databases are created when connected to + establish_connection + return + end + + # Handle file-based databases + unless database_path.present? + raise ArgumentError, "No database file specified. Missing argument: database" + end + + # Convert relative paths to absolute paths + db_file_path = if File.absolute_path?(database_path) + database_path + else + File.expand_path(database_path, root) + end + + # Check if database already exists + if File.exist?(db_file_path) + raise ActiveRecord::DatabaseAlreadyExists + end + + # Create directory if it doesn't exist + dir = File.dirname(db_file_path) + FileUtils.mkdir_p(dir) unless File.directory?(dir) + + # Create the database by establishing a connection + # DuckDB will create the file when we connect to it + begin + establish_connection + puts "Created database '#{database_path}'" + rescue => e + raise ActiveRecord::DatabaseConnectionError.new(e.message) + end + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @raise [ArgumentError] if no database file specified + # @raise [ActiveRecord::NoDatabaseError] if database file doesn't exist + # @raise [ActiveRecord::DatabaseConnectionError] if operation fails + # @return [void] + def drop + database_path = db_config.respond_to?(:database) ? db_config.database : db_config[:database] + + # Handle in-memory databases + if database_path == ":memory:" + # In-memory databases can't be "dropped" in the traditional sense + # Just disconnect + begin + connection.disconnect! if connection&.active? + rescue + # Ignore errors during disconnect for in-memory databases + end + return + end + + # Handle file-based databases + unless database_path.present? + raise ArgumentError, "No database file specified. Missing argument: database" + end + + # Convert relative paths to absolute paths + db_file_path = if File.absolute_path?(database_path) + database_path + else + File.expand_path(database_path, root) + end + + # Disconnect from database first + begin + connection.disconnect! if connection&.active? + rescue + # Continue even if disconnect fails + end + + # Remove the database file + begin + if File.exist?(db_file_path) + FileUtils.rm(db_file_path) + puts "Dropped database '#{database_path}'" + else + puts "Database '#{database_path}' does not exist" + end + + # Also remove any WAL files that might exist + wal_file = "#{db_file_path}.wal" + FileUtils.rm(wal_file) if File.exist?(wal_file) + + rescue Errno::ENOENT => error + raise ActiveRecord::NoDatabaseError.new(error.message) + rescue => error + raise ActiveRecord::DatabaseConnectionError.new(error.message) + end + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @return [void] + def purge + drop + create + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @return [String] Database character set encoding + def charset + connection.encoding rescue 'UTF-8' + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @param [String] filename Output filename for structure dump + # @param [Array] extra_flags Additional command line flags + # @return [void] + def structure_dump(filename, extra_flags) + args = [] + args.concat(Array(extra_flags)) if extra_flags + args << (db_config.respond_to?(:database) ? db_config.database : db_config[:database]) + + ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + if ignore_tables.any? + ignore_tables = connection.data_sources.select { |table| ignore_tables.any? { |pattern| pattern === table } } + condition = ignore_tables.map { |table| connection.quote(table) }.join(", ") + # DuckDB provides sqlite_master for SQLite compatibility + args << "SELECT sql FROM sqlite_master WHERE tbl_name NOT IN (#{condition}) ORDER BY tbl_name, type DESC, name" + else + args << ".schema" + end + run_cmd("duckdb", args, filename) + end + + # @override + # @note Implements ActiveRecord::Tasks interface method + # @param [String] filename Input filename for structure load + # @param [Array] extra_flags Additional command line flags + # @return [void] + def structure_load(filename, extra_flags) + database_path = db_config.respond_to?(:database) ? db_config.database : db_config[:database] + flags = extra_flags.join(" ") if extra_flags + `duckdb #{flags} #{database_path} < "#{filename}"` + end + + private + attr_reader :db_config, :root + + # @note get database connection for DuckDB + # @return [ActiveRecord::ConnectionAdapters::DuckdbAdapter] Database connection + def connection + # Connection pooling is less critical for DuckDB since it's an embedded database + # with lightweight connections (no network overhead), but we maintain ActiveRecord + # compatibility by using lease_connection when available for thread safety + if ActiveRecord::Base.respond_to?(:lease_connection) + ActiveRecord::Base.lease_connection + else + ActiveRecord::Base.connection + end + end + + # @note establish connection to DuckDB database + # @param [Object] config Database configuration (defaults to db_config) + # @return [ActiveRecord::ConnectionAdapters::DuckdbAdapter] Database connection + def establish_connection(config = db_config) + ActiveRecord::Base.establish_connection(config) + connection + end + + # @note run shell command for DuckDB operations + # @param [String] cmd Command to run + # @param [Array] args Command arguments + # @param [String] out Output file path + # @return [void] + # @raise [RuntimeError] if command fails + def run_cmd(cmd, args, out) + fail run_cmd_error(cmd, args) unless Kernel.system(cmd, *args, out: out) + end + + # @note generate error message for failed shell commands + # @param [String] cmd Command that failed + # @param [Array] args Command arguments + # @return [String] Error message + def run_cmd_error(cmd, args) + msg = +"failed to execute:\n" + msg << "#{cmd} #{args.join(' ')}\n\n" + msg << "Please check the output above for any errors and make sure that `#{cmd}` is installed in your PATH and has proper permissions.\n\n" + msg + end + + # @note determine root directory for database files + # @return [String] Root directory path + def determine_root_directory + # Try different ways to determine the root directory + if defined?(Rails) && Rails.respond_to?(:root) && Rails.root + Rails.root.to_s + elsif defined?(Rails) && Rails.respond_to?(:application) && Rails.application&.config&.root + Rails.application.config.root.to_s + elsif ENV['RAILS_ROOT'] + ENV['RAILS_ROOT'] + else + # Fall back to current working directory + Dir.pwd + end + end + end + end +end diff --git a/lib/active_record/connection_adapters/duckdb_adapter.rb b/lib/active_record/connection_adapters/duckdb_adapter.rb index 97e240a..d271596 100644 --- a/lib/active_record/connection_adapters/duckdb_adapter.rb +++ b/lib/active_record/connection_adapters/duckdb_adapter.rb @@ -1,87 +1,302 @@ # frozen_string_literal: true +require 'duckdb' require 'active_record' require 'active_record/base' require 'active_record/connection_adapters/abstract_adapter' +require 'fileutils' +require 'active_record/connection_adapters/duckdb/quoting' require 'active_record/connection_adapters/duckdb/database_statements' require 'active_record/connection_adapters/duckdb/schema_statements' - -begin - require 'duckdb' -rescue LoadError => e - raise e -end +require 'active_record/connection_adapters/duckdb/explain_pretty_printer' +require 'active_record/connection_adapters/duckdb/tasks' module ActiveRecord - module ConnectionHandling # :nodoc: - def duckdb_connection(config) - config = config.symbolize_keys - connection = ::DuckDB::Database.open.connect - ConnectionAdapters::DuckdbAdapter.new(connection, logger, config) - end - end module ConnectionAdapters # :nodoc: class DuckdbAdapter < AbstractAdapter - ADAPTER_NAME = "DuckDB" + # = Active Record DuckDB Adapter + # + # The DuckDB adapter works with https://github.com/suketa/ruby-duckdb driver. + # + # Options: + # + # * :database - Path to the database file. Defaults to 'db/duckdb.db'. + # Use ':memory:' for in-memory database. + class << self + ADAPTER_NAME = "DuckDB".freeze + + # @note DuckDB-specific client creation + # @param [Hash, nil] config Configuration hash containing database path + # @return [DuckDB::Connection] A new DuckDB connection + def new_client(config = nil) + database_path = config&.dig(:database) || 'db/duckdb.db' + + if database_path == ':memory:' + DuckDB::Database.open.connect # in-memory database + else + # Ensure directory exists for file-based database + dir = File.dirname(database_path) + FileUtils.mkdir_p(dir) unless File.directory?(dir) + DuckDB::Database.open(database_path).connect + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [Hash] config Database configuration + # @param [Hash] options Console options + # @return [void] + def dbconsole(config, options = {}) + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [Array] args Arguments passed to superclass + # @return [DuckdbAdapter] New adapter instance + def initialize(...) + super + @max_identifier_length = nil + @type_map = nil + @raw_connection = self.connect + @notice_receiver_sql_warnings = [] + + # Determine if we're using a memory database + database_path = @config[:database] || 'db/duckdb.db' + @memory_database = database_path == ':memory:' + + # Set up file path for file-based databases + unless @memory_database + case database_path + when "" + raise ArgumentError, "No database file specified. Missing argument: database" + when /\Afile:/ + # Handle file:// URLs by extracting the path + @config[:database] = database_path.sub(/\Afile:/, '') + else + # Handle relative paths - make them relative to Rails.root if in Rails + if defined?(Rails.root) && !File.absolute_path?(database_path) + @config[:database] = File.expand_path(database_path, Rails.root) + else + @config[:database] = File.expand_path(database_path) + end + + # Ensure the directory exists + dirname = File.dirname(@config[:database]) + unless File.directory?(dirname) + begin + FileUtils.mkdir_p(dirname) + rescue SystemCallError + raise ActiveRecord::NoDatabaseError.new(connection_pool: @pool) + end + end + end + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if database exists, false otherwise + def database_exists? + if @memory_database + true # Memory databases always "exist" once created + else + File.exist?(@config[:database].to_s) + end + end + + # @override + # @note Implements AbstractAdapter interface method + # @note Connects to a DuckDB database and sets up the adapter depending on the connected database's characteristics + # @return [DuckDB::Connection] Raw database connection + def connect + @raw_connection = self.class.new_client(@config) + rescue ConnectionNotEstablished => ex + raise ex + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [DuckDB::Connection] Raw database connection + def reconnect + @raw_connection + end include Duckdb::DatabaseStatements include Duckdb::SchemaStatements + include Duckdb::Quoting + + # @override + # @note Implements AbstractAdapter interface method + # @return [Hash] Hash of native database types + def native_database_types # :nodoc: + { + primary_key: "BIGINT PRIMARY KEY", + string: { name: "VARCHAR" }, + text: { name: "TEXT" }, + integer: { name: "INTEGER" }, + bigint: { name: "BIGINT" }, + float: { name: "REAL" }, + decimal: { name: "DECIMAL" }, + datetime: { name: "TIMESTAMP" }, + time: { name: "TIME" }, + date: { name: "DATE" }, + binary: { name: "BLOB" }, + boolean: { name: "BOOLEAN" }, + json: { name: "JSON" } + } + end - NATIVE_DATABASE_TYPES = { - primary_key: "INTEGER PRIMARY KEY", - string: { name: "VARCHAR" }, - integer: { name: "INTEGER" }, - float: { name: "REAL" }, - decimal: { name: "DECIMAL" }, - datetime: { name: "TIMESTAMP" }, - time: { name: "TIME" }, - date: { name: "DATE" }, - bigint: { name: "BIGINT" }, - binary: { name: "BLOB" }, - boolean: { name: "BOOLEAN" }, - uuid: { name: "UUID" }, - } - - def native_database_types - NATIVE_DATABASE_TYPES + # @override + # @note Implements AbstractAdapter interface method + # @return [String] The adapter name + def adapter_name # :nodoc: + "DuckDB" end + # Capability flags - tell ActiveRecord what features DuckDB supports + # These are used internally by ActiveRecord to decide how to handle various operations + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if DuckDB supports savepoints + def supports_savepoints? # :nodoc: + true # DuckDB can create savepoints within transactions (SAVEPOINT sp1, ROLLBACK TO sp1) + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if DuckDB supports transaction isolation + def supports_transaction_isolation? # :nodoc: + true # DuckDB supports transaction isolation using Snapshot Isolation (full ACID compliance) + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if DuckDB supports index sort order + def supports_index_sort_order? # :nodoc: + true # DuckDB can create indexes with sort order (CREATE INDEX idx ON table (col ASC/DESC)) + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if DuckDB supports partial indexes + def supports_partial_index? # :nodoc: + true # DuckDB supports advanced indexing including zone maps and selective indexing + end + + # @override + # @note Implements AbstractAdapter interface method + # @return [Boolean] true if adapter needs periodic reloading + def requires_reloading? # :nodoc: + true # Adapter needs to reload connection info periodically due to DuckDB's file-based nature + end + + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table_name Name of the table + # @return [Array] Array of primary key column names def primary_keys(table_name) # :nodoc: raise ArgumentError unless table_name.present? - results = query("PRAGMA table_info(#{table_name})", "SCHEMA") - results.each_with_object([]) do |result, keys| - _cid, name, _type, _notnull, _dflt_value, pk = result - keys << name if pk - end + # Query DuckDB's information_schema for primary key columns using parameterized query + # Use constraint_type = 'PRIMARY KEY' for reliable identification + sql = <<~SQL + SELECT kcu.column_name + FROM information_schema.key_column_usage kcu + JOIN information_schema.table_constraints tc + ON kcu.constraint_name = tc.constraint_name + AND kcu.table_name = tc.table_name + WHERE kcu.table_name = ? + AND tc.constraint_type = 'PRIMARY KEY' + ORDER BY kcu.ordinal_position + SQL + + # Create bind parameter for the parameterized query + binds = [ + ActiveRecord::Relation::QueryAttribute.new("table_name", table_name, ActiveRecord::Type::String.new) + ] + + results = internal_exec_query(sql, "SCHEMA", binds) + results.rows.map { |row| row[0] } end + # @override + # @note Implements AbstractAdapter interface method + # @param [Symbol, nil] isolation Transaction isolation level + # @param [Boolean] joinable Whether transaction is joinable + # @param [Boolean] _lazy Whether transaction is lazy + # @return [void] def begin_transaction(isolation: nil, joinable: true, _lazy: true); end + # @override + # @note Implements AbstractAdapter interface method + # @param [String] table_name Name of the table + # @return [Array] Array of column objects + def columns(table_name) # :nodoc: + column_definitions(table_name).map do |field| + new_column_from_field(table_name, field) + end + end + + # @note Support for getting the next sequence value for auto-increment + # @param [String] sequence_name Name of the sequence + # @return [String] SQL expression for next sequence value + def next_sequence_value(sequence_name) + "nextval('#{sequence_name}')" + end + + # @override + # @note Implements AbstractAdapter interface method - ActiveRecord needs this to know we support INSERT...RETURNING + # @return [Boolean] true if INSERT...RETURNING is supported + def supports_insert_returning? + true + end + + # @override + # @note Implements AbstractAdapter interface method - Tell ActiveRecord to return the primary key value after insert + # @param [ActiveRecord::ConnectionAdapters::Column] column The column to check + # @return [Boolean] true if should return value after insert + def return_value_after_insert?(column) + (column.type == :integer || column.type == :bigint) && column.name == 'id' + end + private + # @note Simple implementation for now - just execute the SQL + # @param [String] sql SQL to execute + # @param [String] name Query name for logging + # @param [Array] binds Bind parameters + # @param [Boolean] prepare Whether to prepare statement + # @param [Boolean] async Whether to execute asynchronously + # @return [Object] Query result def execute_and_clear(sql, name, binds, prepare: false, async: false) - sql = transform_query(sql) - check_if_write_query(sql) - type_casted_binds = type_casted_binds(binds) - - log(sql, name, binds, type_casted_binds, async: async) do - ActiveSupport::Dependencies.interlock.permit_concurrent_loads do - # TODO: prepare の有無でcacheするっぽい? - if without_prepared_statement?(binds) - @connection.query(sql) - else - @connection.query(sql, *type_casted_binds) - end - end + log(sql, name, binds, async: async) do + @raw_connection.query(sql) end end - def column_definitions(table_name) # :nodoc: - execute("PRAGMA table_info('#{quote_table_name(table_name)}')", "SCHEMA") do |result| - each_hash(result) - end - end + # @note used by columns() method + # @param [String] table_name Name of the table + # @return [Array] Array of column definition arrays + def column_definitions(table_name) # :nodoc: + sql = <<~SQL + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = ? + ORDER BY ordinal_position + SQL + + # Create bind parameter for the parameterized query + binds = [ + ActiveRecord::Relation::QueryAttribute.new("table_name", table_name, ActiveRecord::Type::String.new) + ] + + result = internal_exec_query(sql, "SCHEMA", binds) + + # Convert DuckDB result to array format expected by new_column_from_field + result.rows.map { |row| [row[0], row[1], row[2], row[3]] } + end end end -end \ No newline at end of file +end diff --git a/lib/activerecord-duckdb-adapter.rb b/lib/activerecord-duckdb-adapter.rb new file mode 100644 index 0000000..e5fb927 --- /dev/null +++ b/lib/activerecord-duckdb-adapter.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'active_record' +require "activerecord_duckdb_adapter/version" +require "active_record/connection_adapters/duckdb_adapter" + +# Register the adapter with ActiveRecord +if ActiveRecord::ConnectionAdapters.respond_to?(:register) + ActiveRecord::ConnectionAdapters.register("duckdb", "ActiveRecord::ConnectionAdapters::DuckdbAdapter", "active_record/connection_adapters/duckdb_adapter") +else + # For older ActiveRecord versions, define the connection method manually + module ActiveRecord + module ConnectionHandling # :nodoc: + def duckdb_connection(config) + ActiveRecord::ConnectionAdapters::DuckdbAdapter.new(config) + end + end + end +end + +# Register database tasks (this might not be needed in newer versions) +begin + ActiveRecord::Tasks::DatabaseTasks.register_task(/duckdb/, "ActiveRecord::Tasks::DuckdbDatabaseTasks") +rescue NoMethodError + # Ignore if the method doesn't exist in this ActiveRecord version +end diff --git a/lib/activerecord_duckdb_adapter.rb b/lib/activerecord_duckdb_adapter.rb deleted file mode 100644 index 21e532e..0000000 --- a/lib/activerecord_duckdb_adapter.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "activerecord_duckdb_adapter/version" - -if defined?(Rails) - module ActiveRecord - module ConnectionAdapters - class DuckdbRailtie < ::Rails::Railtie - ActiveSupport.on_load :active_record do - require "active_record/connection_adapters/duckdb_adapter" - end - end - end - end -end \ No newline at end of file diff --git a/lib/activerecord_duckdb_adapter/version.rb b/lib/activerecord_duckdb_adapter/version.rb index 6112302..69cc85b 100644 --- a/lib/activerecord_duckdb_adapter/version.rb +++ b/lib/activerecord_duckdb_adapter/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module ActiveRecordDuckdbAdapter - VERSION = "0.1.0".freeze -end \ No newline at end of file + VERSION = "0.2.0".freeze +end