├── 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 | [](https://travis-ci.org/localytics/odbc_adapter)
4 | [](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 |
--------------------------------------------------------------------------------