Skip to content

Commit

Permalink
Merge pull request #1 from doximity/rk-pull-fixes-from-springbuk-fork
Browse files Browse the repository at this point in the history
Pull lots of fixes and improvements from Springbuk's fork
  • Loading branch information
divoxx authored Aug 22, 2023
2 parents f273df9 + d3576fd commit 0ac147f
Show file tree
Hide file tree
Showing 32 changed files with 896 additions and 114 deletions.
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
AllCops:
DisplayCopNames: true
DisplayStyleGuide: true
TargetRubyVersion: 2.1
TargetRubyVersion: 2.2
Exclude:
- 'vendor/**/*'

Expand Down
12 changes: 12 additions & 0 deletions Dockerfile.dev
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
FROM ruby:2.4.0
MAINTAINER [email protected]

ENV DEBIAN_FRONTEND noninteractive
RUN echo "deb http://deb.debian.org/debian/ jessie main" > /etc/apt/sources.list
RUN echo "deb-src http://deb.debian.org/debian/ jessie main" >> /etc/apt/sources.list
RUN echo "deb http://security.debian.org/ jessie/updates main" >> /etc/apt/sources.list
RUN echo "deb-src http://security.debian.org/ jessie/updates main" >> /etc/apt/sources.list
RUN apt-get update && apt-get -y install libnss3-tools unixodbc-dev libmyodbc mysql-client odbc-postgresql postgresql

WORKDIR /workspace
CMD docker/docker-entrypoint.sh
3 changes: 0 additions & 3 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
source 'https://rubygems.org'

gemspec

gem 'activerecord', '5.0.1'
gem 'pry', '~> 0.11.1'
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ ActiveRecord models that use this connection will now be connecting to the confi

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.

## Testing Using a Docker Container Because ODBC on Mac is Hard

Tested on Sierra.


Run from project root:

```
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
# In container
docker/test.sh
```

## Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/localytics/odbc_adapter.
Expand Down
21 changes: 21 additions & 0 deletions docker/docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/bash
set -e -x

# Installing mysql at startup due to file permissions: https://github.com/geerlingguy/drupal-vm/issues/1497
apt-get install -y mysql-server
bundle install --local
service mysql start

# Allows passwordless auth from command line and odbc
sed -i "s/local all postgres peer/local all postgres trust/" /etc/postgresql/9.4/main/pg_hba.conf
sed -i "s/host all all 127.0.0.1\/32 md5/host all all 127.0.0.1\/32 trust/" /etc/postgresql/9.4/main/pg_hba.conf
service postgresql start

odbcinst -i -d -f /usr/share/libmyodbc/odbcinst.ini
mysql -e "DROP DATABASE IF EXISTS odbc_test; CREATE DATABASE IF NOT EXISTS odbc_test;" -uroot
mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost';" -uroot

odbcinst -i -d -f /usr/share/psqlodbc/odbcinst.ini.template
psql -c "CREATE DATABASE odbc_test;" -U postgres

/bin/bash
5 changes: 5 additions & 0 deletions docker/test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
#!/bin/bash

echo "Testing mysql" && CONN_STR='DRIVER=MySQL;SERVER=localhost;DATABASE=odbc_test;USER=root;PASSWORD=;' bundle exec rake && \
echo "Testing postgres" && CONN_STR='DRIVER={PostgreSQL ANSI};SERVER=localhost;PORT=5432;DATABASE=odbc_test;UID=postgres;' bundle exec rake && \
echo "Testing postgres utf8" && CONN_STR='DRIVER={PostgreSQL UNICODE};SERVER=localhost;PORT=5432;DATABASE=odbc_test;UID=postgres;ENCODING=utf8' bundle exec rake
133 changes: 83 additions & 50 deletions lib/active_record/connection_adapters/odbc_adapter.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
require 'active_record'
require 'arel/visitors/bind_visitor'
require 'odbc'
require 'odbc_utf8'

require 'odbc_adapter/database_limits'
require 'odbc_adapter/database_statements'
Expand All @@ -14,6 +14,9 @@
require 'odbc_adapter/registry'
require 'odbc_adapter/version'

require 'odbc_adapter/type/type'
require 'odbc_adapter/concerns/concern'

module ActiveRecord
class Base
class << self
Expand All @@ -30,7 +33,7 @@ def odbc_connection(config)
raise ArgumentError, 'No data source name (:dsn) or connection string (:conn_str) specified.'
end

database_metadata = ::ODBCAdapter::DatabaseMetadata.new(connection)
database_metadata = ::ODBCAdapter::DatabaseMetadata.new(connection, config[:encoding_bug])
database_metadata.adapter_class.new(connection, logger, config, database_metadata)
end

Expand All @@ -40,21 +43,27 @@ def odbc_connection(config)
def odbc_dsn_connection(config)
username = config[:username] ? config[:username].to_s : nil
password = config[:password] ? config[:password].to_s : nil
connection = ODBC.connect(config[:dsn], username, password)
[connection, config.merge(username: username, password: password)]
odbc_module = config[:encoding] == 'utf8' ? ODBC_UTF8 : ODBC
connection = odbc_module.connect(config[:dsn], username, password)

# encoding_bug indicates that the driver is using non ASCII and has the issue referenced here https://github.com/larskanis/ruby-odbc/issues/2
[connection, config.merge(username: username, password: password, encoding_bug: config[:encoding] == 'utf8')]
end

# Connect using ODBC connection string
# Supports DSN-based or DSN-less connections
# e.g. "DSN=virt5;UID=rails;PWD=rails"
# "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails"
def odbc_conn_str_connection(config)
driver = ODBC::Driver.new
attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h
odbc_module = attrs['ENCODING'] == 'utf8' ? ODBC_UTF8 : ODBC
driver = odbc_module::Driver.new
driver.name = 'odbc'
driver.attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h
driver.attrs = attrs

connection = ODBC::Database.new.drvconnect(driver)
[connection, config.merge(driver: driver)]
connection = odbc_module::Database.new.drvconnect(driver)
# encoding_bug indicates that the driver is using non ASCII and has the issue referenced here https://github.com/larskanis/ruby-odbc/issues/2
[connection, config.merge(driver: driver, encoding: attrs['ENCODING'], encoding_bug: attrs['ENCODING'] == 'utf8')]
end
end
end
Expand All @@ -68,10 +77,15 @@ class ODBCAdapter < AbstractAdapter

ADAPTER_NAME = 'ODBC'.freeze
BOOLEAN_TYPE = 'BOOLEAN'.freeze
VARIANT_TYPE = 'VARIANT'.freeze
DATE_TYPE = 'DATE'.freeze
JSON_TYPE = 'JSON'.freeze

ERR_DUPLICATE_KEY_VALUE = 23_505
ERR_QUERY_TIMED_OUT = 57_014
ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/
ERR_DUPLICATE_KEY_VALUE = 23_505
ERR_QUERY_TIMED_OUT = 57_014
ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/
ERR_CONNECTION_FAILED_REGEX = '^08[0S]0[12347]'.freeze
ERR_CONNECTION_FAILED_MESSAGE = /Client connection failed/

# The object that stores the information that is fetched from the DBMS
# when a connection is first established.
Expand Down Expand Up @@ -107,11 +121,12 @@ def active?
# new connection with the database.
def reconnect!
disconnect!
odbc_module = @config[:encoding] == 'utf8' ? ODBC_UTF8 : ODBC
@connection =
if @config.key?(:dsn)
ODBC.connect(@config[:dsn], @config[:username], @config[:password])
odbc_module.connect(@config[:dsn], @config[:username], @config[:password])
else
ODBC::Database.new.drvconnect(@config[:driver])
odbc_module::Database.new.drvconnect(@config[:driver])
end
configure_time_options(@connection)
super
Expand All @@ -127,54 +142,72 @@ def disconnect!
# Build a new column object from the given options. Effectively the same
# as super except that it also passes in the native type.
# rubocop:disable Metrics/ParameterLists
def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil, native_type = nil)
::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type)
def new_column(name, default, sql_type_metadata, null, native_type = nil)
::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, native_type)
end

#Snowflake doesn't have a mechanism to return the primary key on inserts, it needs prefetched
def prefetch_primary_key?(table_name = nil)
true
end

def next_sequence_value(table_name = nil)
exec_query("SELECT #{table_name}.NEXTVAL as new_id").first["new_id"]
end

def build_merge_sql(merge) # :nodoc:
<<~SQL
MERGE #{merge.into} AS TARGET USING (#{merge.values_list}) AS SOURCE ON #{merge.match}
#{merge.merge_delete}
#{merge.merge_update}
#{merge.merge_insert}
SQL
end

def exec_merge_all(sql, name) # :nodoc:
exec_query(sql, name)
end

protected

# Build the type map for ActiveRecord
#Snowflake ODBC Adapter specific
def initialize_type_map(map)
map.register_type 'boolean', Type::Boolean.new
map.register_type ODBC::SQL_CHAR, Type::String.new
map.register_type ODBC::SQL_LONGVARCHAR, Type::Text.new
map.register_type ODBC::SQL_TINYINT, Type::Integer.new(limit: 4)
map.register_type ODBC::SQL_SMALLINT, Type::Integer.new(limit: 8)
map.register_type ODBC::SQL_INTEGER, Type::Integer.new(limit: 16)
map.register_type ODBC::SQL_BIGINT, Type::BigInteger.new(limit: 32)
map.register_type ODBC::SQL_REAL, Type::Float.new(limit: 24)
map.register_type ODBC::SQL_FLOAT, Type::Float.new
map.register_type ODBC::SQL_DOUBLE, Type::Float.new(limit: 53)
map.register_type ODBC::SQL_DECIMAL, Type::Float.new
map.register_type ODBC::SQL_NUMERIC, Type::Integer.new
map.register_type ODBC::SQL_BINARY, Type::Binary.new
map.register_type ODBC::SQL_DATE, Type::Date.new
map.register_type ODBC::SQL_DATETIME, Type::DateTime.new
map.register_type ODBC::SQL_TIME, Type::Time.new
map.register_type ODBC::SQL_TIMESTAMP, Type::DateTime.new
map.register_type ODBC::SQL_GUID, Type::String.new

alias_type map, ODBC::SQL_BIT, 'boolean'
alias_type map, ODBC::SQL_VARCHAR, ODBC::SQL_CHAR
alias_type map, ODBC::SQL_WCHAR, ODBC::SQL_CHAR
alias_type map, ODBC::SQL_WVARCHAR, ODBC::SQL_CHAR
alias_type map, ODBC::SQL_WLONGVARCHAR, ODBC::SQL_LONGVARCHAR
alias_type map, ODBC::SQL_VARBINARY, ODBC::SQL_BINARY
alias_type map, ODBC::SQL_LONGVARBINARY, ODBC::SQL_BINARY
alias_type map, ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE
alias_type map, ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME
alias_type map, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP
map.register_type %r(boolean)i, Type::Boolean.new
map.register_type %r(date)i, Type::Date.new
map.register_type %r(varchar)i, Type::String.new
map.register_type %r(time)i, Type::Time.new
map.register_type %r(timestamp)i, Type::DateTime.new
map.register_type %r(binary)i, Type::Binary.new
map.register_type %r(double)i, Type::Float.new
map.register_type(%r(decimal)i) do |sql_type|
scale = extract_scale(sql_type)
if scale == 0
::ODBCAdapter::Type::SnowflakeInteger.new
else
Type::Decimal.new(precision: extract_precision(sql_type), scale: scale)
end
end
map.register_type %r(struct)i, ::ODBCAdapter::Type::SnowflakeObject.new
map.register_type %r(array)i, ::ODBCAdapter::Type::ArrayOfValues.new
map.register_type %r(variant)i, ::ODBCAdapter::Type::Variant.new
end

# Translate an exception from the native DBMS to something usable by
# ActiveRecord.
def translate_exception(exception, message)
def translate_exception(exception, message:, sql:, binds:)
error_number = exception.message[/^\d+/].to_i

if error_number == ERR_DUPLICATE_KEY_VALUE
ActiveRecord::RecordNotUnique.new(message, exception)
ActiveRecord::RecordNotUnique.new(message, sql: sql, binds: binds)
elsif error_number == ERR_QUERY_TIMED_OUT || exception.message =~ ERR_QUERY_TIMED_OUT_MESSAGE
::ODBCAdapter::QueryTimeoutError.new(message, exception)
::ODBCAdapter::QueryTimeoutError.new(message, sql: sql, binds: binds)
elsif exception.message.match(ERR_CONNECTION_FAILED_REGEX) || exception.message =~ ERR_CONNECTION_FAILED_MESSAGE
begin
reconnect!
::ODBCAdapter::ConnectionFailedError.new(message, sql: sql, binds: binds)
rescue => e
puts "unable to reconnect #{e}"
end
else
super
end
Expand All @@ -186,8 +219,8 @@ def translate_exception(exception, message)
# work with non-string keys, and in our case the keys are (almost) all
# numeric
def alias_type(map, new_type, old_type)
map.register_type(new_type) do |_, *args|
map.lookup(old_type, *args)
map.register_type(new_type) do |_|
map.lookup(old_type)
end
end

Expand Down
Loading

0 comments on commit 0ac147f

Please sign in to comment.