Skip to content

Commit

Permalink
Merge pull request #17 from lavika/7-0-stable-with-odbc
Browse files Browse the repository at this point in the history
Add ODBC support for SQL Server Adapter v7.0
  • Loading branch information
lavika authored Oct 23, 2023
2 parents 5b720b6 + 8df467c commit c171ca8
Show file tree
Hide file tree
Showing 18 changed files with 310 additions and 36 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## v7.0.4.0.odbc

#### Added

- ODBC restoration.

## v7.0.4.0

#### Changed
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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.102.cv'
end

group :development do
gem "minitest-spec-rails"
gem "mocha"
Expand Down
10 changes: 8 additions & 2 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ task test: ["test:dblib"]
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
t.test_files = test_files
Expand All @@ -21,12 +21,18 @@ namespace :test do
task "dblib:env" 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", "")
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7.0.4.0
7.0.4.0.odbc
2 changes: 1 addition & 1 deletion activerecord-sqlserver-adapter.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]

spec.add_dependency "activerecord", "~> 7.0.0"
spec.add_dependency "tiny_tds"
spec.add_dependency "ruby-odbc"
end
34 changes: 34 additions & 0 deletions lib/active_record/connection_adapters/sqlserver/core_ext/odbc.rb
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,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 begin_db_transaction
Expand Down Expand Up @@ -183,6 +181,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
Expand Down Expand Up @@ -275,17 +285,25 @@ def sql_for_insert(sql, pk, binds)
exclude_output_inserted = exclude_output_inserted_table_name?(table_name, sql)

if exclude_output_inserted
id_sql_type = exclude_output_inserted.is_a?(TrueClass) ? "bigint" : exclude_output_inserted
<<~SQL.squish
id_sql_type = exclude_output_inserted.is_a?(TrueClass) ? 'bigint' : exclude_output_inserted
<<-SQL.strip_heredoc
SET NOCOUNT ON
DECLARE @ssaIdInsertTable table (#{quoted_pk} #{id_sql_type});
#{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/i), " OUTPUT INSERTED.#{quoted_pk} INTO @ssaIdInsertTable"}
SELECT CAST(#{quoted_pk} AS #{id_sql_type}) FROM @ssaIdInsertTable
#{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/), " OUTPUT INSERTED.#{quoted_pk} INTO @ssaIdInsertTable"}
SELECT CAST(#{quoted_pk} AS #{id_sql_type}) FROM @ssaIdInsertTable;
SET NOCOUNT OFF
SQL
else
sql.dup.insert sql.index(/ (DEFAULT )?VALUES/i), " 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
Expand Down Expand Up @@ -368,6 +386,8 @@ def raw_connection_do(sql)
when :dblib
result = ensure_established_connection! { dblib_execute(sql) }
result.do
when :odbc
@connection.do(sql)
end
ensure
@update_sql = false
Expand Down Expand Up @@ -430,19 +450,25 @@ def raw_connection_run(sql)
case @connection_options[:mode]
when :dblib
ensure_established_connection! { dblib_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

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

Expand All @@ -456,10 +482,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.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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 60 additions & 1 deletion lib/active_record/connection_adapters/sqlserver_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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"
Expand Down Expand Up @@ -77,10 +78,12 @@ def dbconsole(config, options = {})
end

def new_client(config)
case config[:mode]
case config[:mode].to_sym
when :dblib
require "tiny_tds"
dblib_connect(config)
when :odbc
odbc_connect(config)
else
raise ArgumentError, "Unknown connection mode in #{config.inspect}."
end
Expand Down Expand Up @@ -121,6 +124,25 @@ def dblib_connect(config)
raise e
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 config_appname(config)
if instance_methods.include?(:configure_application_name)
ActiveSupport::Deprecation.warn <<~MSG.squish
Expand Down Expand Up @@ -155,6 +177,7 @@ def config_encoding(config)
end

def initialize(connection, logger, _connection_options, config)
config[:mode] = config[:mode].to_s.downcase.underscore.to_sym
super(connection, logger, config)
@connection_options = config
perform_connection_configuration
Expand Down Expand Up @@ -302,6 +325,8 @@ def disconnect!
case @connection_options[:mode]
when :dblib
@connection.close rescue nil
when :odbc
@connection.disconnect rescue nil
end
@connection = nil
@spid = nil
Expand Down Expand Up @@ -512,12 +537,46 @@ 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
@connection_errors ||= [].tap do |errors|
errors << TinyTds::Error if defined?(TinyTds::Error)
errors << ODBC::Error if defined?(ODBC::Error)
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
Expand Down
25 changes: 20 additions & 5 deletions lib/active_record/sqlserver_base.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,30 @@ def sqlserver_adapter_class

def sqlserver_connection(config) #:nodoc:
config = config.symbolize_keys
config.reverse_merge!(mode: :dblib)
config[:mode] = config[:mode].to_s.downcase.underscore.to_sym

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
end
2 changes: 1 addition & 1 deletion test/cases/adapter_test_sqlserver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,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}
Expand Down
Loading

0 comments on commit c171ca8

Please sign in to comment.