From 865eed702154a221a953b405b2dfcea8adbedf03 Mon Sep 17 00:00:00 2001 From: Lalitha Date: Wed, 10 Apr 2019 17:05:03 +0530 Subject: [PATCH 01/16] Fix trigger_test_sqlserver test case failure - use SET NOCOUNT ON so that the number of rows affected by INSERT statement is returned instead of the number of rows affected by the trigger - Refer https://techcommunity.microsoft.com/t5/SQL-Server/UPDATE-with-OUTPUT-clause-8211-Triggers-8211-and-SQLMoreResults/ba-p/383457 --- .../connection_adapters/sqlserver/database_statements.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 64a3ed22b..1b52ae657 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -288,9 +288,11 @@ def sql_for_insert(sql, pk, binds, returning) end <<~SQL.squish + SET NOCOUNT ON DECLARE @ssaIdInsertTable table (#{pk_and_types.map { |pk_and_type| "#{pk_and_type[:quoted]} #{pk_and_type[:id_sql_type]}"}.join(", ") }); #{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/i), " OUTPUT #{ pk_and_types.map { |pk_and_type| "INSERTED.#{pk_and_type[:quoted]}" }.join(", ") } INTO @ssaIdInsertTable"} SELECT #{pk_and_types.map {|pk_and_type| "CAST(#{pk_and_type[:quoted]} AS #{pk_and_type[:id_sql_type]}) #{pk_and_type[:quoted]}"}.join(", ")} FROM @ssaIdInsertTable + SET NOCOUNT OFF SQL else returning_columns = returning || Array(pk) From ed0dc21251e4ce21c5ff8d524613053ce078889b Mon Sep 17 00:00:00 2001 From: Lalitha Date: Wed, 17 Apr 2019 15:57:44 +0530 Subject: [PATCH 02/16] Remove tiny_tds dependency from activerecord-sqlserver-adpater - TinyTDS gem for windows is compiled with OpenSSL with a version that has vulnerabilities - Updating to use ruby-odbc as dependency, as this forked repository is for connecting to SQLServer in ODBC mode --- activerecord-sqlserver-adapter.gemspec | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/activerecord-sqlserver-adapter.gemspec b/activerecord-sqlserver-adapter.gemspec index 47d6f0f13..f6d17840b 100644 --- a/activerecord-sqlserver-adapter.gemspec +++ b/activerecord-sqlserver-adapter.gemspec @@ -28,5 +28,5 @@ Gem::Specification.new do |spec| spec.require_paths = ["lib"] spec.add_dependency "activerecord", "~> 7.2.0" - spec.add_dependency "tiny_tds" + spec.add_dependency "ruby-odbc" end From d0067b49c7096ec5784b5e112e961a6942529250 Mon Sep 17 00:00:00 2001 From: Sukeerthi Adiga G Date: Thu, 28 Mar 2019 20:14:57 +0530 Subject: [PATCH 03/16] Force data from a binary column type in SQLServer adapter to be treated as binary / ASCII-8Bit - Added a check to make sure the object is String. ActiveRecord tests passes objects which are other than String. - Added a check to make sure we are not modifying the frozen object. There are tests which passes frozen objects. --- .../connection_adapters/sqlserver/type/binary.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/active_record/connection_adapters/sqlserver/type/binary.rb b/lib/active_record/connection_adapters/sqlserver/type/binary.rb index 1f14a4078..37506e7b9 100644 --- a/lib/active_record/connection_adapters/sqlserver/type/binary.rb +++ b/lib/active_record/connection_adapters/sqlserver/type/binary.rb @@ -5,6 +5,15 @@ module ConnectionAdapters 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 From 6caad833f54b8c10fd2b8fd5c790e6374eb52f79 Mon Sep 17 00:00:00 2001 From: "Lalitha Chitharanjan (c)" Date: Wed, 27 Mar 2019 11:50:38 +0530 Subject: [PATCH 04/16] Fix String or Binary data truncation error - Error found: ActiveRecord::ValueTooLong: ODBC::Error: 22001 (8152)[Microsoft][ODBC Driver 17 for SQL Server][SQL Server]String or binary data would be truncated. - Require odbc_utf8 so that UTF-8 variant of the module is in use, and string data is automatically converted to/from Unicode --- lib/active_record/connection_adapters/sqlserver_adapter.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 3f3e72808..a50bfe11c 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -3,6 +3,7 @@ require "tiny_tds" 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" From 840d57529ff11c42347784c87d03ddad4f68f27f Mon Sep 17 00:00:00 2001 From: Lalitha Date: Wed, 3 Apr 2019 17:50:13 +0530 Subject: [PATCH 05/16] Fix date comparison failures by having sqlserver default date set to 1900-01-01 - SQLserver uses 1900-01-01 as default date instead of current date - Reference: https://docs.microsoft.com/en-us/sql/relational-databases/native-client-odbc-date-time/datetime-data-type-conversions-from-sql-to-c?view=sql-server-2017 --- test/cases/column_test_sqlserver.rb | 22 ++++++++++------------ test/cases/schema_dumper_test_sqlserver.rb | 6 +++--- test/schema/datatypes/2012.sql | 2 +- 3 files changed, 14 insertions(+), 16 deletions(-) diff --git a/test/cases/column_test_sqlserver.rb b/test/cases/column_test_sqlserver.rb index dfbd42e47..aead2aac8 100644 --- a/test/cases/column_test_sqlserver.rb +++ b/test/cases/column_test_sqlserver.rb @@ -277,8 +277,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_tds_73 ? Date.civil(1, 1, 1) : "0001-01-01" - _(obj.date).must_equal Date.civil(1, 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 @@ -286,21 +286,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(1, 4, 1) + obj.date = "04-01-1900" + obj.date.must_equal Date.civil(1900, 4, 1) obj.save! - _(obj.date).must_equal Date.civil(1, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) obj.reload - _(obj.date).must_equal Date.civil(1, 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(1, 4, 1) + obj.date = "1900-04-01" + obj.date.must_equal Date.civil(1900, 4, 1) obj.save! - _(obj.date).must_equal Date.civil(1, 4, 1) + obj.date.must_equal Date.civil(1900, 4, 1) obj.reload - _(obj.date).must_equal Date.civil(1, 4, 1) - # Can filter by date range - _(obj).must_equal obj.class.where(date: obj.date..Date::Infinity.new).first + obj.date.must_equal Date.civil(1900, 4, 1) # Can keep and return assigned date. assert_obj_set_and_save :date, Date.civil(1972, 4, 14) # Can accept and cast time objects. diff --git a/test/cases/schema_dumper_test_sqlserver.rb b/test/cases/schema_dumper_test_sqlserver.rb index 6e7140ddf..6d1d99736 100644 --- a/test/cases/schema_dumper_test_sqlserver.rb +++ b/test/cases/schema_dumper_test_sqlserver.rb @@ -26,9 +26,9 @@ class SchemaDumperTestSQLServer < ActiveRecord::TestCase assert_line :float, type: "float", default: 123.00000001 assert_line :real, type: "real", default: 123.45 # Date and Time - assert_line :date, type: "date", default: "01-01-0001" - assert_line :datetime, type: "datetime", precision: nil, default: "01-01-1753 00:00:00.123" - if connection_tds_73 + 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", precision: 7, default: "12-31-9999 23:59:59.9999999" assert_line :datetime2_3, type: "datetime", precision: 3 assert_line :datetime2_1, type: "datetime", precision: 1 diff --git a/test/schema/datatypes/2012.sql b/test/schema/datatypes/2012.sql index 77b14807d..2dd3f6487 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, From d27bc6784cf2bb4399f95856d713e9a15db0aaf3 Mon Sep 17 00:00:00 2001 From: "Lalitha Chitharanjan (c)" Date: Tue, 26 Mar 2019 17:40:52 +0530 Subject: [PATCH 06/16] fix typerror no impicit conversion of string into integer - Error found: MigrationTestSQLServer::For changing column#test_0002_not drop the default contraint if just renaming: TypeError: no implicit conversion of String into Integer - array needs to be flattened before calling select method --- test/cases/migration_test_sqlserver.rb | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/cases/migration_test_sqlserver.rb b/test/cases/migration_test_sqlserver.rb index 0ffe20861..b2ce45599 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 constraint 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 @@ -60,6 +60,14 @@ class MigrationTestSQLServer < ActiveRecord::TestCase assert_nothing_raised { connection.change_column :people, :lock_version, :integer, limit: 8 } end + it 'change limit' do + assert_nothing_raised { connection.change_column :people, :lock_version, :integer, limit: 8 } + end + + it 'change null and default' do + assert_nothing_raised { connection.change_column :people, :first_name, :text, null: true, default: nil } + end + it "change null and default" do assert_nothing_raised { connection.change_column :people, :first_name, :text, null: true, default: nil } end From 167d3075eef1d11f724cb5c60c9afb103aec7e83 Mon Sep 17 00:00:00 2001 From: Lalitha Date: Wed, 27 Mar 2019 15:04:11 +0530 Subject: [PATCH 07/16] Fix SerializedAttributeTest error - Error found: SerializedAttributeTest#test_json_read_legacy_null: ActiveRecord::RecordNotFound: Couldn't find Topic without an ID - updated sql_for_insert method to use OUTPUT clause to get the ID of the last inserted record and with a single sql statement --- .../connection_adapters/sqlserver/database_statements.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 1b52ae657..4b975b144 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -305,7 +305,13 @@ def sql_for_insert(sql, pk, binds, returning) end 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 [sql, binds] From ff3c56b78123794e7347c740435cb728e267a6e0 Mon Sep 17 00:00:00 2001 From: Sukeerthi Adiga G Date: Wed, 27 Mar 2019 12:01:48 +0530 Subject: [PATCH 08/16] Fix ActiveRecord SQLServer adapter to work with the utf8 version of the Ruby::ODBC module - The UTF8 version does not appear to support multiple statements in a single query. So things like "query; SELECT @@ROWCOUNT" do not work --- .../connection_adapters/sqlserver/database_statements.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 4b975b144..c4254c9af 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -67,13 +67,11 @@ def internal_exec_sql_query(sql, conn) 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 begin_db_transaction From 51679fbf6bdff9f2de2b81561773b4cfc9651487 Mon Sep 17 00:00:00 2001 From: Sukeerthi Adiga G Date: Thu, 28 Mar 2019 20:55:47 +0530 Subject: [PATCH 09/16] Version bump to 7.2.4.odbc to support ODBC --- CHANGELOG.md | 10 ++ Gemfile | 4 + Rakefile | 10 +- VERSION | 2 +- .../sqlserver/core_ext/odbc.rb | 34 +++++++ .../sqlserver/database_statements.rb | 85 +++++++++++++++-- .../connection_adapters/sqlserver_adapter.rb | 91 ++++++++++++++++++- lib/active_record/sqlserver_base.rb | 37 ++++++++ test/cases/adapter_test_sqlserver.rb | 3 + test/cases/connection_test_sqlserver.rb | 83 ++++++++++++++++- test/config.yml | 9 ++ test/support/connection_reflection.rb | 4 + 12 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 lib/active_record/connection_adapters/sqlserver/core_ext/odbc.rb create mode 100644 lib/active_record/sqlserver_base.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index fd61922ce..1cfafa003 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ +## v7.2.4.odbc + +#### Added + +- ODBC restoration. + ## v7.2.4 +#### Changed + +- [#1073](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1073) Improve performance of view default function lookup + #### Fixed - [#1270](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1270) Fix parsing of raw table name from SQL with extra parentheses diff --git a/Gemfile b/Gemfile index 0f435985e..9ff660fda 100644 --- a/Gemfile +++ b/Gemfile @@ -57,6 +57,10 @@ group :tinytds do end # rubocop:enable Bundler/DuplicatedGem +group :odbc do + gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.101.cv' +end + group :development do gem "minitest-spec-rails" gem "mocha" diff --git a/Rakefile b/Rakefile index 3c6f58630..3c5cc262d 100644 --- a/Rakefile +++ b/Rakefile @@ -5,24 +5,25 @@ require "rake/testtask" require_relative "test/support/paths_sqlserver" require_relative "test/support/rake_helpers" -task test: ["test:dblib"] +task test: ["test:dblib", "test:odbc"] task default: [:test] namespace :test do - ENV["ARCONN"] = "sqlserver" + ENV["ARCONN"] ||= "sqlserver" - %w(dblib).each do |mode| + %w(dblib odbc).each do |mode| Rake::TestTask.new(mode) do |t| t.libs = ARTest::SQLServer.test_load_paths t.test_files = test_files t.warning = !!ENV["WARNING"] t.verbose = false + ENV["SQLSERVER_MODE"] = mode end end end namespace :profile do - ["dblib"].each do |mode| + %w(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", "") @@ -30,6 +31,7 @@ namespace :profile do t.libs = ARTest::SQLServer.test_load_paths t.test_files = [test_file] t.verbose = true + ENV["SQLSERVER_MODE"] = mode end end end diff --git a/VERSION b/VERSION index 2bbaead44..0e87797f8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.2.4 +7.2.4.odbc 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 c4254c9af..918701d37 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -171,11 +171,17 @@ def execute_procedure(proc_name, *variables) else variables.map { |v| quote(v) } end.join(", ") + sql = "EXEC #{proc_name} #{vars}".strip - log(sql, "Execute Procedure") do |notification_payload| + log(sql, "Execute Procedure") do |notification_payload| with_raw_connection do |conn| - result = internal_raw_execute(sql, conn) + if odbc_connection?(conn) + result = execute_odbc_procedure(sql, conn) + else + result = internal_raw_execute(sql, conn) + end + verified! options = { as: :hash, cache_rows: true, timezone: ActiveRecord.default_timezone || :utc } @@ -435,7 +441,45 @@ def _raw_select(sql, conn) finish_statement_handle(handle) end + def execute_odbc_procedure(sql, conn) + results = [] + raw_connection_run(sql, conn) 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 + + # Wrapper for raw_connection's run method + def raw_connection_run(sql, conn) + conn.raw_connection.run(sql) do |handle| + yield(handle) + end + end + + def handle_more_results?(handle) + case @connection_options[:mode] + when :dblib + when :odbc + handle.more_results + end + end + 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 + + def handle_to_names_and_values_dblib(handle, options = {}) query_options = {}.tap do |qo| qo[:timezone] = ActiveRecord.default_timezone || :utc qo[:as] = (options[:ar_result] || options[:fetch] == :rows) ? :array : :hash @@ -450,8 +494,29 @@ def handle_to_names_and_values(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) - handle.cancel if 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 @@ -459,11 +524,19 @@ def finish_statement_handle(handle) # Getting around this by raising an exception ourselves while PR # https://github.com/rails-sqlserver/tiny_tds/pull/469 is not released. def internal_raw_execute(sql, conn, perform_do: false) - result = conn.execute(sql).tap do |_result| - raise TinyTds::Error, "failed to execute statement" if _result.is_a?(FalseClass) + if odbc_connection?(conn) + conn.do(sql) + else + result = conn.execute(sql).tap do |_result| + raise TinyTds::Error, "failed to execute statement" if _result.is_a?(FalseClass) + end + + perform_do ? result.do : result end + end - perform_do ? result.do : result + def odbc_connection?(conn) + conn.adapter_name.downcase.include?("odbc") end end end diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index a50bfe11c..2f000511b 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -83,6 +83,17 @@ def dbconsole(config, options = {}) end def new_client(config) + case config[:mode].to_sym + when :dblib + dblib_connect(config) + when :odbc + odbc_connect(config) + else + raise ArgumentError, "Unknown connection mode in #{config.inspect}." + end + end + + def dblib_connect(config) TinyTds::Client.new(config) rescue TinyTds::Error => error if error.message.match(/database .* does not exist/i) @@ -92,6 +103,25 @@ def new_client(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.default_timezone == :utc + rescue Exception + warn 'Ruby ODBC v0.99992 or higher is required.' + end + end + end + def rails_application_name Rails.application.class.name.split("::").first rescue @@ -257,7 +287,13 @@ def reconnect def disconnect! super - @raw_connection&.close rescue nil + case @connection_options[:mode] + when :dblib + @raw_connection&.close rescue nil + when :odbc + @raw_connection.disconnect rescue nil + end + @raw_connection = nil @spid = nil @collation = nil @@ -469,12 +505,65 @@ def translate_exception(e, message:, sql:, binds:) # === SQLServer Specific (Connection Management) ================ # + # def connect + # config = @connection_options + # @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 + # configure_connection + # end + def connection_errors @raw_connection_errors ||= [].tap do |errors| errors << TinyTds::Error if defined?(TinyTds::Error) + errors << ODBC::Error if defined?(ODBC::Error) + 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 + + def config_login_timeout(config) + config[:login_timeout].present? ? config[:login_timeout].to_i : nil + end + + def config_timeout(config) + config[:timeout].present? ? config[:timeout].to_i / 1000 : nil + end + + def config_encoding(config) + config[:encoding].present? ? config[:encoding] : nil + end + + def configure_connection; end + + def configure_application_name; end + def initialize_dateformatter @database_dateformat = user_options_dateformat a, b, c = @database_dateformat.each_char.to_a diff --git a/lib/active_record/sqlserver_base.rb b/lib/active_record/sqlserver_base.rb new file mode 100644 index 000000000..ea3dd53a0 --- /dev/null +++ b/lib/active_record/sqlserver_base.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module ActiveRecord + module ConnectionHandling + def sqlserver_adapter_class + ConnectionAdapters::SQLServerAdapter + end + + def sqlserver_connection(config) #:nodoc: + config = config.symbolize_keys + config.reverse_merge! mode: :dblib + mode = config[:mode].to_s.downcase.underscore.to_sym + 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 + sqlserver_adapter_class.new( + sqlserver_adapter_class.new_client(config), + logger, + nil, + config + ) + rescue ODBC::Error => e + if e.message.match(/database .* does not exist/i) + raise ActiveRecord::NoDatabaseError + else + raise + end + end + end +end \ No newline at end of file diff --git a/test/cases/adapter_test_sqlserver.rb b/test/cases/adapter_test_sqlserver.rb index 7b9f0eca5..20f28e7dc 100644 --- a/test/cases/adapter_test_sqlserver.rb +++ b/test/cases/adapter_test_sqlserver.rb @@ -18,6 +18,9 @@ class AdapterTestSQLServer < ActiveRecord::TestCase it "has basic and non-sensitive information in the adapters inspect method" do string = connection.inspect _(string).must_match %r{ActiveRecord::ConnectionAdapters::SQLServerAdapter} + _(string).must_match %r{version\: \d.\d} + _(string).must_match %r{mode: (dblib|odbc)} + _(string).must_match %r{azure: (true|false)} _(string).wont_match %r{host} _(string).wont_match %r{password} _(string).wont_match %r{username} diff --git a/test/cases/connection_test_sqlserver.rb b/test/cases/connection_test_sqlserver.rb index 1cb05cc3e..7e40f6ffb 100644 --- a/test/cases/connection_test_sqlserver.rb +++ b/test/cases/connection_test_sqlserver.rb @@ -37,6 +37,65 @@ class ConnectionTestSQLServer < ActiveRecord::TestCase describe "Connection management" do it "set spid on connect" do _(["Fixnum", "Integer"]).must_include connection.spid.class.name + 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 + ['Fixnum', 'Integer'].must_include connection.spid.class.name end it "reset spid on disconnect!" do @@ -60,6 +119,28 @@ class ConnectionTestSQLServer < ActiveRecord::TestCase private def disconnect_raw_connection! - connection.raw_connection.close rescue nil + 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/config.yml b/test/config.yml index ef095168c..c01ad3925 100644 --- a/test/config.yml +++ b/test/config.yml @@ -28,3 +28,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/support/connection_reflection.rb b/test/support/connection_reflection.rb index b7fe15ce2..b952d99bb 100644 --- a/test/support/connection_reflection.rb +++ b/test/support/connection_reflection.rb @@ -20,6 +20,10 @@ def connection_tds_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 From 2393e1d470e627d4bde80173e385265ac011c605 Mon Sep 17 00:00:00 2001 From: Lavika Date: Fri, 6 Oct 2023 01:10:43 +0530 Subject: [PATCH 10/16] Fix ruby-odbc gem declaration --- Gemfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile b/Gemfile index 9ff660fda..572df75fb 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,7 @@ end # rubocop:enable Bundler/DuplicatedGem group :odbc do - gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.101.cv' + gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.102.cv' end group :development do From 939cc5bd838609850e14a038e61146e84087a831 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CJyothish=E2=80=9D?= <“Jyothu.kr@gmail.com”> Date: Thu, 23 Jan 2025 15:29:33 +0530 Subject: [PATCH 11/16] Consider config rather than connection_options to capture the mode --- .../sqlserver/database_statements.rb | 6 +++--- .../connection_adapters/sqlserver_adapter.rb | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 918701d37..2dad07061 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -463,7 +463,7 @@ def raw_connection_run(sql, conn) end def handle_more_results?(handle) - case @connection_options[:mode] + case @config[:mode].to_sym when :dblib when :odbc handle.more_results @@ -471,7 +471,7 @@ def handle_more_results?(handle) end def handle_to_names_and_values(handle, options = {}) - case @connection_options[:mode] + case @config[:mode].to_sym when :dblib handle_to_names_and_values_dblib(handle, options) when :odbc @@ -511,7 +511,7 @@ def handle_to_names_and_values_odbc(handle, options = {}) end def finish_statement_handle(handle) - case @connection_options[:mode] + case @config[:mode].to_sym when :dblib handle.cancel if handle when :odbc diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 2f000511b..91ccd06d0 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -276,7 +276,13 @@ def active? end def reconnect - @raw_connection&.close rescue nil + case @config[:mode].to_sym + when :dblib + @raw_connection&.close rescue nil + when :odbc + @raw_connection.disconnect rescue nil + end + @raw_connection = nil @spid = nil @collation = nil @@ -287,7 +293,7 @@ def reconnect def disconnect! super - case @connection_options[:mode] + case @config[:mode].to_sym when :dblib @raw_connection&.close rescue nil when :odbc From 8f56c2a5501fd90fb42ee7221672e8adb1488ef0 Mon Sep 17 00:00:00 2001 From: Lavika Date: Tue, 13 Jun 2023 13:19:04 +0530 Subject: [PATCH 12/16] Fix deprecation warning for ActiveRecord::Base.default_timezone --- .../connection_adapters/sqlserver/database_statements.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 2dad07061..c61af14a4 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -495,7 +495,7 @@ def handle_to_names_and_values_dblib(handle, options = {}) end def handle_to_names_and_values_odbc(handle, options = {}) - @connection.use_utc = ActiveRecord::Base.default_timezone == :utc + @connection.use_utc = ActiveRecord.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 || [] From aa37a655e5edfcf94ca6586b2cd684c904df2cee Mon Sep 17 00:00:00 2001 From: Jyothish Date: Tue, 18 Feb 2025 11:23:56 +0530 Subject: [PATCH 13/16] [AVM-38333] Fix ruby and ruby-odbc versions --- Gemfile | 2 +- activerecord-sqlserver-adapter.gemspec | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 572df75fb..cb92f8b77 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,7 @@ end # rubocop:enable Bundler/DuplicatedGem group :odbc do - gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.102.cv' + gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.103.cv' end group :development do diff --git a/activerecord-sqlserver-adapter.gemspec b/activerecord-sqlserver-adapter.gemspec index f6d17840b..63529106a 100644 --- a/activerecord-sqlserver-adapter.gemspec +++ b/activerecord-sqlserver-adapter.gemspec @@ -7,7 +7,7 @@ Gem::Specification.new do |spec| spec.platform = Gem::Platform::RUBY spec.version = version - spec.required_ruby_version = ">= 3.1.0" + spec.required_ruby_version = ">= 3.2.0" spec.license = "MIT" spec.authors = ["Ken Collins", "Anna Carey", "Will Bond", "Murray Steele", "Shawn Balestracci", "Joe Rafaniello", "Tom Ward", "Aidan Haran"] From d60871d0665e6096f0e01b60a60f681339c24c05 Mon Sep 17 00:00:00 2001 From: Jyothish Date: Wed, 19 Feb 2025 11:08:59 +0530 Subject: [PATCH 14/16] [AVM-38333] Fix Rakefile for ODBC --- Gemfile | 2 +- Rakefile | 15 +++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Gemfile b/Gemfile index cb92f8b77..e0b38a91e 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,7 @@ end # rubocop:enable Bundler/DuplicatedGem group :odbc do - gem 'ruby-odbc', :git => 'https://github.com/cloudvolumes/ruby-odbc.git', :tag => '0.103.cv' + gem 'ruby-odbc', git: 'https://github.com/cloudvolumes/ruby-odbc.git', tag: '0.103.cv' end group :development do diff --git a/Rakefile b/Rakefile index 3c5cc262d..d2546b1cb 100644 --- a/Rakefile +++ b/Rakefile @@ -9,7 +9,7 @@ task test: ["test:dblib", "test:odbc"] task default: [:test] namespace :test do - ENV["ARCONN"] ||= "sqlserver" + ENV["ARCONN"] = "sqlserver" %w(dblib odbc).each do |mode| Rake::TestTask.new(mode) do |t| @@ -17,13 +17,12 @@ namespace :test do t.test_files = test_files t.warning = !!ENV["WARNING"] t.verbose = false - ENV["SQLSERVER_MODE"] = mode end end end namespace :profile do - %w(dblib odbc).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", "") @@ -31,9 +30,17 @@ namespace :profile do t.libs = ARTest::SQLServer.test_load_paths t.test_files = [test_file] t.verbose = true - ENV["SQLSERVER_MODE"] = mode end end end end end + +task "test:odbc" => "test:odbc:env" +task "test:dblib" => "test:dblib:env" + +namespace :test do + task "odbc:env" do + ENV['ARCONN'] = 'odbc' + end +end From c1e1598aa757f138871aefbe4d44fa5363a03cfa Mon Sep 17 00:00:00 2001 From: Jyothish Date: Wed, 19 Feb 2025 14:18:13 +0530 Subject: [PATCH 15/16] [AVM-38333] Remove Tiny TDS gem require line --- lib/active_record/connection_adapters/sqlserver_adapter.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 91ccd06d0..8694e7e76 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -1,6 +1,5 @@ # frozen_string_literal: true -require "tiny_tds" require "base64" require "active_record" require "odbc_utf8" From 7e71fb21390f0852aca747a6c1b2e268610de7b2 Mon Sep 17 00:00:00 2001 From: Jyothish Date: Mon, 3 Mar 2025 16:21:10 +0530 Subject: [PATCH 16/16] [AVM-38333] Fix connecton object --- .../sqlserver/database_statements.rb | 13 ++++---- .../connection_adapters/sqlserver_adapter.rb | 31 ++++++++++--------- 2 files changed, 23 insertions(+), 21 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index c61af14a4..4df88f063 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -16,7 +16,7 @@ def write_query?(sql) # :nodoc: def raw_execute(sql, name, async: false, allow_retry: false, materialize_transactions: true) log(sql, name, async: async) do |notification_payload| with_raw_connection(allow_retry: allow_retry, materialize_transactions: materialize_transactions) do |conn| - result = if id_insert_table_name = query_requires_identity_insert?(sql) + result = if (id_insert_table_name = query_requires_identity_insert?(sql)) with_identity_insert_enabled(id_insert_table_name, conn) { internal_raw_execute(sql, conn, perform_do: true) } else internal_raw_execute(sql, conn, perform_do: true) @@ -174,7 +174,7 @@ def execute_procedure(proc_name, *variables) sql = "EXEC #{proc_name} #{vars}".strip - log(sql, "Execute Procedure") do |notification_payload| + log(sql, "Execute Procedure") do |notification_payload| with_raw_connection do |conn| if odbc_connection?(conn) result = execute_odbc_procedure(sql, conn) @@ -461,7 +461,7 @@ def raw_connection_run(sql, conn) yield(handle) end end - + def handle_more_results?(handle) case @config[:mode].to_sym when :dblib @@ -495,7 +495,8 @@ def handle_to_names_and_values_dblib(handle, options = {}) end def handle_to_names_and_values_odbc(handle, options = {}) - @connection.use_utc = ActiveRecord.default_timezone == :utc + @raw_connection.use_utc = ActiveRecord.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 || [] @@ -535,8 +536,8 @@ def internal_raw_execute(sql, conn, perform_do: false) end end - def odbc_connection?(conn) - conn.adapter_name.downcase.include?("odbc") + def odbc_connection?(connection) + connection.is_a?(ODBC::Database) end end end diff --git a/lib/active_record/connection_adapters/sqlserver_adapter.rb b/lib/active_record/connection_adapters/sqlserver_adapter.rb index 8694e7e76..58dc44e3e 100644 --- a/lib/active_record/connection_adapters/sqlserver_adapter.rb +++ b/lib/active_record/connection_adapters/sqlserver_adapter.rb @@ -110,6 +110,7 @@ def odbc_connect(config) end ODBC::Database.new.drvconnect(driver) else + puts config ODBC.connect config[:dsn], config[:username], config[:password] end.tap do |c| begin @@ -565,8 +566,6 @@ def config_encoding(config) config[:encoding].present? ? config[:encoding] : nil end - def configure_connection; end - def configure_application_name; end def initialize_dateformatter @@ -607,20 +606,22 @@ def connect end def configure_connection - if @config[:azure] - @raw_connection.execute("SET ANSI_NULLS ON").do - @raw_connection.execute("SET ANSI_NULL_DFLT_ON ON").do - @raw_connection.execute("SET ANSI_PADDING ON").do - @raw_connection.execute("SET ANSI_WARNINGS ON").do - else - @raw_connection.execute("SET ANSI_DEFAULTS ON").do - end + unless @config[:mode] == 'odbc' + if @config[:azure] + @raw_connection.execute("SET ANSI_NULLS ON").do + @raw_connection.execute("SET ANSI_NULL_DFLT_ON ON").do + @raw_connection.execute("SET ANSI_PADDING ON").do + @raw_connection.execute("SET ANSI_WARNINGS ON").do + else + @raw_connection.execute("SET ANSI_DEFAULTS ON").do + end - @raw_connection.execute("SET QUOTED_IDENTIFIER ON").do - @raw_connection.execute("SET CURSOR_CLOSE_ON_COMMIT OFF").do - @raw_connection.execute("SET IMPLICIT_TRANSACTIONS OFF").do - @raw_connection.execute("SET TEXTSIZE 2147483647").do - @raw_connection.execute("SET CONCAT_NULL_YIELDS_NULL ON").do + @raw_connection.execute("SET QUOTED_IDENTIFIER ON").do + @raw_connection.execute("SET CURSOR_CLOSE_ON_COMMIT OFF").do + @raw_connection.execute("SET IMPLICIT_TRANSACTIONS OFF").do + @raw_connection.execute("SET TEXTSIZE 2147483647").do + @raw_connection.execute("SET CONCAT_NULL_YIELDS_NULL ON").do + end @spid = _raw_select("SELECT @@SPID", @raw_connection).first.first