Skip to content

Add ODBC support for SQL Server Adapter v7.2 #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 16 commits into
base: 7-2-stable-with-odbc
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.103.cv'
end

group :development do
gem "minitest-spec-rails"
gem "mocha"
Expand Down
15 changes: 12 additions & 3 deletions Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ 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"

%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 @@ -22,7 +22,7 @@ namespace :test do
end

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 All @@ -35,3 +35,12 @@ namespace :profile do
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
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
7.2.4
7.2.4.odbc
4 changes: 2 additions & 2 deletions activerecord-sqlserver-adapter.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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
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
102 changes: 91 additions & 11 deletions lib/active_record/connection_adapters/sqlserver/database_statements.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -173,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|
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 }

Expand Down Expand Up @@ -288,9 +292,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)
Expand All @@ -303,7 +309,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]
Expand Down Expand Up @@ -429,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 @config[:mode].to_sym
when :dblib
when :odbc
handle.more_results
end
end

def handle_to_names_and_values(handle, options = {})
case @config[:mode].to_sym
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
Expand All @@ -444,20 +494,50 @@ 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 = {})
@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 || []
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 @config[:mode].to_sym
when :dblib
handle.cancel if handle
when :odbc
handle.drop if handle && handle.respond_to?(:drop) && !handle.finished?
end
handle
end

# TinyTDS returns false instead of raising an exception if connection fails.
# 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?(connection)
connection.is_a?(ODBC::Database)
end
end
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
Loading
Loading