diff --git a/.rubocop.yml b/.rubocop.yml index 9055a997..bb8a8d65 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -1,35 +1,28 @@ +require: + - rubocop-rake + - rubocop-minitest + +inherit_from: .rubocop_todo.yml + AllCops: + NewCops: enable DisplayCopNames: true DisplayStyleGuide: true - TargetRubyVersion: 2.1 + TargetRubyVersion: 2.7 Exclude: - 'vendor/**/*' -Lint/AmbiguousBlockAssociation: - Enabled: false - -Metrics/AbcSize: - Enabled: false - -Metrics/ClassLength: - Enabled: false - -Metrics/CyclomaticComplexity: +Minitest/MultipleAssertions: Enabled: false Metrics/MethodLength: Enabled: false -Metrics/LineLength: - Enabled: false - -Metrics/PerceivedComplexity: +Style/Documentation: Enabled: false -Style/Documentation: +Metrics/ClassLength: Enabled: false -Style/PercentLiteralDelimiters: - PreferredDelimiters: - default: '[]' - '%r': '{}' +Layout/LineLength: + Max: 170 diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml new file mode 100644 index 00000000..ff62286e --- /dev/null +++ b/.rubocop_todo.yml @@ -0,0 +1,36 @@ +# This configuration was generated by +# `rubocop --auto-gen-config` +# on 2023-03-17 05:40:41 UTC using RuboCop version 1.48.1. +# The point is for the user to remove these configuration records +# one by one as the offenses are removed from the code base. +# Note that changes in the inspected code, or installation of new +# versions of RuboCop, may require this file to be generated again. + +# Offense count: 2 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. +Metrics/AbcSize: + Max: 32 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/CyclomaticComplexity: + Max: 9 + +# Offense count: 2 +# Configuration parameters: Max, CountKeywordArgs. +Metrics/ParameterLists: + MaxOptionalParameters: 4 + +# Offense count: 1 +# Configuration parameters: AllowedMethods, AllowedPatterns. +Metrics/PerceivedComplexity: + Max: 9 + +# Offense count: 5 +# Configuration parameters: MinNameLength, AllowNamesEndingInNumbers, AllowedNames, ForbiddenNames. +# AllowedNames: as, at, by, cc, db, id, if, in, io, ip, of, on, os, pp, to +Naming/MethodParameterName: + Exclude: + - 'lib/active_record/connection_adapters/odbc_adapter.rb' + - 'lib/odbc_adapter/adapters/mysql_odbc_adapter.rb' + - 'lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb' diff --git a/Gemfile b/Gemfile index c0cf8f42..7f4f5e95 100644 --- a/Gemfile +++ b/Gemfile @@ -1,6 +1,5 @@ +# frozen_string_literal: true + source 'https://rubygems.org' gemspec - -gem 'activerecord', '5.0.1' -gem 'pry', '~> 0.11.1' diff --git a/README.md b/README.md index 0f5b5519..36895050 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,6 @@ # ODBCAdapter -[![Build Status](https://travis-ci.org/localytics/odbc_adapter.svg?branch=master)](https://travis-ci.org/localytics/odbc_adapter) -[![Gem](https://img.shields.io/gem/v/odbc_adapter.svg)](https://rubygems.org/gems/odbc_adapter) - -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. +An ActiveRecord ODBC adapter. Pattern has made some updates to get this working with Rails 6 as forked from repo was not actively being maintained to do so. 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. @@ -70,6 +67,93 @@ docker run -it --rm -v $(pwd):/workspace -v /tmp/mysql:/var/lib/mysql odbc-dev:l docker/test.sh ``` +## Datatype References + + +Reference https://docs.snowflake.com/en/sql-reference/intro-summary-data-types + +https://github.com/rails/rails/blob/main/activerecord/lib/active_record/connection_adapters/abstract_adapter.rb#L877-L908 + +``` +["number", + "decimal", + "numeric", + "int", + "integer", + "bigint", + "smallint", + "tinyint", + "byteint", + "float", + "float4", + "float8", + "double", + "real", + "varchar", + "char", + "character", + "string", + "text", + "binary", + "varbinary", + "boolean", + "date", + "datetime", + "time", + "timestamp", + "timestamp_ltz", + "timestamp_ntz", + "timestamp_tz", + "variant", + "object", + "array", + "geography", + "geometry"] +``` + + +Possible mapping from chatgpt + +| Snowflake Data Type | Ruby ActiveRecord Type | PostgreSQL Type | +| --------------------- | ---------------------- | ---------------- | +| number | :decimal | numeric +| decimal | :decimal | numeric +| numeric | :decimal | numeric +| int | :integer | integer +| integer | :integer | integer +| bigint | :bigint | bigint +| smallint | :integer | smallint +| tinyint | :integer | smallint +| byteint | :integer | smallint +| float | :float | double precision +| float4 | :float | real +| float8 | :float | double precision +| double | :float | double precision +| real | :float | real +| varchar | :string | character varying +| char | :string | character +| character | :string | character +| string | :string | character varying +| text | :text | text +| binary | :binary | bytea +| varbinary | :binary | bytea +| boolean | :boolean | boolean +| date | :date | date +| datetime | :datetime | timestamp without time zone +| time | :time | time without time zone +| timestamp | :timestamp | timestamp without time zone +| timestamp_ltz | :timestamp | timestamp without time zone +| timestamp_ntz | :timestamp | timestamp without time zone +| timestamp_tz | :timestamp | timestamp with time zone +| variant | :jsonb | jsonb +| object | :jsonb | jsonb +| array | :jsonb | jsonb +| geography | :st_point, :st_polygon,| geography +| | :st_multipolygon | +| geometry | :st_point, :st_polygon,| geometry +| | :st_multipolygon | + + ## Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/localytics/odbc_adapter. diff --git a/Rakefile b/Rakefile index 6af9c2b8..7a327cb5 100644 --- a/Rakefile +++ b/Rakefile @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'bundler/gem_tasks' require 'rake/testtask' require 'rubocop/rake_task' diff --git a/bin/console b/bin/console index 7853a78b..39ac58ab 100755 --- a/bin/console +++ b/bin/console @@ -1,4 +1,5 @@ #!/usr/bin/env ruby +# frozen_string_literal: true require 'bundler/setup' require 'odbc_adapter' diff --git a/lib/active_record/connection_adapters/odbc_adapter.rb b/lib/active_record/connection_adapters/odbc_adapter.rb index 06686785..9dac72e2 100644 --- a/lib/active_record/connection_adapters/odbc_adapter.rb +++ b/lib/active_record/connection_adapters/odbc_adapter.rb @@ -1,5 +1,6 @@ +# frozen_string_literal: true + require 'active_record' -require 'arel/visitors/bind_visitor' require 'odbc' require 'odbc_utf8' @@ -39,8 +40,8 @@ def odbc_connection(config) # 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 + username = config[:username]&.to_s + password = config[:password]&.to_s odbc_module = config[:encoding] == 'utf8' ? ODBC_UTF8 : ODBC connection = odbc_module.connect(config[:dsn], username, password) @@ -53,7 +54,7 @@ def odbc_dsn_connection(config) # e.g. "DSN=virt5;UID=rails;PWD=rails" # "DRIVER={OpenLink Virtuoso};HOST=carlmbp;UID=rails;PWD=rails" def odbc_conn_str_connection(config) - attrs = config[:conn_str].split(';').map { |option| option.split('=', 2) }.to_h + attrs = config[:conn_str].split(';').to_h { |option| option.split('=', 2) } odbc_module = attrs['ENCODING'] == 'utf8' ? ODBC_UTF8 : ODBC driver = odbc_module::Driver.new driver.name = 'odbc' @@ -61,7 +62,8 @@ def odbc_conn_str_connection(config) connection = odbc_module::Database.new.drvconnect(driver) # encoding_bug indicates that the driver is using non ASCII and has the issue referenced here https://github.com/larskanis/ruby-odbc/issues/2 - [connection, config.merge(driver: driver, encoding: attrs['ENCODING'], encoding_bug: attrs['ENCODING'] == 'utf8')] + [connection, + config.merge(driver: driver, encoding: attrs['ENCODING'], encoding_bug: attrs['ENCODING'] == 'utf8')] end end end @@ -73,14 +75,14 @@ class ODBCAdapter < AbstractAdapter include ::ODBCAdapter::Quoting include ::ODBCAdapter::SchemaStatements - ADAPTER_NAME = 'ODBC'.freeze - BOOLEAN_TYPE = 'BOOLEAN'.freeze + ADAPTER_NAME = 'ODBC' + VARIANT_TYPE = 'VARIANT' ERR_DUPLICATE_KEY_VALUE = 23_505 ERR_QUERY_TIMED_OUT = 57_014 - ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/ - ERR_CONNECTION_FAILED_REGEX = '^08[0S]0[12347]'.freeze - ERR_CONNECTION_FAILED_MESSAGE = /Client connection failed/ + ERR_QUERY_TIMED_OUT_MESSAGE = /Query has timed out/.freeze + ERR_CONNECTION_FAILED_REGEX = '^08[0S]0[12347]' + ERR_CONNECTION_FAILED_MESSAGE = /Client connection failed/.freeze # The object that stores the information that is fetched from the DBMS # when a connection is first established. @@ -137,49 +139,46 @@ def disconnect! # Build a new column object from the given options. Effectively the same # as super except that it also passes in the native type. # rubocop:disable Metrics/ParameterLists - def new_column(name, default, sql_type_metadata, null, table_name, default_function = nil, collation = nil, native_type = nil) - ::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, table_name, default_function, collation, native_type) + def new_column(name, default, sql_type_metadata, null, table_name, native_type = nil) + ::ODBCAdapter::Column.new(name, default, sql_type_metadata, null, table_name, native_type) + end + # rubocop:enable Metrics/ParameterLists + + def clear_cache! # :nodoc: + reload_type_map + super end protected # Build the type map for ActiveRecord # Here, ODBC and ODBC_UTF8 constants are interchangeable - 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 + def initialize_type_map(m = type_map) + super + + m.register_type(/bigint/i, Type::BigInteger.new) + m.alias_type 'float4', 'float' + m.alias_type 'float8', 'float' + m.alias_type 'double', 'float' + m.alias_type 'number', 'decimal' + m.alias_type 'numeric', 'decimal' + m.alias_type 'real', 'float' + m.alias_type 'string', 'char' + m.alias_type 'bool', 'boolean' + m.alias_type 'varbinary', 'binary' + m.alias_type 'variant', 'json' + m.alias_type 'object', 'string' + m.alias_type 'array', 'string' + m.alias_type 'geography', 'char' + m.alias_type 'geometry', 'char' + + # number() data types in Snowflake are interpreted as decimal and must be mapped back to a float + m.alias_type 'decimal', 'float' end # Translate an exception from the native DBMS to something usable by # ActiveRecord. - def translate_exception(exception, message) + def translate_exception(exception, **message) error_number = exception.message[/^\d+/].to_i if error_number == ERR_DUPLICATE_KEY_VALUE @@ -190,7 +189,7 @@ def translate_exception(exception, message) begin reconnect! ::ODBCAdapter::ConnectionFailedError.new(message, exception) - rescue => e + rescue StandardError => e puts "unable to reconnect #{e}" end else diff --git a/lib/odbc_adapter.rb b/lib/odbc_adapter.rb index 194fb562..2d2a16cc 100644 --- a/lib/odbc_adapter.rb +++ b/lib/odbc_adapter.rb @@ -1,2 +1,4 @@ +# frozen_string_literal: true + # Requiring with this pattern to mirror ActiveRecord require 'active_record/connection_adapters/odbc_adapter' diff --git a/lib/odbc_adapter/adapters/mysql_odbc_adapter.rb b/lib/odbc_adapter/adapters/mysql_odbc_adapter.rb index eaa690ef..c4a4a2fe 100644 --- a/lib/odbc_adapter/adapters/mysql_odbc_adapter.rb +++ b/lib/odbc_adapter/adapters/mysql_odbc_adapter.rb @@ -1,16 +1,14 @@ +# frozen_string_literal: true + module ODBCAdapter module Adapters # Overrides specific to MySQL. Mostly taken from # ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter class MySQLODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter - PRIMARY_KEY = 'INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY'.freeze - - class BindSubstitution < Arel::Visitors::MySQL - include Arel::Visitors::BindVisitor - end + PRIMARY_KEY = 'INT(11) NOT NULL AUTO_INCREMENT PRIMARY KEY' def arel_visitor - BindSubstitution.new(self) + Arel::Visitors::MySQL.new(self) end # Explicitly turning off prepared statements in the MySQL adapter because @@ -94,11 +92,10 @@ def rename_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 + options[:default] = column_for(table_name, column_name).default unless options_include_default?(options) - change_column_sql = "ALTER TABLE #{table_name} CHANGE #{column_name} #{column_name} #{type_to_sql(type, options[:limit], options[:precision], options[:scale])}" + 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 @@ -133,11 +130,7 @@ def indexes(table_name, name = nil) # 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 + options.delete(:default) if options.include?(:default) && options[:default].nil? && (options.include?(:column) && options[:column].native_type =~ /timestamp/i) super(options) end diff --git a/lib/odbc_adapter/adapters/null_odbc_adapter.rb b/lib/odbc_adapter/adapters/null_odbc_adapter.rb index 1a179905..9ddeef71 100644 --- a/lib/odbc_adapter/adapters/null_odbc_adapter.rb +++ b/lib/odbc_adapter/adapters/null_odbc_adapter.rb @@ -1,18 +1,19 @@ +# frozen_string_literal: true + module ODBCAdapter 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 NullODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter - class BindSubstitution < Arel::Visitors::ToSql - include Arel::Visitors::BindVisitor - end - + VARIANT_TYPE = 'VARIANT' + DATE_TYPE = 'DATE' + JSON_TYPE = 'JSON' # 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) + Arel::Visitors::PostgreSQL.new(self) end # Explicitly turning off prepared_statements in the null adapter because diff --git a/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb b/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb index 28a28f7c..f7f85ff5 100644 --- a/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb +++ b/lib/odbc_adapter/adapters/postgresql_odbc_adapter.rb @@ -1,10 +1,11 @@ +# frozen_string_literal: true + module ODBCAdapter module Adapters # Overrides specific to PostgreSQL. Mostly taken from # ActiveRecord::ConnectionAdapters::PostgreSQLAdapter class PostgreSQLODBCAdapter < ActiveRecord::ConnectionAdapters::ODBCAdapter - BOOLEAN_TYPE = 'bool'.freeze - PRIMARY_KEY = 'SERIAL PRIMARY KEY'.freeze + PRIMARY_KEY = 'SERIAL PRIMARY KEY' alias create insert @@ -35,7 +36,7 @@ def default_sequence_name(table_name, pk = nil) "#{table_name}_#{pk || 'id'}_seq" end - def sql_for_insert(sql, pk, _id_value, _sequence_name, binds) + def sql_for_insert(sql, pk, binds) unless pk table_ref = extract_table_ref_from_insert_sql(sql) pk = primary_key(table_ref) if table_ref @@ -50,7 +51,8 @@ def type_cast(value, column) case value when String - return super unless 'bytea' == column.native_type + return super unless column.native_type == 'bytea' + { value: value, format: 1 } else super @@ -66,7 +68,7 @@ def quote_string(string) def disable_referential_integrity execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} DISABLE TRIGGER ALL" }.join(';')) yield - ensure + ensure execute(tables.map { |name| "ALTER TABLE #{quote_table_name(name)} ENABLE TRIGGER ALL" }.join(';')) end @@ -115,7 +117,8 @@ def rename_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])}") + 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 @@ -180,9 +183,9 @@ def last_insert_id(sequence_name) private def serial_sequence(table, column) - result = exec_query(<<-eosql, 'SCHEMA') + result = exec_query(<<-EOSQL, 'SCHEMA') SELECT pg_get_serial_sequence('#{table}', '#{column}') - eosql + EOSQL result.rows.first.first end end diff --git a/lib/odbc_adapter/column.rb b/lib/odbc_adapter/column.rb index 36492a82..3093eb6f 100644 --- a/lib/odbc_adapter/column.rb +++ b/lib/odbc_adapter/column.rb @@ -1,13 +1,16 @@ +# frozen_string_literal: true + module ODBCAdapter class Column < ActiveRecord::ConnectionAdapters::Column attr_reader :native_type # Add the native_type accessor to allow the native DBMS to report back what # it uses to represent the column internally. - # rubocop:disable Metrics/ParameterLists - 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) + # rubocop:disable Metrics/ParameterLists, Style/OptionalBooleanParameter + def initialize(name, default, sql_type_metadata = nil, null = true, table_name = nil, native_type = nil) + super(name, default, sql_type_metadata, null, table_name) @native_type = native_type end + # rubocop:enable Metrics/ParameterLists, Style/OptionalBooleanParameter end end diff --git a/lib/odbc_adapter/column_metadata.rb b/lib/odbc_adapter/column_metadata.rb index 8ef89ac4..920efa7b 100644 --- a/lib/odbc_adapter/column_metadata.rb +++ b/lib/odbc_adapter/column_metadata.rb @@ -1,18 +1,20 @@ +# frozen_string_literal: true + module ODBCAdapter class ColumnMetadata GENERICS = { primary_key: [ODBC::SQL_INTEGER, ODBC::SQL_SMALLINT], - string: [ODBC::SQL_VARCHAR], - text: [ODBC::SQL_LONGVARCHAR, ODBC::SQL_VARCHAR], - integer: [ODBC::SQL_INTEGER, ODBC::SQL_SMALLINT], - decimal: [ODBC::SQL_NUMERIC, ODBC::SQL_DECIMAL], - float: [ODBC::SQL_DOUBLE, ODBC::SQL_REAL], - datetime: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], - timestamp: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], - time: [ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], - date: [ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], - binary: [ODBC::SQL_LONGVARBINARY, ODBC::SQL_VARBINARY], - boolean: [ODBC::SQL_BIT, ODBC::SQL_TINYINT, ODBC::SQL_SMALLINT, ODBC::SQL_INTEGER] + string: [ODBC::SQL_VARCHAR], + text: [ODBC::SQL_LONGVARCHAR, ODBC::SQL_VARCHAR], + integer: [ODBC::SQL_INTEGER, ODBC::SQL_SMALLINT], + decimal: [ODBC::SQL_NUMERIC, ODBC::SQL_DECIMAL], + float: [ODBC::SQL_DOUBLE, ODBC::SQL_REAL], + datetime: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], + timestamp: [ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], + time: [ODBC::SQL_TYPE_TIME, ODBC::SQL_TIME, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], + date: [ODBC::SQL_TYPE_DATE, ODBC::SQL_DATE, ODBC::SQL_TYPE_TIMESTAMP, ODBC::SQL_TIMESTAMP], + binary: [ODBC::SQL_LONGVARBINARY, ODBC::SQL_VARBINARY], + boolean: [ODBC::SQL_BIT, ODBC::SQL_TINYINT, ODBC::SQL_SMALLINT, ODBC::SQL_INTEGER] }.freeze attr_reader :adapter @@ -27,6 +29,7 @@ def native_database_types 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 @@ -41,6 +44,7 @@ def native_type_mapping(abstract, rows) # 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 @@ -69,7 +73,7 @@ def reported_types stmt = adapter.raw_connection.types stmt.fetch_all ensure - stmt.drop unless stmt.nil? + stmt&.drop end end end diff --git a/lib/odbc_adapter/database_limits.rb b/lib/odbc_adapter/database_limits.rb index bd6bf032..96aa108d 100644 --- a/lib/odbc_adapter/database_limits.rb +++ b/lib/odbc_adapter/database_limits.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ODBCAdapter module DatabaseLimits # Returns the maximum length of a table name. diff --git a/lib/odbc_adapter/database_metadata.rb b/lib/odbc_adapter/database_metadata.rb index 11fa9255..5c2814f4 100644 --- a/lib/odbc_adapter/database_metadata.rb +++ b/lib/odbc_adapter/database_metadata.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ODBCAdapter # Caches SQLGetInfo output class DatabaseMetadata @@ -17,14 +19,16 @@ class DatabaseMetadata # has_encoding_bug refers to https://github.com/larskanis/ruby-odbc/issues/2 where ruby-odbc in UTF8 mode # returns incorrectly encoded responses to getInfo + # rubocop:disable Style/OptionalBooleanParameter def initialize(connection, has_encoding_bug = false) - @values = Hash[FIELDS.map do |field| + @values = FIELDS.to_h do |field| info = connection.get_info(ODBC.const_get(field)) info = info.encode(Encoding.default_external, 'UTF-16LE') if info.is_a?(String) && has_encoding_bug [field, info] - end] + end end + # rubocop:enable Style/OptionalBooleanParameter def adapter_class ODBCAdapter.adapter_for(dbms_name) diff --git a/lib/odbc_adapter/database_statements.rb b/lib/odbc_adapter/database_statements.rb index cac31682..f8656352 100644 --- a/lib/odbc_adapter/database_statements.rb +++ b/lib/odbc_adapter/database_statements.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ODBCAdapter module DatabaseStatements # ODBC constants missing from Christian Werner's Ruby ODBC driver @@ -9,11 +11,8 @@ module DatabaseStatements # 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 + sql = bind_params(binds, sql) if prepared_statements + @connection.do(sql) end end @@ -22,12 +21,8 @@ def execute(sql, name = nil, binds = []) # the executed +sql+ statement. def exec_query(sql, name = 'SQL', binds = [], prepare: false) # rubocop:disable Lint/UnusedMethodArgument log(sql, name) do - stmt = - if prepared_statements - @connection.run(sql, *prepared_binds(binds)) - else - @connection.run(sql) - end + sql = bind_params(binds, sql) if prepared_statements + stmt = @connection.run(sql) columns = stmt.columns values = stmt.to_a @@ -81,6 +76,14 @@ def dbms_type_cast(_columns, values) values end + def bind_params(binds, sql) + prepared_binds = *prepared_binds(binds) + prepared_binds.each.with_index(1) do |val, ind| + sql = sql.gsub("$#{ind}", "'#{val}'") + end + sql + end + # Assume received identifier is in DBMS's data dictionary case. def format_case(identifier) if database_metadata.upcase_identifiers? @@ -127,8 +130,13 @@ def nullability(col_name, is_nullable, nullable) col_name == 'id' ? false : result end + # Adapt to Rails 5.2 + def prepare_statement_sub(sql) + sql.gsub(/\$\d+/, '?') + end + def prepared_binds(binds) - prepare_binds_for_database(binds).map { |bind| _type_cast(bind) } + binds.map(&:value_for_database).map { |bind| _type_cast(bind) } end end end diff --git a/lib/odbc_adapter/error.rb b/lib/odbc_adapter/error.rb index d0e0172b..6cd0eea4 100644 --- a/lib/odbc_adapter/error.rb +++ b/lib/odbc_adapter/error.rb @@ -1,6 +1,9 @@ +# frozen_string_literal: true + module ODBCAdapter class QueryTimeoutError < ActiveRecord::StatementInvalid end + class ConnectionFailedError < ActiveRecord::StatementInvalid end end diff --git a/lib/odbc_adapter/quoting.rb b/lib/odbc_adapter/quoting.rb index a499612e..fcdd056c 100644 --- a/lib/odbc_adapter/quoting.rb +++ b/lib/odbc_adapter/quoting.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + module ODBCAdapter module Quoting # Quotes a string, escaping any ' (single quote) characters. def quote_string(string) - string.gsub(/\'/, "''") + string.gsub(/'/, "''") end # Returns a quoted form of the column name. @@ -10,16 +12,15 @@ 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? + return name if quote_char.empty? + 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 + return name if database_metadata.upcase_identifiers? && name !~ /([A-Z]+[a-z])|([a-z]+[A-Z])/ "#{quote_char.chr}#{name}#{quote_char.chr}" end @@ -30,9 +31,7 @@ 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 = value.send(zone_conversion_method) if value.respond_to?(zone_conversion_method) value.strftime('%Y-%m-%d %H:%M:%S') # Time, DateTime else value.strftime('%Y-%m-%d') # Date diff --git a/lib/odbc_adapter/registry.rb b/lib/odbc_adapter/registry.rb index 1bb7264e..78f0a18f 100644 --- a/lib/odbc_adapter/registry.rb +++ b/lib/odbc_adapter/registry.rb @@ -1,10 +1,12 @@ +# frozen_string_literal: true + module ODBCAdapter class Registry attr_reader :dbs def initialize @dbs = { - /my.*sql/i => :MySQL, + /my.*sql/i => :MySQL, /postgres/i => :PostgreSQL } end @@ -16,7 +18,7 @@ def adapter_for(reported_name) adapter if reported_name =~ pattern end - normalize_adapter(found && found.last || :Null) + normalize_adapter((found && found.last) || :Null) end def register(pattern, superclass = Object, &block) @@ -27,6 +29,7 @@ def register(pattern, superclass = Object, &block) def normalize_adapter(adapter) return adapter unless adapter.is_a?(Symbol) + require "odbc_adapter/adapters/#{adapter.downcase}_odbc_adapter" Adapters.const_get(:"#{adapter}ODBCAdapter") end diff --git a/lib/odbc_adapter/schema_statements.rb b/lib/odbc_adapter/schema_statements.rb index df149765..da187418 100644 --- a/lib/odbc_adapter/schema_statements.rb +++ b/lib/odbc_adapter/schema_statements.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ODBCAdapter module SchemaStatements # Returns a Hash of mappings from the abstract data types to the native @@ -17,6 +19,7 @@ def tables(_name = nil) 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 @@ -30,7 +33,7 @@ def views def indexes(table_name, _name = nil) stmt = @connection.indexes(native_case(table_name.to_s)) result = stmt.fetch_all || [] - stmt.drop unless stmt.nil? + stmt&.drop index_cols = [] index_name = nil @@ -51,7 +54,8 @@ def indexes(table_name, _name = nil) next_row = result[row_idx + 1] if (row_idx == result.length - 1) || (next_row[6].zero? || next_row[7] == 1) - indices << ActiveRecord::ConnectionAdapters::IndexDefinition.new(table_name, format_case(index_name), unique, index_cols) + indices << ActiveRecord::ConnectionAdapters::IndexDefinition.new(table_name, format_case(index_name), unique, + index_cols) end end end @@ -74,8 +78,7 @@ def columns(table_name, _name = nil) # 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 + args = { sql_type: col_native_type.downcase, type: col_sql_type, limit: col_limit } if [ODBC::SQL_DECIMAL, ODBC::SQL_NUMERIC].include?(col_sql_type) args[:scale] = col_scale || 0 @@ -83,7 +86,8 @@ def columns(table_name, _name = nil) 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) + cols << new_column(format_case(col_name), col_default, sql_type_metadata, col_nullable, table_name, + col_native_type) end end @@ -91,14 +95,14 @@ def columns(table_name, _name = nil) def primary_key(table_name) stmt = @connection.primary_keys(native_case(table_name.to_s)) result = stmt.fetch_all || [] - stmt.drop unless stmt.nil? + stmt&.drop 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? + stmt&.drop result.map do |key| fk_from_table = key[2] # PKTABLE_NAME @@ -107,11 +111,11 @@ def foreign_keys(table_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 + 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 diff --git a/lib/odbc_adapter/version.rb b/lib/odbc_adapter/version.rb index 4a65ac67..59470555 100644 --- a/lib/odbc_adapter/version.rb +++ b/lib/odbc_adapter/version.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + module ODBCAdapter - VERSION = '5.0.5'.freeze + VERSION = '5.0.6' end diff --git a/odbc_adapter.gemspec b/odbc_adapter.gemspec index 4bf0142d..ab326002 100644 --- a/odbc_adapter.gemspec +++ b/odbc_adapter.gemspec @@ -1,4 +1,6 @@ -lib = File.expand_path('../lib', __FILE__) +# frozen_string_literal: true + +lib = File.expand_path('lib', __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require 'odbc_adapter/version' @@ -19,11 +21,17 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] + spec.add_dependency 'activerecord', '>= 6.1.7' spec.add_dependency 'ruby-odbc', '~> 0.9' + spec.required_ruby_version = '>= 2.7' - spec.add_development_dependency 'bundler', '~> 1.14' + spec.add_development_dependency 'bundler', '>= 1.14' spec.add_development_dependency 'minitest', '~> 5.10' - spec.add_development_dependency 'rake', '~> 12.0' - spec.add_development_dependency 'rubocop', '0.48.1' + spec.add_development_dependency 'pry', '~> 0.11.1' + spec.add_development_dependency 'rake', '~> 13.0' + spec.add_development_dependency 'rubocop', '1.48.1' + spec.add_development_dependency 'rubocop-minitest', '0.29.0' + spec.add_development_dependency 'rubocop-rake', '0.6.0' spec.add_development_dependency 'simplecov', '~> 0.14' + spec.metadata['rubygems_mfa_required'] = 'true' end diff --git a/test/attributes_test.rb b/test/attributes_test.rb index 8a0cbf43..0291d397 100644 --- a/test/attributes_test.rb +++ b/test/attributes_test.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require 'test_helper' class AttributesTest < Minitest::Test def test_booleans? - assert_equal true, Todo.first.published? - assert_equal false, Todo.last.published? + assert_predicate Todo.first, :published? + refute_predicate Todo.last, :published? end def test_integers diff --git a/test/calculations_test.rb b/test/calculations_test.rb index 627b4bd1..3f37c15a 100644 --- a/test/calculations_test.rb +++ b/test/calculations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class CalculationsTest < Minitest::Test @@ -8,6 +10,6 @@ def test_count end def test_average - assert_equal 10.33, User.average(:letters).round(2) + assert_in_delta(10.33, User.average(:letters).round(2)) end end diff --git a/test/connection_fail_test.rb b/test/connection_fail_test.rb index 8061af5e..fc6f2e15 100644 --- a/test/connection_fail_test.rb +++ b/test/connection_fail_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class ConnectionFailTest < Minitest::Test @@ -7,7 +9,7 @@ def test_connection_fail skip 'Only executed for MySQL' unless ActiveRecord::Base.connection.instance_values['config'][:conn_str].include? 'MySQL' begin conn.execute('KILL CONNECTION_ID();') - rescue => e + rescue StandardError => e puts "caught exception #{e}" end assert_raises(ODBCAdapter::ConnectionFailedError) { User.average(:letters).round(2) } diff --git a/test/connection_management_test.rb b/test/connection_management_test.rb index 57009a5a..b4f43af6 100644 --- a/test/connection_management_test.rb +++ b/test/connection_management_test.rb @@ -1,17 +1,22 @@ +# frozen_string_literal: true + require 'test_helper' class ConnectionManagementTest < Minitest::Test def test_connection_management - assert conn.active? + assert_predicate conn, :active? conn.disconnect! - refute conn.active? + + refute_predicate conn, :active? conn.disconnect! - refute conn.active? + + refute_predicate conn, :active? conn.reconnect! - assert conn.active? + + assert_predicate conn, :active? ensure conn.reconnect! end diff --git a/test/crud_test.rb b/test/crud_test.rb index 39665a35..4e9be1ab 100644 --- a/test/crud_test.rb +++ b/test/crud_test.rb @@ -1,9 +1,12 @@ +# frozen_string_literal: true + require 'test_helper' class CRUDTest < Minitest::Test def test_creation with_transaction do User.create(first_name: 'foo', last_name: 'bar') + assert_equal 7, User.count end end @@ -21,6 +24,7 @@ def test_update def test_destroy with_transaction do User.last.destroy + assert_equal 5, User.count end end diff --git a/test/metadata_test.rb b/test/metadata_test.rb index eaa75091..24ffc6a9 100644 --- a/test/metadata_test.rb +++ b/test/metadata_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class MetadataTest < Minitest::Test @@ -7,6 +9,7 @@ def test_data_sources def test_column_names expected = %w[created_at first_name id last_name letters updated_at] + assert_equal expected, User.column_names.sort end diff --git a/test/migrations_test.rb b/test/migrations_test.rb index 76e3fbce..1a609c47 100644 --- a/test/migrations_test.rb +++ b/test/migrations_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class MigrationsTest < Minitest::Test @@ -9,9 +11,11 @@ def test_table_crud @connection.create_table(:foos, force: true) do |t| t.timestamps null: false end + assert_equal 3, @connection.columns(:foos).count @connection.rename_table(:foos, :bars) + assert_equal 3, @connection.columns(:bars).count @connection.drop_table(:bars) @@ -21,12 +25,15 @@ def test_column_crud previous_count = @connection.columns(:users).count @connection.add_column(:users, :foo, :integer) + assert_equal previous_count + 1, @connection.columns(:users).count @connection.rename_column(:users, :foo, :bar) + assert_equal previous_count + 1, @connection.columns(:users).count @connection.remove_column(:users, :bar) + assert_equal previous_count, @connection.columns(:users).count end end diff --git a/test/registry_test.rb b/test/registry_test.rb index eb1afe2c..f1b73c7f 100644 --- a/test/registry_test.rb +++ b/test/registry_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class RegistryTest < Minitest::Test @@ -6,6 +8,7 @@ def test_register register_foobar(registry) adapter = registry.adapter_for('Foo Bar') + assert_kind_of Class, adapter assert_equal ODBCAdapter::Adapters::MySQLODBCAdapter, adapter.superclass assert_equal 'foobar', adapter.new.quoted_true @@ -17,11 +20,10 @@ def test_register def register_foobar(registry) require File.join('odbc_adapter', 'adapters', 'mysql_odbc_adapter') registry.register(/foobar/, ODBCAdapter::Adapters::MySQLODBCAdapter) do - def initialize() end - def quoted_true 'foobar' end end end + # rubocop:enable Lint/NestedMethodDefinition end diff --git a/test/selection_test.rb b/test/selection_test.rb index 667dbbf6..41df3226 100644 --- a/test/selection_test.rb +++ b/test/selection_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class SelectionTest < Minitest::Test @@ -7,16 +9,19 @@ def test_first def test_pluck expected = %w[Ash Jason Kevin Michal Ryan Sharif] + assert_equal expected, User.order(:first_name).pluck(:first_name) end def test_limitations expected = %w[Kevin Michal Ryan] + assert_equal expected, User.order(:first_name).limit(3).offset(2).pluck(:first_name) end def test_find user = User.last + assert_equal user, User.find(user.id) end diff --git a/test/test_helper.rb b/test/test_helper.rb index 623b1960..5171e7a2 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,7 +1,9 @@ +# frozen_string_literal: true + require 'simplecov' SimpleCov.start -$LOAD_PATH.unshift File.expand_path('../../lib', __FILE__) +$LOAD_PATH.unshift File.expand_path('../lib', __dir__) require 'odbc_adapter' require 'minitest/autorun' diff --git a/test/version_test.rb b/test/version_test.rb index 232a7c6f..e1cc6f99 100644 --- a/test/version_test.rb +++ b/test/version_test.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'test_helper' class VersionTest < Minitest::Test