diff --git a/Gemfile b/Gemfile index 9a5d6af1f..14a0925f3 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,7 @@ require 'openssl' source 'https://rubygems.org' gemspec -gem 'sqlite3' +gem 'sqlite3', '< 1.4' gem 'minitest', '< 5.3.4' gem 'bcrypt' gem 'tzinfo-data', platforms: [:mingw, :mswin, :x64_mingw, :jruby] @@ -51,6 +51,10 @@ group :tinytds do end end +group :odbc do + gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.101.cv' +end + group :development do gem 'byebug' gem 'mocha' diff --git a/Rakefile b/Rakefile index 79a4a5885..ed71ab146 100644 --- a/Rakefile +++ b/Rakefile @@ -8,7 +8,7 @@ task default: [:test] namespace :test do - %w(dblib).each do |mode| + %w(dblib odbc).each do |mode| Rake::TestTask.new(mode) do |t| t.libs = ARTest::SQLServer.test_load_paths @@ -23,12 +23,17 @@ namespace :test do ENV['ARCONN'] = 'dblib' end + task 'odbc:env' do + ENV['ARCONN'] = 'odbc' + end + end task 'test:dblib' => 'test:dblib:env' +task 'test:odbc' => 'test:odbc:env' namespace :profile do - ['dblib'].each do |mode| + ['dblib', 'odbc'].each do |mode| namespace mode.to_sym do Dir.glob('test/profile/*_profile_case.rb').sort.each do |test_file| profile_case = File.basename(test_file).sub('_profile_case.rb', '') diff --git a/activerecord-sqlserver-adapter.gemspec b/activerecord-sqlserver-adapter.gemspec index c7d7463d6..0dd17033e 100644 --- a/activerecord-sqlserver-adapter.gemspec +++ b/activerecord-sqlserver-adapter.gemspec @@ -17,5 +17,5 @@ Gem::Specification.new do |spec| spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) spec.require_paths = ['lib'] spec.add_dependency 'activerecord', '~> 5.1.0' - spec.add_dependency 'tiny_tds' + spec.add_dependency 'ruby-odbc' end diff --git a/lib/active_record/connection_adapters/sqlserver/core_ext/odbc.rb b/lib/active_record/connection_adapters/sqlserver/core_ext/odbc.rb new file mode 100644 index 000000000..712751a2b --- /dev/null +++ b/lib/active_record/connection_adapters/sqlserver/core_ext/odbc.rb @@ -0,0 +1,34 @@ +module ActiveRecord + module ConnectionAdapters + module SQLServer + module CoreExt + module ODBC + + module Statement + + def finished? + connected? + false + rescue ::ODBC::Error + true + end + + end + + module Database + + def run_block(*args) + yield sth = run(*args) + sth.drop + end + + end + + end + end + end + end +end + +ODBC::Statement.send :include, ActiveRecord::ConnectionAdapters::SQLServer::CoreExt::ODBC::Statement +ODBC::Database.send :include, ActiveRecord::ConnectionAdapters::SQLServer::CoreExt::ODBC::Database diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 9df2422e8..5bf3f81df 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -24,13 +24,11 @@ def exec_insert(sql, name = nil, binds = [], pk = nil, _sequence_name = nil) end def exec_delete(sql, name, binds) - sql = sql.dup << '; SELECT @@ROWCOUNT AS AffectedRows' - super(sql, name, binds).rows.first.first + super.rows.first.try(:first) || super("SELECT @@ROWCOUNT As AffectedRows", "", []).rows.first.try(:first) end def exec_update(sql, name, binds) - sql = sql.dup << '; SELECT @@ROWCOUNT AS AffectedRows' - super(sql, name, binds).rows.first.first + super.rows.first.try(:first) || super("SELECT @@ROWCOUNT As AffectedRows", "", []).rows.first.try(:first) end def supports_statement_cache? @@ -108,6 +106,18 @@ def execute_procedure(proc_name, *variables) yield(r) if block_given? end result.each.map { |row| row.is_a?(Hash) ? row.with_indifferent_access : row } + when :odbc + results = [] + raw_connection_run(sql) do |handle| + get_rows = lambda do + rows = handle_to_names_and_values handle, fetch: :all + rows.each_with_index { |r, i| rows[i] = r.with_indifferent_access } + results << rows + end + get_rows.call + get_rows.call while handle_more_results?(handle) + end + results.many? ? results : results.first end end end @@ -206,7 +216,13 @@ def sql_for_insert(sql, pk, id_value, sequence_name, binds) sql.dup.insert sql.index(/ (DEFAULT )?VALUES/), " OUTPUT INSERTED.#{quoted_pk}" end else - "#{sql}; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident" + table = get_table_name(sql) + id_column = identity_columns(table.to_s.strip).first + if !id_column.blank? + sql.sub(/\s*VALUES\s*\(/, " OUTPUT INSERTED.#{id_column.name} VALUES (") + else + sql.sub(/\s*VALUES\s*\(/, " OUTPUT CAST(SCOPE_IDENTITY() AS bigint) AS Ident VALUES (") + end end super end @@ -282,6 +298,8 @@ def raw_connection_do(sql) case @connection_options[:mode] when :dblib @connection.execute(sql).do + when :odbc + @connection.do(sql) end ensure @update_sql = false @@ -344,12 +362,16 @@ def raw_connection_run(sql) case @connection_options[:mode] when :dblib @connection.execute(sql) + when :odbc + block_given? ? @connection.run_block(sql) { |handle| yield(handle) } : @connection.run(sql) end end def handle_more_results?(handle) case @connection_options[:mode] when :dblib + when :odbc + handle.more_results end end @@ -357,6 +379,8 @@ def handle_to_names_and_values(handle, options = {}) case @connection_options[:mode] when :dblib handle_to_names_and_values_dblib(handle, options) + when :odbc + handle_to_names_and_values_odbc(handle, options) end end @@ -370,10 +394,28 @@ def handle_to_names_and_values_dblib(handle, options = {}) options[:ar_result] ? ActiveRecord::Result.new(columns, results) : results end + def handle_to_names_and_values_odbc(handle, options = {}) + @connection.use_utc = ActiveRecord::Base.default_timezone == :utc + if options[:ar_result] + columns = lowercase_schema_reflection ? handle.columns(true).map { |c| c.name.downcase } : handle.columns(true).map { |c| c.name } + rows = handle.fetch_all || [] + ActiveRecord::Result.new(columns, rows) + else + case options[:fetch] + when :all + handle.each_hash || [] + when :rows + handle.fetch_all || [] + end + end + end + def finish_statement_handle(handle) case @connection_options[:mode] when :dblib handle.cancel if handle + when :odbc + handle.drop if handle && handle.respond_to?(:drop) && !handle.finished? end handle end diff --git a/lib/active_record/connection_adapters/sqlserver/type/binary.rb b/lib/active_record/connection_adapters/sqlserver/type/binary.rb index 06cb42b37..807e323b7 100644 --- a/lib/active_record/connection_adapters/sqlserver/type/binary.rb +++ b/lib/active_record/connection_adapters/sqlserver/type/binary.rb @@ -4,6 +4,14 @@ module SQLServer module Type class Binary < ActiveRecord::Type::Binary + def cast_value(value) + if value.class.to_s == 'String' and !value.frozen? + value.force_encoding(Encoding::BINARY) =~ /[^[:xdigit:]]/ ? value : [value].pack('H*') + else + value + end + end + def type :binary_basic end diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 6dbed485e..0be6af5f2 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -1,5 +1,6 @@ require 'base64' require 'active_record' +require 'odbc_utf8' require 'arel_sqlserver' require 'active_record/connection_adapters/abstract_adapter' require 'active_record/connection_adapters/sqlserver/core_ext/active_record' @@ -169,6 +170,8 @@ def disconnect! case @connection_options[:mode] when :dblib @connection.close rescue nil + when :odbc + @connection.disconnect rescue nil end @connection = nil @spid = nil @@ -356,6 +359,8 @@ def connect @connection = case config[:mode] when :dblib dblib_connect(config) + when :odbc + odbc_connect(config) end @spid = _raw_select('SELECT @@SPID', fetch: :rows).first.first @version_year = version_year @@ -365,6 +370,7 @@ def connect def connection_errors @connection_errors ||= [].tap do |errors| errors << TinyTds::Error if defined?(TinyTds::Error) + errors << ODBC::Error if defined?(ODBC::Error) end end @@ -400,6 +406,25 @@ def dblib_connect(config) end end + def odbc_connect(config) + if config[:dsn].include?(';') + driver = ODBC::Driver.new.tap do |d| + d.name = config[:dsn_name] || 'Driver1' + d.attrs = config[:dsn].split(';').map { |atr| atr.split('=') }.reject { |kv| kv.size != 2 }.reduce({}) { |a, e| k, v = e ; a[k] = v ; a } + end + ODBC::Database.new.drvconnect(driver) + else + ODBC.connect config[:dsn], config[:username], config[:password] + end.tap do |c| + begin + c.use_time = true + c.use_utc = ActiveRecord::Base.default_timezone == :utc + rescue Exception + warn 'Ruby ODBC v0.99992 or higher is required.' + end + end + end + def config_appname(config) config[:appname] || configure_application_name || Rails.application.class.name.split('::').first rescue nil end diff --git a/lib/active_record/sqlserver_base.rb b/lib/active_record/sqlserver_base.rb index 4601b41d1..95eb3fc6e 100644 --- a/lib/active_record/sqlserver_base.rb +++ b/lib/active_record/sqlserver_base.rb @@ -7,6 +7,10 @@ def sqlserver_connection(config) #:nodoc: case mode when :dblib require 'tiny_tds' + when :odbc + raise ArgumentError, 'Missing :dsn configuration.' unless config.key?(:dsn) + require 'odbc' + require 'active_record/connection_adapters/sqlserver/core_ext/odbc' else raise ArgumentError, "Unknown connection mode in #{config.inspect}." end diff --git a/test/cases/adapter_test_sqlserver.rb b/test/cases/adapter_test_sqlserver.rb index 1b7b46635..c69c769c4 100644 --- a/test/cases/adapter_test_sqlserver.rb +++ b/test/cases/adapter_test_sqlserver.rb @@ -17,7 +17,7 @@ class AdapterTestSQLServer < ActiveRecord::TestCase string = connection.inspect string.must_match %r{ActiveRecord::ConnectionAdapters::SQLServerAdapter} string.must_match %r{version\: \d.\d} - string.must_match %r{mode: dblib} + string.must_match %r{mode: (dblib|odbc)} string.must_match %r{azure: (true|false)} string.wont_match %r{host} string.wont_match %r{password} diff --git a/test/cases/column_test_sqlserver.rb b/test/cases/column_test_sqlserver.rb index 42acb08b4..ca52c7758 100644 --- a/test/cases/column_test_sqlserver.rb +++ b/test/cases/column_test_sqlserver.rb @@ -273,8 +273,8 @@ def assert_obj_set_and_save(attribute, value) col.sql_type.must_equal 'date' col.type.must_equal :date col.null.must_equal true - col.default.must_equal connection_dblib_73? ? Date.civil(0001, 1, 1) : '0001-01-01' - obj.date.must_equal Date.civil(0001, 1, 1) + col.default.must_equal Date.civil(1900, 1, 1) + obj.date.must_equal Date.civil(1900, 1, 1) col.default_function.must_be_nil type = connection.lookup_cast_type_from_column(col) type.must_be_instance_of Type::Date @@ -282,19 +282,19 @@ def assert_obj_set_and_save(attribute, value) type.precision.must_be_nil type.scale.must_be_nil # Can cast strings. SQL Server format. - obj.date = '04-01-0001' - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date = '04-01-1900' + obj.date.must_equal Date.civil(1900, 4, 1) obj.save! - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) obj.reload - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) # Can cast strings. ISO format. - obj.date = '0001-04-01' - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date = '1900-04-01' + obj.date.must_equal Date.civil(1900, 4, 1) obj.save! - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) obj.reload - obj.date.must_equal Date.civil(0001, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) # Can keep and return assigned date. assert_obj_set_and_save :date, Date.civil(1972, 04, 14) # Can accept and cast time objects. diff --git a/test/cases/connection_test_sqlserver.rb b/test/cases/connection_test_sqlserver.rb index 656c42fd4..d0ac58f89 100644 --- a/test/cases/connection_test_sqlserver.rb +++ b/test/cases/connection_test_sqlserver.rb @@ -33,6 +33,61 @@ class ConnectionTestSQLServer < ActiveRecord::TestCase end end unless connection_sqlserver_azure? + describe 'ODBC connection management' do + + it 'return finished ODBC statement handle from #execute without block' do + assert_all_odbc_statements_used_are_closed do + connection.execute('SELECT * FROM [topics]') + end + end + + it 'finish ODBC statement handle from #execute with block' do + assert_all_odbc_statements_used_are_closed do + connection.execute('SELECT * FROM [topics]') { } + end + end + + it 'finish connection from #raw_select' do + assert_all_odbc_statements_used_are_closed do + connection.send(:raw_select,'SELECT * FROM [topics]') + end + end + + it 'execute without block closes statement' do + assert_all_odbc_statements_used_are_closed do + connection.execute("SELECT 1") + end + end + + it 'execute with block closes statement' do + assert_all_odbc_statements_used_are_closed do + connection.execute("SELECT 1") do |sth| + assert !sth.finished?, "Statement should still be alive within block" + end + end + end + + it 'insert with identity closes statement' do + assert_all_odbc_statements_used_are_closed do + connection.exec_insert "INSERT INTO accounts ([id],[firm_id],[credit_limit]) VALUES (999, 1, 50)", "SQL", [] + end + end + + it 'insert without identity closes statement' do + assert_all_odbc_statements_used_are_closed do + connection.exec_insert "INSERT INTO accounts ([firm_id],[credit_limit]) VALUES (1, 50)", "SQL", [] + end + end + + it 'active closes statement' do + assert_all_odbc_statements_used_are_closed do + connection.active? + end + end + + end if connection_odbc? + + describe 'Connection management' do it 'set spid on connect' do @@ -65,7 +120,25 @@ def disconnect_raw_connection! case connection_options[:mode] when :dblib connection.raw_connection.close rescue nil + when :odbc + connection.raw_connection.disconnect rescue nil end end + def assert_all_odbc_statements_used_are_closed(&block) + odbc = connection.raw_connection.class.parent + existing_handles = [] + ObjectSpace.each_object(odbc::Statement) { |h| existing_handles << h } + existing_handle_ids = existing_handles.map(&:object_id) + assert existing_handles.all?(&:finished?), "Somewhere before the block some statements were not closed" + GC.disable + yield + used_handles = [] + ObjectSpace.each_object(odbc::Statement) { |h| used_handles << h unless existing_handle_ids.include?(h.object_id) } + assert used_handles.size > 0, "No statements were used within given block" + assert used_handles.all?(&:finished?), "Statement should have been closed within given block" + ensure + GC.enable + end + end diff --git a/test/cases/migration_test_sqlserver.rb b/test/cases/migration_test_sqlserver.rb index d880222c9..17ba57000 100644 --- a/test/cases/migration_test_sqlserver.rb +++ b/test/cases/migration_test_sqlserver.rb @@ -45,7 +45,7 @@ class MigrationTestSQLServer < ActiveRecord::TestCase it 'not drop the default contraint if just renaming' do find_default = lambda do - connection.execute_procedure(:sp_helpconstraint, 'sst_string_defaults', 'nomsg').select do |row| + connection.execute_procedure(:sp_helpconstraint, 'sst_string_defaults', 'nomsg').flatten.select do |row| row['constraint_type'] == "DEFAULT on column string_with_pretend_paren_three" end.last end diff --git a/test/cases/schema_dumper_test_sqlserver.rb b/test/cases/schema_dumper_test_sqlserver.rb index 950c85b62..075138134 100644 --- a/test/cases/schema_dumper_test_sqlserver.rb +++ b/test/cases/schema_dumper_test_sqlserver.rb @@ -23,7 +23,7 @@ class SchemaDumperTestSQLServer < ActiveRecord::TestCase assert_line :float, type: 'float', limit: nil, precision: nil, scale: nil, default: 123.00000001 assert_line :real, type: 'real', limit: nil, precision: nil, scale: nil, default: 123.45 # Date and Time - assert_line :date, type: 'date', limit: nil, precision: nil, scale: nil, default: "01-01-0001" + assert_line :date, type: 'date', limit: nil, precision: nil, scale: nil, default: "01-01-1900" assert_line :datetime, type: 'datetime', limit: nil, precision: nil, scale: nil, default: "01-01-1753 00:00:00.123" if connection_dblib_73? assert_line :datetime2_7, type: 'datetime', limit: nil, precision: 7, scale: nil, default: "12-31-9999 23:59:59.9999999" diff --git a/test/config.yml b/test/config.yml index 9b30ae6be..458e71b11 100644 --- a/test/config.yml +++ b/test/config.yml @@ -29,3 +29,12 @@ connections: azure: <%= !ENV['ACTIVERECORD_UNITTEST_AZURE'].nil? %> timeout: <%= ENV['ACTIVERECORD_UNITTEST_AZURE'].present? ? 20 : 10 %> + odbc: + arunit: + <<: *default_connection_info + dsn: <%= ENV['ACTIVERECORD_UNITTEST_DSN'] || 'activerecord_unittest' %> + arunit2: + <<: *default_connection_info + database: activerecord_unittest2 + dsn: <%= ENV['ACTIVERECORD_UNITTEST2_DSN'] || 'activerecord_unittest2' %> + diff --git a/test/schema/datatypes/2012.sql b/test/schema/datatypes/2012.sql index 044b78c97..369ec9b23 100644 --- a/test/schema/datatypes/2012.sql +++ b/test/schema/datatypes/2012.sql @@ -23,7 +23,7 @@ CREATE TABLE [sst_datatypes] ( [float] [float] NULL DEFAULT 123.00000001, [real] [real] NULL DEFAULT 123.45, -- Date and Time - [date] [date] NULL DEFAULT '0001-01-01', + [date] [date] NULL DEFAULT '1900-01-01', [datetime] [datetime] NULL DEFAULT '1753-01-01T00:00:00.123', [datetime2_7] [datetime2](7) NULL DEFAULT '9999-12-31 23:59:59.9999999', [datetime2_3] [datetime2](3) NULL, diff --git a/test/support/connection_reflection.rb b/test/support/connection_reflection.rb index 20ef1fd8a..1c2e08e5c 100644 --- a/test/support/connection_reflection.rb +++ b/test/support/connection_reflection.rb @@ -24,6 +24,10 @@ def connection_dblib_73? rc.respond_to?(:tds_73?) && rc.tds_73? end + def connection_odbc? + connection_options[:mode] == :odbc + end + def connection_sqlserver_azure? connection.sqlserver_azure? end