├── lib ├── odbc_adapter │ ├── version.rb │ ├── error.rb │ ├── database_limits.rb │ ├── column.rb │ ├── adapters │ │ ├── null_odbc_adapter.rb │ │ ├── mysql_odbc_adapter.rb │ │ └── postgresql_odbc_adapter.rb │ ├── registry.rb │ ├── quoting.rb │ ├── database_metadata.rb │ ├── column_metadata.rb │ ├── schema_statements.rb │ └── database_statements.rb ├── odbc_adapter.rb └── active_record │ └── connection_adapters │ └── odbc_adapter.rb ├── Gemfile ├── .gitignore ├── bin ├── setup ├── console └── ci-setup ├── test ├── version_test.rb ├── calculations_test.rb ├── connection_management_test.rb ├── metadata_test.rb ├── attributes_test.rb ├── connection_fail_test.rb ├── crud_test.rb ├── registry_test.rb ├── selection_test.rb ├── migrations_test.rb └── test_helper.rb ├── Rakefile ├── docker ├── test.sh └── docker-entrypoint.sh ├── Dockerfile.dev ├── .rubocop.yml ├── .travis.yml ├── LICENSE ├── odbc_adapter.gemspec └── README.md /lib/odbc_adapter/version.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | VERSION = '5.0.5'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | 5 | gem 'activerecord', '5.0.1' 6 | gem 'pry', '~> 0.11.1' 7 | -------------------------------------------------------------------------------- /lib/odbc_adapter.rb: -------------------------------------------------------------------------------- 1 | # Requiring with this pattern to mirror ActiveRecord 2 | require 'active_record/connection_adapters/odbc_adapter' 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /Gemfile.lock 4 | /_yardoc/ 5 | /coverage/ 6 | /doc/ 7 | /pkg/ 8 | /spec/reports/ 9 | /tmp/ 10 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /test/version_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class VersionTest < Minitest::Test 4 | def test_version 5 | refute_nil ODBCAdapter::VERSION 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /lib/odbc_adapter/error.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | class QueryTimeoutError < ActiveRecord::StatementInvalid 3 | end 4 | class ConnectionFailedError < ActiveRecord::StatementInvalid 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'bundler/setup' 4 | require 'odbc_adapter' 5 | 6 | options = { adapter: 'odbc' } 7 | options[:dsn] = ENV['DSN'] if ENV['DSN'] 8 | options[:conn_str] = ENV['CONN_STR'] if ENV['CONN_STR'] 9 | ActiveRecord::Base.establish_connection(options) if options.any? 10 | 11 | require 'irb' 12 | IRB.start 13 | -------------------------------------------------------------------------------- /test/calculations_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CalculationsTest < Minitest::Test 4 | def test_count 5 | assert_equal 6, User.count 6 | assert_equal 10, Todo.count 7 | assert_equal 3, User.find(1).todos.count 8 | end 9 | 10 | def test_average 11 | assert_equal 10.33, User.average(:letters).round(2) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rake/testtask' 3 | require 'rubocop/rake_task' 4 | 5 | Rake::TestTask.new(:test) do |t| 6 | t.libs << 'test' 7 | t.libs << 'lib' 8 | t.test_files = FileList['test/**/*_test.rb'] 9 | end 10 | 11 | RuboCop::RakeTask.new(:rubocop) 12 | Rake::Task[:test].prerequisites << :rubocop 13 | 14 | task default: :test 15 | -------------------------------------------------------------------------------- /lib/odbc_adapter/database_limits.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module DatabaseLimits 3 | # Returns the maximum length of a table name. 4 | def table_alias_length 5 | max_identifier_length = database_metadata.max_identifier_len 6 | max_table_name_length = database_metadata.max_table_name_len 7 | [max_identifier_length, max_table_name_length].max 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /docker/test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Testing mysql" && CONN_STR='DRIVER=MySQL;SERVER=localhost;DATABASE=odbc_test;USER=root;PASSWORD=;' bundle exec rake && \ 4 | echo "Testing postgres" && CONN_STR='DRIVER={PostgreSQL ANSI};SERVER=localhost;PORT=5432;DATABASE=odbc_test;UID=postgres;' bundle exec rake && \ 5 | echo "Testing postgres utf8" && CONN_STR='DRIVER={PostgreSQL UNICODE};SERVER=localhost;PORT=5432;DATABASE=odbc_test;UID=postgres;ENCODING=utf8' bundle exec rake -------------------------------------------------------------------------------- /bin/ci-setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | case "$DB" in 4 | mysql) 5 | sudo odbcinst -i -d -f /usr/share/libmyodbc/odbcinst.ini 6 | mysql -e "DROP DATABASE IF EXISTS odbc_test; CREATE DATABASE IF NOT EXISTS odbc_test;" -uroot 7 | mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost';" -uroot 8 | ;; 9 | postgresql) 10 | sudo odbcinst -i -d -f /usr/share/psqlodbc/odbcinst.ini.template 11 | psql -c "CREATE DATABASE odbc_test;" -U postgres 12 | ;; 13 | esac 14 | -------------------------------------------------------------------------------- /test/connection_management_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ConnectionManagementTest < Minitest::Test 4 | def test_connection_management 5 | assert conn.active? 6 | 7 | conn.disconnect! 8 | refute conn.active? 9 | 10 | conn.disconnect! 11 | refute conn.active? 12 | 13 | conn.reconnect! 14 | assert conn.active? 15 | ensure 16 | conn.reconnect! 17 | end 18 | 19 | private 20 | 21 | def conn 22 | ActiveRecord::Base.connection 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/metadata_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class MetadataTest < Minitest::Test 4 | def test_data_sources 5 | assert_equal %w[ar_internal_metadata todos users], User.connection.data_sources.sort 6 | end 7 | 8 | def test_column_names 9 | expected = %w[created_at first_name id last_name letters updated_at] 10 | assert_equal expected, User.column_names.sort 11 | end 12 | 13 | def test_primary_key 14 | assert_equal 'id', User.connection.primary_key('users') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /test/attributes_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class AttributesTest < Minitest::Test 4 | def test_booleans? 5 | assert_equal true, Todo.first.published? 6 | assert_equal false, Todo.last.published? 7 | end 8 | 9 | def test_integers 10 | assert_kind_of Integer, User.first.letters 11 | end 12 | 13 | def test_strings 14 | assert_kind_of String, User.first.first_name 15 | assert_kind_of String, Todo.first.body 16 | end 17 | 18 | def test_attributes 19 | assert_kind_of Hash, User.first.attributes 20 | assert_kind_of Hash, Todo.first.attributes 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/odbc_adapter/column.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | class Column < ActiveRecord::ConnectionAdapters::Column 3 | attr_reader :native_type 4 | 5 | # Add the native_type accessor to allow the native DBMS to report back what 6 | # it uses to represent the column internally. 7 | # rubocop:disable Metrics/ParameterLists 8 | def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, native_type = nil, default_function = nil, collation = nil) 9 | super(name, default, sql_type_metadata, null, table_name, default_function, collation) 10 | @native_type = native_type 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Dockerfile.dev: -------------------------------------------------------------------------------- 1 | FROM ruby:2.4.0 2 | MAINTAINER data@localytics.com 3 | 4 | ENV DEBIAN_FRONTEND noninteractive 5 | RUN echo "deb http://deb.debian.org/debian/ jessie main" > /etc/apt/sources.list 6 | RUN echo "deb-src http://deb.debian.org/debian/ jessie main" >> /etc/apt/sources.list 7 | RUN echo "deb http://security.debian.org/ jessie/updates main" >> /etc/apt/sources.list 8 | RUN echo "deb-src http://security.debian.org/ jessie/updates main" >> /etc/apt/sources.list 9 | RUN apt-get update && apt-get -y install libnss3-tools unixodbc-dev libmyodbc mysql-client odbc-postgresql postgresql 10 | 11 | WORKDIR /workspace 12 | CMD docker/docker-entrypoint.sh 13 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | DisplayCopNames: true 3 | DisplayStyleGuide: true 4 | TargetRubyVersion: 2.1 5 | Exclude: 6 | - 'vendor/**/*' 7 | 8 | Lint/AmbiguousBlockAssociation: 9 | Enabled: false 10 | 11 | Metrics/AbcSize: 12 | Enabled: false 13 | 14 | Metrics/ClassLength: 15 | Enabled: false 16 | 17 | Metrics/CyclomaticComplexity: 18 | Enabled: false 19 | 20 | Metrics/MethodLength: 21 | Enabled: false 22 | 23 | Metrics/LineLength: 24 | Enabled: false 25 | 26 | Metrics/PerceivedComplexity: 27 | Enabled: false 28 | 29 | Style/Documentation: 30 | Enabled: false 31 | 32 | Style/PercentLiteralDelimiters: 33 | PreferredDelimiters: 34 | default: '[]' 35 | '%r': '{}' 36 | -------------------------------------------------------------------------------- /test/connection_fail_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ConnectionFailTest < Minitest::Test 4 | def test_connection_fail 5 | # We're only interested in testing a MySQL connection failure for now. 6 | # Postgres disconnects generate a different class of errors 7 | skip 'Only executed for MySQL' unless ActiveRecord::Base.connection.instance_values['config'][:conn_str].include? 'MySQL' 8 | begin 9 | conn.execute('KILL CONNECTION_ID();') 10 | rescue => e 11 | puts "caught exception #{e}" 12 | end 13 | assert_raises(ODBCAdapter::ConnectionFailedError) { User.average(:letters).round(2) } 14 | end 15 | 16 | def conn 17 | ActiveRecord::Base.connection 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | language: ruby 3 | cache: bundler 4 | matrix: 5 | include: 6 | - rvm: 2.3.1 7 | env: 8 | - DB=mysql 9 | - CONN_STR='DRIVER=MySQL;SERVER=localhost;DATABASE=odbc_test;USER=root;PASSWORD=;' 10 | addons: 11 | mysql: "5.5" 12 | apt: 13 | packages: 14 | - unixodbc 15 | - unixodbc-dev 16 | - libmyodbc 17 | - mysql-client 18 | - rvm: 2.3.1 19 | env: 20 | - DB=postgresql 21 | - CONN_STR='DRIVER={PostgreSQL ANSI};SERVER=localhost;PORT=5432;DATABASE=odbc_test;UID=postgres;' 22 | addons: 23 | postgresql: "9.1" 24 | apt: 25 | packages: 26 | - unixodbc 27 | - unixodbc-dev 28 | - odbc-postgresql 29 | before_script: bin/ci-setup 30 | -------------------------------------------------------------------------------- /test/crud_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class CRUDTest < Minitest::Test 4 | def test_creation 5 | with_transaction do 6 | User.create(first_name: 'foo', last_name: 'bar') 7 | assert_equal 7, User.count 8 | end 9 | end 10 | 11 | def test_update 12 | with_transaction do 13 | user = User.first 14 | user.letters = 47 15 | user.save! 16 | 17 | assert_equal 47, user.reload.letters 18 | end 19 | end 20 | 21 | def test_destroy 22 | with_transaction do 23 | User.last.destroy 24 | assert_equal 5, User.count 25 | end 26 | end 27 | 28 | private 29 | 30 | def with_transaction(&_block) 31 | User.transaction do 32 | yield 33 | raise ActiveRecord::Rollback 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/registry_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class RegistryTest < Minitest::Test 4 | def test_register 5 | registry = ODBCAdapter::Registry.new 6 | register_foobar(registry) 7 | 8 | adapter = registry.adapter_for('Foo Bar') 9 | assert_kind_of Class, adapter 10 | assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, adapter.superclass 11 | assert_equal 'foobar', adapter.new.quoted_true 12 | end 13 | 14 | private 15 | 16 | # rubocop:disable Lint/NestedMethodDefinition 17 | def register_foobar(registry) 18 | require File.join('odbc_adapter', 'adapters', 'mysql_odbc_adapter') 19 | registry.register(/foobar/, ODBCAdapter::Adapters::MySQLODBCAdapter) do 20 | def initialize() end 21 | 22 | def quoted_true 23 | 'foobar' 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/selection_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class SelectionTest < Minitest::Test 4 | def test_first 5 | assert_equal 'Kevin', User.first.first_name 6 | end 7 | 8 | def test_pluck 9 | expected = %w[Ash Jason Kevin Michal Ryan Sharif] 10 | assert_equal expected, User.order(:first_name).pluck(:first_name) 11 | end 12 | 13 | def test_limitations 14 | expected = %w[Kevin Michal Ryan] 15 | assert_equal expected, User.order(:first_name).limit(3).offset(2).pluck(:first_name) 16 | end 17 | 18 | def test_find 19 | user = User.last 20 | assert_equal user, User.find(user.id) 21 | end 22 | 23 | def test_arel_conditions 24 | assert_equal 2, User.lots_of_letters.count 25 | end 26 | 27 | def test_where_boolean 28 | assert_equal 4, Todo.where(published: true).count 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/migrations_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class MigrationsTest < Minitest::Test 4 | def setup 5 | @connection = User.connection 6 | end 7 | 8 | def test_table_crud 9 | @connection.create_table(:foos, force: true) do |t| 10 | t.timestamps null: false 11 | end 12 | assert_equal 3, @connection.columns(:foos).count 13 | 14 | @connection.rename_table(:foos, :bars) 15 | assert_equal 3, @connection.columns(:bars).count 16 | 17 | @connection.drop_table(:bars) 18 | end 19 | 20 | def test_column_crud 21 | previous_count = @connection.columns(:users).count 22 | 23 | @connection.add_column(:users, :foo, :integer) 24 | assert_equal previous_count + 1, @connection.columns(:users).count 25 | 26 | @connection.rename_column(:users, :foo, :bar) 27 | assert_equal previous_count + 1, @connection.columns(:users).count 28 | 29 | @connection.remove_column(:users, :bar) 30 | assert_equal previous_count, @connection.columns(:users).count 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /docker/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e -x 3 | 4 | # Installing mysql at startup due to file permissions: https://github.com/geerlingguy/drupal-vm/issues/1497 5 | apt-get install -y mysql-server 6 | bundle install --local 7 | service mysql start 8 | 9 | # Allows passwordless auth from command line and odbc 10 | sed -i "s/local all postgres peer/local all postgres trust/" /etc/postgresql/9.4/main/pg_hba.conf 11 | 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 12 | service postgresql start 13 | 14 | odbcinst -i -d -f /usr/share/libmyodbc/odbcinst.ini 15 | mysql -e "DROP DATABASE IF EXISTS odbc_test; CREATE DATABASE IF NOT EXISTS odbc_test;" -uroot 16 | mysql -e "GRANT ALL PRIVILEGES ON *.* TO 'root'@'localhost';" -uroot 17 | 18 | odbcinst -i -d -f /usr/share/psqlodbc/odbcinst.ini.template 19 | psql -c "CREATE DATABASE odbc_test;" -U postgres 20 | 21 | /bin/bash 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2017 Localytics http://www.localytics.com 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /odbc_adapter.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'odbc_adapter/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'odbc_adapter' 7 | spec.version = ODBCAdapter::VERSION 8 | spec.authors = ['Localytics'] 9 | spec.email = ['oss@localytics.com'] 10 | 11 | spec.summary = 'An ActiveRecord ODBC adapter' 12 | spec.homepage = 'https://github.com/localytics/odbc_adapter' 13 | spec.license = 'MIT' 14 | 15 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 16 | f.match(%r{^(test|spec|features)/}) 17 | end 18 | spec.bindir = 'exe' 19 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 20 | spec.require_paths = ['lib'] 21 | 22 | spec.add_dependency 'ruby-odbc', '~> 0.9' 23 | 24 | spec.add_development_dependency 'bundler', '~> 1.14' 25 | spec.add_development_dependency 'minitest', '~> 5.10' 26 | spec.add_development_dependency 'rake', '~> 12.0' 27 | spec.add_development_dependency 'rubocop', '0.48.1' 28 | spec.add_development_dependency 'simplecov', '~> 0.14' 29 | end 30 | -------------------------------------------------------------------------------- /lib/odbc_adapter/adapters/null_odbc_adapter.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module Adapters 3 | # A default adapter used for databases that are no explicitly listed in the 4 | # registry. This allows for minimal support for DBMSs for which we don't 5 | # have an explicit adapter. 6 | class NullODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter 7 | class BindSubstitution < Arel::Visitors::ToSql 8 | include Arel::Visitors::BindVisitor 9 | end 10 | 11 | # Using a BindVisitor so that the SQL string gets substituted before it is 12 | # sent to the DBMS (to attempt to get as much coverage as possible for 13 | # DBMSs we don't support). 14 | def arel_visitor 15 | BindSubstitution.new(self) 16 | end 17 | 18 | # Explicitly turning off prepared_statements in the null adapter because 19 | # there isn't really a standard on which substitution character to use. 20 | def prepared_statements 21 | false 22 | end 23 | 24 | # Turning off support for migrations because there is no information to 25 | # go off of for what syntax the DBMS will expect. 26 | def supports_migrations? 27 | false 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/odbc_adapter/registry.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | class Registry 3 | attr_reader :dbs 4 | 5 | def initialize 6 | @dbs = { 7 | /my.*sql/i => :MySQL, 8 | /postgres/i => :PostgreSQL 9 | } 10 | end 11 | 12 | def adapter_for(reported_name) 13 | reported_name = reported_name.downcase.gsub(/\s/, '') 14 | found = 15 | dbs.detect do |pattern, adapter| 16 | adapter if reported_name =~ pattern 17 | end 18 | 19 | normalize_adapter(found && found.last || :Null) 20 | end 21 | 22 | def register(pattern, superclass = Object, &block) 23 | dbs[pattern] = Class.new(superclass, &block) 24 | end 25 | 26 | private 27 | 28 | def normalize_adapter(adapter) 29 | return adapter unless adapter.is_a?(Symbol) 30 | require "odbc_adapter/adapters/#{adapter.downcase}_odbc_adapter" 31 | Adapters.const_get(:"#{adapter}ODBCAdapter") 32 | end 33 | end 34 | 35 | class << self 36 | def adapter_for(reported_name) 37 | registry.adapter_for(reported_name) 38 | end 39 | 40 | def register(pattern, superclass = Object, &block) 41 | registry.register(pattern, superclass, &block) 42 | end 43 | 44 | private 45 | 46 | def registry 47 | @registry ||= Registry.new 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/odbc_adapter/quoting.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module Quoting 3 | # Quotes a string, escaping any ' (single quote) characters. 4 | def quote_string(string) 5 | string.gsub(/\'/, "''") 6 | end 7 | 8 | # Returns a quoted form of the column name. 9 | def quote_column_name(name) 10 | name = name.to_s 11 | quote_char = database_metadata.identifier_quote_char.to_s.strip 12 | 13 | return name if quote_char.length.zero? 14 | quote_char = quote_char[0] 15 | 16 | # Avoid quoting any already quoted name 17 | return name if name[0] == quote_char && name[-1] == quote_char 18 | 19 | # If upcase identifiers, only quote mixed case names. 20 | if database_metadata.upcase_identifiers? 21 | return name unless name =~ /([A-Z]+[a-z])|([a-z]+[A-Z])/ 22 | end 23 | 24 | "#{quote_char.chr}#{name}#{quote_char.chr}" 25 | end 26 | 27 | # Ideally, we'd return an ODBC date or timestamp literal escape 28 | # sequence, but not all ODBC drivers support them. 29 | def quoted_date(value) 30 | if value.acts_like?(:time) 31 | zone_conversion_method = ActiveRecord::Base.default_timezone == :utc ? :getutc : :getlocal 32 | 33 | if value.respond_to?(zone_conversion_method) 34 | value = value.send(zone_conversion_method) 35 | end 36 | value.strftime('%Y-%m-%d %H:%M:%S') # Time, DateTime 37 | else 38 | value.strftime('%Y-%m-%d') # Date 39 | end 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /lib/odbc_adapter/database_metadata.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | # Caches SQLGetInfo output 3 | class DatabaseMetadata 4 | FIELDS = %i[ 5 | SQL_DBMS_NAME 6 | SQL_DBMS_VER 7 | SQL_IDENTIFIER_CASE 8 | SQL_QUOTED_IDENTIFIER_CASE 9 | SQL_IDENTIFIER_QUOTE_CHAR 10 | SQL_MAX_IDENTIFIER_LEN 11 | SQL_MAX_TABLE_NAME_LEN 12 | SQL_USER_NAME 13 | SQL_DATABASE_NAME 14 | ].freeze 15 | 16 | attr_reader :values 17 | 18 | # has_encoding_bug refers to https://github.com/larskanis/ruby-odbc/issues/2 where ruby-odbc in UTF8 mode 19 | # returns incorrectly encoded responses to getInfo 20 | def initialize(connection, has_encoding_bug = false) 21 | @values = Hash[FIELDS.map do |field| 22 | info = connection.get_info(ODBC.const_get(field)) 23 | info = info.encode(Encoding.default_external, 'UTF-16LE') if info.is_a?(String) && has_encoding_bug 24 | 25 | [field, info] 26 | end] 27 | end 28 | 29 | def adapter_class 30 | ODBCAdapter.adapter_for(dbms_name) 31 | end 32 | 33 | def upcase_identifiers? 34 | @upcase_identifiers ||= (identifier_case == ODBC::SQL_IC_UPPER) 35 | end 36 | 37 | # A little bit of metaprogramming magic here to create accessors for each of 38 | # the fields reported on by the DBMS. 39 | FIELDS.each do |field| 40 | define_method(field.to_s.downcase.gsub('sql_', '')) do 41 | value_for(field) 42 | end 43 | end 44 | 45 | private 46 | 47 | def value_for(field) 48 | values[field] 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | $LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) 5 | require 'odbc_adapter' 6 | 7 | require 'minitest/autorun' 8 | require 'pry' 9 | 10 | options = { adapter: 'odbc' } 11 | options[:conn_str] = ENV['CONN_STR'] if ENV['CONN_STR'] 12 | options[:dsn] = ENV['DSN'] if ENV['DSN'] 13 | options[:dsn] = 'ODBCAdapterPostgreSQLTest' if options.values_at(:conn_str, :dsn).compact.empty? 14 | 15 | ActiveRecord::Base.establish_connection(options) 16 | 17 | ActiveRecord::Schema.define do 18 | create_table :users, force: true do |t| 19 | t.string :first_name 20 | t.string :last_name 21 | t.integer :letters 22 | t.timestamps null: false 23 | end 24 | 25 | create_table :todos, force: true do |t| 26 | t.integer :user_id 27 | t.text :body 28 | t.boolean :published, null: false, default: false 29 | t.timestamps null: false 30 | end 31 | end 32 | 33 | class User < ActiveRecord::Base 34 | has_many :todos, dependent: :destroy 35 | 36 | scope :lots_of_letters, -> { where(arel_table[:letters].gt(10)) } 37 | 38 | create( 39 | [ 40 | { first_name: 'Kevin', last_name: 'Deisz', letters: 10 }, 41 | { first_name: 'Michal', last_name: 'Klos', letters: 10 }, 42 | { first_name: 'Jason', last_name: 'Dsouza', letters: 11 }, 43 | { first_name: 'Ash', last_name: 'Hepburn', letters: 10 }, 44 | { first_name: 'Sharif', last_name: 'Younes', letters: 12 }, 45 | { first_name: 'Ryan', last_name: 'Brüwn', letters: 9 } 46 | ] 47 | ) 48 | end 49 | 50 | class Todo < ActiveRecord::Base 51 | belongs_to :user 52 | end 53 | 54 | User.find(1).todos.create( 55 | [ 56 | { body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.', published: true }, 57 | { body: 'Praesent ut dolor nec eros euismod hendrerit.' }, 58 | { body: 'Curabitur lacinia metus eget interdum volutpat.' } 59 | ] 60 | ) 61 | 62 | User.find(2).todos.create( 63 | [ 64 | { body: 'Nulla sollicitudin venenatis turpis vitae finibus.', published: true }, 65 | { body: 'Proin consectetur id lacus vel feugiat.', published: true }, 66 | { body: 'Pellentesque augue orci, aliquet nec ipsum ultrices, cursus blandit metus.' }, 67 | { body: 'Nulla posuere nisl risus, eget scelerisque leo congue non.' }, 68 | { body: 'Curabitur eget massa mollis, iaculis risus in, tristique metus.' } 69 | ] 70 | ) 71 | 72 | User.find(4).todos.create( 73 | [ 74 | { body: 'In hac habitasse platea dictumst.', published: true }, 75 | { body: 'Integer molestie ornare velit, eu interdum felis euismod vitae.' } 76 | ] 77 | ) 78 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ODBCAdapter 2 | 3 | [![Build Status](https://travis-ci.org/localytics/odbc_adapter.svg?branch=master)](https://travis-ci.org/localytics/odbc_adapter) 4 | [![Gem](https://img.shields.io/gem/v/odbc_adapter.svg)](https://rubygems.org/gems/odbc_adapter) 5 | 6 | An ActiveRecord ODBC adapter. Master branch is working off of Rails 5.0.1. Previous work has been done to make it compatible with Rails 3.2 and 4.2; for those versions use the 3.2.x or 4.2.x gem releases. 7 | 8 | This adapter will work for basic queries for most DBMSs out of the box, without support for migrations. Full support is built-in for MySQL 5 and PostgreSQL 9 databases. You can register your own adapter to get more support for your DBMS using the `ODBCAdapter.register` function. 9 | 10 | A lot of this work is based on [OpenLink's ActiveRecord adapter](http://odbc-rails.rubyforge.org/) which works for earlier versions of Rails. 11 | 12 | ## Installation 13 | 14 | Ensure you have the ODBC driver installed on your machine. You will also need the driver for whichever database to which you want ODBC to connect. 15 | 16 | Add this line to your application's Gemfile: 17 | 18 | ```ruby 19 | gem 'odbc_adapter' 20 | ``` 21 | 22 | And then execute: 23 | 24 | $ bundle 25 | 26 | Or install it yourself as: 27 | 28 | $ gem install odbc_adapter 29 | 30 | ## Usage 31 | 32 | Configure your `database.yml` by either using the `dsn` option to point to a DSN that corresponds to a valid entry in your `~/.odbc.ini` file: 33 | 34 | ``` 35 | development: 36 | adapter: odbc 37 | dsn: MyDatabaseDSN 38 | ``` 39 | 40 | or by using the `conn_str` option and specifying the entire connection string: 41 | 42 | ``` 43 | development: 44 | adapter: odbc 45 | conn_str: "DRIVER={PostgreSQL ANSI};SERVER=localhost;PORT=5432;DATABASE=my_database;UID=postgres;" 46 | ``` 47 | 48 | ActiveRecord models that use this connection will now be connecting to the configured database using the ODBC driver. 49 | 50 | ## Testing 51 | 52 | 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. 53 | 54 | ## Testing Using a Docker Container Because ODBC on Mac is Hard 55 | 56 | Tested on Sierra. 57 | 58 | 59 | Run from project root: 60 | 61 | ``` 62 | bundle package 63 | docker build -f Dockerfile.dev -t odbc-dev . 64 | 65 | # Local mount mysql directory to avoid some permissions problems 66 | mkdir -p /tmp/mysql 67 | docker run -it --rm -v $(pwd):/workspace -v /tmp/mysql:/var/lib/mysql odbc-dev:latest 68 | 69 | # In container 70 | docker/test.sh 71 | ``` 72 | 73 | ## Contributing 74 | 75 | Bug reports and pull requests are welcome on GitHub at https://github.com/localytics/odbc_adapter. 76 | 77 | ## License 78 | 79 | The gem is available as open source under the terms of the [MIT License](http://opensource.org/licenses/MIT). 80 | -------------------------------------------------------------------------------- /lib/odbc_adapter/column_metadata.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | class ColumnMetadata 3 | GENERICS = { 4 | primary_key: [ODBC::SQL_INTEGER, ODBC::SQL_SMALLINT], 5 | string: [ODBC::SQL_VARCHAR], 6 | text: [ODBC::SQL_LONGVARCHAR, ODBC::SQL_VARCHAR], 7 | integer: [ODBC::SQL_INTEGER, ODBC::SQL_SMALLINT], 8 | decimal: [ODBC::SQL_NUMERIC, ODBC::SQL_DECIMAL], 9 | float: [ODBC::SQL_DOUBLE, ODBC::SQL_REAL], 10 | datetime: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], 11 | timestamp: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], 12 | time: [ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], 13 | date: [ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], 14 | binary: [ODBC::SQL_LONGVARBINARY, ODBC::SQL_VARBINARY], 15 | boolean: [ODBC::SQL_BIT, ODBC::SQL_TINYINT, ODBC::SQL_SMALLINT, ODBC::SQL_INTEGER] 16 | }.freeze 17 | 18 | attr_reader :adapter 19 | 20 | def initialize(adapter) 21 | @adapter = adapter 22 | end 23 | 24 | def native_database_types 25 | grouped = reported_types.group_by { |row| row[1] } 26 | 27 | GENERICS.each_with_object({}) do |(abstract, candidates), mapped| 28 | candidates.detect do |candidate| 29 | next unless grouped[candidate] 30 | mapped[abstract] = native_type_mapping(abstract, grouped[candidate]) 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | # Creates a Hash describing a mapping from an abstract type to a 38 | # DBMS native type for use by #native_database_types 39 | def native_type_mapping(abstract, rows) 40 | # The appropriate SQL for :primary_key is hard to derive as 41 | # ODBC doesn't provide any info on a DBMS's native syntax for 42 | # autoincrement columns. So we use a lookup instead. 43 | return adapter.class::PRIMARY_KEY if abstract == :primary_key 44 | selected_row = rows[0] 45 | 46 | # If more than one native type corresponds to the SQL type we're 47 | # handling, the type in the first descriptor should be the 48 | # best match, because the ODBC specification states that 49 | # SQLGetTypeInfo returns the results ordered by SQL type and then by 50 | # how closely the native type maps to that SQL type. 51 | # But, for :text and :binary, select the native type with the 52 | # largest capacity. (Compare SQLGetTypeInfo:COLUMN_SIZE values) 53 | selected_row = rows.max_by { |row| row[2] } if %i[text binary].include?(abstract) 54 | result = { name: selected_row[0] } # SQLGetTypeInfo: TYPE_NAME 55 | 56 | create_params = selected_row[5] 57 | # Depending on the column type, the CREATE_PARAMS keywords can 58 | # include length, precision or scale. 59 | if create_params && !create_params.strip.empty? && abstract != :decimal 60 | result[:limit] = selected_row[2] # SQLGetTypeInfo: COL_SIZE 61 | end 62 | 63 | result 64 | end 65 | 66 | def reported_types 67 | @reported_types ||= 68 | begin 69 | stmt = adapter.raw_connection.types 70 | stmt.fetch_all 71 | ensure 72 | stmt.drop unless stmt.nil? 73 | end 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/odbc_adapter/schema_statements.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module SchemaStatements 3 | # Returns a Hash of mappings from the abstract data types to the native 4 | # database types. See TableDefinition#column for details on the recognized 5 | # abstract data types. 6 | def native_database_types 7 | @native_database_types ||= ColumnMetadata.new(self).native_database_types 8 | end 9 | 10 | # Returns an array of table names, for database tables visible on the 11 | # current connection. 12 | def tables(_name = nil) 13 | stmt = @connection.tables 14 | result = stmt.fetch_all || [] 15 | stmt.drop 16 | 17 | result.each_with_object([]) do |row, table_names| 18 | schema_name, table_name, table_type = row[1..3] 19 | next if respond_to?(:table_filtered?) && table_filtered?(schema_name, table_type) 20 | table_names << format_case(table_name) 21 | end 22 | end 23 | 24 | # Returns an array of view names defined in the database. 25 | def views 26 | [] 27 | end 28 | 29 | # Returns an array of indexes for the given table. 30 | def indexes(table_name, _name = nil) 31 | stmt = @connection.indexes(native_case(table_name.to_s)) 32 | result = stmt.fetch_all || [] 33 | stmt.drop unless stmt.nil? 34 | 35 | index_cols = [] 36 | index_name = nil 37 | unique = nil 38 | 39 | result.each_with_object([]).with_index do |(row, indices), row_idx| 40 | # Skip table statistics 41 | next if row[6].zero? # SQLStatistics: TYPE 42 | 43 | if row[7] == 1 # SQLStatistics: ORDINAL_POSITION 44 | # Start of column descriptor block for next index 45 | index_cols = [] 46 | unique = row[3].zero? # SQLStatistics: NON_UNIQUE 47 | index_name = String.new(row[5]) # SQLStatistics: INDEX_NAME 48 | end 49 | 50 | index_cols << format_case(row[8]) # SQLStatistics: COLUMN_NAME 51 | next_row = result[row_idx + 1] 52 | 53 | if (row_idx == result.length - 1) || (next_row[6].zero? || next_row[7] == 1) 54 | indices << ActiveRecord::ConnectionAdapters::IndexDefinition.new(table_name, format_case(index_name), unique, index_cols) 55 | end 56 | end 57 | end 58 | 59 | # Returns an array of Column objects for the table specified by 60 | # +table_name+. 61 | def columns(table_name, _name = nil) 62 | stmt = @connection.columns(native_case(table_name.to_s)) 63 | result = stmt.fetch_all || [] 64 | stmt.drop 65 | 66 | result.each_with_object([]) do |col, cols| 67 | col_name = col[3] # SQLColumns: COLUMN_NAME 68 | col_default = col[12] # SQLColumns: COLUMN_DEF 69 | col_sql_type = col[4] # SQLColumns: DATA_TYPE 70 | col_native_type = col[5] # SQLColumns: TYPE_NAME 71 | col_limit = col[6] # SQLColumns: COLUMN_SIZE 72 | col_scale = col[8] # SQLColumns: DECIMAL_DIGITS 73 | 74 | # SQLColumns: IS_NULLABLE, SQLColumns: NULLABLE 75 | col_nullable = nullability(col_name, col[17], col[10]) 76 | 77 | args = { sql_type: col_sql_type, type: col_sql_type, limit: col_limit } 78 | args[:sql_type] = 'boolean' if col_native_type == self.class::BOOLEAN_TYPE 79 | 80 | if [ODBC::SQL_DECIMAL, ODBC::SQL_NUMERIC].include?(col_sql_type) 81 | args[:scale] = col_scale || 0 82 | args[:precision] = col_limit 83 | end 84 | sql_type_metadata = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(**args) 85 | 86 | cols << new_column(format_case(col_name), col_default, sql_type_metadata, col_nullable, table_name, col_native_type) 87 | end 88 | end 89 | 90 | # Returns just a table's primary key 91 | def primary_key(table_name) 92 | stmt = @connection.primary_keys(native_case(table_name.to_s)) 93 | result = stmt.fetch_all || [] 94 | stmt.drop unless stmt.nil? 95 | result[0] && result[0][3] 96 | end 97 | 98 | def foreign_keys(table_name) 99 | stmt = @connection.foreign_keys(native_case(table_name.to_s)) 100 | result = stmt.fetch_all || [] 101 | stmt.drop unless stmt.nil? 102 | 103 | result.map do |key| 104 | fk_from_table = key[2] # PKTABLE_NAME 105 | fk_to_table = key[6] # FKTABLE_NAME 106 | 107 | ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new( 108 | fk_from_table, 109 | fk_to_table, 110 | name: key[11], # FK_NAME 111 | column: key[3], # PKCOLUMN_NAME 112 | primary_key: key[7], # FKCOLUMN_NAME 113 | on_delete: key[10], # DELETE_RULE 114 | on_update: key[9] # UPDATE_RULE 115 | ) 116 | end 117 | end 118 | 119 | # Ensure it's shorter than the maximum identifier length for the current 120 | # dbms 121 | def index_name(table_name, options) 122 | maximum = database_metadata.max_identifier_len || 255 123 | super(table_name, options)[0...maximum] 124 | end 125 | 126 | def current_database 127 | database_metadata.database_name.strip 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/odbc_adapter/database_statements.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module DatabaseStatements 3 | # ODBC constants missing from Christian Werner's Ruby ODBC driver 4 | SQL_NO_NULLS = 0 5 | SQL_NULLABLE = 1 6 | SQL_NULLABLE_UNKNOWN = 2 7 | 8 | # Executes the SQL statement in the context of this connection. 9 | # Returns the number of rows affected. 10 | def execute(sql, name = nil, binds = []) 11 | log(sql, name) do 12 | if prepared_statements 13 | @connection.do(sql, *prepared_binds(binds)) 14 | else 15 | @connection.do(sql) 16 | end 17 | end 18 | end 19 | 20 | # Executes +sql+ statement in the context of this connection using 21 | # +binds+ as the bind substitutes. +name+ is logged along with 22 | # the executed +sql+ statement. 23 | def exec_query(sql, name = 'SQL', binds = [], prepare: false) # rubocop:disable Lint/UnusedMethodArgument 24 | log(sql, name) do 25 | stmt = 26 | if prepared_statements 27 | @connection.run(sql, *prepared_binds(binds)) 28 | else 29 | @connection.run(sql) 30 | end 31 | 32 | columns = stmt.columns 33 | values = stmt.to_a 34 | stmt.drop 35 | 36 | values = dbms_type_cast(columns.values, values) 37 | column_names = columns.keys.map { |key| format_case(key) } 38 | ActiveRecord::Result.new(column_names, values) 39 | end 40 | end 41 | 42 | # Executes delete +sql+ statement in the context of this connection using 43 | # +binds+ as the bind substitutes. +name+ is logged along with 44 | # the executed +sql+ statement. 45 | def exec_delete(sql, name, binds) 46 | execute(sql, name, binds) 47 | end 48 | alias exec_update exec_delete 49 | 50 | # Begins the transaction (and turns off auto-committing). 51 | def begin_db_transaction 52 | @connection.autocommit = false 53 | end 54 | 55 | # Commits the transaction (and turns on auto-committing). 56 | def commit_db_transaction 57 | @connection.commit 58 | @connection.autocommit = true 59 | end 60 | 61 | # Rolls back the transaction (and turns on auto-committing). Must be 62 | # done if the transaction block raises an exception or returns false. 63 | def exec_rollback_db_transaction 64 | @connection.rollback 65 | @connection.autocommit = true 66 | end 67 | 68 | # Returns the default sequence name for a table. 69 | # Used for databases which don't support an autoincrementing column 70 | # type, but do support sequences. 71 | def default_sequence_name(table, _column) 72 | "#{table}_seq" 73 | end 74 | 75 | private 76 | 77 | # A custom hook to allow end users to overwrite the type casting before it 78 | # is returned to ActiveRecord. Useful before a full adapter has made its way 79 | # back into this repository. 80 | def dbms_type_cast(_columns, values) 81 | values 82 | end 83 | 84 | # Assume received identifier is in DBMS's data dictionary case. 85 | def format_case(identifier) 86 | if database_metadata.upcase_identifiers? 87 | identifier =~ /[a-z]/ ? identifier : identifier.downcase 88 | else 89 | identifier 90 | end 91 | end 92 | 93 | # In general, ActiveRecord uses lowercase attribute names. This may 94 | # conflict with the database's data dictionary case. 95 | # 96 | # The ODBCAdapter uses the following conventions for databases 97 | # which report SQL_IDENTIFIER_CASE = SQL_IC_UPPER: 98 | # * if a name is returned from the DBMS in all uppercase, convert it 99 | # to lowercase before returning it to ActiveRecord. 100 | # * if a name is returned from the DBMS in lowercase or mixed case, 101 | # assume the underlying schema object's name was quoted when 102 | # the schema object was created. Leave the name untouched before 103 | # returning it to ActiveRecord. 104 | # * before making an ODBC catalog call, if a supplied identifier is all 105 | # lowercase, convert it to uppercase. Leave mixed case or all 106 | # uppercase identifiers unchanged. 107 | # * columns created with quoted lowercase names are not supported. 108 | # 109 | # Converts an identifier to the case conventions used by the DBMS. 110 | # Assume received identifier is in ActiveRecord case. 111 | def native_case(identifier) 112 | if database_metadata.upcase_identifiers? 113 | identifier =~ /[A-Z]/ ? identifier : identifier.upcase 114 | else 115 | identifier 116 | end 117 | end 118 | 119 | # Assume column is nullable if nullable == SQL_NULLABLE_UNKNOWN 120 | def nullability(col_name, is_nullable, nullable) 121 | not_nullable = (!is_nullable || !nullable.to_s.match('NO').nil?) 122 | result = !(not_nullable || nullable == SQL_NO_NULLS) 123 | 124 | # HACK! 125 | # MySQL native ODBC driver doesn't report nullability accurately. 126 | # So force nullability of 'id' columns 127 | col_name == 'id' ? false : result 128 | end 129 | 130 | def prepared_binds(binds) 131 | prepare_binds_for_database(binds).map { |bind| _type_cast(bind) } 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /lib/odbc_adapter/adapters/mysql_odbc_adapter.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module Adapters 3 | # Overrides specific to MySQL. Mostly taken from 4 | # ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter 5 | class MySQLODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter 6 | PRIMARY_KEY = 'INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY'.freeze 7 | 8 | class BindSubstitution < Arel::Visitors::MySQL 9 | include Arel::Visitors::BindVisitor 10 | end 11 | 12 | def arel_visitor 13 | BindSubstitution.new(self) 14 | end 15 | 16 | # Explicitly turning off prepared statements in the MySQL adapter because 17 | # of a weird bug with SQLDescribeParam returning a string type for LIMIT 18 | # parameters. This is blocking them from running with an error: 19 | # 20 | # You have an error in your SQL syntax; ... 21 | # ... right syntax to use near ''1'' at line 1: ... 22 | def prepared_statements 23 | false 24 | end 25 | 26 | def truncate(table_name, name = nil) 27 | execute("TRUNCATE TABLE #{quote_table_name(table_name)}", name) 28 | end 29 | 30 | # Quotes a string, escaping any ' (single quote) and \ (backslash) 31 | # characters. 32 | def quote_string(string) 33 | string.gsub(/\\/, '\&\&').gsub(/'/, "''") 34 | end 35 | 36 | def quoted_true 37 | '1' 38 | end 39 | 40 | def unquoted_true 41 | 1 42 | end 43 | 44 | def quoted_false 45 | '0' 46 | end 47 | 48 | def unquoted_false 49 | 0 50 | end 51 | 52 | def disable_referential_integrity(&_block) 53 | old = select_value('SELECT @@FOREIGN_KEY_CHECKS') 54 | 55 | begin 56 | update('SET FOREIGN_KEY_CHECKS = 0') 57 | yield 58 | ensure 59 | update("SET FOREIGN_KEY_CHECKS = #{old}") 60 | end 61 | end 62 | 63 | # Create a new MySQL database with optional :charset and 64 | # :collation. Charset defaults to utf8. 65 | # 66 | # Example: 67 | # create_database 'charset_test', charset: 'latin1', 68 | # collation: 'latin1_bin' 69 | # create_database 'rails_development' 70 | # create_database 'rails_development', charset: :big5 71 | def create_database(name, options = {}) 72 | if options[:collation] 73 | execute("CREATE DATABASE `#{name}` DEFAULT CHARACTER SET `#{options[:charset] || 'utf8'}` COLLATE `#{options[:collation]}`") 74 | else 75 | execute("CREATE DATABASE `#{name}` DEFAULT CHARACTER SET `#{options[:charset] || 'utf8'}`") 76 | end 77 | end 78 | 79 | # Drops a MySQL database. 80 | # 81 | # Example: 82 | # drop_database('rails_development') 83 | def drop_database(name) 84 | execute("DROP DATABASE IF EXISTS `#{name}`") 85 | end 86 | 87 | def create_table(name, options = {}) 88 | super(name, { options: 'ENGINE=InnoDB' }.merge(options)) 89 | end 90 | 91 | # Renames a table. 92 | def rename_table(name, new_name) 93 | execute("RENAME TABLE #{quote_table_name(name)} TO #{quote_table_name(new_name)}") 94 | end 95 | 96 | def change_column(table_name, column_name, type, options = {}) 97 | unless options_include_default?(options) 98 | options[:default] = column_for(table_name, column_name).default 99 | end 100 | 101 | change_column_sql = "ALTER TABLE #{table_name} CHANGE #{column_name} #{column_name} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}" 102 | add_column_options!(change_column_sql, options) 103 | execute(change_column_sql) 104 | end 105 | 106 | def change_column_default(table_name, column_name, default_or_changes) 107 | default = extract_new_default_value(default_or_changes) 108 | column = column_for(table_name, column_name) 109 | change_column(table_name, column_name, column.sql_type, default: default) 110 | end 111 | 112 | def change_column_null(table_name, column_name, null, default = nil) 113 | column = column_for(table_name, column_name) 114 | 115 | unless null || default.nil? 116 | execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL") 117 | end 118 | change_column(table_name, column_name, column.sql_type, null: null) 119 | end 120 | 121 | def rename_column(table_name, column_name, new_column_name) 122 | column = column_for(table_name, column_name) 123 | current_type = column.native_type 124 | current_type << "(#{column.limit})" if column.limit 125 | execute("ALTER TABLE #{table_name} CHANGE #{column_name} #{new_column_name} #{current_type}") 126 | end 127 | 128 | # Skip primary key indexes 129 | def indexes(table_name, name = nil) 130 | super(table_name, name).reject { |i| i.unique && i.name =~ /^PRIMARY$/ } 131 | end 132 | 133 | # MySQL 5.x doesn't allow DEFAULT NULL for first timestamp column in a 134 | # table 135 | def options_include_default?(options) 136 | if options.include?(:default) && options[:default].nil? 137 | if options.include?(:column) && options[:column].native_type =~ /timestamp/i 138 | options.delete(:default) 139 | end 140 | end 141 | super(options) 142 | end 143 | 144 | protected 145 | 146 | def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil) 147 | super 148 | id_value || last_inserted_id(nil) 149 | end 150 | 151 | def last_inserted_id(_result) 152 | select_value('SELECT LAST_INSERT_ID()').to_i 153 | end 154 | end 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb: -------------------------------------------------------------------------------- 1 | module ODBCAdapter 2 | module Adapters 3 | # Overrides specific to PostgreSQL. Mostly taken from 4 | # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter 5 | class PostgreSQLODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter 6 | BOOLEAN_TYPE = 'bool'.freeze 7 | PRIMARY_KEY = 'SERIAL PRIMARY KEY'.freeze 8 | 9 | alias create insert 10 | 11 | # Override to handle booleans appropriately 12 | def native_database_types 13 | @native_database_types ||= super.merge(boolean: { name: 'bool' }) 14 | end 15 | 16 | def arel_visitor 17 | Arel::Visitors::PostgreSQL.new(self) 18 | end 19 | 20 | # Filter for ODBCAdapter#tables 21 | # Omits table from #tables if table_filter returns true 22 | def table_filtered?(schema_name, table_type) 23 | %w[information_schema pg_catalog].include?(schema_name) || table_type !~ /TABLE/i 24 | end 25 | 26 | def truncate(table_name, name = nil) 27 | exec_query("TRUNCATE TABLE #{quote_table_name(table_name)}", name) 28 | end 29 | 30 | # Returns the sequence name for a table's primary key or some other 31 | # specified key. 32 | def default_sequence_name(table_name, pk = nil) 33 | serial_sequence(table_name, pk || 'id').split('.').last 34 | rescue ActiveRecord::StatementInvalid 35 | "#{table_name}_#{pk || 'id'}_seq" 36 | end 37 | 38 | def sql_for_insert(sql, pk, _id_value, _sequence_name, binds) 39 | unless pk 40 | table_ref = extract_table_ref_from_insert_sql(sql) 41 | pk = primary_key(table_ref) if table_ref 42 | end 43 | 44 | sql = "#{sql} RETURNING #{quote_column_name(pk)}" if pk 45 | [sql, binds] 46 | end 47 | 48 | def type_cast(value, column) 49 | return super unless column 50 | 51 | case value 52 | when String 53 | return super unless 'bytea' == column.native_type 54 | { value: value, format: 1 } 55 | else 56 | super 57 | end 58 | end 59 | 60 | # Quotes a string, escaping any ' (single quote) and \ (backslash) 61 | # characters. 62 | def quote_string(string) 63 | string.gsub(/\\/, '\&\&').gsub(/'/, "''") 64 | end 65 | 66 | def disable_referential_integrity 67 | execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(';')) 68 | yield 69 | ensure 70 | execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(';')) 71 | end 72 | 73 | # Create a new PostgreSQL database. Options include :owner, 74 | # :template, :encoding, :tablespace, and 75 | # :connection_limit (note that MySQL uses :charset 76 | # while PostgreSQL uses :encoding). 77 | # 78 | # Example: 79 | # create_database config[:database], config 80 | # create_database 'foo_development', encoding: 'unicode' 81 | def create_database(name, options = {}) 82 | options = options.reverse_merge(encoding: 'utf8') 83 | 84 | option_string = options.symbolize_keys.sum do |key, value| 85 | case key 86 | when :owner 87 | " OWNER = \"#{value}\"" 88 | when :template 89 | " TEMPLATE = \"#{value}\"" 90 | when :encoding 91 | " ENCODING = '#{value}'" 92 | when :tablespace 93 | " TABLESPACE = \"#{value}\"" 94 | when :connection_limit 95 | " CONNECTION LIMIT = #{value}" 96 | else 97 | '' 98 | end 99 | end 100 | 101 | execute("CREATE DATABASE #{quote_table_name(name)}#{option_string}") 102 | end 103 | 104 | # Drops a PostgreSQL database. 105 | # 106 | # Example: 107 | # drop_database 'rails_development' 108 | def drop_database(name) 109 | execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}" 110 | end 111 | 112 | # Renames a table. 113 | def rename_table(name, new_name) 114 | execute("ALTER TABLE #{quote_table_name(name)} RENAME TO #{quote_table_name(new_name)}") 115 | end 116 | 117 | def change_column(table_name, column_name, type, options = {}) 118 | execute("ALTER TABLE #{table_name} ALTER #{column_name} TYPE #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}") 119 | change_column_default(table_name, column_name, options[:default]) if options_include_default?(options) 120 | end 121 | 122 | def change_column_default(table_name, column_name, default) 123 | execute("ALTER TABLE #{table_name} ALTER COLUMN #{column_name} SET DEFAULT #{quote(default)}") 124 | end 125 | 126 | def rename_column(table_name, column_name, new_column_name) 127 | execute("ALTER TABLE #{table_name} RENAME #{column_name} TO #{new_column_name}") 128 | end 129 | 130 | def remove_index!(_table_name, index_name) 131 | execute("DROP INDEX #{quote_table_name(index_name)}") 132 | end 133 | 134 | def rename_index(_table_name, old_name, new_name) 135 | execute("ALTER INDEX #{quote_column_name(old_name)} RENAME TO #{quote_table_name(new_name)}") 136 | end 137 | 138 | # Returns a SELECT DISTINCT clause for a given set of columns and a given 139 | # ORDER BY clause. 140 | # 141 | # PostgreSQL requires the ORDER BY columns in the select list for 142 | # distinct queries, and requires that the ORDER BY include the distinct 143 | # column. 144 | # 145 | # distinct("posts.id", "posts.created_at desc") 146 | def distinct(columns, orders) 147 | return "DISTINCT #{columns}" if orders.empty? 148 | 149 | # Construct a clean list of column names from the ORDER BY clause, 150 | # removing any ASC/DESC modifiers 151 | order_columns = orders.map { |s| s.gsub(/\s+(ASC|DESC)\s*(NULLS\s+(FIRST|LAST)\s*)?/i, '') } 152 | order_columns.reject!(&:blank?) 153 | order_columns = order_columns.zip((0...order_columns.size).to_a).map { |s, i| "#{s} AS alias_#{i}" } 154 | 155 | "DISTINCT #{columns}, #{order_columns * ', '}" 156 | end 157 | 158 | protected 159 | 160 | # Executes an INSERT query and returns the new record's ID 161 | def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil) 162 | unless pk 163 | table_ref = extract_table_ref_from_insert_sql(sql) 164 | pk = primary_key(table_ref) if table_ref 165 | end 166 | 167 | if pk 168 | select_value("#{sql} RETURNING #{quote_column_name(pk)}") 169 | else 170 | super 171 | end 172 | end 173 | 174 | # Returns the current ID of a table's sequence. 175 | def last_insert_id(sequence_name) 176 | r = exec_query("SELECT currval('#{sequence_name}')", 'SQL') 177 | Integer(r.rows.first.first) 178 | end 179 | 180 | private 181 | 182 | def serial_sequence(table, column) 183 | result = exec_query(<<-eosql, 'SCHEMA') 184 | SELECT pg_get_serial_sequence('#{table}', '#{column}') 185 | eosql 186 | result.rows.first.first 187 | end 188 | end 189 | end 190 | end 191 | -------------------------------------------------------------------------------- /lib/active_record/connection_adapters/odbc_adapter.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'arel/visitors/bind_visitor' 3 | require 'odbc' 4 | require 'odbc_utf8' 5 | 6 | require 'odbc_adapter/database_limits' 7 | require 'odbc_adapter/database_statements' 8 | require 'odbc_adapter/error' 9 | require 'odbc_adapter/quoting' 10 | require 'odbc_adapter/schema_statements' 11 | 12 | require 'odbc_adapter/column' 13 | require 'odbc_adapter/column_metadata' 14 | require 'odbc_adapter/database_metadata' 15 | require 'odbc_adapter/registry' 16 | require 'odbc_adapter/version' 17 | 18 | module ActiveRecord 19 | class Base 20 | class << self 21 | # Build a new ODBC connection with the given configuration. 22 | def odbc_connection(config) 23 | config = config.symbolize_keys 24 | 25 | connection, config = 26 | if config.key?(:dsn) 27 | odbc_dsn_connection(config) 28 | elsif config.key?(:conn_str) 29 | odbc_conn_str_connection(config) 30 | else 31 | raise ArgumentError, 'No data source name (:dsn) or connection string (:conn_str) specified.' 32 | end 33 | 34 | database_metadata = ::ODBCAdapter::DatabaseMetadata.new(connection, config[:encoding_bug]) 35 | database_metadata.adapter_class.new(connection, logger, config, database_metadata) 36 | end 37 | 38 | private 39 | 40 | # Connect using a predefined DSN. 41 | def odbc_dsn_connection(config) 42 | username = config[:username] ? config[:username].to_s : nil 43 | password = config[:password] ? config[:password].to_s : nil 44 | odbc_module = config[:encoding] == 'utf8' ? ODBC_UTF8 : ODBC 45 | connection = odbc_module.connect(config[:dsn], username, password) 46 | 47 | # encoding_bug indicates that the driver is using non ASCII and has the issue referenced here https://github.com/larskanis/ruby-odbc/issues/2 48 | [connection, config.merge(username: username, password: password, encoding_bug: config[:encoding] == 'utf8')] 49 | end 50 | 51 | # Connect using ODBC connection string 52 | # Supports DSN-based or DSN-less connections 53 | # e.g. "DSN=virt5;UID=rails;PWD=rails" 54 | # "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails" 55 | def odbc_conn_str_connection(config) 56 | attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h 57 | odbc_module = attrs['ENCODING'] == 'utf8' ? ODBC_UTF8 : ODBC 58 | driver = odbc_module::Driver.new 59 | driver.name = 'odbc' 60 | driver.attrs = attrs 61 | 62 | connection = odbc_module::Database.new.drvconnect(driver) 63 | # encoding_bug indicates that the driver is using non ASCII and has the issue referenced here https://github.com/larskanis/ruby-odbc/issues/2 64 | [connection, config.merge(driver: driver, encoding: attrs['ENCODING'], encoding_bug: attrs['ENCODING'] == 'utf8')] 65 | end 66 | end 67 | end 68 | 69 | module ConnectionAdapters 70 | class ODBCAdapter < AbstractAdapter 71 | include ::ODBCAdapter::DatabaseLimits 72 | include ::ODBCAdapter::DatabaseStatements 73 | include ::ODBCAdapter::Quoting 74 | include ::ODBCAdapter::SchemaStatements 75 | 76 | ADAPTER_NAME = 'ODBC'.freeze 77 | BOOLEAN_TYPE = 'BOOLEAN'.freeze 78 | 79 | ERR_DUPLICATE_KEY_VALUE = 23_505 80 | ERR_QUERY_TIMED_OUT = 57_014 81 | ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/ 82 | ERR_CONNECTION_FAILED_REGEX = '^08[0S]0[12347]'.freeze 83 | ERR_CONNECTION_FAILED_MESSAGE = /Client connection failed/ 84 | 85 | # The object that stores the information that is fetched from the DBMS 86 | # when a connection is first established. 87 | attr_reader :database_metadata 88 | 89 | def initialize(connection, logger, config, database_metadata) 90 | configure_time_options(connection) 91 | super(connection, logger, config) 92 | @database_metadata = database_metadata 93 | end 94 | 95 | # Returns the human-readable name of the adapter. 96 | def adapter_name 97 | ADAPTER_NAME 98 | end 99 | 100 | # Does this adapter support migrations? Backend specific, as the abstract 101 | # adapter always returns +false+. 102 | def supports_migrations? 103 | true 104 | end 105 | 106 | # CONNECTION MANAGEMENT ==================================== 107 | 108 | # Checks whether the connection to the database is still active. This 109 | # includes checking whether the database is actually capable of 110 | # responding, i.e. whether the connection isn't stale. 111 | def active? 112 | @connection.connected? 113 | end 114 | 115 | # Disconnects from the database if already connected, and establishes a 116 | # new connection with the database. 117 | def reconnect! 118 | disconnect! 119 | odbc_module = @config[:encoding] == 'utf8' ? ODBC_UTF8 : ODBC 120 | @connection = 121 | if @config.key?(:dsn) 122 | odbc_module.connect(@config[:dsn], @config[:username], @config[:password]) 123 | else 124 | odbc_module::Database.new.drvconnect(@config[:driver]) 125 | end 126 | configure_time_options(@connection) 127 | super 128 | end 129 | alias reset! reconnect! 130 | 131 | # Disconnects from the database if already connected. Otherwise, this 132 | # method does nothing. 133 | def disconnect! 134 | @connection.disconnect if @connection.connected? 135 | end 136 | 137 | # Build a new column object from the given options. Effectively the same 138 | # as super except that it also passes in the native type. 139 | # rubocop:disable Metrics/ParameterLists 140 | def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil, native_type = nil) 141 | ::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type) 142 | end 143 | 144 | protected 145 | 146 | # Build the type map for ActiveRecord 147 | # Here, ODBC and ODBC_UTF8 constants are interchangeable 148 | def initialize_type_map(map) 149 | map.register_type 'boolean', Type::Boolean.new 150 | map.register_type ODBC::SQL_CHAR, Type::String.new 151 | map.register_type ODBC::SQL_LONGVARCHAR, Type::Text.new 152 | map.register_type ODBC::SQL_TINYINT, Type::Integer.new(limit: 4) 153 | map.register_type ODBC::SQL_SMALLINT, Type::Integer.new(limit: 8) 154 | map.register_type ODBC::SQL_INTEGER, Type::Integer.new(limit: 16) 155 | map.register_type ODBC::SQL_BIGINT, Type::BigInteger.new(limit: 32) 156 | map.register_type ODBC::SQL_REAL, Type::Float.new(limit: 24) 157 | map.register_type ODBC::SQL_FLOAT, Type::Float.new 158 | map.register_type ODBC::SQL_DOUBLE, Type::Float.new(limit: 53) 159 | map.register_type ODBC::SQL_DECIMAL, Type::Float.new 160 | map.register_type ODBC::SQL_NUMERIC, Type::Integer.new 161 | map.register_type ODBC::SQL_BINARY, Type::Binary.new 162 | map.register_type ODBC::SQL_DATE, Type::Date.new 163 | map.register_type ODBC::SQL_DATETIME, Type::DateTime.new 164 | map.register_type ODBC::SQL_TIME, Type::Time.new 165 | map.register_type ODBC::SQL_TIMESTAMP, Type::DateTime.new 166 | map.register_type ODBC::SQL_GUID, Type::String.new 167 | 168 | alias_type map, ODBC::SQL_BIT, 'boolean' 169 | alias_type map, ODBC::SQL_VARCHAR, ODBC::SQL_CHAR 170 | alias_type map, ODBC::SQL_WCHAR, ODBC::SQL_CHAR 171 | alias_type map, ODBC::SQL_WVARCHAR, ODBC::SQL_CHAR 172 | alias_type map, ODBC::SQL_WLONGVARCHAR, ODBC::SQL_LONGVARCHAR 173 | alias_type map, ODBC::SQL_VARBINARY, ODBC::SQL_BINARY 174 | alias_type map, ODBC::SQL_LONGVARBINARY, ODBC::SQL_BINARY 175 | alias_type map, ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE 176 | alias_type map, ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME 177 | alias_type map, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP 178 | end 179 | 180 | # Translate an exception from the native DBMS to something usable by 181 | # ActiveRecord. 182 | def translate_exception(exception, message) 183 | error_number = exception.message[/^\d+/].to_i 184 | 185 | if error_number == ERR_DUPLICATE_KEY_VALUE 186 | ActiveRecord::RecordNotUnique.new(message, exception) 187 | elsif error_number == ERR_QUERY_TIMED_OUT || exception.message =~ ERR_QUERY_TIMED_OUT_MESSAGE 188 | ::ODBCAdapter::QueryTimeoutError.new(message, exception) 189 | elsif exception.message.match(ERR_CONNECTION_FAILED_REGEX) || exception.message =~ ERR_CONNECTION_FAILED_MESSAGE 190 | begin 191 | reconnect! 192 | ::ODBCAdapter::ConnectionFailedError.new(message, exception) 193 | rescue => e 194 | puts "unable to reconnect #{e}" 195 | end 196 | else 197 | super 198 | end 199 | end 200 | 201 | private 202 | 203 | # Can't use the built-in ActiveRecord map#alias_type because it doesn't 204 | # work with non-string keys, and in our case the keys are (almost) all 205 | # numeric 206 | def alias_type(map, new_type, old_type) 207 | map.register_type(new_type) do |_, *args| 208 | map.lookup(old_type, *args) 209 | end 210 | end 211 | 212 | # Ensure ODBC is mapping time-based fields to native ruby objects 213 | def configure_time_options(connection) 214 | connection.use_time = true 215 | end 216 | end 217 | end 218 | end 219 | --------------------------------------------------------------------------------