From 549a4ae7b38779f27fd08fc199c7d0571e50c417 Mon Sep 17 00:00:00 2001 From: Eddie A Tejeda Date: Sat, 21 Jun 2025 09:02:08 -0700 Subject: [PATCH] Update core DuckDB adapter implementation - Update database_statements.rb transaction support and query execution, default to using @raw_connection, and add support for binding params - Add schema_statements.rb DDL operations (CREATE/ALTER/DROP table support) - BREAKING: Remove legacy Railtie approach and use modern ActiveRecord::ConnectionAdapters.register approach for registering gem - Add YARD documentation to make it clear which files were implementing necessary methods - Default to writing DuckDB file to disk - Validated lifecycle of creating and deploying databases and tested bundle exec rails db:drop db:create db:migrate db:seed db:reset - Use DuckDB's information_schema for accessing meta data - Default primary ids as bigints --- .../duckdb/database_statements.rb | 291 ++++++++++++++-- .../duckdb/schema_statements.rb | 259 ++++++++++++-- .../connection_adapters/duckdb_adapter.rb | 325 +++++++++++++++--- lib/activerecord-duckdb-adapter.rb | 26 ++ lib/activerecord_duckdb_adapter.rb | 15 - lib/activerecord_duckdb_adapter/version.rb | 4 +- 6 files changed, 804 insertions(+), 116 deletions(-) create mode 100644 lib/activerecord-duckdb-adapter.rb delete mode 100644 lib/activerecord_duckdb_adapter.rb 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/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_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