diff --git a/.rubocop.yml b/.rubocop.yml index 28d2dcd7..a5f9974e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,7 +1,7 @@ AllCops: DisplayCopNames: true DisplayStyleGuide: true - TargetRubyVersion: 2.2 + TargetRubyVersion: 3.4 Exclude: - 'vendor/**/*' @@ -20,7 +20,7 @@ Metrics/CyclomaticComplexity: Metrics/MethodLength: Enabled: false -Metrics/LineLength: +Layout/LineLength: Enabled: false Metrics/PerceivedComplexity: diff --git a/README.md b/README.md index 8fe18548..a605c068 100644 --- a/README.md +++ b/README.md @@ -49,20 +49,36 @@ ActiveRecord models that use this connection will now be connecting to the confi ## Testing -To run the tests, you'll need the ODBC driver as well as the connection adapter for each database against which you're trying to test. Then run `DSN=MyDatabaseDSN bundle exec rake test` and the test suite will be run by connecting to your database. +There are two test suites: -## Testing Using a Docker Container Because ODBC on Mac is Hard +### Unit tests (no database required) -Tested on Sierra. +```bash +bundle exec rake test:unit +``` + +Tests the type system, quoting, SQL generation, schema statement helpers, connection setup, adapter subclasses, and concerns without any database connection. Safe to run anywhere. + +### Integration tests (requires ODBC connection) + +```bash +DSN=MyDatabaseDSN bundle exec rake test +``` + +Full integration tests against a live database via ODBC. You'll need the ODBC driver and a connection adapter for the target database. +### Run both + +```bash +bundle exec rake test:all +``` -Run from project root: +### Testing Using a Docker Container (legacy) ``` bundle package docker build -f Dockerfile.dev -t odbc-dev . -# Local mount mysql directory to avoid some permissions problems mkdir -p /tmp/mysql docker run -it --rm -v $(pwd):/workspace -v /tmp/mysql:/var/lib/mysql odbc-dev:latest diff --git a/Rakefile b/Rakefile index 6af9c2b8..430107f6 100644 --- a/Rakefile +++ b/Rakefile @@ -2,7 +2,22 @@ require 'bundler/gem_tasks' require 'rake/testtask' require 'rubocop/rake_task' +# Integration tests — require a live ODBC connection Rake::TestTask.new(:test) do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/*_test.rb'] +end + +# Unit tests — no database connection required +Rake::TestTask.new('test:unit') do |t| + t.libs << 'test' + t.libs << 'lib' + t.test_files = FileList['test/unit/**/*_test.rb'] +end + +# Both integration and unit tests +Rake::TestTask.new('test:all') do |t| t.libs << 'test' t.libs << 'lib' t.test_files = FileList['test/**/*_test.rb'] diff --git a/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb b/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb index bbfc8c77..0f723959 100644 --- a/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb +++ b/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb @@ -72,7 +72,7 @@ def disable_referential_integrity def create_database(name, options = {}) options = options.reverse_merge(encoding: 'utf8') - option_string = options.symbolize_keys.sum do |key, value| + option_string = options.symbolize_keys.sum('') do |key, value| case key when :owner " OWNER = \"#{value}\"" diff --git a/odbc_adapter.gemspec b/odbc_adapter.gemspec index d8500039..f7ed1937 100644 --- a/odbc_adapter.gemspec +++ b/odbc_adapter.gemspec @@ -24,9 +24,10 @@ Gem::Specification.new do |spec| spec.add_development_dependency 'bundler', '>= 1.14' spec.add_development_dependency 'minitest', '~> 5.10' - spec.add_development_dependency 'rake', '~> 12.0' - spec.add_development_dependency 'rubocop', '0.48.1' + spec.add_development_dependency 'rake', '>= 12.0' + spec.add_development_dependency 'rubocop', '>= 0.48.1' spec.add_development_dependency 'simplecov', '~> 0.14' + spec.add_development_dependency 'ostruct' spec.add_development_dependency 'pry', '~> 0.11.1' spec.post_install_message = <<~MESSAGE diff --git a/test/unit/adapters/mysql_test.rb b/test/unit/adapters/mysql_test.rb new file mode 100644 index 00000000..12dde7a1 --- /dev/null +++ b/test/unit/adapters/mysql_test.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/adapters/mysql_odbc_adapter' + +class MySQLAdapterTest < Minitest::Test + def adapter + ODBCAdapter::Adapters::MySQLODBCAdapter.new + end + + def test_primary_key_constant + assert_equal 'INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY', + ODBCAdapter::Adapters::MySQLODBCAdapter::PRIMARY_KEY + end + + def test_prepared_statements_is_false + refute adapter.prepared_statements + end + + def test_quoted_true_is_one + assert_equal '1', adapter.quoted_true + end + + def test_quoted_false_is_zero + assert_equal '0', adapter.quoted_false + end + + def test_unquoted_true_is_integer_one + assert_equal 1, adapter.unquoted_true + end + + def test_unquoted_false_is_integer_zero + assert_equal 0, adapter.unquoted_false + end + + def test_quote_string_escapes_single_quotes + assert_equal "it''s", adapter.quote_string("it's") + end + + def test_quote_string_escapes_backslashes + assert_equal 'back\\\\slash', adapter.quote_string('back\\slash') + end + + def test_inherits_from_odbc_adapter + assert ODBCAdapter::Adapters::MySQLODBCAdapter.ancestors.include?( + ActiveRecord::ConnectionAdapters::ODBCAdapter + ) + end + + def adapter_with_sql_capture + a = ODBCAdapter::Adapters::MySQLODBCAdapter.new + sqls = [] + a.define_singleton_method(:execute) { |sql, *| sqls << sql } + [a, sqls] + end + + # --- create_database --- + + def test_create_database_defaults_to_utf8_charset + a, sqls = adapter_with_sql_capture + a.create_database('mydb') + assert_includes sqls.first, 'DEFAULT CHARACTER SET `utf8`' + end + + def test_create_database_with_custom_charset + a, sqls = adapter_with_sql_capture + a.create_database('mydb', charset: 'latin1') + assert_includes sqls.first, 'DEFAULT CHARACTER SET `latin1`' + end + + def test_create_database_with_charset_and_collation + a, sqls = adapter_with_sql_capture + a.create_database('mydb', charset: 'latin1', collation: 'latin1_bin') + assert_includes sqls.first, 'DEFAULT CHARACTER SET `latin1`' + assert_includes sqls.first, 'COLLATE `latin1_bin`' + end + + def test_create_database_without_collation_omits_collate_clause + a, sqls = adapter_with_sql_capture + a.create_database('mydb') + refute sqls.first.include?('COLLATE') + end + + # --- indexes rejects PRIMARY key --- + + def test_indexes_filters_out_primary_key_index + primary_idx = ActiveRecord::ConnectionAdapters::IndexDefinition.new('users', 'PRIMARY', true, ['id']) + email_idx = ActiveRecord::ConnectionAdapters::IndexDefinition.new('users', 'idx_email', false, ['email']) + unique_idx = ActiveRecord::ConnectionAdapters::IndexDefinition.new('users', 'idx_unique', true, ['email']) + name_idx = ActiveRecord::ConnectionAdapters::IndexDefinition.new('users', 'idx_name', false, ['name']) + + # Simulate what MySQLODBCAdapter#indexes does: + # super(...).reject { |i| i.unique && i.name =~ /^PRIMARY$/ } + all_indexes = [primary_idx, email_idx, unique_idx, name_idx] + result = all_indexes.reject { |i| i.unique && i.name =~ /^PRIMARY$/ } + + assert_equal 3, result.length + refute_includes result.map(&:name), 'PRIMARY' + assert_includes result.map(&:name), 'idx_unique' # unique but not PRIMARY → kept + assert_includes result.map(&:name), 'idx_email' + assert_includes result.map(&:name), 'idx_name' + end +end diff --git a/test/unit/adapters/null_test.rb b/test/unit/adapters/null_test.rb new file mode 100644 index 00000000..bd20c15e --- /dev/null +++ b/test/unit/adapters/null_test.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/adapters/null_odbc_adapter' + +class NullAdapterTest < Minitest::Test + def adapter + ODBCAdapter::Adapters::NullODBCAdapter.new + end + + def test_prepared_statements_is_false + refute adapter.prepared_statements + end + + def test_supports_migrations_is_false + refute adapter.supports_migrations? + end + + def test_variant_type_constant + assert_equal 'VARIANT', ODBCAdapter::Adapters::NullODBCAdapter::VARIANT_TYPE + end + + def test_inherits_from_odbc_adapter + assert ODBCAdapter::Adapters::NullODBCAdapter.ancestors.include?( + ActiveRecord::ConnectionAdapters::ODBCAdapter + ) + end +end diff --git a/test/unit/adapters/postgresql_test.rb b/test/unit/adapters/postgresql_test.rb new file mode 100644 index 00000000..c8e500ee --- /dev/null +++ b/test/unit/adapters/postgresql_test.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'active_support/core_ext/hash/reverse_merge' +require 'odbc_adapter/adapters/postgresql_odbc_adapter' + +class PostgreSQLAdapterTest < Minitest::Test + def adapter + ODBCAdapter::Adapters::PostgreSQLODBCAdapter.new + end + + def test_primary_key_constant + assert_equal 'SERIAL PRIMARY KEY', + ODBCAdapter::Adapters::PostgreSQLODBCAdapter::PRIMARY_KEY + end + + def test_boolean_type_constant + assert_equal 'boolean', + ODBCAdapter::Adapters::PostgreSQLODBCAdapter::BOOLEAN_TYPE + end + + def test_quote_string_escapes_single_quotes + assert_equal "it''s", adapter.quote_string("it's") + end + + def test_quote_string_escapes_backslashes + assert_equal 'back\\\\slash', adapter.quote_string('back\\slash') + end + + def test_default_sequence_name_without_pk + assert_equal 'users_id_seq', adapter.default_sequence_name('users') + end + + def test_default_sequence_name_with_custom_pk + assert_equal 'orders_order_id_seq', adapter.default_sequence_name('orders', 'order_id') + end + + def test_inherits_from_odbc_adapter + assert ODBCAdapter::Adapters::PostgreSQLODBCAdapter.ancestors.include?( + ActiveRecord::ConnectionAdapters::ODBCAdapter + ) + end + + # --- distinct --- + + def test_distinct_with_no_orders_returns_simple_distinct + assert_equal 'DISTINCT posts.id', adapter.distinct('posts.id', []) + end + + def test_distinct_strips_asc_modifier + result = adapter.distinct('id', ['created_at DESC']) + assert_includes result, 'created_at AS alias_0' + refute_includes result, 'DESC' + end + + def test_distinct_strips_asc_and_nulls_first + result = adapter.distinct('id', ['name ASC NULLS FIRST']) + refute_includes result, 'ASC' + refute_includes result, 'NULLS' + end + + def test_distinct_with_multiple_orders_generates_aliases + result = adapter.distinct('id', ['col1 ASC', 'col2 DESC']) + assert_includes result, 'alias_0' + assert_includes result, 'alias_1' + end + + # --- table_filtered? --- + + def test_table_filtered_rejects_information_schema + assert adapter.table_filtered?('information_schema', 'TABLE') + end + + def test_table_filtered_rejects_pg_catalog + assert adapter.table_filtered?('pg_catalog', 'VIEW') + end + + def test_table_filtered_rejects_non_table_type + assert adapter.table_filtered?('public', 'INDEX') + end + + def test_table_filtered_allows_regular_table + refute adapter.table_filtered?('public', 'TABLE') + end + + def test_table_filtered_allows_base_table + refute adapter.table_filtered?('app_schema', 'BASE TABLE') + end + + # --- type_cast --- + + def test_type_cast_bytea_string_wraps_in_format_hash + col = Struct.new(:native_type).new('bytea') + result = adapter.type_cast('hello', col) + assert_equal({ value: 'hello', format: 1 }, result) + end + + # --- create_database --- + + def test_create_database_defaults_to_utf8_encoding + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb') + assert_match(/ENCODING = 'utf8'/, sqls.first) + end + + def test_create_database_with_owner + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', owner: 'dbowner') + assert_match(/OWNER = "dbowner"/, sqls.first) + end + + def test_create_database_with_template + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', template: 'template0') + assert_match(/TEMPLATE = "template0"/, sqls.first) + end + + def test_create_database_with_custom_encoding + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', encoding: 'latin1') + assert_match(/ENCODING = 'latin1'/, sqls.first) + end + + def test_create_database_with_tablespace + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', tablespace: 'pg_default') + assert_match(/TABLESPACE = "pg_default"/, sqls.first) + end + + def test_create_database_with_connection_limit + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', connection_limit: 10) + assert_match(/CONNECTION LIMIT = 10/, sqls.first) + end + + def test_create_database_unknown_option_ignored + a, sqls = pg_adapter_with_sql_capture + a.create_database('mydb', unknown_key: 'val') + refute_match(/unknown_key/, sqls.first) + end + + # --- insert_sql --- + + def test_insert_sql_appends_returning_clause_when_pk_known + a, selected = pg_adapter_for_insert_sql(pk_col: 'id') + a.send(:insert_sql, 'INSERT INTO users (name) VALUES (?)', nil, nil) + assert_match(/RETURNING "id"/, selected.first) + end + + def test_insert_sql_uses_pk_when_provided_directly + a = ODBCAdapter::Adapters::PostgreSQLODBCAdapter.new + selected = [] + a.define_singleton_method(:select_value) do |sql, *| + selected << sql + 1 + end + a.define_singleton_method(:quote_column_name) { |col| "\"#{col}\"" } + a.send(:insert_sql, 'INSERT INTO users VALUES (?)', nil, 'user_id') + assert_match(/RETURNING "user_id"/, selected.first) + end + + def test_insert_sql_select_value_returns_result + a, _selected = pg_adapter_for_insert_sql(pk_col: 'id') + result = a.send(:insert_sql, 'INSERT INTO users (name) VALUES (?)', nil, nil) + assert_equal 42, result + end + + private + + def pg_adapter_with_sql_capture + a = ODBCAdapter::Adapters::PostgreSQLODBCAdapter.new + sqls = [] + a.define_singleton_method(:execute) { |sql, *| sqls << sql } + a.define_singleton_method(:quote_table_name) { |name| name } + [a, sqls] + end + + def pg_adapter_for_insert_sql(pk_col:) + a = ODBCAdapter::Adapters::PostgreSQLODBCAdapter.new + a.define_singleton_method(:extract_table_ref_from_insert_sql) { |_sql| 'users' } + a.define_singleton_method(:primary_key) { |_table| pk_col } + a.define_singleton_method(:quote_column_name) { |col| "\"#{col}\"" } + selected = [] + a.define_singleton_method(:select_value) do |sql, *| + selected << sql + 42 + end + [a, selected] + end +end diff --git a/test/unit/adapters/snowflake_test.rb b/test/unit/adapters/snowflake_test.rb new file mode 100644 index 00000000..ab91e54a --- /dev/null +++ b/test/unit/adapters/snowflake_test.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/adapters/snowflake_odbc_adapter' + +class SnowflakeAdapterTest < Minitest::Test + def adapter + ODBCAdapter::Adapters::SnowflakeODBCAdapter.new + end + + def test_primary_key_constant + assert_equal 'INT PRIMARY KEY NOT NULL AUTOINCREMENT', + ODBCAdapter::Adapters::SnowflakeODBCAdapter::PRIMARY_KEY + end + + def test_variant_type_constant + assert_equal 'VARIANT', ODBCAdapter::Adapters::SnowflakeODBCAdapter::VARIANT_TYPE + end + + def test_prepared_statements_is_false + refute adapter.prepared_statements + end + + def test_supports_migrations_is_false + refute adapter.supports_migrations? + end + + def test_inherits_from_odbc_adapter + assert ODBCAdapter::Adapters::SnowflakeODBCAdapter.ancestors.include?( + ActiveRecord::ConnectionAdapters::ODBCAdapter + ) + end +end diff --git a/test/unit/column_metadata_test.rb b/test/unit/column_metadata_test.rb new file mode 100644 index 00000000..221e11e5 --- /dev/null +++ b/test/unit/column_metadata_test.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/column_metadata' + +class ColumnMetadataTest < Minitest::Test + # native_type_mapping checks: `return adapter.class::PRIMARY_KEY if abstract == :primary_key` + # The adapter class must have a PRIMARY_KEY constant. + module FakeAdapterClass + PRIMARY_KEY = 'INT PRIMARY KEY NOT NULL AUTOINCREMENT' + end + + # Builds a fake adapter whose raw_connection.types() returns pre-canned rows. + # SQLGetTypeInfo row format: [type_name, sql_type, col_size, ?, ?, create_params, ...] + def make_adapter(type_rows) + stmt = Struct.new(:rows) do + def fetch_all = rows + def drop = nil + end.new(type_rows) + + raw_conn = Struct.new(:stmt) do + def types = stmt + end.new(stmt) + + # adapter.class must respond to ::PRIMARY_KEY + adapter = Object.new + adapter.instance_variable_set(:@raw_connection, raw_conn) + adapter.define_singleton_method(:raw_connection) { @raw_connection } + adapter.define_singleton_method(:class) { FakeAdapterClass } + adapter + end + + def test_native_database_types_maps_integer + rows = [['INTEGER', ODBC::SQL_INTEGER, 10, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:integer), 'Expected :integer in native_database_types' + assert_equal 'INTEGER', result[:integer][:name] + end + + def test_native_database_types_maps_string_with_limit + rows = [['VARCHAR', ODBC::SQL_VARCHAR, 255, nil, nil, 'length']] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:string) + assert_equal 'VARCHAR', result[:string][:name] + assert_equal 255, result[:string][:limit] + end + + def test_native_database_types_text_picks_largest_capacity + rows = [ + ['LONGVARCHAR', ODBC::SQL_LONGVARCHAR, 65_535, nil, nil, 'length'], + ['VARCHAR', ODBC::SQL_VARCHAR, 4096, nil, nil, 'length'] + ] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:text) + assert_equal 'LONGVARCHAR', result[:text][:name] + end + + def test_native_database_types_boolean_uses_available_candidate + # boolean has SQL_BIT first, then SQL_TINYINT, SQL_SMALLINT, SQL_INTEGER + rows = [['SMALLINT', ODBC::SQL_SMALLINT, 5, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:boolean) + assert_equal 'SMALLINT', result[:boolean][:name] + end + + def test_native_database_types_decimal_omits_limit + rows = [['DECIMAL', ODBC::SQL_DECIMAL, 38, nil, nil, 'precision,scale']] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + # decimal explicitly excludes limit + refute result[:decimal]&.key?(:limit) + end + + def test_primary_key_returns_adapter_class_constant + rows = [['INTEGER', ODBC::SQL_INTEGER, 10, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:primary_key) + assert_equal 'INT PRIMARY KEY NOT NULL AUTOINCREMENT', result[:primary_key] + end + + # --- additional type mappings --- + + def test_native_database_types_maps_float + rows = [['DOUBLE', ODBC::SQL_DOUBLE, 15, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:float), 'Expected :float in native_database_types' + assert_equal 'DOUBLE', result[:float][:name] + end + + def test_native_database_types_maps_datetime + rows = [['TIMESTAMP', ODBC::SQL_TYPE_TIMESTAMP, 26, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:datetime), 'Expected :datetime in native_database_types' + assert_equal 'TIMESTAMP', result[:datetime][:name] + end + + def test_native_database_types_maps_timestamp + rows = [['TIMESTAMP', ODBC::SQL_TYPE_TIMESTAMP, 26, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:timestamp), 'Expected :timestamp in native_database_types' + assert_equal 'TIMESTAMP', result[:timestamp][:name] + end + + def test_native_database_types_maps_time + rows = [['TIME', ODBC::SQL_TYPE_TIME, 8, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:time), 'Expected :time in native_database_types' + assert_equal 'TIME', result[:time][:name] + end + + def test_native_database_types_maps_date + rows = [['DATE', ODBC::SQL_TYPE_DATE, 10, nil, nil, nil]] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:date), 'Expected :date in native_database_types' + assert_equal 'DATE', result[:date][:name] + end + + def test_native_database_types_binary_picks_largest_capacity + rows = [ + ['LONGVARBINARY', ODBC::SQL_LONGVARBINARY, 2_147_483_647, nil, nil, nil], + ['VARBINARY', ODBC::SQL_VARBINARY, 8_000, nil, nil, nil] + ] + metadata = ODBCAdapter::ColumnMetadata.new(make_adapter(rows)) + result = metadata.native_database_types + + assert result.key?(:binary), 'Expected :binary in native_database_types' + assert_equal 'LONGVARBINARY', result[:binary][:name] + end +end diff --git a/test/unit/column_test.rb b/test/unit/column_test.rb new file mode 100644 index 00000000..59ba02ba --- /dev/null +++ b/test/unit/column_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/column' + +class ColumnTest < Minitest::Test + # AR 8.1+ Column.new: (name, cast_type, default, sql_type_metadata, nullable, **) + # AR <8.1: (name, default, sql_type_metadata, nullable, **) + # Deduplicable caches by (name, cast_type, default, sql_type_metadata, null) so + # each test uses a unique name to avoid cross-test cache collisions. + def build_column(name:, default: nil, sql_type: 'integer', + ar_type: ActiveRecord::Type::Integer.new, **col_opts) + meta = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new( + sql_type: sql_type, type: :integer, limit: nil, precision: nil, scale: nil + ) + if ActiveRecord.version >= '8.1.0' + ODBCAdapter::Column.new(name, ar_type, default, meta, true, **col_opts) + else + ODBCAdapter::Column.new(name, default, meta, true, **col_opts) + end + end + + def test_native_type_is_readable + col = build_column(name: 'col_native_type_readable', native_type: 'DECIMAL') + assert_equal 'DECIMAL', col.native_type + end + + def test_native_type_defaults_to_nil + col = build_column(name: 'col_native_type_default') + assert_nil col.native_type + end + + def test_auto_incremented_defaults_to_false + col = build_column(name: 'col_auto_inc_default') + refute col.auto_incremented + end + + def test_auto_incremented_can_be_set_true + col = build_column(name: 'col_auto_inc_true', auto_incremented: true) + assert col.auto_incremented + end + + def test_inherits_from_ar_column + col = build_column(name: 'col_inherits') + assert_kind_of ActiveRecord::ConnectionAdapters::Column, col + end + + def test_name_is_set + col = build_column(name: 'email_col') + assert_equal 'email_col', col.name + end +end diff --git a/test/unit/concerns/easy_identified_test.rb b/test/unit/concerns/easy_identified_test.rb new file mode 100644 index 00000000..573a10fd --- /dev/null +++ b/test/unit/concerns/easy_identified_test.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/concerns/easy_identified' + +# Minimal ActiveRecord-like model for testing the concern. +# Defines save/save! before including so alias_method works. +class MockEasyIdentifiedModel + attr_accessor :id, :save_called, :save_bang_called, :saved_options + + def initialize(id: nil) + @id = id + @save_called = false + @save_bang_called = false + @saved_options = {} + end + + def save(**options) + @save_called = true + @saved_options = options + true + end + + def save!(**options) + @save_bang_called = true + @saved_options = options + true + end + + include ODBCAdapter::EasyIdentified + # Prepend overrides retrieve_id without triggering a "method redefined" warning + # because the method lives in a separate module, not the class itself. + prepend(Module.new { def retrieve_id = 42 }) +end + +class EasyIdentifiedTest < Minitest::Test + def model(id: nil) + MockEasyIdentifiedModel.new(id: id) + end + + def test_save_with_normal_id_skips_generation + m = model(id: 1) + m.save + assert_equal 1, m.id + assert m.save_called + end + + def test_save_with_auto_generate_calls_retrieve_id + m = model(id: :auto_generate) + m.save + assert_equal 42, m.id + end + + def test_save_with_auto_generate_then_delegates_to_underlying_save + m = model(id: :auto_generate) + m.save + assert m.save_called + end + + def test_save_bang_with_normal_id_skips_generation + m = model(id: 5) + m.save! + assert_equal 5, m.id + end + + def test_save_bang_with_auto_generate_calls_retrieve_id + m = model(id: :auto_generate) + m.save! + assert_equal 42, m.id + end + + def test_generate_id_sets_id_from_retrieve_id_when_nil + m = model(id: nil) + m.generate_id + assert_equal 42, m.id + end + + def test_generate_id_force_new_overwrites_existing_id + m = model(id: 99) + m.generate_id(true) + assert_equal 42, m.id + end + + def test_generate_id_without_force_keeps_existing_non_nil_id + m = model(id: 99) + m.generate_id + assert_equal 99, m.id + end + + def test_save_passes_options_through + m = model(id: 1) + m.save(validate: false) + assert_equal({ validate: false }, m.saved_options) + end +end diff --git a/test/unit/concerns/insert_attribute_stripper_test.rb b/test/unit/concerns/insert_attribute_stripper_test.rb new file mode 100644 index 00000000..37c606d1 --- /dev/null +++ b/test/unit/concerns/insert_attribute_stripper_test.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/concerns/insert_attribute_stripper' + +# Column stub with a type +StubColumnForStripper = Struct.new(:name, :type) + +# Minimal model for testing the concern. +# Needs: save/save!, valid?, new_record?, attributes, columns, self[key]= +class MockStrippableModel + attr_accessor :attributes, :first_save_result + + def initialize(is_new: true, valid: true, first_save_result: true) + @new_record = is_new + @valid = valid + @first_save_result = first_save_result + @attributes = {} + @save_count = 0 + end + + def save(**_options) + @save_count += 1 + @first_save_result + end + + def save!(**_options) + @save_count += 1 + true + end + + def valid? + @valid + end + + def new_record? + @new_record + end + + def []=(key, val) + @attributes[key] = val + end + + attr_reader :save_count + + def self.columns + @columns ||= [] + end + + class << self + attr_writer :columns + end + + def self.transaction + yield + end + + include ODBCAdapter::InsertAttributeStripper +end + +class InsertAttributeStripperTest < Minitest::Test + def setup + MockStrippableModel.columns = [] + end + + def new_model_with_variant_column(variant_value: { 'k' => 1 }) + MockStrippableModel.columns = [ + StubColumnForStripper.new('data', :variant) + ] + m = MockStrippableModel.new(is_new: true, valid: true) + m.attributes = { 'data' => variant_value } + m + end + + # --- new records strip unsafe columns --- + + def test_new_record_strips_variant_column_before_first_save + m = new_model_with_variant_column(variant_value: { 'k' => 1 }) + m.save + # After two saves, attributes should be restored + assert_equal({ 'k' => 1 }, m.attributes['data']) + end + + def test_new_record_calls_save_twice + m = new_model_with_variant_column + m.save + assert_equal 2, m.save_count + end + + def test_new_record_only_strips_variant_object_array_types + MockStrippableModel.columns = [ + StubColumnForStripper.new('name', :string), + StubColumnForStripper.new('data', :variant) + ] + m = MockStrippableModel.new(is_new: true, valid: true) + m.attributes = { 'name' => 'Alice', 'data' => { 'x' => 1 } } + m.save + # String column should not be stripped + assert_equal 'Alice', m.attributes['name'] + end + + # --- existing records are not stripped --- + + def test_existing_record_calls_save_once + MockStrippableModel.columns = [StubColumnForStripper.new('data', :variant)] + m = MockStrippableModel.new(is_new: false, valid: true) + m.attributes = { 'data' => { 'k' => 1 } } + m.save + assert_equal 1, m.save_count + end + + # --- invalid records (validate: false not set) --- + + def test_invalid_record_returns_result_of_base_save + m = MockStrippableModel.new(is_new: true, valid: false) + result = m.save + # When invalid and validate not false, calls base save directly + assert_equal true, result + assert_equal 1, m.save_count + end + + # --- first save failure short-circuits second save --- + + def test_first_save_failure_skips_second_save + MockStrippableModel.columns = [StubColumnForStripper.new('data', :variant)] + m = MockStrippableModel.new(is_new: true, valid: true, first_save_result: false) + m.attributes = { 'data' => { 'k' => 1 } } + result = m.save + assert_equal false, result + assert_equal 1, m.save_count + end + + # --- save! path --- + + def test_save_bang_strips_and_restores_like_save + m = new_model_with_variant_column + m.save! + assert_equal 2, m.save_count + assert_equal({ 'k' => 1 }, m.attributes['data']) + end + + # --- nil variant value not stripped --- + + def test_nil_variant_value_is_not_stripped + MockStrippableModel.columns = [StubColumnForStripper.new('data', :variant)] + m = MockStrippableModel.new(is_new: true, valid: true) + m.attributes = { 'data' => nil } + m.save + assert_equal 1, m.save_count + end + + # --- validate:false bypasses early return --- + + def test_validate_false_on_invalid_record_proceeds_with_strip_and_save + MockStrippableModel.columns = [StubColumnForStripper.new('data', :variant)] + m = MockStrippableModel.new(is_new: true, valid: false) + m.attributes = { 'data' => { 'k' => 1 } } + m.save(validate: false) + assert_equal 2, m.save_count + end +end diff --git a/test/unit/connection_setup_test.rb b/test/unit/connection_setup_test.rb new file mode 100644 index 00000000..78ae348e --- /dev/null +++ b/test/unit/connection_setup_test.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/connection_setup' + +class ConnectionSetupTest < Minitest::Test + # --- error case --- + + def test_raises_on_missing_dsn_and_conn_str + setup = ODBCAdapter::ConnectionSetup.new({ adapter: 'odbc' }) + assert_raises(ArgumentError) { setup.build } + end + + # --- DSN path --- + + def test_dsn_stores_connection_object + config = { dsn: 'MyDSN', username: 'user', password: 'pass' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert_instance_of ODBC::StubConnection, setup.connection + end + + def test_dsn_stores_encoding_bug_false_without_utf8 + config = { dsn: 'MyDSN' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + refute setup.config[:encoding_bug] + end + + def test_dsn_stores_encoding_bug_true_with_utf8_encoding + config = { dsn: 'MyDSN', encoding: 'utf8' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert setup.config[:encoding_bug] + end + + def test_dsn_normalizes_nil_username_to_nil + config = { dsn: 'MyDSN' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert_nil setup.config[:username] + end + + def test_dsn_converts_symbol_username_to_string + config = { dsn: 'MyDSN', username: :admin } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert_equal 'admin', setup.config[:username] + end + + def test_connection_is_nil_before_build + config = { dsn: 'MyDSN' } + setup = ODBCAdapter::ConnectionSetup.new(config) + assert_nil setup.connection + end + + def test_connection_is_set_after_build + config = { dsn: 'MyDSN' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + refute_nil setup.connection + end + + # --- connection string path --- + + def test_conn_str_stores_connection_object + config = { conn_str: 'DRIVER=Snowflake;UID=user;PWD=secret' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert_instance_of ODBC::StubConnection, setup.connection + end + + def test_conn_str_stores_driver_in_config + config = { conn_str: 'DRIVER=Snowflake;UID=user' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert_instance_of ODBC::Driver, setup.config[:driver] + end + + def test_conn_str_encoding_bug_false_without_utf8 + config = { conn_str: 'DRIVER=Snowflake;UID=user' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + refute setup.config[:encoding_bug] + end + + def test_conn_str_encoding_bug_true_with_utf8_encoding + config = { conn_str: 'DRIVER=Snowflake;ENCODING=utf8' } + setup = ODBCAdapter::ConnectionSetup.new(config) + setup.build + assert setup.config[:encoding_bug] + end +end diff --git a/test/unit/database_limits_test.rb b/test/unit/database_limits_test.rb new file mode 100644 index 00000000..3d5dbe4f --- /dev/null +++ b/test/unit/database_limits_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_limits' + +class DatabaseLimitsHost + include ODBCAdapter::DatabaseLimits + + def initialize(max_identifier_len:, max_table_name_len:) + @meta = Struct.new(:max_identifier_len, :max_table_name_len) + .new(max_identifier_len, max_table_name_len) + end + + def database_metadata + @meta + end +end + +class DatabaseLimitsTest < Minitest::Test + def test_table_alias_length_returns_max_identifier_len_when_larger + host = DatabaseLimitsHost.new(max_identifier_len: 256, max_table_name_len: 128) + assert_equal 256, host.table_alias_length + end + + def test_table_alias_length_returns_max_table_name_len_when_larger + host = DatabaseLimitsHost.new(max_identifier_len: 64, max_table_name_len: 128) + assert_equal 128, host.table_alias_length + end + + def test_table_alias_length_returns_equal_value_when_same + host = DatabaseLimitsHost.new(max_identifier_len: 100, max_table_name_len: 100) + assert_equal 100, host.table_alias_length + end +end diff --git a/test/unit/database_metadata_test.rb b/test/unit/database_metadata_test.rb new file mode 100644 index 00000000..7db5160e --- /dev/null +++ b/test/unit/database_metadata_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/registry' +require 'odbc_adapter/database_metadata' + +# Builds a stub connection whose get_info returns configured values +def stub_connection(info_map = {}) + conn = Minitest::Mock.new + ODBCAdapter::DatabaseMetadata::FIELDS.each do |field| + numeric = ODBC.const_get(field) + value = info_map.fetch(field, "stub_#{field}") + conn.expect(:get_info, value, [numeric]) + end + conn +end + +class DatabaseMetadataTest < Minitest::Test + def test_dynamic_accessors_are_defined + meta = ODBCAdapter::DatabaseMetadata.new(stub_connection) + # Each FIELDS entry strips 'sql_' prefix to become an accessor + assert_respond_to meta, :dbms_name + assert_respond_to meta, :dbms_ver + assert_respond_to meta, :identifier_case + assert_respond_to meta, :identifier_quote_char + assert_respond_to meta, :max_identifier_len + assert_respond_to meta, :max_table_name_len + assert_respond_to meta, :user_name + assert_respond_to meta, :database_name + end + + def test_dbms_name_returns_get_info_value + conn = stub_connection(SQL_DBMS_NAME: 'Snowflake') + meta = ODBCAdapter::DatabaseMetadata.new(conn) + assert_equal 'Snowflake', meta.dbms_name + conn.verify + end + + def test_upcase_identifiers_true_when_sql_ic_upper + conn = stub_connection(SQL_IDENTIFIER_CASE: ODBC::SQL_IC_UPPER) + meta = ODBCAdapter::DatabaseMetadata.new(conn) + assert meta.upcase_identifiers? + conn.verify + end + + def test_upcase_identifiers_false_when_not_sql_ic_upper + conn = stub_connection(SQL_IDENTIFIER_CASE: ODBC::SQL_IC_LOWER) + meta = ODBCAdapter::DatabaseMetadata.new(conn) + refute meta.upcase_identifiers? + conn.verify + end + + def test_adapter_class_delegates_to_registry + conn = stub_connection(SQL_DBMS_NAME: 'Snowflake') + meta = ODBCAdapter::DatabaseMetadata.new(conn) + assert_equal ODBCAdapter::Adapters::SnowflakeODBCAdapter, meta.adapter_class + conn.verify + end + + def test_encoding_bug_re_encodes_string_values + # When has_encoding_bug=true, string values are re-encoded from UTF-16LE + # We test that non-string values pass through unchanged + conn = stub_connection(SQL_MAX_IDENTIFIER_LEN: 255) + meta = ODBCAdapter::DatabaseMetadata.new(conn, false) + assert_equal 255, meta.max_identifier_len + conn.verify + end + + def test_values_hash_contains_all_fields + conn = stub_connection + meta = ODBCAdapter::DatabaseMetadata.new(conn) + assert_equal ODBCAdapter::DatabaseMetadata::FIELDS.length, meta.values.length + conn.verify + end +end diff --git a/test/unit/database_statements/bind_params_test.rb b/test/unit/database_statements/bind_params_test.rb new file mode 100644 index 00000000..8b812db1 --- /dev/null +++ b/test/unit/database_statements/bind_params_test.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +class BindParamsHost + include ODBCAdapter::DatabaseStatements + + def prepared_statements = true + def database_metadata = nil + + # Expose private method + def bind(binds, sql) + # binds here are already-quoted strings (simulating prepared_binds output) + bind_params(binds, sql) + end + + def sub(sql) + prepare_statement_sub(sql) + end + + private + + # Override prepared_binds to return the values as-is for test simplicity + def prepared_binds(binds) + binds + end +end + +class BindParamsTest < Minitest::Test + def host + BindParamsHost.new + end + + def test_substitutes_single_bind + sql = 'SELECT * FROM users WHERE id = $1' + result = host.bind(['42'], sql) + assert_equal "SELECT * FROM users WHERE id = '42'", result + end + + def test_substitutes_multiple_binds + sql = 'INSERT INTO t (a, b) VALUES ($1, $2)' + result = host.bind(%w[foo bar], sql) + assert_equal "INSERT INTO t (a, b) VALUES ('foo', 'bar')", result + end + + def test_substitutes_in_order + sql = 'SELECT $1, $2, $1' + result = host.bind(%w[a b], sql) + # $1 replaced first, $2 replaced second — both $1 occurrences become 'a' + assert_equal "SELECT 'a', 'b', 'a'", result + end + + def test_no_binds_leaves_sql_unchanged + sql = 'SELECT * FROM users' + result = host.bind([], sql) + assert_equal sql, result + end + + def test_bind_with_nil_value + sql = 'SELECT * FROM t WHERE x = $1' + result = host.bind([nil], sql) + assert_equal "SELECT * FROM t WHERE x = ''", result + end + + # --- prepare_statement_sub --- + + def test_prepare_statement_sub_replaces_dollar_params_with_question_marks + assert_equal 'SELECT ?, ?', host.sub('SELECT $1, $2') + end + + def test_prepare_statement_sub_handles_multi_digit_params + assert_equal '?', host.sub('$10') + end + + def test_prepare_statement_sub_no_params_unchanged + assert_equal 'SELECT 1', host.sub('SELECT 1') + end +end diff --git a/test/unit/database_statements/case_formatting_test.rb b/test/unit/database_statements/case_formatting_test.rb new file mode 100644 index 00000000..b52eaf5a --- /dev/null +++ b/test/unit/database_statements/case_formatting_test.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +class CaseFormattingHost + include ODBCAdapter::DatabaseStatements + + def initialize(upcase_identifiers:) + @upcase = upcase_identifiers + end + + def database_metadata + upcase = @upcase + @database_metadata ||= Struct.new(:upcase_identifiers?).new(upcase) + end + + def prepared_statements = false + + # Expose private methods for testing + def call_format_case(id) + format_case(id) + end + + def call_native_case(id) + native_case(id) + end +end + +class CaseFormattingTest < Minitest::Test + def upcase_host + CaseFormattingHost.new(upcase_identifiers: true) + end + + def normal_host + CaseFormattingHost.new(upcase_identifiers: false) + end + + # --- format_case (DB dict case → AR case) --- + + def test_format_case_upcase_all_caps_converts_to_lowercase + assert_equal 'name', upcase_host.call_format_case('NAME') + end + + def test_format_case_upcase_mixed_case_unchanged + assert_equal 'camelCase', upcase_host.call_format_case('camelCase') + end + + def test_format_case_upcase_lowercase_unchanged + assert_equal 'name', upcase_host.call_format_case('name') + end + + def test_format_case_no_upcase_returns_unchanged + assert_equal 'NAME', normal_host.call_format_case('NAME') + assert_equal 'name', normal_host.call_format_case('name') + assert_equal 'camelCase', normal_host.call_format_case('camelCase') + end + + # --- native_case (AR case → DB dict case) --- + + def test_native_case_upcase_all_lowercase_converts_to_uppercase + assert_equal 'NAME', upcase_host.call_native_case('name') + end + + def test_native_case_upcase_mixed_case_unchanged + assert_equal 'camelCase', upcase_host.call_native_case('camelCase') + end + + def test_native_case_upcase_already_uppercase_unchanged + assert_equal 'NAME', upcase_host.call_native_case('NAME') + end + + def test_native_case_no_upcase_returns_unchanged + assert_equal 'name', normal_host.call_native_case('name') + assert_equal 'NAME', normal_host.call_native_case('NAME') + end +end diff --git a/test/unit/database_statements/dbms_type_cast_test.rb b/test/unit/database_statements/dbms_type_cast_test.rb new file mode 100644 index 00000000..bbbf3b06 --- /dev/null +++ b/test/unit/database_statements/dbms_type_cast_test.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +# Host that exposes the private dbms_type_cast for testing +class DbmsTypeCastHost + include ODBCAdapter::DatabaseStatements + + # Expose private method + def type_cast(columns, rows) + dbms_type_cast(columns, rows) + end + + # Not used in dbms_type_cast but required by the module + def prepared_statements = false + def database_metadata = nil +end + +StubCol = Struct.new(:type, :scale, :name) + +class DbmsTypeCastTest < Minitest::Test + def host + DbmsTypeCastHost.new + end + + def cast(col_type, value, scale: 0) + col = StubCol.new(col_type, scale, 'col') + rows = [[value]] + host.type_cast([col], rows) + rows[0][0] + end + + def test_nil_passes_through + assert_nil cast(ODBC::SQL_VARCHAR, nil) + end + + # --- varchar / string types --- + + def test_varchar_encodes_to_utf8 + val = 'hello'.dup.force_encoding('ASCII') + result = cast(ODBC::SQL_VARCHAR, val) + assert_equal Encoding::UTF_8, result.encoding + assert_equal 'hello', result + end + + def test_char_encodes_to_utf8 + result = cast(ODBC::SQL_CHAR, 'world'.dup.force_encoding('ASCII')) + assert_equal Encoding::UTF_8, result.encoding + end + + def test_longvarchar_encodes_to_utf8 + result = cast(ODBC::SQL_LONGVARCHAR, 'long'.dup.force_encoding('ASCII')) + assert_equal Encoding::UTF_8, result.encoding + end + + # --- numeric types --- + + def test_decimal_scale_zero_casts_to_integer + result = cast(ODBC::SQL_DECIMAL, '42', scale: 0) + assert_equal 42, result + assert_kind_of Integer, result + end + + def test_decimal_scale_positive_casts_to_float + result = cast(ODBC::SQL_DECIMAL, '3.14', scale: 2) + assert_in_delta 3.14, result, 0.001 + assert_kind_of Float, result + end + + def test_numeric_scale_zero_casts_to_integer + result = cast(ODBC::SQL_NUMERIC, '7', scale: 0) + assert_equal 7, result + end + + def test_real_casts_to_float + result = cast(ODBC::SQL_REAL, '1.5') + assert_in_delta 1.5, result, 0.001 + assert_kind_of Float, result + end + + def test_float_casts_to_float + result = cast(ODBC::SQL_FLOAT, '2.5') + assert_kind_of Float, result + end + + def test_double_casts_to_float + result = cast(ODBC::SQL_DOUBLE, '3.5') + assert_kind_of Float, result + end + + def test_integer_casts_to_integer + result = cast(ODBC::SQL_INTEGER, '99') + assert_equal 99, result + assert_kind_of Integer, result + end + + def test_smallint_casts_to_integer + result = cast(ODBC::SQL_SMALLINT, '10') + assert_kind_of Integer, result + end + + def test_tinyint_casts_to_integer + result = cast(ODBC::SQL_TINYINT, '1') + assert_kind_of Integer, result + end + + def test_bigint_casts_to_integer + result = cast(ODBC::SQL_BIGINT, '12345678901234') + assert_kind_of Integer, result + end + + # --- boolean --- + + def test_bit_one_casts_to_true + result = cast(ODBC::SQL_BIT, 1) + assert_equal true, result + end + + def test_bit_zero_casts_to_false + result = cast(ODBC::SQL_BIT, 0) + assert_equal false, result + end + + # --- date / time --- + + def test_date_type_casts_to_date + val = Date.new(2024, 1, 15) + result = cast(ODBC::SQL_DATE, val) + assert_kind_of Date, result + end + + def test_type_date_casts_to_date + val = Date.new(2024, 6, 1) + result = cast(ODBC::SQL_TYPE_DATE, val) + assert_kind_of Date, result + end + + def test_time_type_casts_to_time + val = Time.now + result = cast(ODBC::SQL_TIME, val) + assert_kind_of Time, result + end + + def test_timestamp_casts_to_datetime + val = Time.now + result = cast(ODBC::SQL_TIMESTAMP, val) + assert_kind_of DateTime, result + end + + def test_type_timestamp_casts_to_datetime + val = Time.now + result = cast(ODBC::SQL_TYPE_TIMESTAMP, val) + assert_kind_of DateTime, result + end + + # --- binary --- + + def test_binary_passes_through + val = 'raw bytes' + result = cast(ODBC::SQL_BINARY, val) + assert_equal val, result + end + + # --- unknown type raises --- + + def test_unknown_type_raises + # The raise message tries to call @raw_connection.types — stub it on the host + host_with_conn = DbmsTypeCastHost.new + host_with_conn.instance_variable_set( + :@raw_connection, + Struct.new(:stub) { def types(_code) = [['UNKNOWN']] }.new(nil) + ) + col = StubCol.new(9999, 0, 'col') + rows = [['x']] + assert_raises(RuntimeError) { host_with_conn.type_cast([col], rows) } + end + + # --- multiple rows and columns --- + + def test_casts_multiple_rows + col = StubCol.new(ODBC::SQL_INTEGER, 0, 'n') + rows = [['1'], ['2'], ['3']] + host.type_cast([col], rows) + assert_equal [1, 2, 3], rows.map { |r| r[0] } + end +end diff --git a/test/unit/database_statements/misc_test.rb b/test/unit/database_statements/misc_test.rb new file mode 100644 index 00000000..09053151 --- /dev/null +++ b/test/unit/database_statements/misc_test.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +class MiscStatementsHost + include ODBCAdapter::DatabaseStatements + + def database_metadata = nil + def prepared_statements = false +end + +class MiscDatabaseStatementsTest < Minitest::Test + def host + MiscStatementsHost.new + end + + # --- write_query? --- + + def test_write_query_true_for_insert + assert_equal true, host.write_query?('INSERT INTO t VALUES (1)') + end + + def test_write_query_true_for_update + assert_equal true, host.write_query?('UPDATE t SET x=1') + end + + def test_write_query_false_for_show + assert_equal false, host.write_query?('SHOW TABLES') + end + + def test_write_query_false_for_set + assert_equal false, host.write_query?('SET x = 1') + end + + def test_write_query_handles_invalid_encoding_gracefully + bad_sql = "SELECT \xFF\xFE".dup.force_encoding('UTF-8') + result = host.write_query?(bad_sql) + assert_includes [true, false], result + end + + # --- default_sequence_name --- + + def test_default_sequence_name + assert_equal 'users_seq', host.default_sequence_name('users', 'id') + end + + def test_default_sequence_name_ignores_column + assert_equal 'orders_seq', host.default_sequence_name('orders', 'order_id') + end + + # --- empty_insert_statement_value --- + + def test_empty_insert_statement_value_with_pk + assert_equal '(id) VALUES (DEFAULT)', host.empty_insert_statement_value('id') + end + + def test_empty_insert_statement_value_with_nil + assert_equal '() VALUES (DEFAULT)', host.empty_insert_statement_value + end + + # --- handle_query_preprocessing --- + + def test_handle_query_preprocessing_calls_preprocess_query_when_available + h = host + called_with = nil + h.define_singleton_method(:preprocess_query) do |sql| + called_with = sql + sql + end + h.send(:handle_query_preprocessing, 'SELECT 1') + assert_equal 'SELECT 1', called_with + end + + def test_handle_query_preprocessing_falls_back_to_transform_query + h = host + called_with = nil + h.define_singleton_method(:transform_query) do |sql| + called_with = sql + sql + end + h.send(:handle_query_preprocessing, 'SELECT 2') + assert_equal 'SELECT 2', called_with + end +end diff --git a/test/unit/database_statements/nullability_test.rb b/test/unit/database_statements/nullability_test.rb new file mode 100644 index 00000000..ecbe8f22 --- /dev/null +++ b/test/unit/database_statements/nullability_test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +class NullabilityHost + include ODBCAdapter::DatabaseStatements + + def prepared_statements = false + def database_metadata = nil + + # Expose private method + def call_nullability(col_name, is_nullable, nullable) + nullability(col_name, is_nullable, nullable) + end +end + +class NullabilityTest < Minitest::Test + SQL_NO_NULLS = ODBCAdapter::DatabaseStatements::SQL_NO_NULLS + SQL_NULLABLE = ODBCAdapter::DatabaseStatements::SQL_NULLABLE + + def host + NullabilityHost.new + end + + # MySQL hack: 'id' is always non-nullable regardless of reported value + def test_id_column_always_non_nullable + refute host.call_nullability('id', true, SQL_NULLABLE) + end + + def test_id_column_always_non_nullable_even_when_nullable_true + refute host.call_nullability('id', true, 'YES') + end + + # SQL_NO_NULLS means not nullable + def test_sql_no_nulls_returns_false + refute host.call_nullability('name', true, SQL_NO_NULLS) + end + + # SQL_NULLABLE means nullable + def test_sql_nullable_returns_true + assert host.call_nullability('name', true, SQL_NULLABLE) + end + + # is_nullable=false means not nullable + def test_is_nullable_false_returns_false + refute host.call_nullability('name', false, SQL_NULLABLE) + end + + # nullable string 'NO' means not nullable + def test_nullable_string_no_returns_false + refute host.call_nullability('name', true, 'NO') + end + + # nullable string 'YES' means nullable + def test_nullable_string_yes_returns_true + assert host.call_nullability('name', true, 'YES') + end + + # SQL_NULLABLE_UNKNOWN: assume nullable + def test_sql_nullable_unknown_returns_true + unknown = ODBCAdapter::DatabaseStatements::SQL_NULLABLE_UNKNOWN + assert host.call_nullability('name', true, unknown) + end +end diff --git a/test/unit/database_statements/transactions_test.rb b/test/unit/database_statements/transactions_test.rb new file mode 100644 index 00000000..0ab573e3 --- /dev/null +++ b/test/unit/database_statements/transactions_test.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/database_statements' + +class TrackingConnection + attr_accessor :autocommit + attr_reader :committed, :rolled_back + + def initialize + @autocommit = true + @committed = false + @rolled_back = false + end + + def commit + @committed = true + end + + def rollback + @rolled_back = true + end +end + +class TransactionHost + include ODBCAdapter::DatabaseStatements + + def database_metadata = nil + def prepared_statements = false + + def initialize + @raw_connection = TrackingConnection.new + end + + def tracking_conn + @raw_connection + end +end + +class TransactionTest < Minitest::Test + def host + TransactionHost.new + end + + def test_begin_db_transaction_sets_autocommit_false + h = host + h.begin_db_transaction + assert_equal false, h.tracking_conn.autocommit + end + + def test_commit_db_transaction_calls_commit + h = host + h.commit_db_transaction + assert_equal true, h.tracking_conn.committed + end + + def test_commit_db_transaction_restores_autocommit_true + h = host + h.begin_db_transaction + h.commit_db_transaction + assert_equal true, h.tracking_conn.autocommit + end + + def test_exec_rollback_db_transaction_calls_rollback + h = host + h.exec_rollback_db_transaction + assert_equal true, h.tracking_conn.rolled_back + end + + def test_exec_rollback_db_transaction_restores_autocommit_true + h = host + h.begin_db_transaction + h.exec_rollback_db_transaction + assert_equal true, h.tracking_conn.autocommit + end +end diff --git a/test/unit/error_test.rb b/test/unit/error_test.rb new file mode 100644 index 00000000..200ec82d --- /dev/null +++ b/test/unit/error_test.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/error' + +class ErrorTest < Minitest::Test + def test_query_timeout_error_inherits_from_statement_invalid + assert ODBCAdapter::QueryTimeoutError.ancestors.include?(ActiveRecord::StatementInvalid), + 'QueryTimeoutError must inherit from ActiveRecord::StatementInvalid' + end + + def test_connection_failed_error_inherits_from_statement_invalid + assert ODBCAdapter::ConnectionFailedError.ancestors.include?(ActiveRecord::StatementInvalid), + 'ConnectionFailedError must inherit from ActiveRecord::StatementInvalid' + end + + def test_query_timeout_error_can_be_instantiated_with_message + err = ODBCAdapter::QueryTimeoutError.new('query timed out') + assert_equal 'query timed out', err.message + end + + def test_connection_failed_error_can_be_instantiated_with_message + err = ODBCAdapter::ConnectionFailedError.new('connection failed') + assert_equal 'connection failed', err.message + end + + def test_query_timeout_error_rescuable_as_statement_invalid + raised = nil + begin + raise ODBCAdapter::QueryTimeoutError, 'timeout' + rescue ActiveRecord::StatementInvalid => e + raised = e + end + assert_instance_of ODBCAdapter::QueryTimeoutError, raised + end + + def test_connection_failed_error_rescuable_as_statement_invalid + raised = nil + begin + raise ODBCAdapter::ConnectionFailedError, 'failed' + rescue ActiveRecord::StatementInvalid => e + raised = e + end + assert_instance_of ODBCAdapter::ConnectionFailedError, raised + end +end diff --git a/test/unit/quoting_test.rb b/test/unit/quoting_test.rb new file mode 100644 index 00000000..72daa493 --- /dev/null +++ b/test/unit/quoting_test.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'active_support/core_ext/date_time' +require 'odbc_adapter/type/internal/snowflake_variant' +require 'odbc_adapter/quoting' + +# Minimal base quoting so Quoting#quote can call super for primitive values. +module BaseQuoting + def quote(value) + case value + when String then "'#{value.gsub("'", "''")}'" + when Integer then value.to_s + when Float then value.to_s + when NilClass then 'NULL' + else "'#{value}'" + end + end +end + +class QuotingHost + include BaseQuoting + include ODBCAdapter::Quoting + + def initialize(quote_char: '"', upcase: false) + @quote_char = quote_char + @upcase = upcase + end + + def database_metadata + upcase = @upcase + qc = @quote_char + @database_metadata ||= Struct.new(:identifier_quote_char, :upcase_identifiers?).new(qc, upcase) + end +end + +class QuotingTest < Minitest::Test + def host(quote_char: '"', upcase: false) + QuotingHost.new(quote_char: quote_char, upcase: upcase) + end + + # --- quote_string --- + + def test_quote_string_escapes_single_quotes + assert_equal "it''s", host.quote_string("it's") + end + + def test_quote_string_no_escaping_needed + assert_equal 'hello', host.quote_string('hello') + end + + def test_quote_string_multiple_quotes + assert_equal "a''b''c", host.quote_string("a'b'c") + end + + # --- quote_column_name --- + + def test_quote_column_name_wraps_in_quote_char + assert_equal '"name"', host.quote_column_name('name') + end + + def test_quote_column_name_already_quoted_passes_through + assert_equal '"name"', host.quote_column_name('"name"') + end + + def test_quote_column_name_no_quote_char_returns_plain + assert_equal 'name', host(quote_char: '').quote_column_name('name') + end + + def test_quote_column_name_upcase_pure_uppercase_passes_through + assert_equal 'NAME', host(upcase: true).quote_column_name('NAME') + end + + def test_quote_column_name_upcase_mixed_case_gets_quoted + assert_equal '"camelCase"', host(upcase: true).quote_column_name('camelCase') + end + + def test_quote_column_name_upcase_lowercase_passes_through + assert_equal 'name', host(upcase: true).quote_column_name('name') + end + + # --- quote_table_name --- + + def test_quote_table_name_delegates_to_quote_column_name + assert_equal '"users"', host.quote_table_name('users') + end + + # --- quote_hash --- + + def test_quote_hash_generates_object_construct + result = host.quote_hash(hash: { 'key' => 'val' }) + assert_equal "OBJECT_CONSTRUCT('key','val')", result + end + + def test_quote_hash_multiple_pairs + result = host.quote_hash(hash: { 'a' => 1, 'b' => 2 }) + assert_equal "OBJECT_CONSTRUCT('a',1,'b',2)", result + end + + def test_quote_hash_empty + result = host.quote_hash(hash: {}) + assert_equal 'OBJECT_CONSTRUCT()', result + end + + # --- quote_array --- + + def test_quote_array_generates_array_construct + result = host.quote_array(array: %w[a b]) + assert_equal "ARRAY_CONSTRUCT('a','b')", result + end + + def test_quote_array_empty + result = host.quote_array(array: []) + assert_equal 'ARRAY_CONSTRUCT()', result + end + + # --- quote dispatch --- + + def test_quote_dispatches_hash_to_quote_hash + result = host.quote({ 'k' => 'v' }) + assert_equal "OBJECT_CONSTRUCT('k','v')", result + end + + def test_quote_dispatches_array_to_quote_array + result = host.quote(['x']) + assert_equal "ARRAY_CONSTRUCT('x')", result + end + + def test_quote_dispatches_snowflake_variant + sv = ODBCAdapter::Type::SnowflakeVariant.new('hello') + result = host.quote(sv) + assert_equal "'hello'::VARIANT", result + end + + def test_quote_string_via_super + result = host.quote('world') + assert_equal "'world'", result + end + + def test_quote_nil_via_super + assert_equal 'NULL', host.quote(nil) + end + + def test_quote_integer_via_super + assert_equal '42', host.quote(42) + end + + # --- quoted_date --- + + def test_quoted_date_formats_date + assert_equal '2024-03-15', host.quoted_date(Date.new(2024, 3, 15)) + end + + def test_quoted_date_formats_time_as_datetime_string + original_timezone = ActiveRecord.default_timezone + ActiveRecord.default_timezone = :utc + begin + result = host.quoted_date(Time.utc(2024, 3, 15, 10, 30, 45)) + assert_equal '2024-03-15 10:30:45', result + ensure + ActiveRecord.default_timezone = original_timezone + end + end + + def test_quoted_date_formats_datetime + result = host.quoted_date(DateTime.new(2024, 3, 15, 10, 30, 45)) + assert_match(/2024-03-15 10:30:45/, result) + end + + def test_quoted_date_formats_time_in_local_timezone + original_timezone = ActiveRecord.default_timezone + ActiveRecord.default_timezone = :local + begin + result = host.quoted_date(Time.local(2024, 3, 15, 10, 30, 45)) + assert_equal '2024-03-15 10:30:45', result + ensure + ActiveRecord.default_timezone = original_timezone + end + end +end diff --git a/test/unit/registry_test.rb b/test/unit/registry_test.rb new file mode 100644 index 00000000..11a5e7f6 --- /dev/null +++ b/test/unit/registry_test.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/registry' + +class RegistryUnitTest < Minitest::Test + def registry + ODBCAdapter::Registry.new + end + + def test_mysql_pattern_resolves_to_mysql_adapter + adapter = registry.adapter_for('MySQL') + assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, adapter + end + + def test_mysql_pattern_is_case_insensitive + assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, registry.adapter_for('mysql') + assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, registry.adapter_for('MYSQL') + end + + def test_mysql_pattern_matches_partial_name + assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, registry.adapter_for('mysql57') + end + + def test_postgres_pattern_resolves_to_postgresql_adapter + adapter = registry.adapter_for('PostgreSQL') + assert_equal ODBCAdapter::Adapters::PostgreSQLODBCAdapter, adapter + end + + def test_snowflake_pattern_resolves_to_snowflake_adapter + adapter = registry.adapter_for('Snowflake') + assert_equal ODBCAdapter::Adapters::SnowflakeODBCAdapter, adapter + end + + def test_snowflake_with_whitespace_stripped + # adapter_for lowercases and strips spaces before matching + adapter = registry.adapter_for('Snow Flake') + assert_equal ODBCAdapter::Adapters::SnowflakeODBCAdapter, adapter + end + + def test_unknown_name_resolves_to_null_adapter + adapter = registry.adapter_for('SomeUnknownDatabase') + assert_equal ODBCAdapter::Adapters::NullODBCAdapter, adapter + end + + def test_empty_string_resolves_to_null_adapter + adapter = registry.adapter_for('') + assert_equal ODBCAdapter::Adapters::NullODBCAdapter, adapter + end + + def test_custom_registration_with_block + r = registry + r.register(/foobar/i) {} + adapter = r.adapter_for('FooBar DB') + assert_kind_of Class, adapter + end + + def test_custom_registration_with_superclass + r = registry + r.register(/custom/i, ODBCAdapter::Adapters::MySQLODBCAdapter) + adapter = r.adapter_for('custom db') + assert adapter.ancestors.include?(ODBCAdapter::Adapters::MySQLODBCAdapter) + end + + def test_adapter_for_class_method_delegates_to_registry + adapter = ODBCAdapter.adapter_for('Snowflake') + assert_equal ODBCAdapter::Adapters::SnowflakeODBCAdapter, adapter + end +end diff --git a/test/unit/schema_statements/columns_test.rb b/test/unit/schema_statements/columns_test.rb new file mode 100644 index 00000000..986a505c --- /dev/null +++ b/test/unit/schema_statements/columns_test.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' +require 'odbc_adapter/database_statements' +require 'odbc_adapter/column' + +class ColumnsSchemaHost + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(column_data) + @column_data = column_data + end + + def database_metadata + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name).new(false, 'MYDB') + end + + def current_database = 'MYDB' + def current_schema = 'PUBLIC' + + # Stub to avoid needing a real type map — return a generic Value type. + def lookup_cast_type_from_column(_meta) + ActiveRecord::Type::Value.new + end + + private + + def retrieve_column_data(_table_name) + @column_data + end +end + +class ColumnsSchemaTest < Minitest::Test + def col_data(column_name:, col_native_type:, **opts) + { column_size: 0, numeric_scale: 0, col_default: nil, is_nullable: true, + auto_incremented: false, column_name:, col_native_type: }.merge(opts) + end + + def columns_for(data) + ColumnsSchemaHost.new([data]).columns('users') + end + + def test_columns_varchar_type + col = columns_for(col_data(column_name: 'email', col_native_type: 'VARCHAR', column_size: 255)).first + assert_equal :string, col.type + assert_equal 'VARCHAR(255)', col.sql_type + end + + def test_columns_boolean_type + col = columns_for(col_data(column_name: 'active', col_native_type: 'BOOLEAN')).first + assert_equal :boolean, col.type + end + + def test_columns_variant_type + col = columns_for(col_data(column_name: 'payload', col_native_type: 'VARIANT')).first + assert_equal :variant, col.type + end + + def test_columns_date_type + col = columns_for(col_data(column_name: 'dob', col_native_type: 'DATE')).first + assert_equal :date, col.type + end + + def test_columns_timestamp_type + col = columns_for(col_data(column_name: 'created_at', col_native_type: 'TIMESTAMP')).first + assert_equal :datetime, col.type + end + + def test_columns_time_type + col = columns_for(col_data(column_name: 'start_time', col_native_type: 'TIME')).first + assert_equal :time, col.type + end + + def test_columns_binary_type + col = columns_for(col_data(column_name: 'data', col_native_type: 'BINARY')).first + assert_equal :binary, col.type + end + + def test_columns_double_type + col = columns_for(col_data(column_name: 'score', col_native_type: 'DOUBLE')).first + assert_equal :float, col.type + end + + def test_columns_decimal_scale_zero_type + col = columns_for(col_data(column_name: 'count', col_native_type: 'DECIMAL', numeric_scale: 0)).first + assert_equal :integer, col.type + end + + def test_columns_decimal_with_scale_type + col = columns_for( + col_data(column_name: 'amount', col_native_type: 'DECIMAL', column_size: 10, numeric_scale: 2) + ).first + assert_equal :decimal, col.type + end + + def test_columns_auto_incremented_flag + col = columns_for( + col_data(column_name: 'id_auto_inc', col_native_type: 'DECIMAL', auto_incremented: true) + ).first + assert col.auto_incremented + end + + def test_columns_native_type_preserved + col = columns_for(col_data(column_name: 'email', col_native_type: 'VARCHAR', column_size: 100)).first + assert_equal 'VARCHAR', col.native_type + end + + def test_columns_nullable_flag + col = columns_for( + col_data(column_name: 'required_field', col_native_type: 'VARCHAR', is_nullable: false) + ).first + assert_equal false, col.null + end + + def test_columns_returns_multiple_columns + host = ColumnsSchemaHost.new([ + col_data(column_name: 'id', col_native_type: 'DECIMAL'), + col_data(column_name: 'email', col_native_type: 'VARCHAR', column_size: 255) + ]) + assert_equal 2, host.columns('users').length + end + + def test_columns_unknown_type_maps_to_nil_type + # An unrecognised native type falls through to the else branch and returns nil. + # The columns method should not raise. + col = columns_for(col_data(column_name: 'mystery', col_native_type: 'UNKNOWN_SF_TYPE')).first + assert_nil col.type + end + + # --- STRUCT and ARRAY types --- + + def test_columns_struct_maps_to_object_type + col = columns_for(col_data(column_name: 'meta', col_native_type: 'STRUCT')).first + assert_equal :object, col.type + end + + def test_columns_array_maps_to_array_type + col = columns_for(col_data(column_name: 'tags', col_native_type: 'ARRAY')).first + assert_equal :array, col.type + end + + # --- extract_scale_from_snowflake --- + + def test_extract_scale_from_snowflake_uses_scale_key + col = columns_for( + col_data(column_name: 'price', col_native_type: 'DECIMAL', column_size: 10, numeric_scale: 3) + ).first + assert_equal 'DECIMAL(10,3)', col.sql_type + end + + def test_extract_scale_from_snowflake_defaults_to_zero_when_absent + col = columns_for( + col_data(column_name: 'count', col_native_type: 'DECIMAL', column_size: 0, numeric_scale: 0) + ).first + assert_equal :integer, col.type + end +end diff --git a/test/unit/schema_statements/foreign_keys_test.rb b/test/unit/schema_statements/foreign_keys_test.rb new file mode 100644 index 00000000..3fcacae4 --- /dev/null +++ b/test/unit/schema_statements/foreign_keys_test.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' +require 'odbc_adapter/database_statements' +require 'odbc_adapter/column' + +class ForeignKeySchemaHost + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(rows) + stmt = Struct.new(:rows) do + def fetch_all = rows + def drop = nil + end.new(rows) + + @raw_connection = Struct.new(:stmt) do + def foreign_keys(_name) = stmt + end.new(stmt) + end + + def database_metadata + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name).new(false, 'MYDB') + end + + def current_database = 'MYDB' + def current_schema = 'PUBLIC' +end + +class ForeignKeysSchemaTest < Minitest::Test + # key[0]=db, key[1]=schema, key[2]=pktable, key[3]=pk_col, + # key[6]=fktable, key[7]=fk_col, key[9]=update_rule, key[10]=delete_rule, key[11]=fk_name + def fk_row(from_table: 'users', to_table: 'orders', pk_col: 'id', **opts) + fk_col = opts.fetch(:fk_col, 'user_id') + fk_name = opts.fetch(:fk_name, 'fk_orders_users') + update_rule = opts.fetch(:update_rule, 0) + delete_rule = opts.fetch(:delete_rule, 0) + db = opts.fetch(:db, 'MYDB') + schema = opts.fetch(:schema, 'PUBLIC') + row = Array.new(12) + row[0] = db + row[1] = schema + row[2] = from_table + row[3] = pk_col + row[6] = to_table + row[7] = fk_col + row[9] = update_rule + row[10] = delete_rule + row[11] = fk_name + row + end + + def test_foreign_keys_empty_for_no_rows + host = ForeignKeySchemaHost.new([]) + assert_equal [], host.foreign_keys('users') + end + + def test_foreign_key_from_and_to_table + host = ForeignKeySchemaHost.new([fk_row]) + fk = host.foreign_keys('users').first + assert_equal 'users', fk.from_table + assert_equal 'orders', fk.to_table + end + + def test_foreign_key_name + host = ForeignKeySchemaHost.new([fk_row]) + fk = host.foreign_keys('users').first + assert_equal 'fk_orders_users', fk.name + end + + def test_foreign_key_column_and_primary_key + host = ForeignKeySchemaHost.new([fk_row(pk_col: 'id', fk_col: 'user_id')]) + fk = host.foreign_keys('users').first + assert_equal 'id', fk.column + assert_equal 'user_id', fk.primary_key + end + + def test_skips_rows_with_wrong_db_schema + # The source uses `next unless` inside map, which returns nil for skipped rows. + host = ForeignKeySchemaHost.new([fk_row(db: 'WRONG')]) + result = host.foreign_keys('users') + assert_equal 1, result.length + assert_nil result.first + end +end diff --git a/test/unit/schema_statements/indexes_test.rb b/test/unit/schema_statements/indexes_test.rb new file mode 100644 index 00000000..c8285a63 --- /dev/null +++ b/test/unit/schema_statements/indexes_test.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' +require 'odbc_adapter/database_statements' +require 'odbc_adapter/column' + +class IndexSchemaHost + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(rows) + stmt = Struct.new(:rows) do + def fetch_all = rows + def drop = nil + end.new(rows) + + @raw_connection = Struct.new(:stmt) do + def indexes(_name) = stmt + end.new(stmt) + end + + def database_metadata + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name).new(false, 'MYDB') + end + + def current_database = 'MYDB' + def current_schema = 'PUBLIC' +end + +class IndexesSchemaTest < Minitest::Test + # row[0]=db, row[1]=schema, row[3]=non_unique, row[5]=idx_name, + # row[6]=type, row[7]=ordinal, row[8]=col_name + def index_row(col_name:, idx_name: 'idx_users_email', ordinal: 1, **opts) + non_unique = opts.fetch(:non_unique, 0) + type = opts.fetch(:type, 1) + db = opts.fetch(:db, 'MYDB') + schema = opts.fetch(:schema, 'PUBLIC') + row = Array.new(10) + row[0] = db + row[1] = schema + row[3] = non_unique + row[5] = idx_name + row[6] = type + row[7] = ordinal + row[8] = col_name + row + end + + def test_indexes_returns_empty_for_no_rows + host = IndexSchemaHost.new([]) + assert_equal [], host.indexes('users') + end + + def test_single_column_unique_index + host = IndexSchemaHost.new([index_row(col_name: 'email', non_unique: 0)]) + result = host.indexes('users') + assert result.first.unique + assert_equal ['email'], result.first.columns + end + + def test_single_column_non_unique_index + host = IndexSchemaHost.new([index_row(col_name: 'email', non_unique: 1)]) + result = host.indexes('users') + refute result.first.unique + end + + def test_multi_column_index_groups_by_ordinal_position + rows = [ + index_row(col_name: 'a', idx_name: 'idx', ordinal: 1, non_unique: 0), + index_row(col_name: 'b', idx_name: 'idx', ordinal: 2, non_unique: 0) + ] + host = IndexSchemaHost.new(rows) + result = host.indexes('users') + assert_equal 1, result.length + assert_equal %w[a b], result.first.columns + end + + def test_skips_table_statistics_rows + host = IndexSchemaHost.new([index_row(col_name: 'email', type: 0)]) + assert_equal [], host.indexes('users') + end + + def test_skips_rows_with_wrong_db_name + host = IndexSchemaHost.new([index_row(col_name: 'email', db: 'OTHER')]) + assert_equal [], host.indexes('users') + end + + def test_index_name_is_correct + host = IndexSchemaHost.new([index_row(col_name: 'email', idx_name: 'idx_users_email')]) + result = host.indexes('users') + assert_equal 'idx_users_email', result.first.name + end +end + +# Provides the base index_name used by ODBCAdapter::SchemaStatements#index_name via super. +module BaseIndexNameProvider + def index_name(table_name, options) + if options.is_a?(Hash) + if options[:column] + "index_#{table_name}_on_#{Array(options[:column]) * '_and_'}" + elsif options[:name] + options[:name].to_s + end + else + index_name(table_name, column: options) + end + end +end + +class IndexNameSchemaHost + include BaseIndexNameProvider + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(max_len:) + @max_len = max_len + end + + def database_metadata + max_len = @max_len + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name, :max_identifier_len) + .new(false, 'MYDB', max_len) + end + + def current_database = 'MYDB' + def current_schema = 'PUBLIC' +end + +class IndexNameSchemaTest < Minitest::Test + def test_index_name_respects_max_identifier_len + host = IndexNameSchemaHost.new(max_len: 20) + result = host.index_name('users', column: 'email') + assert result.length <= 20 + end + + def test_index_name_falls_back_to_255_when_max_len_nil + host = IndexNameSchemaHost.new(max_len: nil) + result = host.index_name('users', column: 'email') + assert result.length <= 255 + end + + def test_index_name_truncates_long_generated_name + host = IndexNameSchemaHost.new(max_len: 10) + result = host.index_name('very_long_table_name', column: 'very_long_column_name') + assert_equal 10, result.length + end +end diff --git a/test/unit/schema_statements/primary_key_test.rb b/test/unit/schema_statements/primary_key_test.rb new file mode 100644 index 00000000..ca566153 --- /dev/null +++ b/test/unit/schema_statements/primary_key_test.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' +require 'odbc_adapter/database_statements' + +class PrimaryKeySchemaHost + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(rows) + stmt = Struct.new(:rows) do + def fetch_all = rows + def drop = nil + end.new(rows) + + @raw_connection = Struct.new(:stmt) do + def primary_keys(_name) = stmt + end.new(stmt) + end + + def database_metadata + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name).new(false, 'MYDB') + end + + def current_database = 'MYDB' + def current_schema = 'PUBLIC' +end + +class PrimaryKeySchemaTest < Minitest::Test + def pk_row(col_name:, db: 'MYDB', schema: 'PUBLIC', table: 'users') + row = Array.new(5) + row[0] = db + row[1] = schema + row[2] = table + row[3] = col_name + row + end + + def test_primary_key_returns_nil_for_no_rows + host = PrimaryKeySchemaHost.new([]) + assert_nil host.primary_key('users') + end + + def test_primary_key_returns_column_name + host = PrimaryKeySchemaHost.new([pk_row(col_name: 'id')]) + assert_equal 'id', host.primary_key('users') + end + + def test_primary_key_filters_by_db_and_schema + host = PrimaryKeySchemaHost.new([pk_row(col_name: 'id', db: 'WRONG')]) + assert_nil host.primary_key('users') + end + + def test_primary_key_returns_last_match_when_multiple_rows + rows = [ + pk_row(col_name: 'first_key'), + pk_row(col_name: 'second_key') + ] + host = PrimaryKeySchemaHost.new(rows) + assert_equal 'second_key', host.primary_key('users') + end +end diff --git a/test/unit/schema_statements/snowflake_helpers_test.rb b/test/unit/schema_statements/snowflake_helpers_test.rb new file mode 100644 index 00000000..586a5c3f --- /dev/null +++ b/test/unit/schema_statements/snowflake_helpers_test.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' + +# Host class that exposes the private helper methods. +class SchemaStatementsHost + include ODBCAdapter::SchemaStatements + + # Expose private helpers + def extract_default(val) + extract_default_from_snowflake(val) + end + + def extract_data_type(type) + extract_data_type_from_snowflake(type) + end + + def extract_column_size(type_info) + extract_column_size_from_snowflake(type_info) + end + + def build_sql_type(native, limit, scale) + construct_sql_type(native, limit, scale) + end + + def name_regex_for(name) + name_regex(name) + end +end + +class SnowflakeHelpersTest < Minitest::Test + def host + SchemaStatementsHost.new + end + + # --- extract_default_from_snowflake --- + + def test_extract_default_nil_returns_nil + assert_nil host.extract_default(nil) + end + + def test_extract_default_quoted_string + assert_equal 'hello', host.extract_default("'hello'") + end + + def test_extract_default_quoted_string_with_escaped_quote + assert_equal "it's", host.extract_default("'it''s'") + end + + def test_extract_default_boolean_true + assert_equal 'true', host.extract_default('TRUE') + end + + def test_extract_default_boolean_false + assert_equal 'false', host.extract_default('FALSE') + end + + def test_extract_default_integer + assert_equal '42', host.extract_default('42') + end + + def test_extract_default_negative_integer + assert_equal '-7', host.extract_default('-7') + end + + def test_extract_default_float + assert_equal '3.14', host.extract_default('3.14') + end + + def test_extract_default_sequence_returns_nil + assert_nil host.extract_default('MY_SEQ.nextval') + end + + def test_extract_default_unrecognised_returns_nil + assert_nil host.extract_default('SOME_FUNCTION()') + end + + # --- extract_data_type_from_snowflake --- + + def test_extract_data_type_number_maps_to_decimal + assert_equal 'DECIMAL', host.extract_data_type('NUMBER') + end + + def test_extract_data_type_timestamp_ltz_maps_to_timestamp + assert_equal 'TIMESTAMP', host.extract_data_type('TIMESTAMP_LTZ') + end + + def test_extract_data_type_timestamp_ntz_maps_to_timestamp + assert_equal 'TIMESTAMP', host.extract_data_type('TIMESTAMP_NTZ') + end + + def test_extract_data_type_text_maps_to_varchar + assert_equal 'VARCHAR', host.extract_data_type('TEXT') + end + + def test_extract_data_type_float_maps_to_double + assert_equal 'DOUBLE', host.extract_data_type('FLOAT') + end + + def test_extract_data_type_real_maps_to_double + assert_equal 'DOUBLE', host.extract_data_type('REAL') + end + + def test_extract_data_type_fixed_maps_to_decimal + assert_equal 'DECIMAL', host.extract_data_type('FIXED') + end + + def test_extract_data_type_passthrough_for_unknown + assert_equal 'BOOLEAN', host.extract_data_type('BOOLEAN') + assert_equal 'VARIANT', host.extract_data_type('VARIANT') + assert_equal 'DATE', host.extract_data_type('DATE') + end + + # --- extract_column_size_from_snowflake --- + + def test_extract_column_size_timestamp_hardcoded + assert_equal 35, host.extract_column_size('type' => 'TIMESTAMP_LTZ') + end + + def test_extract_column_size_date_hardcoded + assert_equal 10, host.extract_column_size('type' => 'DATE') + end + + def test_extract_column_size_float_hardcoded + assert_equal 38, host.extract_column_size('type' => 'FLOAT') + end + + def test_extract_column_size_real_hardcoded + assert_equal 38, host.extract_column_size('type' => 'REAL') + end + + def test_extract_column_size_boolean_hardcoded + assert_equal 1, host.extract_column_size('type' => 'BOOLEAN') + end + + def test_extract_column_size_uses_length_for_varchar + info = { 'type' => 'TEXT', 'length' => 255 } + assert_equal 255, host.extract_column_size(info) + end + + def test_extract_column_size_uses_precision_when_no_length + info = { 'type' => 'NUMBER', 'precision' => 38 } + assert_equal 38, host.extract_column_size(info) + end + + def test_extract_column_size_returns_zero_when_no_size_info + assert_equal 0, host.extract_column_size('type' => 'VARIANT') + end + + # --- construct_sql_type --- + + def test_construct_sql_type_with_scale + assert_equal 'DECIMAL(10,2)', host.build_sql_type('DECIMAL', 10, 2) + end + + def test_construct_sql_type_with_limit_no_scale + assert_equal 'VARCHAR(255)', host.build_sql_type('VARCHAR', 255, 0) + end + + def test_construct_sql_type_no_limit_no_scale + assert_equal 'BOOLEAN', host.build_sql_type('BOOLEAN', 0, 0) + end + + # --- name_regex --- + + def test_name_regex_quoted_is_case_sensitive_exact + regex = host.name_regex_for('"MySchema"') + assert_match regex, 'MySchema' + refute_match regex, 'myschema' + end + + def test_name_regex_unquoted_is_case_insensitive + regex = host.name_regex_for('MySchema') + assert_match regex, 'MySchema' + assert_match regex, 'myschema' + assert_match regex, 'MYSCHEMA' + end +end diff --git a/test/unit/schema_statements/tables_test.rb b/test/unit/schema_statements/tables_test.rb new file mode 100644 index 00000000..446891d5 --- /dev/null +++ b/test/unit/schema_statements/tables_test.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/schema_statements' +require 'odbc_adapter/database_statements' +require 'odbc_adapter/column' + +class TablesSchemaHost + include ODBCAdapter::SchemaStatements + include ODBCAdapter::DatabaseStatements + + def initialize(rows, db: 'MYDB', schema: 'PUBLIC', upcase_identifiers: false) + @db = db + @schema = schema + @upcase_identifiers = upcase_identifiers + + each_hash_rows = rows + execute_result = Object.new + execute_result.define_singleton_method(:each_hash) do |&blk| + each_hash_rows.each { |r| blk.call(r) } + end + + stmt = Object.new + stmt.define_singleton_method(:execute) { execute_result } + stmt.define_singleton_method(:drop) {} + + @raw_connection = Object.new + @raw_connection.define_singleton_method(:prepare) { |_sql| stmt } + end + + def database_metadata + upcase = @upcase_identifiers + @database_metadata ||= Struct.new(:upcase_identifiers?, :database_name).new(upcase, @db) + end + + def current_database = @db + def current_schema = @schema +end + +class TablesSchemaTest < Minitest::Test + def table_row(name:, db: 'MYDB', schema: 'PUBLIC', kind: 'TABLE') + { 'name' => name, 'database_name' => db, 'schema_name' => schema, 'kind' => kind } + end + + def test_tables_returns_empty_for_no_rows + host = TablesSchemaHost.new([]) + assert_equal [], host.tables + end + + def test_tables_returns_table_names + host = TablesSchemaHost.new([table_row(name: 'users')]) + assert_equal ['users'], host.tables + end + + def test_tables_returns_multiple_tables + host = TablesSchemaHost.new([ + table_row(name: 'users'), + table_row(name: 'orders') + ]) + assert_equal %w[users orders], host.tables + end + + def test_tables_filters_wrong_database + host = TablesSchemaHost.new([ + table_row(name: 'users', db: 'OTHER'), + table_row(name: 'orders') + ]) + assert_equal ['orders'], host.tables + end + + def test_tables_filters_wrong_schema + host = TablesSchemaHost.new([ + table_row(name: 'users', schema: 'OTHER'), + table_row(name: 'orders') + ]) + assert_equal ['orders'], host.tables + end + + def test_tables_calls_table_filtered_if_present + host = TablesSchemaHost.new([table_row(name: 'users', kind: 'TABLE')]) + # Extend with table_filtered? that excludes TABLE kind rows. + host.define_singleton_method(:table_filtered?) { |_schema, kind| kind == 'TABLE' } + assert_equal [], host.tables + end + + def test_tables_applies_format_case + # With upcase_identifiers?: true, an all-caps name from the DB is downcased. + host = TablesSchemaHost.new([table_row(name: 'USERS')], upcase_identifiers: true) + assert_equal ['users'], host.tables + end +end diff --git a/test/unit/type/array_of_test.rb b/test/unit/type/array_of_test.rb new file mode 100644 index 00000000..0b8ce90d --- /dev/null +++ b/test/unit/type/array_of_test.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/type/array_of' + +class ArrayOfTest < Minitest::Test + def integer_array_type + ODBCAdapter::Type.array_of(ActiveRecord::Type::Integer.new) + end + + def string_array_type + ODBCAdapter::Type.array_of(ActiveRecord::Type::String.new) + end + + def test_factory_creates_a_class + assert_kind_of Class, integer_array_type + end + + def test_factory_creates_subclass_of_ar_type_value + assert integer_array_type.ancestors.include?(ActiveRecord::Type::Value) + end + + def test_cast_value_parses_json_string_to_array + type = integer_array_type.new + result = type.cast_value('[1, 2, 3]') + assert_equal [1, 2, 3], result + end + + def test_cast_value_casts_elements_with_inner_type + type = integer_array_type.new + result = type.cast_value('["4", "5"]') + assert_equal [4, 5], result + end + + def test_cast_value_returns_non_strings_unchanged + type = integer_array_type.new + arr = [1, 2] + assert_equal arr, type.cast_value(arr) + end + + def test_cast_value_invalid_json_returns_nil_mapped + type = integer_array_type.new + # invalid JSON → rescue returns nil → nil.map raises + # The implementation does base_array.map, so invalid JSON → nil → error + # This tests that bad input propagates predictably + assert_raises(NoMethodError) { type.cast_value('not json') } + end + + def test_serialize_maps_elements_through_inner_type + type = string_array_type.new + result = type.serialize(%w[a b]) + assert_equal %w[a b], result + end + + def test_serialize_nil_returns_nil + type = integer_array_type.new + assert_nil type.serialize(nil) + end + + def test_serialize_converts_to_array + type = integer_array_type.new + result = type.serialize([1, 2]) + assert_equal [1, 2], result + end + + def test_changed_in_place_same_data + type = integer_array_type.new + refute type.changed_in_place?('[1,2]', [1, 2]) + end + + def test_changed_in_place_different_data + type = integer_array_type.new + assert type.changed_in_place?('[1,2]', [1, 3]) + end +end diff --git a/test/unit/type/object_test.rb b/test/unit/type/object_test.rb new file mode 100644 index 00000000..fed6ad1f --- /dev/null +++ b/test/unit/type/object_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'ostruct' +require 'odbc_adapter/type/object' + +class SnowflakeObjectTest < Minitest::Test + def subject + ODBCAdapter::Type::SnowflakeObject.new + end + + def test_cast_value_json_string_to_hash + result = subject.cast_value('{"key":"value"}') + assert_equal({ 'key' => 'value' }, result) + end + + def test_cast_value_hash_passes_through + hash = { 'a' => 1 } + assert_equal hash, subject.cast_value(hash) + end + + def test_cast_value_invalid_json_returns_nil + assert_nil subject.cast_value('not json {') + end + + def test_serialize_hash_returns_hash + hash = { 'a' => 1, 'b' => 2 } + assert_equal hash, subject.serialize(hash) + end + + def test_serialize_nil_returns_nil + assert_nil subject.serialize(nil) + end + + def test_serialize_calls_to_h + obj = OpenStruct.new(a: 1) + result = subject.serialize(obj) + assert_equal({ a: 1 }, result) + end + + def test_changed_in_place_returns_false_when_equal + refute subject.changed_in_place?('{"x":1}', { 'x' => 1 }) + end + + def test_changed_in_place_returns_true_when_different + assert subject.changed_in_place?('{"x":1}', { 'x' => 2 }) + end + + def test_accessor_returns_string_keyed_hash_accessor + assert_equal ActiveRecord::Store::StringKeyedHashAccessor, subject.accessor + end +end diff --git a/test/unit/type/snowflake_integer_test.rb b/test/unit/type/snowflake_integer_test.rb new file mode 100644 index 00000000..a9c5498c --- /dev/null +++ b/test/unit/type/snowflake_integer_test.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/type/snowflake_integer' + +class SnowflakeIntegerTest < Minitest::Test + def subject + ODBCAdapter::Type::SnowflakeInteger.new + end + + def test_inherits_from_big_integer + assert_kind_of ActiveRecord::Type::BigInteger, subject + end + + def test_cast_auto_generate_passes_through + assert_equal :auto_generate, subject.cast(:auto_generate) + end + + def test_cast_integer_string + assert_equal 42, subject.cast('42') + end + + def test_cast_integer + assert_equal 7, subject.cast(7) + end + + def test_cast_nil_returns_nil + assert_nil subject.cast(nil) + end + + def test_cast_float_truncates + assert_equal 3, subject.cast(3.9) + end +end diff --git a/test/unit/type/snowflake_variant_test.rb b/test/unit/type/snowflake_variant_test.rb new file mode 100644 index 00000000..9cb5c45a --- /dev/null +++ b/test/unit/type/snowflake_variant_test.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/type/internal/snowflake_variant' + +class SnowflakeVariantTest < Minitest::Test + def test_stores_internal_data + sv = ODBCAdapter::Type::SnowflakeVariant.new({ 'k' => 1 }) + assert_equal({ 'k' => 1 }, sv.internal_data) + end + + def test_internal_data_with_string + sv = ODBCAdapter::Type::SnowflakeVariant.new('hello') + assert_equal 'hello', sv.internal_data + end + + def test_internal_data_with_nil + sv = ODBCAdapter::Type::SnowflakeVariant.new(nil) + assert_nil sv.internal_data + end + + def test_quote_appends_variant_cast + adapter = Minitest::Mock.new + adapter.expect(:quote, "'hello'", ['hello']) + + sv = ODBCAdapter::Type::SnowflakeVariant.new('hello') + result = sv.quote(adapter) + + assert_equal "'hello'::VARIANT", result + adapter.verify + end + + def test_quote_with_hash_value + adapter = Minitest::Mock.new + adapter.expect(:quote, "OBJECT_CONSTRUCT('k','v')", [{ 'k' => 'v' }]) + + sv = ODBCAdapter::Type::SnowflakeVariant.new({ 'k' => 'v' }) + result = sv.quote(adapter) + + assert_equal "OBJECT_CONSTRUCT('k','v')::VARIANT", result + adapter.verify + end +end diff --git a/test/unit/type/type_test.rb b/test/unit/type/type_test.rb new file mode 100644 index 00000000..c2445276 --- /dev/null +++ b/test/unit/type/type_test.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/type/type' + +class TypeRegistrationTest < Minitest::Test + # --- Array type constants --- + + def test_array_constants_are_classes + [ + ODBCAdapter::Type::ArrayOfBigIntegers, + ODBCAdapter::Type::ArrayOfIntegers, + ODBCAdapter::Type::ArrayOfStrings + ].each { |c| assert_kind_of Class, c } + end + + def test_array_constants_are_independent + refute_equal ODBCAdapter::Type::ArrayOfIntegers, ODBCAdapter::Type::ArrayOfStrings + end + + def test_array_of_integers_can_cast_json_array + result = ODBCAdapter::Type::ArrayOfIntegers.new.cast('[1,2,3]') + assert_equal [1, 2, 3], result + end + + # --- Registered types --- + + def test_variant_is_registered_with_odbc_adapter + type = ActiveRecord::Type.lookup(:variant, adapter: :odbc) + assert_kind_of ODBCAdapter::Type::Variant, type + end + + def test_object_is_registered_with_odbc_adapter + type = ActiveRecord::Type.lookup(:object, adapter: :odbc) + assert_kind_of Object, type + end + + def test_integer_is_registered_with_odbc_adapter + type = ActiveRecord::Type.lookup(:integer, adapter: :odbc) + assert_kind_of ODBCAdapter::Type::SnowflakeInteger, type + end +end diff --git a/test/unit/type/variant_test.rb b/test/unit/type/variant_test.rb new file mode 100644 index 00000000..97555ef5 --- /dev/null +++ b/test/unit/type/variant_test.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'unit_test_helper' +require 'odbc_adapter/type/internal/snowflake_variant' +require 'odbc_adapter/type/variant' + +class VariantTest < Minitest::Test + def subject + ODBCAdapter::Type::Variant.new + end + + def test_type_is_variant + assert_equal :variant, subject.type + end + + def test_deserialize_json_string_to_hash + result = subject.deserialize('{"key":"value"}') + assert_equal({ 'key' => 'value' }, result) + end + + def test_deserialize_json_string_to_array + result = subject.deserialize('[1,2,3]') + assert_equal [1, 2, 3], result + end + + def test_deserialize_invalid_json_returns_nil + assert_nil subject.deserialize('not valid json {') + end + + def test_deserialize_nil_returns_nil + assert_nil subject.deserialize(nil) + end + + def test_deserialize_snowflake_variant_returns_internal_data + data = { 'foo' => 1 } + sv = ODBCAdapter::Type::SnowflakeVariant.new(data) + assert_equal data, subject.deserialize(sv) + end + + def test_cast_passes_through_hash + val = { 'x' => 1 } + assert_equal val, subject.cast(val) + end + + def test_cast_passes_through_nil + assert_nil subject.cast(nil) + end + + def test_cast_passes_through_string + assert_equal 'raw', subject.cast('raw') + end + + def test_serialize_wraps_value_in_snowflake_variant + result = subject.serialize({ 'a' => 1 }) + assert_instance_of ODBCAdapter::Type::SnowflakeVariant, result + assert_equal({ 'a' => 1 }, result.internal_data) + end + + def test_serialize_nil_returns_nil + assert_nil subject.serialize(nil) + end + + def test_changed_in_place_returns_false_when_equal + refute subject.changed_in_place?('{"x":1}', { 'x' => 1 }) + end + + def test_changed_in_place_returns_true_when_different + assert subject.changed_in_place?('{"x":1}', { 'x' => 2 }) + end + + def test_accessor_returns_string_keyed_hash_accessor + assert_equal ActiveRecord::Store::StringKeyedHashAccessor, subject.accessor + end +end diff --git a/test/unit_test_helper.rb b/test/unit_test_helper.rb new file mode 100644 index 00000000..46503c03 --- /dev/null +++ b/test/unit_test_helper.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require 'simplecov' +SimpleCov.start do + command_name 'Unit Tests' +end + +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) + +require 'active_record' +require 'minitest/autorun' + +# --------------------------------------------------------------------------- +# ODBC stub — lets unit tests run without the ruby-odbc C extension. +# Values match the ODBC specification so tests can use the constants directly. +# --------------------------------------------------------------------------- +module ODBC + # SQL data type codes + SQL_CHAR = 1 + SQL_NUMERIC = 2 + SQL_DECIMAL = 3 + SQL_INTEGER = 4 + SQL_SMALLINT = 5 + SQL_FLOAT = 6 + SQL_REAL = 7 + SQL_DOUBLE = 8 + SQL_DATE = 9 + SQL_TIME = 10 + SQL_TIMESTAMP = 11 + SQL_VARCHAR = 12 + SQL_LONGVARCHAR = -1 + SQL_BINARY = -2 + SQL_VARBINARY = -3 + SQL_LONGVARBINARY = -4 + SQL_BIGINT = -5 + SQL_TINYINT = -6 + SQL_BIT = -7 + SQL_DATETIME = 9 # same numeric as SQL_DATE per ODBC spec + SQL_TYPE_DATE = 91 + SQL_TYPE_TIME = 92 + SQL_TYPE_TIMESTAMP = 93 + + # Identifier case + SQL_IC_UPPER = 1 + SQL_IC_LOWER = 2 + SQL_IC_SENSITIVE = 3 + SQL_IC_MIXED = 4 + + # SQLGetInfo field codes (used by DatabaseMetadata) + SQL_DBMS_NAME = 17 + SQL_DBMS_VER = 18 + SQL_IDENTIFIER_CASE = 28 + SQL_QUOTED_IDENTIFIER_CASE = 93 + SQL_IDENTIFIER_QUOTE_CHAR = 29 + SQL_MAX_IDENTIFIER_LEN = 10_005 + SQL_MAX_TABLE_NAME_LEN = 35 + SQL_USER_NAME = 47 + SQL_DATABASE_NAME = 16 + + # Stub connection objects + class Database + def drvconnect(_driver) = StubConnection.new + end + + class Driver + attr_accessor :name, :attrs + end + + # Minimal stub connection returned by ODBC.connect / Database#drvconnect + class StubConnection + def connected? = true + def disconnect; end + def use_time=(_val); end + def get_info(_field) = 'stub' + def autocommit=(_val); end + def commit; end + def rollback; end + end + + class << self + def connect(_dsn, _user, _pass) + StubConnection.new + end + end +end + +# ODBC_UTF8 is a UTF-8 variant; same stub for testing. +ODBC_UTF8 = ODBC unless defined?(ODBC_UTF8) + +# --------------------------------------------------------------------------- +# Stub ODBCAdapter parent class so adapter subclasses can be loaded and +# instantiated without a real database connection. +# --------------------------------------------------------------------------- +module ActiveRecord + module ConnectionAdapters + class ODBCAdapter < AbstractAdapter + attr_reader :database_metadata + + def initialize(*_args) # rubocop:disable Lint/MissingSuper + # Skip the real connection setup — calling super requires a live connection + end + + def supports_migrations? + true + end + + def prepared_statements + true + end + end + end +end