From b1fc497f7a3c2986d6a57af544641bde261cca79 Mon Sep 17 00:00:00 2001 From: Jonathan McKenzie Date: Thu, 10 Jan 2019 11:05:03 -0500 Subject: [PATCH 1/4] Adding utf8-enabled odbc adapter --- .../connection_adapters/odbc_utf8_adapter.rb | 200 ++++++++++++++++++ lib/odbc_utf8_adapter.rb | 2 + 2 files changed, 202 insertions(+) create mode 100644 lib/active_record/connection_adapters/odbc_utf8_adapter.rb create mode 100644 lib/odbc_utf8_adapter.rb 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..3c6f06dd --- /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_adapter/database_limits' +require 'odbc_adapter/database_statements' +require 'odbc_adapter/error' +require 'odbc_adapter/quoting' +require 'odbc_adapter/schema_statements' + +require 'odbc_adapter/column' +require 'odbc_adapter/column_metadata' +require 'odbc_adapter/database_metadata' +require 'odbc_adapter/registry' +require 'odbc_adapter/version' + +module ActiveRecord + class Base + class << self + # Build a new ODBC connection with the given configuration. + def odbc_connection(config) + config = config.symbolize_keys + + connection, config = + if config.key?(:dsn) + odbc_dsn_connection(config) + elsif config.key?(:conn_str) + odbc_conn_str_connection(config) + else + raise ArgumentError, 'No data source name (:dsn) or connection string (:conn_str) specified.' + end + + database_metadata = ::ODBCAdapter::DatabaseMetadata.new(connection) + database_metadata.adapter_class.new(connection, logger, config, database_metadata) + end + + private + + # Connect using a predefined DSN. + def odbc_dsn_connection(config) + username = config[:username] ? config[:username].to_s : nil + password = config[:password] ? config[:password].to_s : nil + connection = ODBC.connect(config[:dsn], username, password) + [connection, config.merge(username: username, password: password)] + end + + # Connect using ODBC connection string + # Supports DSN-based or DSN-less connections + # e.g. "DSN=virt5;UID=rails;PWD=rails" + # "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails" + def odbc_conn_str_connection(config) + driver = ODBC::Driver.new + driver.name = 'odbc' + driver.attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h + + connection = ODBC::Database.new.drvconnect(driver) + [connection, config.merge(driver: driver)] + end + end + end + + module ConnectionAdapters + class ODBCAdapter < AbstractAdapter + include ::ODBCAdapter::DatabaseLimits + include ::ODBCAdapter::DatabaseStatements + include ::ODBCAdapter::Quoting + include ::ODBCAdapter::SchemaStatements + + ADAPTER_NAME = 'ODBC'.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.connect(@config[:dsn], @config[:username], @config[:password]) + else + ODBC::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) + ::ODBCAdapter::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::SQL_CHAR, Type::String.new + map.register_type ODBC::SQL_LONGVARCHAR, Type::Text.new + map.register_type ODBC::SQL_TINYINT, Type::Integer.new(limit: 4) + map.register_type ODBC::SQL_SMALLINT, Type::Integer.new(limit: 8) + map.register_type ODBC::SQL_INTEGER, Type::Integer.new(limit: 16) + map.register_type ODBC::SQL_BIGINT, Type::BigInteger.new(limit: 32) + map.register_type ODBC::SQL_REAL, Type::Float.new(limit: 24) + map.register_type ODBC::SQL_FLOAT, Type::Float.new + map.register_type ODBC::SQL_DOUBLE, Type::Float.new(limit: 53) + map.register_type ODBC::SQL_DECIMAL, Type::Float.new + map.register_type ODBC::SQL_NUMERIC, Type::Integer.new + map.register_type ODBC::SQL_BINARY, Type::Binary.new + map.register_type ODBC::SQL_DATE, Type::Date.new + map.register_type ODBC::SQL_DATETIME, Type::DateTime.new + map.register_type ODBC::SQL_TIME, Type::Time.new + map.register_type ODBC::SQL_TIMESTAMP, Type::DateTime.new + map.register_type ODBC::SQL_GUID, Type::String.new + + alias_type map, ODBC::SQL_BIT, 'boolean' + alias_type map, ODBC::SQL_VARCHAR, ODBC::SQL_CHAR + alias_type map, ODBC::SQL_WCHAR, ODBC::SQL_CHAR + alias_type map, ODBC::SQL_WVARCHAR, ODBC::SQL_CHAR + alias_type map, ODBC::SQL_WLONGVARCHAR, ODBC::SQL_LONGVARCHAR + alias_type map, ODBC::SQL_VARBINARY, ODBC::SQL_BINARY + alias_type map, ODBC::SQL_LONGVARBINARY, ODBC::SQL_BINARY + alias_type map, ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE + alias_type map, ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME + alias_type map, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP + 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_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' From 8f2ee1cc39f2e273b3714d4498c238824fcba16a Mon Sep 17 00:00:00 2001 From: Jonathan McKenzie Date: Thu, 10 Jan 2019 11:08:30 -0500 Subject: [PATCH 2/4] Fix gem --- lib/active_record/connection_adapters/odbc_utf8_adapter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb index 3c6f06dd..c2fa7ba0 100644 --- a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb +++ b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb @@ -1,6 +1,6 @@ require 'active_record' require 'arel/visitors/bind_visitor' -require 'odbc_utf8' +require 'odbc_utf8_ext' require 'odbc_adapter/database_limits' require 'odbc_adapter/database_statements' From 53f6aa47463557ca193aadb28b823d6f174c98b1 Mon Sep 17 00:00:00 2001 From: Jonathan McKenzie Date: Mon, 14 Jan 2019 13:03:16 -0500 Subject: [PATCH 3/4] Split out into new adapter, update to use ODBC_UTF8 from odbc_utf8 --- .../connection_adapters/odbc_utf8_adapter.rb | 116 +++++------ lib/odbc_adapter/type_caster.rb | 42 ++++ .../adapters/mysql_odbc_utf8_adapter.rb | 156 ++++++++++++++ .../adapters/null_odbc_utf8_adapter.rb | 31 +++ .../adapters/postgresql_odbc_utf8_adapter.rb | 190 ++++++++++++++++++ lib/odbc_utf8_adapter/column.rb | 10 + lib/odbc_utf8_adapter/column_metadata.rb | 76 +++++++ lib/odbc_utf8_adapter/database_limits.rb | 10 + lib/odbc_utf8_adapter/database_metadata.rb | 44 ++++ lib/odbc_utf8_adapter/database_statements.rb | 138 +++++++++++++ lib/odbc_utf8_adapter/error.rb | 4 + lib/odbc_utf8_adapter/quoting.rb | 42 ++++ lib/odbc_utf8_adapter/registry.rb | 50 +++++ lib/odbc_utf8_adapter/schema_statements.rb | 128 ++++++++++++ lib/odbc_utf8_adapter/type_caster.rb | 42 ++++ lib/odbc_utf8_adapter/version.rb | 3 + 16 files changed, 1024 insertions(+), 58 deletions(-) create mode 100644 lib/odbc_adapter/type_caster.rb create mode 100644 lib/odbc_utf8_adapter/adapters/mysql_odbc_utf8_adapter.rb create mode 100644 lib/odbc_utf8_adapter/adapters/null_odbc_utf8_adapter.rb create mode 100644 lib/odbc_utf8_adapter/adapters/postgresql_odbc_utf8_adapter.rb create mode 100644 lib/odbc_utf8_adapter/column.rb create mode 100644 lib/odbc_utf8_adapter/column_metadata.rb create mode 100644 lib/odbc_utf8_adapter/database_limits.rb create mode 100644 lib/odbc_utf8_adapter/database_metadata.rb create mode 100644 lib/odbc_utf8_adapter/database_statements.rb create mode 100644 lib/odbc_utf8_adapter/error.rb create mode 100644 lib/odbc_utf8_adapter/quoting.rb create mode 100644 lib/odbc_utf8_adapter/registry.rb create mode 100644 lib/odbc_utf8_adapter/schema_statements.rb create mode 100644 lib/odbc_utf8_adapter/type_caster.rb create mode 100644 lib/odbc_utf8_adapter/version.rb diff --git a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb index c2fa7ba0..7bb0b7f7 100644 --- a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb +++ b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb @@ -2,45 +2,45 @@ require 'arel/visitors/bind_visitor' require 'odbc_utf8_ext' -require 'odbc_adapter/database_limits' -require 'odbc_adapter/database_statements' -require 'odbc_adapter/error' -require 'odbc_adapter/quoting' -require 'odbc_adapter/schema_statements' - -require 'odbc_adapter/column' -require 'odbc_adapter/column_metadata' -require 'odbc_adapter/database_metadata' -require 'odbc_adapter/registry' -require 'odbc_adapter/version' +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_connection(config) + def odbc_utf8_connection(config) config = config.symbolize_keys connection, config = if config.key?(:dsn) - odbc_dsn_connection(config) + odbc_utf8_dsn_connection(config) elsif config.key?(:conn_str) - odbc_conn_str_connection(config) + odbc_utf8_conn_str_connection(config) else raise ArgumentError, 'No data source name (:dsn) or connection string (:conn_str) specified.' end - database_metadata = ::ODBCAdapter::DatabaseMetadata.new(connection) + 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_dsn_connection(config) + def odbc_utf8_dsn_connection(config) username = config[:username] ? config[:username].to_s : nil password = config[:password] ? config[:password].to_s : nil - connection = ODBC.connect(config[:dsn], username, password) + connection = ODBC_UTF8.connect(config[:dsn], username, password) [connection, config.merge(username: username, password: password)] end @@ -48,25 +48,25 @@ def odbc_dsn_connection(config) # Supports DSN-based or DSN-less connections # e.g. "DSN=virt5;UID=rails;PWD=rails" # "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails" - def odbc_conn_str_connection(config) - driver = ODBC::Driver.new - driver.name = 'odbc' + 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::Database.new.drvconnect(driver) + connection = ODBC_UTF8::Database.new.drvconnect(driver) [connection, config.merge(driver: driver)] end end end module ConnectionAdapters - class ODBCAdapter < AbstractAdapter - include ::ODBCAdapter::DatabaseLimits - include ::ODBCAdapter::DatabaseStatements - include ::ODBCAdapter::Quoting - include ::ODBCAdapter::SchemaStatements + class ODBCUTF8Adapter < AbstractAdapter + include ::ODBCUTF8Adapter::DatabaseLimits + include ::ODBCUTF8Adapter::DatabaseStatements + include ::ODBCUTF8Adapter::Quoting + include ::ODBCUTF8Adapter::SchemaStatements - ADAPTER_NAME = 'ODBC'.freeze + ADAPTER_NAME = 'ODBC_UTF8'.freeze BOOLEAN_TYPE = 'BOOLEAN'.freeze ERR_DUPLICATE_KEY_VALUE = 23_505 @@ -109,9 +109,9 @@ def reconnect! disconnect! @connection = if @config.key?(:dsn) - ODBC.connect(@config[:dsn], @config[:username], @config[:password]) + ODBC_UTF8.connect(@config[:dsn], @config[:username], @config[:password]) else - ODBC::Database.new.drvconnect(@config[:driver]) + ODBC_UTF8::Database.new.drvconnect(@config[:driver]) end configure_time_options(@connection) super @@ -128,7 +128,7 @@ def disconnect! # as super except that it also passes in the native type. # rubocop:disable Metrics/ParameterLists def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil, native_type = nil) - ::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type) + ::ODBCUTF8Adapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type) end protected @@ -136,34 +136,34 @@ def new_column(name, default, sql_type_metadata, null, table_name, default_funct # Build the type map for ActiveRecord def initialize_type_map(map) map.register_type 'boolean', Type::Boolean.new - map.register_type ODBC::SQL_CHAR, Type::String.new - map.register_type ODBC::SQL_LONGVARCHAR, Type::Text.new - map.register_type ODBC::SQL_TINYINT, Type::Integer.new(limit: 4) - map.register_type ODBC::SQL_SMALLINT, Type::Integer.new(limit: 8) - map.register_type ODBC::SQL_INTEGER, Type::Integer.new(limit: 16) - map.register_type ODBC::SQL_BIGINT, Type::BigInteger.new(limit: 32) - map.register_type ODBC::SQL_REAL, Type::Float.new(limit: 24) - map.register_type ODBC::SQL_FLOAT, Type::Float.new - map.register_type ODBC::SQL_DOUBLE, Type::Float.new(limit: 53) - map.register_type ODBC::SQL_DECIMAL, Type::Float.new - map.register_type ODBC::SQL_NUMERIC, Type::Integer.new - map.register_type ODBC::SQL_BINARY, Type::Binary.new - map.register_type ODBC::SQL_DATE, Type::Date.new - map.register_type ODBC::SQL_DATETIME, Type::DateTime.new - map.register_type ODBC::SQL_TIME, Type::Time.new - map.register_type ODBC::SQL_TIMESTAMP, Type::DateTime.new - map.register_type ODBC::SQL_GUID, Type::String.new - - alias_type map, ODBC::SQL_BIT, 'boolean' - alias_type map, ODBC::SQL_VARCHAR, ODBC::SQL_CHAR - alias_type map, ODBC::SQL_WCHAR, ODBC::SQL_CHAR - alias_type map, ODBC::SQL_WVARCHAR, ODBC::SQL_CHAR - alias_type map, ODBC::SQL_WLONGVARCHAR, ODBC::SQL_LONGVARCHAR - alias_type map, ODBC::SQL_VARBINARY, ODBC::SQL_BINARY - alias_type map, ODBC::SQL_LONGVARBINARY, ODBC::SQL_BINARY - alias_type map, ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE - alias_type map, ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME - alias_type map, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP + map.register_type 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 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/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 From 95027b24cd70a63435614b088e229a08dbdf6fbc Mon Sep 17 00:00:00 2001 From: Jonathan McKenzie Date: Mon, 14 Jan 2019 13:04:07 -0500 Subject: [PATCH 4/4] Fix gem name --- lib/active_record/connection_adapters/odbc_utf8_adapter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb index 7bb0b7f7..41640c44 100644 --- a/lib/active_record/connection_adapters/odbc_utf8_adapter.rb +++ b/lib/active_record/connection_adapters/odbc_utf8_adapter.rb @@ -1,6 +1,6 @@ require 'active_record' require 'arel/visitors/bind_visitor' -require 'odbc_utf8_ext' +require 'odbc_utf8' require 'odbc_utf8_adapter/database_limits' require 'odbc_utf8_adapter/database_statements'