diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d5dacad34..b790e93d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,7 +5,7 @@ on: [push, pull_request] jobs: test: name: Run test suite - runs-on: ubuntu-latest + runs-on: ubuntu-20.04 # TODO: Change back to 'ubuntu-latest' when https://github.com/microsoft/mssql-docker/issues/899 resolved. env: COMPOSE_FILE: docker-compose.ci.yml @@ -23,7 +23,7 @@ jobs: uses: actions/checkout@v2 - name: Build docker images - run: docker-compose build --build-arg TARGET_VERSION=${{ matrix.ruby }} + run: docker compose build --build-arg TARGET_VERSION=${{ matrix.ruby }} - name: Run tests - run: docker-compose run ci + run: docker compose run ci diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a37d2971..122f09ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,56 @@ +## v7.1.11 + +#### Fixed + +- [#1271](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1271) Fix parsing of raw table name from SQL with extra parentheses + +## v7.1.10 + +#### Fixed + +- [#1262](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1262) Fix distinct alias when multiple databases used. + +## v7.1.9 + +#### Fixed + +- [#1245](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1245) Allow INSERT statements with SELECT notation + +## v7.1.8 + +#### Fixed + +- [#1232](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1232) Enable identity insert on view's base table + +## v7.1.7 + +#### Fixed + +- [#1210](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1210) Handle blank SQL when parsing table name + +## v7.1.6 + +#### Fixed + +- [#1208](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1208) Exclude "guest" schema in schema dumper + +## v7.1.5 + +#### Added + +- [#1201](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1201) Support non-dbo schemas in schema dumper +- [#1206](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1206) Support table names containing spaces + +## v7.1.4 + +#### Fixed + +- [#1164](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1164) Fix composite primary key with different data type with triggers + +#### Changed + +- [#1199](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/pull/1199) Remove ActiveRecord::Relation#calculate patch + ## v7.1.3 #### Fixed diff --git a/README.md b/README.md index b7ffee7c7..5b8c144db 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,18 @@ The SQL Server adapter for ActiveRecord using SQL Server 2012 or higher. Interested in older versions? We follow a rational versioning policy that tracks Rails. That means that our 7.x version of the adapter is only for the latest 7.x version of Rails. If you need the adapter for SQL Server 2008 or 2005, you are still in the right spot. Just install the latest 3.2.x to 4.1.x version of the adapter that matches your Rails version. We also have stable branches for each major/minor release of ActiveRecord. +See [Rubygems](https://rubygems.org/gems/activerecord-sqlserver-adapter/versions) for the latest version of the adapter for each Rails release. + | Adapter Version | Rails Version | Support | Branch | |-----------------|---------------|---------|--------------------------------------------------------------------------------------------------| -| `7.1.3` | `7.1.x` | Active | [main](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/main) | -| `7.0.5.1` | `7.0.x` | Active | [7-0-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/7-0-stable) | -| `6.1.3.0` | `6.1.x` | Active | [6-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/6-1-stable) | -| `6.0.3` | `6.0.x` | Ended | [6-0-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/6-0-stable) | -| `5.2.1` | `5.2.x` | Ended | [5-2-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/5-2-stable) | -| `5.1.6` | `5.1.x` | Ended | [5-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/5-1-stable) | -| `4.2.18` | `4.2.x` | Ended | [4-2-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/4-2-stable) | -| `4.1.8` | `4.1.x` | Ended | [4-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/4-1-stable) | +| `7.1.x` | `7.1.x` | Active | [7-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/7-1-stable) | +| `7.0.x` | `7.0.x` | Ended | [7-0-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/7-0-stable) | +| `6.1.x` | `6.1.x` | Ended | [6-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/6-1-stable) | +| `6.0.x` | `6.0.x` | Ended | [6-0-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/6-0-stable) | +| `5.2.x` | `5.2.x` | Ended | [5-2-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/5-2-stable) | +| `5.1.x` | `5.1.x` | Ended | [5-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/5-1-stable) | +| `4.2.x` | `4.2.x` | Ended | [4-2-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/4-2-stable) | +| `4.1.x` | `4.1.x` | Ended | [4-1-stable](https://github.com/rails-sqlserver/activerecord-sqlserver-adapter/tree/4-1-stable) | For older versions, please check their stable branches. @@ -49,6 +51,10 @@ adapter.exclude_output_inserted_table_names['my_table_name'] = true # Explicitly set the data type for the temporary key table. adapter.exclude_output_inserted_table_names['my_uuid_table_name'] = 'uniqueidentifier' + + +# Explicitly set data types when data type is different for composite primary keys. +adapter.exclude_output_inserted_table_names['my_composite_pk_table_name'] = { pk_col_one: "uniqueidentifier", pk_col_two: "int" } ``` diff --git a/VERSION b/VERSION index 1996c5044..e0eaaa0bb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.1.3 +7.1.11 diff --git a/lib/active_record/connection_adapters/sqlserver/core_ext/calculations.rb b/lib/active_record/connection_adapters/sqlserver/core_ext/calculations.rb index 34355ef53..b7b529262 100644 --- a/lib/active_record/connection_adapters/sqlserver/core_ext/calculations.rb +++ b/lib/active_record/connection_adapters/sqlserver/core_ext/calculations.rb @@ -8,51 +8,9 @@ module ConnectionAdapters module SQLServer module CoreExt module Calculations - def calculate(operation, column_name) - if klass.connection.sqlserver? - _calculate(operation, column_name) - else - super - end - end - + private - # Same as original `calculate` method except we don't perform PostgreSQL hack that removes ordering. - def _calculate(operation, column_name) - operation = operation.to_s.downcase - - if @none - case operation - when "count", "sum" - result = group_values.any? ? Hash.new : 0 - return @async ? Promise::Complete.new(result) : result - when "average", "minimum", "maximum" - result = group_values.any? ? Hash.new : nil - return @async ? Promise::Complete.new(result) : result - end - end - - if has_include?(column_name) - relation = apply_join_dependency - - if operation == "count" - unless distinct_value || distinct_select?(column_name || select_for_count) - relation.distinct! - relation.select_values = [ klass.primary_key || table[Arel.star] ] - end - # PostgreSQL: ORDER BY expressions must appear in SELECT list when using DISTINCT - # Start of monkey-patch - # relation.order_values = [] if group_values.empty? - # End of monkey-patch - end - - relation.calculate(operation, column_name) - else - perform_calculation(operation, column_name) - end - end - def build_count_subquery(relation, column_name, distinct) return super unless klass.connection.adapter_name == "SQLServer" diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 31f17033f..d06cdd899 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -45,6 +45,9 @@ def internal_exec_query(sql, name = "SQL", binds = [], prepare: false, async: fa log(sql, name, binds, async: async) do with_raw_connection do |conn| if id_insert_table_name = query_requires_identity_insert?(sql) + # If the table name is a view, we need to get the base table name for enabling identity insert. + id_insert_table_name = view_table_name(id_insert_table_name) if view_exists?(id_insert_table_name) + with_identity_insert_enabled(id_insert_table_name, conn) do result = internal_exec_sql_query(sql, conn) end @@ -278,13 +281,17 @@ def sql_for_insert(sql, pk, binds, returning) exclude_output_inserted = exclude_output_inserted_table_name?(table_name, sql) if exclude_output_inserted - quoted_pk = Array(pk).map { |subkey| SQLServer::Utils.extract_identifiers(subkey).quoted } + pk_and_types = Array(pk).map do |subkey| + { + quoted: SQLServer::Utils.extract_identifiers(subkey).quoted, + id_sql_type: exclude_output_inserted_id_sql_type(subkey, exclude_output_inserted) + } + end - id_sql_type = exclude_output_inserted.is_a?(TrueClass) ? "bigint" : exclude_output_inserted <<~SQL.squish - DECLARE @ssaIdInsertTable table (#{quoted_pk.map { |subkey| "#{subkey} #{id_sql_type}"}.join(", ") }); - #{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/i), " OUTPUT #{ quoted_pk.map { |subkey| "INSERTED.#{subkey}" }.join(", ") } INTO @ssaIdInsertTable"} - SELECT #{quoted_pk.map {|subkey| "CAST(#{subkey} AS #{id_sql_type}) #{subkey}"}.join(", ")} FROM @ssaIdInsertTable + DECLARE @ssaIdInsertTable table (#{pk_and_types.map { |pk_and_type| "#{pk_and_type[:quoted]} #{pk_and_type[:id_sql_type]}"}.join(", ") }); + #{sql.dup.insert sql.index(/ (DEFAULT )?VALUES/i), " OUTPUT #{ pk_and_types.map { |pk_and_type| "INSERTED.#{pk_and_type[:quoted]}" }.join(", ") } INTO @ssaIdInsertTable"} + SELECT #{pk_and_types.map {|pk_and_type| "CAST(#{pk_and_type[:quoted]} AS #{pk_and_type[:id_sql_type]}) #{pk_and_type[:quoted]}"}.join(", ")} FROM @ssaIdInsertTable SQL else returning_columns = returning || Array(pk) @@ -382,6 +389,12 @@ def exclude_output_inserted_table_name?(table_name, sql) self.class.exclude_output_inserted_table_names[table_name] end + def exclude_output_inserted_id_sql_type(pk, exclude_output_inserted) + return "bigint" if exclude_output_inserted.is_a?(TrueClass) + return exclude_output_inserted[pk.to_sym] if exclude_output_inserted.is_a?(Hash) + exclude_output_inserted + end + def query_requires_identity_insert?(sql) return false unless insert_sql?(sql) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb index 9da6eef6f..d5bb1b348 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_dumper.rb @@ -39,6 +39,17 @@ def schema_collation(column) def default_primary_key?(column) super && column.is_identity? end + + def schemas(stream) + schema_names = @connection.schema_names + + if schema_names.any? + schema_names.sort.each do |name| + stream.puts " create_schema #{name.inspect}" + end + stream.puts + end + end end end end diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index a0b43d1b3..d1c4382f3 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -347,12 +347,16 @@ def add_timestamps(table_name, **options) def columns_for_distinct(columns, orders) order_columns = orders.reject(&:blank?).map { |s| - s = s.to_sql unless s.is_a?(String) + s = visitor.compile(s) unless s.is_a?(String) s.gsub(/\s+(?:ASC|DESC)\b/i, "") .gsub(/\s+NULLS\s+(?:FIRST|LAST)\b/i, "") - }.reject(&:blank?).map.with_index { |column, i| "#{column} AS alias_#{i}" } + } + .reject(&:blank?) + .reject { |s| columns.include?(s) } - (order_columns << super).join(", ") + order_columns_aliased = order_columns.map.with_index { |column, i| "#{column} AS alias_#{i}" } + + (order_columns_aliased << super).join(", ") end def update_table_definition(table_name, base) @@ -393,19 +397,37 @@ def drop_schema(schema_name) execute "DROP SCHEMA [#{schema_name}]" end + # Returns an array of schema names. + def schema_names + sql = <<~SQL.squish + SELECT name + FROM sys.schemas + WHERE + name NOT LIKE 'db_%' AND + name NOT IN ('INFORMATION_SCHEMA', 'sys', 'guest') + SQL + + query_values(sql, "SCHEMA") + end + private def data_source_sql(name = nil, type: nil) - scope = quoted_scope name, type: type + scope = quoted_scope(name, type: type) - table_name = lowercase_schema_reflection_sql 'TABLE_NAME' - database = scope[:database].present? ? "#{scope[:database]}." : "" + table_schema = lowercase_schema_reflection_sql('TABLE_SCHEMA') + table_name = lowercase_schema_reflection_sql('TABLE_NAME') + database = scope[:database].present? ? "#{scope[:database]}." : "" table_catalog = scope[:database].present? ? quote(scope[:database]) : "DB_NAME()" - sql = "SELECT #{table_name}" + sql = "SELECT " + sql += " CASE" + sql += " WHEN #{table_schema} = 'dbo' THEN #{table_name}" + sql += " ELSE CONCAT(#{table_schema}, '.', #{table_name})" + sql += " END" sql += " FROM #{database}INFORMATION_SCHEMA.TABLES WITH (NOLOCK)" sql += " WHERE TABLE_CATALOG = #{table_catalog}" - sql += " AND TABLE_SCHEMA = #{quote(scope[:schema])}" + sql += " AND TABLE_SCHEMA = #{quote(scope[:schema])}" if scope[:schema] sql += " AND TABLE_NAME = #{quote(scope[:name])}" if scope[:name] sql += " AND TABLE_TYPE = #{quote(scope[:type])}" if scope[:type] sql += " ORDER BY #{table_name}" @@ -414,9 +436,10 @@ def data_source_sql(name = nil, type: nil) def quoted_scope(name = nil, type: nil) identifier = SQLServer::Utils.extract_identifiers(name) + {}.tap do |scope| scope[:database] = identifier.database if identifier.database - scope[:schema] = identifier.schema || "dbo" + scope[:schema] = identifier.schema || "dbo" if name.present? scope[:name] = identifier.object if identifier.object scope[:type] = type if type end @@ -654,12 +677,21 @@ def get_table_name(sql) # Parses the raw table name that is used in the SQL. Table name could include database/schema/etc. def get_raw_table_name(sql) - case sql - when /^\s*(INSERT|EXEC sp_executesql N'INSERT)(\s+INTO)?\s+([^\(\s]+)\s*|^\s*update\s+([^\(\s]+)\s*/i - Regexp.last_match[3] || Regexp.last_match[4] - when /FROM\s+([^\(\s]+)\s*/i - Regexp.last_match[1] - end + return if sql.blank? + + s = sql.gsub(/^\s*EXEC sp_executesql N'/i, "") + + if s.match?(/^\s*INSERT INTO.*/i) + s.split(/INSERT INTO/i)[1] + .split(/OUTPUT INSERTED/i)[0] + .split(/(DEFAULT)?\s+VALUES/i)[0] + .split(/\bSELECT\b(?![^\[]*\])/i)[0] + .match(/\s*([^(]*)/i)[0] + elsif s.match?(/^\s*UPDATE\s+.*/i) + s.match(/UPDATE\s+([^\(\s]+)\s*/i)[1] + else + s.match(/FROM[\s|\(]+((\[[^\(\]]+\])|[^\(\s]+)\s*/i)[1] + end.strip end def default_constraint_name(table_name, column_name) diff --git a/lib/arel/visitors/sqlserver.rb b/lib/arel/visitors/sqlserver.rb index fe56bbef1..ef6a23888 100644 --- a/lib/arel/visitors/sqlserver.rb +++ b/lib/arel/visitors/sqlserver.rb @@ -30,10 +30,42 @@ def visit_Arel_Nodes_Concat(o, collector) end def visit_Arel_Nodes_UpdateStatement(o, collector) - if o.orders.any? && o.limit.nil? - o.limit = Nodes::Limit.new(9_223_372_036_854_775_807) + if has_join_and_composite_primary_key?(o) + update_statement_using_join(o, collector) + else + o.limit = Nodes::Limit.new(9_223_372_036_854_775_807) if o.orders.any? && o.limit.nil? + + super end - super + end + + def visit_Arel_Nodes_DeleteStatement(o, collector) + if has_join_and_composite_primary_key?(o) + delete_statement_using_join(o, collector) + else + super + end + end + + def has_join_and_composite_primary_key?(o) + has_join_sources?(o) && o.relation.left.instance_variable_get(:@klass).composite_primary_key? + end + + def delete_statement_using_join(o, collector) + collector << "DELETE " + visit o.relation.left, collector + collector << " FROM " + visit o.relation, collector + collect_nodes_for o.wheres, collector, " WHERE ", " AND " + end + + def update_statement_using_join(o, collector) + collector << "UPDATE " + visit o.relation.left, collector + collect_nodes_for o.values, collector, " SET " + collector << " FROM " + visit o.relation, collector + collect_nodes_for o.wheres, collector, " WHERE ", " AND " end def visit_Arel_Nodes_Lock(o, collector) diff --git a/test/cases/adapter_test_sqlserver.rb b/test/cases/adapter_test_sqlserver.rb index 34394732b..cb433b74e 100644 --- a/test/cases/adapter_test_sqlserver.rb +++ b/test/cases/adapter_test_sqlserver.rb @@ -550,11 +550,23 @@ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes describe 'table is in non-dbo schema' do it "records can be created successfully" do - Alien.create!(name: 'Trisolarans') + assert_difference("Alien.count", 1) do + Alien.create!(name: 'Trisolarans') + end end it 'records can be inserted using SQL' do - Alien.connection.exec_insert("insert into [test].[aliens] (id, name) VALUES(1, 'Trisolarans'), (2, 'Xenomorph')") + assert_difference("Alien.count", 2) do + Alien.connection.exec_insert("insert into [test].[aliens] (id, name) VALUES(1, 'Trisolarans'), (2, 'Xenomorph')") + end + end + end + + describe 'table names contains spaces' do + it 'records can be created successfully' do + assert_difference("TableWithSpaces.count", 1) do + TableWithSpaces.create!(name: 'Bob') + end end end @@ -569,4 +581,13 @@ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes end end end + + describe "distinct select query" do + it "generated SQL does not contain unnecessary alias projection" do + sqls = capture_sql do + Post.includes(:comments).joins(:comments).first + end + assert_no_match(/AS alias_0/, sqls.first) + end + end end diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index 315a2ac45..78b65c52a 100644 --- a/test/cases/coerced_tests.rb +++ b/test/cases/coerced_tests.rb @@ -702,7 +702,6 @@ def migrate(x) ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate assert connection.table_exists?(long_table_name[0...-1]) assert_not connection.table_exists?(:more_testings) - assert connection.table_exists?(long_table_name[0...-1]) ensure connection.drop_table(:more_testings) rescue nil connection.drop_table(long_table_name[0...-1]) rescue nil @@ -727,6 +726,28 @@ def migrate(x) assert_match(/Index name \'#{long_index_name}\' on table \'testings\' is too long/i, error.message) end + # SQL Server truncates long table names when renaming. + coerce_tests! :test_rename_table_errors_on_too_long_index_name_7_0 + def test_rename_table_errors_on_too_long_index_name_7_0_coerced + long_table_name = "a" * (connection.table_name_length + 1) + + migration = Class.new(ActiveRecord::Migration[7.0]) { + def migrate(x) + add_index :testings, :foo + long_table_name = "a" * (connection.table_name_length + 1) + rename_table :testings, long_table_name + end + }.new + + ActiveRecord::Migrator.new(:up, [migration], @schema_migration, @internal_metadata).migrate + + assert_not connection.table_exists?(:testings) + assert connection.table_exists?(long_table_name[0...-1]) + assert connection.index_exists?(long_table_name[0...-1], :foo) + ensure + connection.drop_table(long_table_name[0...-1], if_exists: true) + end + # SQL Server has a different maximum index name length. coerce_tests! :test_create_table_add_index_errors_on_too_long_name_7_0 def test_create_table_add_index_errors_on_too_long_name_7_0_coerced @@ -2484,6 +2505,25 @@ def test_sqlcommenter_format_value_string_coercible_coerced end end + # SQL requires double single-quotes. + coerce_tests! :test_sqlcommenter_format_allows_string_keys + def test_sqlcommenter_format_allows_string_keys_coerced + ActiveRecord::QueryLogs.update_formatter(:sqlcommenter) + + ActiveRecord::QueryLogs.tags = [ + :application, + { + "string" => "value", + tracestate: "congo=t61rcWkgMzE,rojo=00f067aa0ba902b7", + custom_proc: -> { "Joe's Shack" } + }, + ] + + assert_sql(%r{custom_proc=''Joe%27s%20Shack'',string=''value'',tracestate=''congo%3Dt61rcWkgMzE%2Crojo%3D00f067aa0ba902b7''\*/}) do + Dashboard.first + end + end + # Invalid character encoding causes `ActiveRecord::StatementInvalid` error similar to Postgres. coerce_tests! :test_invalid_encoding_query def test_invalid_encoding_query_coerced diff --git a/test/cases/schema_dumper_test_sqlserver.rb b/test/cases/schema_dumper_test_sqlserver.rb index 13ba5b5ce..57c576b26 100644 --- a/test/cases/schema_dumper_test_sqlserver.rb +++ b/test/cases/schema_dumper_test_sqlserver.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "cases/helper_sqlserver" +require "stringio" class SchemaDumperTestSQLServer < ActiveRecord::TestCase before { all_tables } @@ -141,7 +142,7 @@ class SchemaDumperTestSQLServer < ActiveRecord::TestCase it "honor nonstandard primary keys" do generate_schema_for_table("movies") do |output| match = output.match(%r{create_table "movies"(.*)do}) - assert_not_nil(match, "nonstandardpk table not found") + assert_not_nil(match, "non-standard primary key table not found") assert_match %r(primary_key: "movieid"), match[1], "non-standard primary key not preserved" end end @@ -159,14 +160,36 @@ class SchemaDumperTestSQLServer < ActiveRecord::TestCase _(output.scan('t.integer "unique_field"').length).must_equal(1) end + it "schemas are dumped and tables names only include non-default schema" do + stream = StringIO.new + ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + generated_schema = stream.string + + # Only generate non-default schemas. Default schema is 'dbo'. + assert_not_includes generated_schema, 'create_schema "dbo"' + assert_not_includes generated_schema, 'create_schema "db_owner"' + assert_not_includes generated_schema, 'create_schema "INFORMATION_SCHEMA"' + assert_not_includes generated_schema, 'create_schema "sys"' + assert_not_includes generated_schema, 'create_schema "guest"' + assert_includes generated_schema, 'create_schema "test"' + assert_includes generated_schema, 'create_schema "test2"' + + # Only non-default schemas should be included in table names. Default schema is 'dbo'. + assert_includes generated_schema, 'create_table "accounts"' + assert_includes generated_schema, 'create_table "test.aliens"' + assert_includes generated_schema, 'create_table "test2.sst_schema_test_mulitple_schema"' + end + private def generate_schema_for_table(*table_names) - require "stringio" + previous_ignore_tables = ActiveRecord::SchemaDumper.ignore_tables + ActiveRecord::SchemaDumper.ignore_tables = all_tables - table_names stream = StringIO.new ActiveRecord::SchemaDumper.ignore_tables = all_tables - table_names ActiveRecord::SchemaDumper.dump(ActiveRecord::Base.connection, stream) + @generated_schema = stream.string yield @generated_schema if block_given? @schema_lines = Hash.new @@ -177,6 +200,8 @@ def generate_schema_for_table(*table_names) @schema_lines[Regexp.last_match[1]] = SchemaLine.new(line) end @generated_schema + ensure + ActiveRecord::SchemaDumper.ignore_tables = previous_ignore_tables end def line(column_name) diff --git a/test/cases/schema_test_sqlserver.rb b/test/cases/schema_test_sqlserver.rb index fc119fe27..124c2d6dc 100644 --- a/test/cases/schema_test_sqlserver.rb +++ b/test/cases/schema_test_sqlserver.rb @@ -39,7 +39,7 @@ class SchemaTestSQLServer < ActiveRecord::TestCase assert_equal 1, columns.select { |c| c.is_identity? }.size end - it "return correct varchar and nvarchar column limit length when table is in non dbo schema" do + it "return correct varchar and nvarchar column limit length when table is in non-dbo schema" do columns = connection.columns("test.sst_schema_columns") assert_equal 255, columns.find { |c| c.name == "name" }.limit @@ -48,4 +48,64 @@ class SchemaTestSQLServer < ActiveRecord::TestCase assert_equal 1000, columns.find { |c| c.name == "n_description" }.limit end end + + describe "parsing table name from raw SQL" do + describe 'SELECT statements' do + it do + assert_equal "[sst_schema_columns]", connection.send(:get_raw_table_name, "SELECT [sst_schema_columns].[id] FROM [sst_schema_columns]") + end + + it do + assert_equal "sst_schema_columns", connection.send(:get_raw_table_name, "SELECT [sst_schema_columns].[id] FROM sst_schema_columns") + end + + it do + assert_equal "[WITH - SPACES]", connection.send(:get_raw_table_name, "SELECT id FROM [WITH - SPACES]") + end + + it do + assert_equal "[WITH - SPACES$DOLLAR]", connection.send(:get_raw_table_name, "SELECT id FROM [WITH - SPACES$DOLLAR]") + end + + it do + assert_nil connection.send(:get_raw_table_name, nil) + end + end + + describe 'INSERT statements' do + it do + assert_equal "[dashboards]", connection.send(:get_raw_table_name, "INSERT INTO [dashboards] DEFAULT VALUES; SELECT CAST(SCOPE_IDENTITY() AS bigint) AS Ident") + end + + it do + assert_equal "lock_without_defaults", connection.send(:get_raw_table_name, "INSERT INTO lock_without_defaults(title) VALUES('title1')") + end + + it do + assert_equal "json_data_type", connection.send(:get_raw_table_name, "insert into json_data_type (payload) VALUES ('null')") + end + + it do + assert_equal "[auto_increments]", connection.send(:get_raw_table_name, "INSERT INTO [auto_increments] OUTPUT INSERTED.[id] DEFAULT VALUES") + end + + it do + assert_equal "[WITH - SPACES]", connection.send(:get_raw_table_name, "EXEC sp_executesql N'INSERT INTO [WITH - SPACES] ([external_id]) OUTPUT INSERTED.[id] VALUES (@0)', N'@0 bigint', @0 = 10") + end + + it do + assert_equal "[test].[aliens]", connection.send(:get_raw_table_name, "EXEC sp_executesql N'INSERT INTO [test].[aliens] ([name]) OUTPUT INSERTED.[id] VALUES (@0)', N'@0 varchar(255)', @0 = 'Trisolarans'") + end + + it do + assert_equal "[with].[select notation]", connection.send(:get_raw_table_name, "INSERT INTO [with].[select notation] SELECT * FROM [table_name]") + end + end + + describe 'CREATE VIEW statements' do + it do + assert_equal "test_table_as", connection.send(:get_raw_table_name, "CREATE VIEW test_views ( test_table_a_id, test_table_b_id ) AS SELECT test_table_as.id as test_table_a_id, test_table_bs.id as test_table_b_id FROM (test_table_as with(nolock) LEFT JOIN test_table_bs with(nolock) ON (test_table_as.id = test_table_bs.test_table_a_id))") + end + end + end end diff --git a/test/cases/trigger_test_sqlserver.rb b/test/cases/trigger_test_sqlserver.rb index e530b4840..e964e0d96 100644 --- a/test/cases/trigger_test_sqlserver.rb +++ b/test/cases/trigger_test_sqlserver.rb @@ -38,4 +38,14 @@ class SQLServerTriggerTest < ActiveRecord::TestCase _(obj.pk_col_two).must_equal 42 _(obj.pk_col_one.to_s).must_equal SSTestTriggerHistory.first.id_source end + + it "can insert into a table with composite pk with different data type with output inserted - with a hash setting for table name" do + exclude_output_inserted_table_names["sst_table_with_composite_pk_trigger_with_different_data_type"] = { pk_col_one: "uniqueidentifier", pk_col_two: "int" } + assert SSTestTriggerHistory.all.empty? + obj = SSTestTriggerCompositePkWithDefferentDataType.create! pk_col_two: 123, event_name: "test trigger" + _(obj.event_name).must_equal "test trigger" + _(obj.pk_col_one).must_be :present? + _(obj.pk_col_two).must_equal 123 + _(obj.pk_col_one.to_s).must_equal SSTestTriggerHistory.first.id_source + end end diff --git a/test/cases/view_test_sqlserver.rb b/test/cases/view_test_sqlserver.rb index b371bd0b1..04f14b957 100644 --- a/test/cases/view_test_sqlserver.rb +++ b/test/cases/view_test_sqlserver.rb @@ -47,4 +47,12 @@ class ViewTestSQLServer < ActiveRecord::TestCase assert_equal 1, klass.count end end + + describe 'identity insert' do + it "identity insert works with views" do + assert_difference("SSTestCustomersView.count", 1) do + SSTestCustomersView.create!(id: 5, name: "Bob") + end + end + end end diff --git a/test/models/sqlserver/table_with_spaces.rb b/test/models/sqlserver/table_with_spaces.rb new file mode 100644 index 000000000..d5f07ec4a --- /dev/null +++ b/test/models/sqlserver/table_with_spaces.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class TableWithSpaces < ActiveRecord::Base + self.table_name = "A Table With Spaces" +end diff --git a/test/models/sqlserver/trigger.rb b/test/models/sqlserver/trigger.rb index bbdd66827..c668f1f2a 100644 --- a/test/models/sqlserver/trigger.rb +++ b/test/models/sqlserver/trigger.rb @@ -11,3 +11,7 @@ class SSTestTriggerUuid < ActiveRecord::Base class SSTestTriggerCompositePk < ActiveRecord::Base self.table_name = "sst_table_with_composite_pk_trigger" end + +class SSTestTriggerCompositePkWithDefferentDataType < ActiveRecord::Base + self.table_name = "sst_table_with_composite_pk_trigger_with_different_data_type" +end diff --git a/test/schema/sqlserver_specific_schema.rb b/test/schema/sqlserver_specific_schema.rb index 8982a2170..b11cd6fa9 100644 --- a/test/schema/sqlserver_specific_schema.rb +++ b/test/schema/sqlserver_specific_schema.rb @@ -151,6 +151,10 @@ SELECT GETUTCDATE() utcdate SQL + create_table 'A Table With Spaces', force: true do |t| + t.string :name + end + # Constraints create_table(:sst_has_fks, force: true) do |t| @@ -249,6 +253,23 @@ SELECT pk_col_one AS id_source, event_name FROM INSERTED SQL + execute "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sst_table_with_composite_pk_trigger_with_different_data_type') DROP TABLE sst_table_with_composite_pk_trigger_with_different_data_type" + execute <<-SQL + CREATE TABLE sst_table_with_composite_pk_trigger_with_different_data_type( + pk_col_one uniqueidentifier DEFAULT NEWID(), + pk_col_two int NOT NULL, + event_name nvarchar(255), + CONSTRAINT PK_sst_table_with_composite_pk_trigger_with_different_data_type PRIMARY KEY (pk_col_one, pk_col_two) + ) + SQL + execute <<-SQL + CREATE TRIGGER sst_table_with_composite_pk_trigger_with_different_data_type_t ON sst_table_with_composite_pk_trigger_with_different_data_type + FOR INSERT + AS + INSERT INTO sst_table_with_trigger_history (id_source, event_name) + SELECT pk_col_one AS id_source, event_name FROM INSERTED + SQL + # Another schema. create_table :sst_schema_columns, force: true do |t|