diff --git a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb
new file mode 100644
index 00000000..41640c44
--- /dev/null
+++ b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb
@@ -0,0 +1,200 @@
+require 'active_record'
+require 'arel/visitors/bind_visitor'
+require 'odbc_utf8'
+
+require 'odbc_utf8_adapter/database_limits'
+require 'odbc_utf8_adapter/database_statements'
+require 'odbc_utf8_adapter/error'
+require 'odbc_utf8_adapter/quoting'
+require 'odbc_utf8_adapter/schema_statements'
+
+require 'odbc_utf8_adapter/column'
+require 'odbc_utf8_adapter/column_metadata'
+require 'odbc_utf8_adapter/database_metadata'
+require 'odbc_utf8_adapter/registry'
+require 'odbc_utf8_adapter/version'
+
+module ActiveRecord
+ class Base
+ class << self
+ # Build a new ODBC connection with the given configuration.
+ def odbc_utf8_connection(config)
+ config = config.symbolize_keys
+
+ connection, config =
+ if config.key?(:dsn)
+ odbc_utf8_dsn_connection(config)
+ elsif config.key?(:conn_str)
+ odbc_utf8_conn_str_connection(config)
+ else
+ raise ArgumentError, 'No data source name (:dsn) or connection string (:conn_str) specified.'
+ end
+
+ database_metadata = ::ODBCUTF8Adapter::DatabaseMetadata.new(connection)
+ database_metadata.adapter_class.new(connection, logger, config, database_metadata)
+ end
+
+ private
+
+ # Connect using a predefined DSN.
+ def odbc_utf8_dsn_connection(config)
+ username = config[:username] ? config[:username].to_s : nil
+ password = config[:password] ? config[:password].to_s : nil
+ connection = ODBC_UTF8.connect(config[:dsn], username, password)
+ [connection, config.merge(username: username, password: password)]
+ end
+
+ # Connect using ODBC connection string
+ # Supports DSN-based or DSN-less connections
+ # e.g. "DSN=virt5;UID=rails;PWD=rails"
+ # "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails"
+ def odbc_utf8_conn_str_connection(config)
+ driver = ODBC_UTF8::Driver.new
+ driver.name = 'odbc_utf8'
+ driver.attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h
+
+ connection = ODBC_UTF8::Database.new.drvconnect(driver)
+ [connection, config.merge(driver: driver)]
+ end
+ end
+ end
+
+ module ConnectionAdapters
+ class ODBCUTF8Adapter < AbstractAdapter
+ include ::ODBCUTF8Adapter::DatabaseLimits
+ include ::ODBCUTF8Adapter::DatabaseStatements
+ include ::ODBCUTF8Adapter::Quoting
+ include ::ODBCUTF8Adapter::SchemaStatements
+
+ ADAPTER_NAME = 'ODBC_UTF8'.freeze
+ BOOLEAN_TYPE = 'BOOLEAN'.freeze
+
+ ERR_DUPLICATE_KEY_VALUE = 23_505
+ ERR_QUERY_TIMED_OUT = 57_014
+ ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/
+
+ # The object that stores the information that is fetched from the DBMS
+ # when a connection is first established.
+ attr_reader :database_metadata
+
+ def initialize(connection, logger, config, database_metadata)
+ configure_time_options(connection)
+ super(connection, logger, config)
+ @database_metadata = database_metadata
+ end
+
+ # Returns the human-readable name of the adapter.
+ def adapter_name
+ ADAPTER_NAME
+ end
+
+ # Does this adapter support migrations? Backend specific, as the abstract
+ # adapter always returns +false+.
+ def supports_migrations?
+ true
+ end
+
+ # CONNECTION MANAGEMENT ====================================
+
+ # Checks whether the connection to the database is still active. This
+ # includes checking whether the database is actually capable of
+ # responding, i.e. whether the connection isn't stale.
+ def active?
+ @connection.connected?
+ end
+
+ # Disconnects from the database if already connected, and establishes a
+ # new connection with the database.
+ def reconnect!
+ disconnect!
+ @connection =
+ if @config.key?(:dsn)
+ ODBC_UTF8.connect(@config[:dsn], @config[:username], @config[:password])
+ else
+ ODBC_UTF8::Database.new.drvconnect(@config[:driver])
+ end
+ configure_time_options(@connection)
+ super
+ end
+ alias reset! reconnect!
+
+ # Disconnects from the database if already connected. Otherwise, this
+ # method does nothing.
+ def disconnect!
+ @connection.disconnect if @connection.connected?
+ end
+
+ # Build a new column object from the given options. Effectively the same
+ # as super except that it also passes in the native type.
+ # rubocop:disable Metrics/ParameterLists
+ def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil, native_type = nil)
+ ::ODBCUTF8Adapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type)
+ end
+
+ protected
+
+ # Build the type map for ActiveRecord
+ def initialize_type_map(map)
+ map.register_type 'boolean', Type::Boolean.new
+ map.register_type ODBC_UTF8::SQL_CHAR, Type::String.new
+ map.register_type ODBC_UTF8::SQL_LONGVARCHAR, Type::Text.new
+ map.register_type ODBC_UTF8::SQL_TINYINT, Type::Integer.new(limit: 4)
+ map.register_type ODBC_UTF8::SQL_SMALLINT, Type::Integer.new(limit: 8)
+ map.register_type ODBC_UTF8::SQL_INTEGER, Type::Integer.new(limit: 16)
+ map.register_type ODBC_UTF8::SQL_BIGINT, Type::BigInteger.new(limit: 32)
+ map.register_type ODBC_UTF8::SQL_REAL, Type::Float.new(limit: 24)
+ map.register_type ODBC_UTF8::SQL_FLOAT, Type::Float.new
+ map.register_type ODBC_UTF8::SQL_DOUBLE, Type::Float.new(limit: 53)
+ map.register_type ODBC_UTF8::SQL_DECIMAL, Type::Float.new
+ map.register_type ODBC_UTF8::SQL_NUMERIC, Type::Integer.new
+ map.register_type ODBC_UTF8::SQL_BINARY, Type::Binary.new
+ map.register_type ODBC_UTF8::SQL_DATE, Type::Date.new
+ map.register_type ODBC_UTF8::SQL_DATETIME, Type::DateTime.new
+ map.register_type ODBC_UTF8::SQL_TIME, Type::Time.new
+ map.register_type ODBC_UTF8::SQL_TIMESTAMP, Type::DateTime.new
+ map.register_type ODBC_UTF8::SQL_GUID, Type::String.new
+
+ alias_type map, ODBC_UTF8::SQL_BIT, 'boolean'
+ alias_type map, ODBC_UTF8::SQL_VARCHAR, ODBC_UTF8::SQL_CHAR
+ alias_type map, ODBC_UTF8::SQL_WCHAR, ODBC_UTF8::SQL_CHAR
+ alias_type map, ODBC_UTF8::SQL_WVARCHAR, ODBC_UTF8::SQL_CHAR
+ alias_type map, ODBC_UTF8::SQL_WLONGVARCHAR, ODBC_UTF8::SQL_LONGVARCHAR
+ alias_type map, ODBC_UTF8::SQL_VARBINARY, ODBC_UTF8::SQL_BINARY
+ alias_type map, ODBC_UTF8::SQL_LONGVARBINARY, ODBC_UTF8::SQL_BINARY
+ alias_type map, ODBC_UTF8::SQL_TYPE_DATE, ODBC_UTF8::SQL_DATE
+ alias_type map, ODBC_UTF8::SQL_TYPE_TIME, ODBC_UTF8::SQL_TIME
+ alias_type map, ODBC_UTF8::SQL_TYPE_TIMESTAMP, ODBC_UTF8::SQL_TIMESTAMP
+ end
+
+ # Translate an exception from the native DBMS to something usable by
+ # ActiveRecord.
+ def translate_exception(exception, message)
+ error_number = exception.message[/^\d+/].to_i
+
+ if error_number == ERR_DUPLICATE_KEY_VALUE
+ ActiveRecord::RecordNotUnique.new(message, exception)
+ elsif error_number == ERR_QUERY_TIMED_OUT || exception.message =~ ERR_QUERY_TIMED_OUT_MESSAGE
+ ::ODBCAdapter::QueryTimeoutError.new(message, exception)
+ else
+ super
+ end
+ end
+
+ private
+
+ # Can't use the built-in ActiveRecord map#alias_type because it doesn't
+ # work with non-string keys, and in our case the keys are (almost) all
+ # numeric
+ def alias_type(map, new_type, old_type)
+ map.register_type(new_type) do |_, *args|
+ map.lookup(old_type, *args)
+ end
+ end
+
+ # Ensure ODBC is mapping time-based fields to native ruby objects
+ def configure_time_options(connection)
+ connection.use_time = true
+ end
+ end
+ end
+end
diff --git a/lib/odbc_adapter/type_caster.rb b/lib/odbc_adapter/type_caster.rb
new file mode 100644
index 00000000..4ca73c99
--- /dev/null
+++ b/lib/odbc_adapter/type_caster.rb
@@ -0,0 +1,42 @@
+module ODBCAdapter
+ class TypeCaster
+ # When fetching a result set, the Ruby ODBC driver converts all ODBC
+ # SQL types to an equivalent Ruby type; with the exception of
+ # SQL_DATE, SQL_TIME and SQL_TIMESTAMP.
+ TYPES = [
+ ODBC::SQL_DATE,
+ ODBC::SQL_TIME,
+ ODBC::SQL_TIMESTAMP
+ ]
+
+ attr_reader :idx
+
+ def initialize(idx)
+ @idx = idx
+ end
+
+ def cast(value)
+ case value
+ when ODBC::TimeStamp
+ Time.gm(value.year, value.month, value.day, value.hour, value.minute, value.second)
+ when ODBC::Time
+ now = DateTime.now
+ Time.gm(now.year, now.month, now.day, value.hour, value.minute, value.second)
+ when ODBC::Date
+ Date.new(value.year, value.month, value.day)
+ else
+ value
+ end
+ rescue
+ # Handle pre-epoch dates
+ DateTime.new(value.year, value.month, value.day, value.hour, value.minute, value.second)
+ end
+
+ # Build a list of casters from a list of columns
+ def self.build_from(columns)
+ columns.each_with_index.each_with_object([]) do |(column, idx), casters|
+ casters << new(idx) if TYPES.include?(column.type)
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter.rb b/lib/odbc_utf8_adapter.rb
new file mode 100644
index 00000000..65fff078
--- /dev/null
+++ b/lib/odbc_utf8_adapter.rb
@@ -0,0 +1,2 @@
+# Requiring with this pattern to mirror ActiveRecord
+require 'active_record/connection_adapters/odbc_utf8_adapter'
diff --git a/lib/odbc_utf8_adapter/adapters/mysql_odbc_utf8_adapter.rb b/lib/odbc_utf8_adapter/adapters/mysql_odbc_utf8_adapter.rb
new file mode 100644
index 00000000..b43ba791
--- /dev/null
+++ b/lib/odbc_utf8_adapter/adapters/mysql_odbc_utf8_adapter.rb
@@ -0,0 +1,156 @@
+module ODBCUTF8Adapter
+ module Adapters
+ # Overrides specific to MySQL. Mostly taken from
+ # ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter
+ class MySQLODBCUTF8Adapter < ActiveRecord::ConnectionAdapters::ODBCUTF8Adapter
+ PRIMARY_KEY = 'INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY'.freeze
+
+ class BindSubstitution < Arel::Visitors::MySQL
+ include Arel::Visitors::BindVisitor
+ end
+
+ def arel_visitor
+ BindSubstitution.new(self)
+ end
+
+ # Explicitly turning off prepared statements in the MySQL adapter because
+ # of a weird bug with SQLDescribeParam returning a string type for LIMIT
+ # parameters. This is blocking them from running with an error:
+ #
+ # You have an error in your SQL syntax; ...
+ # ... right syntax to use near ''1'' at line 1: ...
+ def prepared_statements
+ false
+ end
+
+ def truncate(table_name, name = nil)
+ execute("TRUNCATE TABLE #{quote_table_name(table_name)}", name)
+ end
+
+ # Quotes a string, escaping any ' (single quote) and \ (backslash)
+ # characters.
+ def quote_string(string)
+ string.gsub(/\\/, '\&\&').gsub(/'/, "''")
+ end
+
+ def quoted_true
+ '1'
+ end
+
+ def unquoted_true
+ 1
+ end
+
+ def quoted_false
+ '0'
+ end
+
+ def unquoted_false
+ 0
+ end
+
+ def disable_referential_integrity(&block)
+ old = select_value("SELECT @@FOREIGN_KEY_CHECKS")
+
+ begin
+ update("SET FOREIGN_KEY_CHECKS = 0")
+ yield
+ ensure
+ update("SET FOREIGN_KEY_CHECKS = #{old}")
+ end
+ end
+
+ # Create a new MySQL database with optional :charset and
+ # :collation. Charset defaults to utf8.
+ #
+ # Example:
+ # create_database 'charset_test', charset: 'latin1',
+ # collation: 'latin1_bin'
+ # create_database 'rails_development'
+ # create_database 'rails_development', charset: :big5
+ def create_database(name, options = {})
+ if options[:collation]
+ execute("CREATE DATABASE `#{name}` DEFAULT CHARACTER SET `#{options[:charset] || 'utf8'}` COLLATE `#{options[:collation]}`")
+ else
+ execute("CREATE DATABASE `#{name}` DEFAULT CHARACTER SET `#{options[:charset] || 'utf8'}`")
+ end
+ end
+
+ # Drops a MySQL database.
+ #
+ # Example:
+ # drop_database('rails_development')
+ def drop_database(name)
+ execute("DROP DATABASE IF EXISTS `#{name}`")
+ end
+
+ def create_table(name, options = {})
+ super(name, { options: 'ENGINE=InnoDB' }.merge(options))
+ end
+
+ # Renames a table.
+ def rename_table(name, new_name)
+ execute("RENAME TABLE #{quote_table_name(name)} TO #{quote_table_name(new_name)}")
+ end
+
+ def change_column(table_name, column_name, type, options = {})
+ unless options_include_default?(options)
+ options[:default] = column_for(table_name, column_name).default
+ end
+
+ change_column_sql = "ALTER TABLE #{table_name} CHANGE #{column_name} #{column_name} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}"
+ add_column_options!(change_column_sql, options)
+ execute(change_column_sql)
+ end
+
+ def change_column_default(table_name, column_name, default_or_changes)
+ default = extract_new_default_value(default_or_changes)
+ column = column_for(table_name, column_name)
+ change_column(table_name, column_name, column.sql_type, default: default)
+ end
+
+ def change_column_null(table_name, column_name, null, default = nil)
+ column = column_for(table_name, column_name)
+
+ unless null || default.nil?
+ execute("UPDATE #{quote_table_name(table_name)} SET #{quote_column_name(column_name)}=#{quote(default)} WHERE #{quote_column_name(column_name)} IS NULL")
+ end
+ change_column(table_name, column_name, column.sql_type, null: null)
+ end
+
+ def rename_column(table_name, column_name, new_column_name)
+ column = column_for(table_name, column_name)
+ current_type = column.native_type
+ current_type << "(#{column.limit})" if column.limit
+ execute("ALTER TABLE #{table_name} CHANGE #{column_name} #{new_column_name} #{current_type}")
+ end
+
+ # Skip primary key indexes
+ def indexes(table_name, name = nil)
+ super(table_name, name).reject { |i| i.unique && i.name =~ /^PRIMARY$/ }
+ end
+
+ # MySQL 5.x doesn't allow DEFAULT NULL for first timestamp column in a
+ # table
+ def options_include_default?(options)
+ if options.include?(:default) && options[:default].nil?
+ if options.include?(:column) && options[:column].native_type =~ /timestamp/i
+ options.delete(:default)
+ end
+ end
+ super(options)
+ end
+
+ protected
+
+ def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
+ super
+ id_value || last_inserted_id(nil)
+ end
+
+ def last_inserted_id(_result)
+ select_value('SELECT LAST_INSERT_ID()').to_i
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/adapters/null_odbc_utf8_adapter.rb b/lib/odbc_utf8_adapter/adapters/null_odbc_utf8_adapter.rb
new file mode 100644
index 00000000..efca5e0c
--- /dev/null
+++ b/lib/odbc_utf8_adapter/adapters/null_odbc_utf8_adapter.rb
@@ -0,0 +1,31 @@
+module ODBCUTF8Adapter
+ module Adapters
+ # A default adapter used for databases that are no explicitly listed in the
+ # registry. This allows for minimal support for DBMSs for which we don't
+ # have an explicit adapter.
+ class NullODBCUTF8Adapter < ActiveRecord::ConnectionAdapters::ODBCUTF8Adapter
+ class BindSubstitution < Arel::Visitors::ToSql
+ include Arel::Visitors::BindVisitor
+ end
+
+ # Using a BindVisitor so that the SQL string gets substituted before it is
+ # sent to the DBMS (to attempt to get as much coverage as possible for
+ # DBMSs we don't support).
+ def arel_visitor
+ BindSubstitution.new(self)
+ end
+
+ # Explicitly turning off prepared_statements in the null adapter because
+ # there isn't really a standard on which substitution character to use.
+ def prepared_statements
+ false
+ end
+
+ # Turning off support for migrations because there is no information to
+ # go off of for what syntax the DBMS will expect.
+ def supports_migrations?
+ false
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/adapters/postgresql_odbc_utf8_adapter.rb b/lib/odbc_utf8_adapter/adapters/postgresql_odbc_utf8_adapter.rb
new file mode 100644
index 00000000..8cff2bfd
--- /dev/null
+++ b/lib/odbc_utf8_adapter/adapters/postgresql_odbc_utf8_adapter.rb
@@ -0,0 +1,190 @@
+module ODBCUTF8Adapter
+ module Adapters
+ # Overrides specific to PostgreSQL. Mostly taken from
+ # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter
+ class PostgreSQLODBCUTF8Adapter < ActiveRecord::ConnectionAdapters::ODBCUTF8Adapter
+ BOOLEAN_TYPE = 'bool'.freeze
+ PRIMARY_KEY = 'SERIAL PRIMARY KEY'.freeze
+
+ alias :create :insert
+
+ # Override to handle booleans appropriately
+ def native_database_types
+ @native_database_types ||= super.merge(boolean: { name: 'bool' })
+ end
+
+ def arel_visitor
+ Arel::Visitors::PostgreSQL.new(self)
+ end
+
+ # Filter for ODBCUTF8Adapter#tables
+ # Omits table from #tables if table_filter returns true
+ def table_filter(schema_name, table_type)
+ %w[information_schema pg_catalog].include?(schema_name) || table_type !~ /TABLE/i
+ end
+
+ def truncate(table_name, name = nil)
+ exec_query("TRUNCATE TABLE #{quote_table_name(table_name)}", name)
+ end
+
+ # Returns the sequence name for a table's primary key or some other
+ # specified key.
+ def default_sequence_name(table_name, pk = nil)
+ serial_sequence(table_name, pk || 'id').split('.').last
+ rescue ActiveRecord::StatementInvalid
+ "#{table_name}_#{pk || 'id'}_seq"
+ end
+
+ def sql_for_insert(sql, pk, id_value, sequence_name, binds)
+ unless pk
+ table_ref = extract_table_ref_from_insert_sql(sql)
+ pk = primary_key(table_ref) if table_ref
+ end
+
+ sql = "#{sql} RETURNING #{quote_column_name(pk)}" if pk
+ [sql, binds]
+ end
+
+ def type_cast(value, column)
+ return super unless column
+
+ case value
+ when String
+ return super unless 'bytea' == column.native_type
+ { value: value, format: 1 }
+ else
+ super
+ end
+ end
+
+ # Quotes a string, escaping any ' (single quote) and \ (backslash)
+ # characters.
+ def quote_string(string)
+ string.gsub(/\\/, '\&\&').gsub(/'/, "''")
+ end
+
+ def disable_referential_integrity
+ execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(';'))
+ yield
+ ensure
+ execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(';'))
+ end
+
+ # Create a new PostgreSQL database. Options include :owner,
+ # :template, :encoding, :tablespace, and
+ # :connection_limit (note that MySQL uses :charset
+ # while PostgreSQL uses :encoding).
+ #
+ # Example:
+ # create_database config[:database], config
+ # create_database 'foo_development', encoding: 'unicode'
+ def create_database(name, options = {})
+ options = options.reverse_merge(encoding: 'utf8')
+
+ option_string = options.symbolize_keys.sum do |key, value|
+ case key
+ when :owner
+ " OWNER = \"#{value}\""
+ when :template
+ " TEMPLATE = \"#{value}\""
+ when :encoding
+ " ENCODING = '#{value}'"
+ when :tablespace
+ " TABLESPACE = \"#{value}\""
+ when :connection_limit
+ " CONNECTION LIMIT = #{value}"
+ else
+ ""
+ end
+ end
+
+ execute("CREATE DATABASE #{quote_table_name(name)}#{option_string}")
+ end
+
+ # Drops a PostgreSQL database.
+ #
+ # Example:
+ # drop_database 'rails_development'
+ def drop_database(name)
+ execute "DROP DATABASE IF EXISTS #{quote_table_name(name)}"
+ end
+
+ # Renames a table.
+ def rename_table(name, new_name)
+ execute("ALTER TABLE #{quote_table_name(name)} RENAME TO #{quote_table_name(new_name)}")
+ end
+
+ def change_column(table_name, column_name, type, options = {})
+ execute("ALTER TABLE #{table_name} ALTER #{column_name} TYPE #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}")
+ change_column_default(table_name, column_name, options[:default]) if options_include_default?(options)
+ end
+
+ def change_column_default(table_name, column_name, default)
+ execute("ALTER TABLE #{table_name} ALTER COLUMN #{column_name} SET DEFAULT #{quote(default)}")
+ end
+
+ def rename_column(table_name, column_name, new_column_name)
+ execute("ALTER TABLE #{table_name} RENAME #{column_name} TO #{new_column_name}")
+ end
+
+ def remove_index!(_table_name, index_name)
+ execute("DROP INDEX #{quote_table_name(index_name)}")
+ end
+
+ def rename_index(table_name, old_name, new_name)
+ execute("ALTER INDEX #{quote_column_name(old_name)} RENAME TO #{quote_table_name(new_name)}")
+ end
+
+ # Returns a SELECT DISTINCT clause for a given set of columns and a given
+ # ORDER BY clause.
+ #
+ # PostgreSQL requires the ORDER BY columns in the select list for
+ # distinct queries, and requires that the ORDER BY include the distinct
+ # column.
+ #
+ # distinct("posts.id", "posts.created_at desc")
+ def distinct(columns, orders)
+ return "DISTINCT #{columns}" if orders.empty?
+
+ # Construct a clean list of column names from the ORDER BY clause,
+ # removing any ASC/DESC modifiers
+ order_columns = orders.map { |s| s.gsub(/\s+(ASC|DESC)\s*(NULLS\s+(FIRST|LAST)\s*)?/i, '') }
+ order_columns.reject! { |c| c.blank? }
+ order_columns = order_columns.zip((0...order_columns.size).to_a).map { |s,i| "#{s} AS alias_#{i}" }
+
+ "DISTINCT #{columns}, #{order_columns * ', '}"
+ end
+
+ protected
+
+ # Executes an INSERT query and returns the new record's ID
+ def insert_sql(sql, name = nil, pk = nil, id_value = nil, sequence_name = nil)
+ unless pk
+ table_ref = extract_table_ref_from_insert_sql(sql)
+ pk = primary_key(table_ref) if table_ref
+ end
+
+ if pk
+ select_value("#{sql} RETURNING #{quote_column_name(pk)}")
+ else
+ super
+ end
+ end
+
+ # Returns the current ID of a table's sequence.
+ def last_insert_id(sequence_name)
+ r = exec_query("SELECT currval('#{sequence_name}')", 'SQL')
+ Integer(r.rows.first.first)
+ end
+
+ private
+
+ def serial_sequence(table, column)
+ result = exec_query(<<-eosql, 'SCHEMA')
+ SELECT pg_get_serial_sequence('#{table}', '#{column}')
+ eosql
+ result.rows.first.first
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/column.rb b/lib/odbc_utf8_adapter/column.rb
new file mode 100644
index 00000000..ff167880
--- /dev/null
+++ b/lib/odbc_utf8_adapter/column.rb
@@ -0,0 +1,10 @@
+module ODBCUTF8Adapter
+ class Column < ActiveRecord::ConnectionAdapters::Column
+ attr_reader :native_type
+
+ def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, native_type = nil, default_function = nil, collation = nil)
+ super(name, default, sql_type_metadata, null, table_name, default_function, collation)
+ @native_type = native_type
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/column_metadata.rb b/lib/odbc_utf8_adapter/column_metadata.rb
new file mode 100644
index 00000000..f43b9c46
--- /dev/null
+++ b/lib/odbc_utf8_adapter/column_metadata.rb
@@ -0,0 +1,76 @@
+module ODBCUTF8Adapter
+ class ColumnMetadata
+ GENERICS = {
+ primary_key: [ODBC_UTF8::SQL_INTEGER, ODBC_UTF8::SQL_SMALLINT],
+ string: [ODBC_UTF8::SQL_VARCHAR],
+ text: [ODBC_UTF8::SQL_LONGVARCHAR, ODBC_UTF8::SQL_VARCHAR],
+ integer: [ODBC_UTF8::SQL_INTEGER, ODBC_UTF8::SQL_SMALLINT],
+ decimal: [ODBC_UTF8::SQL_NUMERIC, ODBC_UTF8::SQL_DECIMAL],
+ float: [ODBC_UTF8::SQL_DOUBLE, ODBC_UTF8::SQL_REAL],
+ datetime: [ODBC_UTF8::SQL_TYPE_TIMESTAMP, ODBC_UTF8::SQL_TIMESTAMP],
+ timestamp: [ODBC_UTF8::SQL_TYPE_TIMESTAMP, ODBC_UTF8::SQL_TIMESTAMP],
+ time: [ODBC_UTF8::SQL_TYPE_TIME, ODBC_UTF8::SQL_TIME, ODBC_UTF8::SQL_TYPE_TIMESTAMP, ODBC_UTF8::SQL_TIMESTAMP],
+ date: [ODBC_UTF8::SQL_TYPE_DATE, ODBC_UTF8::SQL_DATE, ODBC_UTF8::SQL_TYPE_TIMESTAMP, ODBC_UTF8::SQL_TIMESTAMP],
+ binary: [ODBC_UTF8::SQL_LONGVARBINARY, ODBC_UTF8::SQL_VARBINARY],
+ boolean: [ODBC_UTF8::SQL_BIT, ODBC_UTF8::SQL_TINYINT, ODBC_UTF8::SQL_SMALLINT, ODBC_UTF8::SQL_INTEGER]
+ }
+
+ attr_reader :adapter
+
+ def initialize(adapter)
+ @adapter = adapter
+ end
+
+ def native_database_types
+ grouped = reported_types.group_by { |row| row[1] }
+
+ GENERICS.each_with_object({}) do |(abstract, candidates), mapped|
+ candidates.detect do |candidate|
+ next unless grouped[candidate]
+ mapped[abstract] = native_type_mapping(abstract, grouped[candidate])
+ end
+ end
+ end
+
+ private
+
+ # Creates a Hash describing a mapping from an abstract type to a
+ # DBMS native type for use by #native_database_types
+ def native_type_mapping(abstract, rows)
+ # The appropriate SQL for :primary_key is hard to derive as
+ # ODBC doesn't provide any info on a DBMS's native syntax for
+ # autoincrement columns. So we use a lookup instead.
+ return adapter.class::PRIMARY_KEY if abstract == :primary_key
+ selected_row = rows[0]
+
+ # If more than one native type corresponds to the SQL type we're
+ # handling, the type in the first descriptor should be the
+ # best match, because the ODBC specification states that
+ # SQLGetTypeInfo returns the results ordered by SQL type and then by
+ # how closely the native type maps to that SQL type.
+ # But, for :text and :binary, select the native type with the
+ # largest capacity. (Compare SQLGetTypeInfo:COLUMN_SIZE values)
+ selected_row = rows.max_by { |row| row[2] } if [:text, :binary].include?(abstract)
+ result = { name: selected_row[0] } # SQLGetTypeInfo: TYPE_NAME
+
+ create_params = selected_row[5]
+ # Depending on the column type, the CREATE_PARAMS keywords can
+ # include length, precision or scale.
+ if create_params && create_params.strip.length > 0 && abstract != :decimal
+ result[:limit] = selected_row[2] # SQLGetTypeInfo: COL_SIZE
+ end
+
+ result
+ end
+
+ def reported_types
+ @reported_types ||=
+ begin
+ stmt = adapter.raw_connection.types
+ stmt.fetch_all
+ ensure
+ stmt.drop unless stmt.nil?
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/database_limits.rb b/lib/odbc_utf8_adapter/database_limits.rb
new file mode 100644
index 00000000..b3aba293
--- /dev/null
+++ b/lib/odbc_utf8_adapter/database_limits.rb
@@ -0,0 +1,10 @@
+module ODBCUTF8Adapter
+ module DatabaseLimits
+ # Returns the maximum length of a table name.
+ def table_alias_length
+ max_identifier_length = database_metadata.max_identifier_len
+ max_table_name_length = database_metadata.max_table_name_len
+ [max_identifier_length, max_table_name_length].max
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/database_metadata.rb b/lib/odbc_utf8_adapter/database_metadata.rb
new file mode 100644
index 00000000..5f415d80
--- /dev/null
+++ b/lib/odbc_utf8_adapter/database_metadata.rb
@@ -0,0 +1,44 @@
+module ODBCUTF8Adapter
+ # Caches SQLGetInfo output
+ class DatabaseMetadata
+ FIELDS = %i[
+ SQL_DBMS_NAME
+ SQL_DBMS_VER
+ SQL_IDENTIFIER_CASE
+ SQL_QUOTED_IDENTIFIER_CASE
+ SQL_IDENTIFIER_QUOTE_CHAR
+ SQL_MAX_IDENTIFIER_LEN
+ SQL_MAX_TABLE_NAME_LEN
+ SQL_USER_NAME
+ SQL_DATABASE_NAME
+ ]
+
+ attr_reader :values
+
+ def initialize(connection)
+ @values = Hash[FIELDS.map { |field| [field, connection.get_info(ODBC_UTF8.const_get(field))] }]
+ end
+
+ def adapter_class
+ ODBCUTF8Adapter.adapter_for(dbms_name)
+ end
+
+ def upcase_identifiers?
+ @upcase_identifiers ||= (identifier_case == ODBC_UTF8::SQL_IC_UPPER)
+ end
+
+ # A little bit of metaprogramming magic here to create accessors for each of
+ # the fields reported on by the DBMS.
+ FIELDS.each do |field|
+ define_method(field.to_s.downcase.gsub('sql_', '')) do
+ value_for(field)
+ end
+ end
+
+ private
+
+ def value_for(field)
+ values[field]
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/database_statements.rb b/lib/odbc_utf8_adapter/database_statements.rb
new file mode 100644
index 00000000..283d650a
--- /dev/null
+++ b/lib/odbc_utf8_adapter/database_statements.rb
@@ -0,0 +1,138 @@
+module ODBCUTF8Adapter
+ module DatabaseStatements
+ # ODBC constants missing from Christian Werner's Ruby ODBC driver
+ SQL_NO_NULLS = 0
+ SQL_NULLABLE = 1
+ SQL_NULLABLE_UNKNOWN = 2
+
+ # Executes the SQL statement in the context of this connection.
+ # Returns the number of rows affected.
+ def execute(sql, name = nil, binds = [])
+ log(sql, name) do
+ if prepared_statements
+ @connection.do(sql, *prepared_binds(binds))
+ else
+ @connection.do(sql)
+ end
+ end
+ end
+
+ # Executes +sql+ statement in the context of this connection using
+ # +binds+ as the bind substitutes. +name+ is logged along with
+ # the executed +sql+ statement.
+ def exec_query(sql, name = 'SQL', binds = [], prepare: false)
+ log(sql, name) do
+ stmt =
+ if prepared_statements
+ @connection.run(sql, *prepared_binds(binds))
+ else
+ @connection.run(sql)
+ end
+
+ columns = stmt.columns
+ values = stmt.to_a
+ stmt.drop
+
+ casters = TypeCaster.build_from(columns.values)
+ if casters.any?
+ values.each do |row|
+ casters.each { |caster| row[caster.idx] = caster.cast(row[caster.idx]) }
+ end
+ end
+
+ values = dbms_type_cast(columns.values, values)
+ column_names = columns.keys.map { |key| format_case(key) }
+ result = ActiveRecord::Result.new(column_names, values)
+ end
+ end
+
+ # Executes delete +sql+ statement in the context of this connection using
+ # +binds+ as the bind substitutes. +name+ is logged along with
+ # the executed +sql+ statement.
+ def exec_delete(sql, name, binds)
+ execute(sql, name, binds)
+ end
+ alias :exec_update :exec_delete
+
+ # Begins the transaction (and turns off auto-committing).
+ def begin_db_transaction
+ @connection.autocommit = false
+ end
+
+ # Commits the transaction (and turns on auto-committing).
+ def commit_db_transaction
+ @connection.commit
+ @connection.autocommit = true
+ end
+
+ # Rolls back the transaction (and turns on auto-committing). Must be
+ # done if the transaction block raises an exception or returns false.
+ def exec_rollback_db_transaction
+ @connection.rollback
+ @connection.autocommit = true
+ end
+
+ # Returns the default sequence name for a table.
+ # Used for databases which don't support an autoincrementing column
+ # type, but do support sequences.
+ def default_sequence_name(table, _column)
+ "#{table}_seq"
+ end
+
+ private
+
+ def dbms_type_cast(columns, values)
+ values
+ end
+
+ # Assume received identifier is in DBMS's data dictionary case.
+ def format_case(identifier)
+ if database_metadata.upcase_identifiers?
+ identifier =~ /[a-z]/ ? identifier : identifier.downcase
+ else
+ identifier
+ end
+ end
+
+ # In general, ActiveRecord uses lowercase attribute names. This may
+ # conflict with the database's data dictionary case.
+ #
+ # The ODBCUTF8Adapter uses the following conventions for databases
+ # which report SQL_IDENTIFIER_CASE = SQL_IC_UPPER:
+ # * if a name is returned from the DBMS in all uppercase, convert it
+ # to lowercase before returning it to ActiveRecord.
+ # * if a name is returned from the DBMS in lowercase or mixed case,
+ # assume the underlying schema object's name was quoted when
+ # the schema object was created. Leave the name untouched before
+ # returning it to ActiveRecord.
+ # * before making an ODBC catalog call, if a supplied identifier is all
+ # lowercase, convert it to uppercase. Leave mixed case or all
+ # uppercase identifiers unchanged.
+ # * columns created with quoted lowercase names are not supported.
+ #
+ # Converts an identifier to the case conventions used by the DBMS.
+ # Assume received identifier is in ActiveRecord case.
+ def native_case(identifier)
+ if database_metadata.upcase_identifiers?
+ identifier =~ /[A-Z]/ ? identifier : identifier.upcase
+ else
+ identifier
+ end
+ end
+
+ # Assume column is nullable if nullable == SQL_NULLABLE_UNKNOWN
+ def nullability(col_name, is_nullable, nullable)
+ not_nullable = (!is_nullable || nullable.to_s.match('NO') != nil)
+ result = !(not_nullable || nullable == SQL_NO_NULLS)
+
+ # HACK!
+ # MySQL native ODBC driver doesn't report nullability accurately.
+ # So force nullability of 'id' columns
+ col_name == 'id' ? false : result
+ end
+
+ def prepared_binds(binds)
+ prepare_binds_for_database(binds).map { |bind| _type_cast(bind) }
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/error.rb b/lib/odbc_utf8_adapter/error.rb
new file mode 100644
index 00000000..8d3b944c
--- /dev/null
+++ b/lib/odbc_utf8_adapter/error.rb
@@ -0,0 +1,4 @@
+module ODBCUTF8Adapter
+ class QueryTimeoutError < ActiveRecord::StatementInvalid
+ end
+end
diff --git a/lib/odbc_utf8_adapter/quoting.rb b/lib/odbc_utf8_adapter/quoting.rb
new file mode 100644
index 00000000..1e4f2704
--- /dev/null
+++ b/lib/odbc_utf8_adapter/quoting.rb
@@ -0,0 +1,42 @@
+module ODBCUTF8Adapter
+ module Quoting
+ # Quotes a string, escaping any ' (single quote) characters.
+ def quote_string(string)
+ string.gsub(/\'/, "''")
+ end
+
+ # Returns a quoted form of the column name.
+ def quote_column_name(name)
+ name = name.to_s
+ quote_char = database_metadata.identifier_quote_char.to_s.strip
+
+ return name if quote_char.length.zero?
+ quote_char = quote_char[0]
+
+ # Avoid quoting any already quoted name
+ return name if name[0] == quote_char && name[-1] == quote_char
+
+ # If upcase identifiers, only quote mixed case names.
+ if database_metadata.upcase_identifiers?
+ return name unless (name =~ /([A-Z]+[a-z])|([a-z]+[A-Z])/)
+ end
+
+ "#{quote_char.chr}#{name}#{quote_char.chr}"
+ end
+
+ # Ideally, we'd return an ODBC date or timestamp literal escape
+ # sequence, but not all ODBC drivers support them.
+ def quoted_date(value)
+ if value.acts_like?(:time)
+ zone_conversion_method = ActiveRecord::Base.default_timezone == :utc ? :getutc : :getlocal
+
+ if value.respond_to?(zone_conversion_method)
+ value = value.send(zone_conversion_method)
+ end
+ value.strftime("%Y-%m-%d %H:%M:%S") # Time, DateTime
+ else
+ value.strftime("%Y-%m-%d") # Date
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/registry.rb b/lib/odbc_utf8_adapter/registry.rb
new file mode 100644
index 00000000..306a55ee
--- /dev/null
+++ b/lib/odbc_utf8_adapter/registry.rb
@@ -0,0 +1,50 @@
+module ODBCUTF8Adapter
+ class Registry
+ attr_reader :dbs
+
+ def initialize
+ @dbs = {
+ /my.*sql/i => :MySQL,
+ /postgres/i => :PostgreSQL
+ }
+ end
+
+ def adapter_for(reported_name)
+ reported_name = reported_name.downcase.gsub(/\s/, '')
+ found =
+ dbs.detect do |pattern, adapter|
+ adapter if reported_name =~ pattern
+ end
+
+ normalize_adapter(found && found.last || :Null)
+ end
+
+ def register(pattern, superclass = Object, &block)
+ dbs[pattern] = Class.new(superclass, &block)
+ end
+
+ private
+
+ def normalize_adapter(adapter)
+ return adapter unless adapter.is_a?(Symbol)
+ require "odbc_utf8_adapter/adapters/#{adapter.downcase}_odbc_utf8_adapter"
+ Adapters.const_get(:"#{adapter}ODBCUTF8Adapter")
+ end
+ end
+
+ class << self
+ def adapter_for(reported_name)
+ registry.adapter_for(reported_name)
+ end
+
+ def register(pattern, superclass = Object, &block)
+ registry.register(pattern, superclass, &block)
+ end
+
+ private
+
+ def registry
+ @registry ||= Registry.new
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/schema_statements.rb b/lib/odbc_utf8_adapter/schema_statements.rb
new file mode 100644
index 00000000..9251fe5a
--- /dev/null
+++ b/lib/odbc_utf8_adapter/schema_statements.rb
@@ -0,0 +1,128 @@
+module ODBCUTF8Adapter
+ module SchemaStatements
+ # Returns a Hash of mappings from the abstract data types to the native
+ # database types. See TableDefinition#column for details on the recognized
+ # abstract data types.
+ def native_database_types
+ @native_database_types ||= ColumnMetadata.new(self).native_database_types
+ end
+
+ # Returns an array of table names, for database tables visible on the
+ # current connection.
+ def tables(_name = nil)
+ stmt = @connection.tables
+ result = stmt.fetch_all || []
+ stmt.drop
+
+ result.each_with_object([]) do |row, table_names|
+ schema_name, table_name, table_type = row[1..3]
+ next if respond_to?(:table_filtered?) && table_filtered?(schema_name, table_type)
+ table_names << format_case(table_name)
+ end
+ end
+
+ # Returns an array of view names defined in the database.
+ def views
+ []
+ end
+
+ # Returns an array of indexes for the given table.
+ def indexes(table_name, name = nil)
+ stmt = @connection.indexes(native_case(table_name.to_s))
+ result = stmt.fetch_all || []
+ stmt.drop unless stmt.nil?
+
+ index_cols = []
+ index_name = nil
+ unique = nil
+
+ result.each_with_object([]).with_index do |(row, indices), row_idx|
+ # Skip table statistics
+ next if row[6] == 0 # SQLStatistics: TYPE
+
+ if row[7] == 1 # SQLStatistics: ORDINAL_POSITION
+ # Start of column descriptor block for next index
+ index_cols = []
+ unique = row[3].zero? # SQLStatistics: NON_UNIQUE
+ index_name = String.new(row[5]) # SQLStatistics: INDEX_NAME
+ end
+
+ index_cols << format_case(row[8]) # SQLStatistics: COLUMN_NAME
+ next_row = result[row_idx + 1]
+
+ if (row_idx == result.length - 1) || (next_row[6] == 0 || next_row[7] == 1)
+ indices << ActiveRecord::ConnectionAdapters::IndexDefinition.new(table_name, format_case(index_name), unique, index_cols)
+ end
+ end
+ end
+
+ # Returns an array of Column objects for the table specified by
+ # +table_name+.
+ def columns(table_name, name = nil)
+ stmt = @connection.columns(native_case(table_name.to_s))
+ result = stmt.fetch_all || []
+ stmt.drop
+
+ result.each_with_object([]) do |col, cols|
+ col_name = col[3] # SQLColumns: COLUMN_NAME
+ col_default = col[12] # SQLColumns: COLUMN_DEF
+ col_sql_type = col[4] # SQLColumns: DATA_TYPE
+ col_native_type = col[5] # SQLColumns: TYPE_NAME
+ col_limit = col[6] # SQLColumns: COLUMN_SIZE
+ col_scale = col[8] # SQLColumns: DECIMAL_DIGITS
+
+ # SQLColumns: IS_NULLABLE, SQLColumns: NULLABLE
+ col_nullable = nullability(col_name, col[17], col[10])
+
+ args = { sql_type: col_sql_type, type: col_sql_type, limit: col_limit }
+ args[:sql_type] = 'boolean' if col_native_type == self.class::BOOLEAN_TYPE
+
+ if [ODBC_UTF8::SQL_DECIMAL, ODBC_UTF8::SQL_NUMERIC].include?(col_sql_type)
+ args[:scale] = col_scale || 0
+ args[:precision] = col_limit
+ end
+ sql_type_metadata = ActiveRecord::ConnectionAdapters::SqlTypeMetadata.new(**args)
+
+ cols << new_column(format_case(col_name), col_default, sql_type_metadata, col_nullable, table_name, col_native_type)
+ end
+ end
+
+ # Returns just a table's primary key
+ def primary_key(table_name)
+ stmt = @connection.primary_keys(native_case(table_name.to_s))
+ result = stmt.fetch_all || []
+ stmt.drop unless stmt.nil?
+ result[0] && result[0][3]
+ end
+
+ def foreign_keys(table_name)
+ stmt = @connection.foreign_keys(native_case(table_name.to_s))
+ result = stmt.fetch_all || []
+ stmt.drop unless stmt.nil?
+
+ result.map do |key|
+ fk_from_table = key[2] # PKTABLE_NAME
+ fk_to_table = key[6] # FKTABLE_NAME
+
+ ActiveRecord::ConnectionAdapters::ForeignKeyDefinition.new(fk_from_table, fk_to_table,
+ name: key[11], # FK_NAME
+ column: key[3], # PKCOLUMN_NAME
+ primary_key: key[7], # FKCOLUMN_NAME
+ on_delete: key[10], # DELETE_RULE
+ on_update: key[9] # UPDATE_RULE
+ )
+ end
+ end
+
+ # Ensure it's shorter than the maximum identifier length for the current
+ # dbms
+ def index_name(table_name, options)
+ maximum = database_metadata.max_identifier_len || 255
+ super(table_name, options)[0...maximum]
+ end
+
+ def current_database
+ database_metadata.database_name.strip
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/type_caster.rb b/lib/odbc_utf8_adapter/type_caster.rb
new file mode 100644
index 00000000..c52bf183
--- /dev/null
+++ b/lib/odbc_utf8_adapter/type_caster.rb
@@ -0,0 +1,42 @@
+module ODBCUTF8Adapter
+ class TypeCaster
+ # When fetching a result set, the Ruby ODBC driver converts all ODBC
+ # SQL types to an equivalent Ruby type; with the exception of
+ # SQL_DATE, SQL_TIME and SQL_TIMESTAMP.
+ TYPES = [
+ ODBC_UTF8::SQL_DATE,
+ ODBC_UTF8::SQL_TIME,
+ ODBC_UTF8::SQL_TIMESTAMP
+ ]
+
+ attr_reader :idx
+
+ def initialize(idx)
+ @idx = idx
+ end
+
+ def cast(value)
+ case value
+ when ODBC_UTF8::TimeStamp
+ Time.gm(value.year, value.month, value.day, value.hour, value.minute, value.second)
+ when ODBC_UTF8::Time
+ now = DateTime.now
+ Time.gm(now.year, now.month, now.day, value.hour, value.minute, value.second)
+ when ODBC_UTF8::Date
+ Date.new(value.year, value.month, value.day)
+ else
+ value
+ end
+ rescue
+ # Handle pre-epoch dates
+ DateTime.new(value.year, value.month, value.day, value.hour, value.minute, value.second)
+ end
+
+ # Build a list of casters from a list of columns
+ def self.build_from(columns)
+ columns.each_with_index.each_with_object([]) do |(column, idx), casters|
+ casters << new(idx) if TYPES.include?(column.type)
+ end
+ end
+ end
+end
diff --git a/lib/odbc_utf8_adapter/version.rb b/lib/odbc_utf8_adapter/version.rb
new file mode 100644
index 00000000..6f49d8f0
--- /dev/null
+++ b/lib/odbc_utf8_adapter/version.rb
@@ -0,0 +1,3 @@
+module ODBCUTF8Adapter
+ VERSION = '5.0.2'
+end