From 9f54178aba7c38344e527cd1b6e03a30e966c42d Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 21 Mar 2026 20:59:58 +0000 Subject: [PATCH 01/12] Allow QueryIntent to drive preprocessing Ref: https://github.com/rails/rails/commit/95a906294cb199f204b9fd437416fa510343bacb --- .../sqlserver/database_statements.rb | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 657e31417..ce485789b 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -16,7 +16,7 @@ def write_query?(sql) # :nodoc: def perform_query(raw_connection, intent) sql = intent.processed_sql - unless intent.binds.nil? || intent.binds.empty? + if intent.has_binds? types, params = sp_executesql_types_and_parameters(intent.binds) sql = sp_executesql_sql(intent.processed_sql, types, params, intent.notification_payload[:name]) end @@ -73,13 +73,10 @@ def delete(arel, name = nil, binds = []) intent = QueryIntent.new(adapter: self, arel: arel, name: name, binds: binds) - # Compile Arel to get SQL - compile_arel_in_intent(intent) - # Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases. sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql ensure_writes_are_allowed(sql) if write_query?(sql) - intent.processed_sql = "#{sql}; SELECT @@ROWCOUNT AS AffectedRows" + intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") affected_rows(raw_execute(intent)) end @@ -93,13 +90,10 @@ def update(arel, name = nil, binds = []) intent = QueryIntent.new(adapter: self, arel: arel, name: name, binds: binds) - # Compile Arel to get SQL - compile_arel_in_intent(intent) - # Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases. sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql ensure_writes_are_allowed(sql) if write_query?(sql) - intent.processed_sql = "#{sql}; SELECT @@ROWCOUNT AS AffectedRows" + intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") affected_rows(raw_execute(intent)) end From 191a580f9b41975b8f0bc4691d7650a6a08ceae5 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 18 Apr 2026 19:27:39 +0100 Subject: [PATCH 02/12] Add QueryIntent#execute! Ref: https://github.com/rails/rails/commit/082a7c23746946cf84e7538ef5559575507d54ea --- .../sqlserver/database_statements.rb | 14 ++++++++------ .../connection_adapters/sqlserver/savepoints.rb | 4 ++-- .../connection_adapters/sqlserver/showplan.rb | 3 ++- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index ce485789b..652b9420d 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -78,7 +78,8 @@ def delete(arel, name = nil, binds = []) ensure_writes_are_allowed(sql) if write_query?(sql) intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") - affected_rows(raw_execute(intent)) + intent.execute! + intent.affected_rows end # Executes the update statement and returns the number of rows affected. @@ -95,11 +96,12 @@ def update(arel, name = nil, binds = []) ensure_writes_are_allowed(sql) if write_query?(sql) intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") - affected_rows(raw_execute(intent)) + intent.execute! + intent.affected_rows end def begin_db_transaction - internal_execute("BEGIN TRANSACTION", "TRANSACTION", allow_retry: true, materialize_transactions: false) + query_command("BEGIN TRANSACTION", "TRANSACTION", allow_retry: true, materialize_transactions: false) end def transaction_isolation_levels @@ -112,15 +114,15 @@ def begin_isolated_db_transaction(isolation) end def set_transaction_isolation_level(isolation_level) - internal_execute("SET TRANSACTION ISOLATION LEVEL #{isolation_level}", "TRANSACTION", allow_retry: true, materialize_transactions: false) + query_command("SET TRANSACTION ISOLATION LEVEL #{isolation_level}", "TRANSACTION", allow_retry: true, materialize_transactions: false) end def commit_db_transaction - internal_execute("COMMIT TRANSACTION", "TRANSACTION", allow_retry: false, materialize_transactions: true) + query_command("COMMIT TRANSACTION", "TRANSACTION", allow_retry: false, materialize_transactions: true) end def exec_rollback_db_transaction - internal_execute("IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION", "TRANSACTION", allow_retry: false, materialize_transactions: true) + query_command("IF @@TRANCOUNT > 0 ROLLBACK TRANSACTION", "TRANSACTION", allow_retry: false, materialize_transactions: true) end def case_sensitive_comparison(attribute, value) diff --git a/lib/active_record/connection_adapters/sqlserver/savepoints.rb b/lib/active_record/connection_adapters/sqlserver/savepoints.rb index 915cd07bb..e68f2f673 100644 --- a/lib/active_record/connection_adapters/sqlserver/savepoints.rb +++ b/lib/active_record/connection_adapters/sqlserver/savepoints.rb @@ -9,11 +9,11 @@ def current_savepoint_name end def create_savepoint(name = current_savepoint_name) - internal_execute("SAVE TRANSACTION #{name}", "TRANSACTION") + query_command("SAVE TRANSACTION #{name}", "TRANSACTION") end def exec_rollback_to_savepoint(name = current_savepoint_name) - internal_execute("ROLLBACK TRANSACTION #{name}", "TRANSACTION") + query_command("ROLLBACK TRANSACTION #{name}", "TRANSACTION") end # SQL Server does require save-points to be explicitly released. diff --git a/lib/active_record/connection_adapters/sqlserver/showplan.rb b/lib/active_record/connection_adapters/sqlserver/showplan.rb index f35e26053..01dfb608f 100644 --- a/lib/active_record/connection_adapters/sqlserver/showplan.rb +++ b/lib/active_record/connection_adapters/sqlserver/showplan.rb @@ -31,7 +31,8 @@ def with_showplan_on def set_showplan_option(enable = true) intent = QueryIntent.new(adapter: self, raw_sql: "SET #{showplan_option} #{enable ? "ON" : "OFF"}", name: "SCHEMA") - raw_execute(intent) + intent.execute! + intent.finish rescue raise ActiveRecordError, "#{showplan_option} could not be turned #{enable ? "ON" : "OFF"}, perhaps you do not have SHOWPLAN permissions?" end From 2104bfcd94f28664bad3a0a0cdebfb5b8b0479c5 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 18 Apr 2026 19:57:22 +0100 Subject: [PATCH 03/12] Fix select to get indexes --- .../connection_adapters/sqlserver/schema_statements.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index 87194ef40..4c3be477d 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -33,8 +33,7 @@ def drop_table(*table_names, **options) def indexes(table_name) data = begin - intent = QueryIntent.new(adapter: self, raw_sql: "EXEC sp_helpindex #{quote(table_name)}", name: "SCHEMA") - select(intent) + select_all("EXEC sp_helpindex #{quote(table_name)}", "SCHEMA") rescue [] end From 29985cb30a8dec59882a6476cccf8fb14325e51e Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 18 Apr 2026 20:43:50 +0100 Subject: [PATCH 04/12] Drop internal_exec_query Ref: https://github.com/rails/rails/pull/56129/commits/a1842f3094481758e9b2e29d0d56052498091065 --- .../connection_adapters/sqlserver/schema_statements.rb | 8 ++++---- .../connection_adapters/sqlserver/showplan.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index 4c3be477d..a73bb7b69 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -161,7 +161,7 @@ def primary_keys_select(table_name) binds << Relation::QueryAttribute.new("TABLE_NAME", identifier.object, nv128) binds << Relation::QueryAttribute.new("TABLE_SCHEMA", identifier.schema, nv128) unless identifier.schema.blank? - internal_exec_query(sql, "SCHEMA", binds).map { |row| row["name"] } + select_all(sql, "SCHEMA", binds).map { |row| row["name"] } end def rename_table(table_name, new_name, **options) @@ -322,7 +322,7 @@ def check_constraints(table_name) st.name = '#{table_name}' SQL - chk_info = internal_exec_query(sql, "SCHEMA") + chk_info = select_all(sql, "SCHEMA") chk_info.map do |row| options = { @@ -512,7 +512,7 @@ def column_definitions(table_name) FROM #{database}.INFORMATION_SCHEMA.COLUMNS c WHERE c.TABLE_NAME = #{quote(view_table_name(table_name))} SQL - results = internal_exec_query(sql, "SCHEMA") + results = select_all(sql, "SCHEMA") default_functions = results.each.with_object({}) { |row, out| out[row["name"]] = row["default"] }.compact end @@ -523,7 +523,7 @@ def column_definitions(table_name) binds << Relation::QueryAttribute.new("TABLE_NAME", identifier.object, nv128) binds << Relation::QueryAttribute.new("TABLE_SCHEMA", identifier.schema, nv128) unless identifier.schema.blank? - results = internal_exec_query(sql, "SCHEMA", binds) + results = select_all(sql, "SCHEMA", binds) raise ActiveRecord::StatementInvalid, "Table '#{table_name}' doesn't exist" if results.empty? results.map do |ci| diff --git a/lib/active_record/connection_adapters/sqlserver/showplan.rb b/lib/active_record/connection_adapters/sqlserver/showplan.rb index 01dfb608f..f07408c4a 100644 --- a/lib/active_record/connection_adapters/sqlserver/showplan.rb +++ b/lib/active_record/connection_adapters/sqlserver/showplan.rb @@ -14,7 +14,7 @@ module Showplan def explain(arel, binds = [], options = []) sql = to_sql(arel) - result = with_showplan_on { internal_exec_query(sql, "EXPLAIN", binds) } + result = with_showplan_on { select_all(sql, "EXPLAIN", binds) } printer = showplan_printer.new(result) printer.pp From a32e4620e2039127ac6cb91a07851ea0fbcf26a3 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 18 Apr 2026 21:19:26 +0100 Subject: [PATCH 05/12] Update database_statements.rb Ref: https://github.com/rails/rails/pull/56129/commits/d632189604c3203623241b14abf16c5cdc4054ec --- .../connection_adapters/sqlserver/database_statements.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 652b9420d..3c8d3d9d9 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -75,7 +75,6 @@ def delete(arel, name = nil, binds = []) # Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases. sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql - ensure_writes_are_allowed(sql) if write_query?(sql) intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") intent.execute! @@ -93,7 +92,6 @@ def update(arel, name = nil, binds = []) # Add `SELECT @@ROWCOUNT` to the end of the SQL to get the number of affected rows. This is needed because SQL Server does not return the number of affected rows in the same way as other databases. sql = intent.processed_sql.present? ? intent.processed_sql : intent.raw_sql - ensure_writes_are_allowed(sql) if write_query?(sql) intent.instance_variable_set(:@processed_sql, "#{sql}; SELECT @@ROWCOUNT AS AffectedRows") intent.execute! From a1e17e7da93335759433176316bfead45de0a294 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sat, 25 Apr 2026 18:32:07 +0100 Subject: [PATCH 06/12] Deprecate exec_{insert,update,delete} Ref: https://github.com/rails/rails/commit/3fc2ca0725c8c5f5a7deebbbdc4a865cfce61560 --- test/cases/adapter_test_sqlserver.rb | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/cases/adapter_test_sqlserver.rb b/test/cases/adapter_test_sqlserver.rb index 6d5c87d61..b6c35f210 100644 --- a/test/cases/adapter_test_sqlserver.rb +++ b/test/cases/adapter_test_sqlserver.rb @@ -615,7 +615,9 @@ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes it "records can be inserted using SQL" do assert_difference("Alien.count", 2) do - Alien.lease_connection.exec_insert("insert into [test].[aliens] (id, name) VALUES(1, 'Trisolarans'), (2, 'Xenomorph')") + assert_deprecated(ActiveRecord.deprecator) do + Alien.lease_connection.exec_insert("insert into [test].[aliens] (id, name) VALUES(1, 'Trisolarans'), (2, 'Xenomorph')") + end end end end @@ -630,9 +632,14 @@ def test_doesnt_error_when_a_select_query_is_called_while_preventing_writes describe "exec_insert" do it "values clause should be case-insensitive" do + first_insert = nil + second_insert = nil + assert_difference("Post.count", 4) do - first_insert = connection.exec_insert("INSERT INTO [posts] ([id],[title],[body]) VALUES(100, 'Title', 'Body'), (102, 'Title', 'Body')") - second_insert = connection.exec_insert("INSERT INTO [posts] ([id],[title],[body]) values(113, 'Body', 'Body'), (114, 'Body', 'Body')") + assert_deprecated(ActiveRecord.deprecator) do + first_insert = connection.exec_insert("INSERT INTO [posts] ([id],[title],[body]) VALUES(100, 'Title', 'Body'), (102, 'Title', 'Body')") + second_insert = connection.exec_insert("INSERT INTO [posts] ([id],[title],[body]) values(113, 'Body', 'Body'), (114, 'Body', 'Body')") + end assert_equal first_insert.rows.map(&:first), [100, 102] assert_equal second_insert.rows.map(&:first), [113, 114] From 34dce7d503e666ff0e13e5f6df85849c11491706 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Sun, 26 Apr 2026 17:42:30 +0100 Subject: [PATCH 07/12] Remove deprecated fetch cast type Ref: https://github.com/rails/rails/pull/56484 --- .../connection_adapters/sqlserver/quoting.rb | 5 ++ test/cases/coerced_tests.rb | 23 +----- test/cases/column_test_sqlserver.rb | 78 +++++++++---------- 3 files changed, 46 insertions(+), 60 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/quoting.rb b/lib/active_record/connection_adapters/sqlserver/quoting.rb index 5f609aaa0..66a094518 100644 --- a/lib/active_record/connection_adapters/sqlserver/quoting.rb +++ b/lib/active_record/connection_adapters/sqlserver/quoting.rb @@ -79,6 +79,7 @@ def quote_string_single_national(s) def quote_default_expression(value, column) cast_type = lookup_cast_type(column.sql_type) + if cast_type.type == :uuid && value.is_a?(String) && value.include?("()") value else @@ -133,6 +134,10 @@ def type_cast(value) super end end + + def lookup_cast_type(sql_type) + type_map.lookup(sql_type) + end end end end diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index 44087dbe9..137aa8253 100644 --- a/test/cases/coerced_tests.rb +++ b/test/cases/coerced_tests.rb @@ -523,8 +523,8 @@ def test_create_table_with_defaults_coerce five = columns.detect { |c| c.name == "five" } assert_equal "hello", one.default - assert_equal true, two.fetch_cast_type(connection).deserialize(two.default) - assert_equal false, three.fetch_cast_type(connection).deserialize(three.default) + assert_equal true, two.cast_type.deserialize(two.default) + assert_equal false, three.cast_type.deserialize(three.default) assert_equal 1, four.default assert_equal "hello", five.default end @@ -1993,20 +1993,6 @@ class SchemaCacheTest < ActiveRecord::TestCase # Tests fail on Windows AppVeyor CI with 'Permission denied' error when renaming file during `File.atomic_write` call. coerce_tests! :test_yaml_dump_and_load, :test_yaml_dump_and_load_with_gzip if /mswin|mingw/.match?(RbConfig::CONFIG["host_os"]) - # Cast type in SQL Server is :varchar rather than Unicode :string. - coerce_tests! :test_yaml_load_8_0_dump_without_cast_type_still_get_the_right_one - def test_yaml_load_8_0_dump_without_cast_type_still_get_the_right_one - cache = load_bound_reflection(schema_dump_8_0_path) - - assert_no_queries do - columns = cache.columns_hash("courses") - assert_equal 3, columns.size - cast_type = columns["name"].fetch_cast_type(@connection) - assert_not_nil cast_type, "expected cast_type to be present" - assert_equal :varchar, cast_type.type - end - end - private # We need to give the full paths for this to work. @@ -2014,11 +2000,6 @@ def test_yaml_load_8_0_dump_without_cast_type_still_get_the_right_one def schema_dump_5_1_path File.join(ARTest::SQLServer.root_activerecord, "test/assets/schema_dump_5_1.yml") end - - undef_method :schema_dump_8_0_path - def schema_dump_8_0_path - File.join(ARTest::SQLServer.root_activerecord, "test/assets/schema_dump_8_0.yml") - end end end end diff --git a/test/cases/column_test_sqlserver.rb b/test/cases/column_test_sqlserver.rb index e55937662..b4ae10d11 100644 --- a/test/cases/column_test_sqlserver.rb +++ b/test/cases/column_test_sqlserver.rb @@ -44,7 +44,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal 42 _(obj.bigint).must_equal 42 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::BigInteger _(type.limit).must_equal 8 assert_obj_set_and_save :bigint, -9_223_372_036_854_775_808 @@ -59,7 +59,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal 42 _(obj.int).must_equal 42 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Integer _(type.limit).must_equal 4 assert_obj_set_and_save :int, -2_147_483_648 @@ -74,7 +74,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal 42 _(obj.smallint).must_equal 42 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::SmallInteger _(type.limit).must_equal 2 assert_obj_set_and_save :smallint, -32_768 @@ -89,7 +89,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal 42 _(obj.tinyint).must_equal 42 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::TinyInteger _(type.limit).must_equal 1 assert_obj_set_and_save :tinyint, 0 @@ -104,7 +104,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal true _(obj.bit).must_equal true _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Boolean _(type.limit).must_be_nil obj.bit = 0 @@ -125,7 +125,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal BigDecimal("12345.01") _(obj.decimal_9_2).must_equal BigDecimal("12345.01") _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Decimal _(type.limit).must_be_nil _(type.precision).must_equal 9 @@ -142,7 +142,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal BigDecimal("1234567.89") _(obj.decimal_16_4).must_equal BigDecimal("1234567.89") _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type.precision).must_equal 16 _(type.scale).must_equal 4 obj.decimal_16_4 = "1234567.8901001" @@ -160,7 +160,7 @@ def assert_obj_set_and_save(attribute, value) _(obj.numeric_18_0).must_equal BigDecimal(191) _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::DecimalWithoutScale _(type.limit).must_be_nil _(type.precision).must_equal 18 @@ -181,7 +181,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal BigDecimal("12345678901234567890.01") _(obj.numeric_36_2).must_equal BigDecimal("12345678901234567890.01") _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Decimal _(type.limit).must_be_nil _(type.precision).must_equal 36 @@ -200,7 +200,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal BigDecimal("4.20") _(obj.money).must_equal BigDecimal("4.20") _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Money _(type.limit).must_be_nil _(type.precision).must_equal 19 @@ -219,7 +219,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal BigDecimal("4.20") _(obj.smallmoney).must_equal BigDecimal("4.20") _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::SmallMoney _(type.limit).must_be_nil _(type.precision).must_equal 10 @@ -242,7 +242,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal 123.00000001 _(obj.float).must_equal 123.00000001 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Float _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -261,7 +261,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_be_close_to 123.45, 0.01 _(obj.real).must_be_close_to 123.45, 0.01 _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Real _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -282,7 +282,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal Date.civil(1, 1, 1) _(obj.date).must_equal Date.civil(1, 1, 1) _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Date _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -321,7 +321,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal time, "Microseconds were <#{col.default.usec}> vs <123000>" _(obj.datetime).must_equal time, "Microseconds were <#{obj.datetime.usec}> vs <123000>" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTime _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -367,7 +367,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal time, "Nanoseconds were <#{col.default.nsec}> vs <999999900>" _(obj.datetime2_7).must_equal time, "Nanoseconds were <#{obj.datetime2_7.nsec}> vs <999999900>" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTime2 _(type.limit).must_be_nil _(type.precision).must_equal 7 @@ -397,7 +397,7 @@ def assert_obj_set_and_save(attribute, value) # datetime2_3 time = Time.utc 9999, 12, 31, 23, 59, 59, Rational(123456789, 1000) col = column("datetime2_3") - _(col.fetch_cast_type(connection).precision).must_equal 3 + _(col.cast_type.precision).must_equal 3 obj.datetime2_3 = time _(obj.datetime2_3).must_equal time.change(nsec: 123000000), "Nanoseconds were <#{obj.datetime2_3.nsec}> vs <123000000>" obj.save! @@ -406,7 +406,7 @@ def assert_obj_set_and_save(attribute, value) _(obj).must_equal obj.class.where(datetime2_3: time).first # datetime2_1 col = column("datetime2_1") - _(col.fetch_cast_type(connection).precision).must_equal 1 + _(col.cast_type.precision).must_equal 1 obj.datetime2_1 = time _(obj.datetime2_1).must_equal time.change(nsec: 100000000), "Nanoseconds were <#{obj.datetime2_1.nsec}> vs <100000000>" obj.save! @@ -415,7 +415,7 @@ def assert_obj_set_and_save(attribute, value) _(obj).must_equal obj.class.where(datetime2_1: time).first # datetime2_0 col = column("datetime2_0") - _(col.fetch_cast_type(connection).precision).must_equal 0 + _(col.cast_type.precision).must_equal 0 time = Time.utc 2016, 4, 19, 16, 45, 40, 771036 obj.datetime2_0 = time _(obj.datetime2_0).must_equal time.change(nsec: 0), "Nanoseconds were <#{obj.datetime2_0.nsec}> vs <0>" @@ -433,7 +433,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal Time.new(1984, 1, 24, 4, 20, 0, -28800).change(nsec: 123456700), "Nanoseconds <#{col.default.nsec}> vs <123456700>" _(obj.datetimeoffset_7).must_equal Time.new(1984, 1, 24, 4, 20, 0, -28800).change(nsec: 123456700), "Nanoseconds were <#{obj.datetimeoffset_7.nsec}> vs <999999900>" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::DateTimeOffset _(type.limit).must_be_nil _(type.precision).must_equal 7 @@ -458,13 +458,13 @@ def assert_obj_set_and_save(attribute, value) # With other precisions. time = ActiveSupport::TimeZone["America/Los_Angeles"].local 2010, 12, 31, 23, 59, 59, Rational(123456755, 1000) col = column("datetimeoffset_3") - _(col.fetch_cast_type(connection).precision).must_equal 3 + _(col.cast_type.precision).must_equal 3 obj.datetimeoffset_3 = time _(obj.datetimeoffset_3).must_equal time.change(nsec: 123000000), "Nanoseconds were <#{obj.datetimeoffset_3.nsec}> vs <123000000>" obj.save! _(obj.datetimeoffset_3).must_equal time.change(nsec: 123000000), "Nanoseconds were <#{obj.datetimeoffset_3.nsec}> vs <123000000>" col = column("datetime2_1") - _(col.fetch_cast_type(connection).precision).must_equal 1 + _(col.cast_type.precision).must_equal 1 obj.datetime2_1 = time _(obj.datetime2_1).must_equal time.change(nsec: 100000000), "Nanoseconds were <#{obj.datetime2_1.nsec}> vs <100000000>" obj.save! @@ -479,7 +479,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal Time.utc(1901, 1, 1, 15, 45, 0, 0) _(obj.smalldatetime).must_equal Time.utc(1901, 1, 1, 15, 45, 0, 0) _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::SmallDateTime _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -500,7 +500,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_equal Time.utc(1900, 1, 1, 4, 20, 0, Rational(288321500, 1000)), "Nanoseconds were <#{col.default.nsec}> vs <288321500>" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Time _(type.limit).must_be_nil _(type.precision).must_equal 7 @@ -534,7 +534,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Time _(type.limit).must_be_nil _(type.precision).must_equal 2 @@ -566,7 +566,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_equal Time.utc(1900, 1, 1, 15, 3, 42, Rational(62197800, 1000)), "Nanoseconds were <#{col.default.nsec}> vs <62197800>" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Time _(type.limit).must_be_nil _(type.precision).must_equal 7 @@ -603,7 +603,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "1234567890" _(obj.char_10).must_equal "1234567890" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Char _(type.limit).must_equal 10 _(type.precision).must_be_nil @@ -623,7 +623,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test varchar_50" _(obj.varchar_50).must_equal "test varchar_50" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Varchar _(type.limit).must_equal 50 _(type.precision).must_be_nil @@ -640,7 +640,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test varchar_max" _(obj.varchar_max).must_equal "test varchar_max" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::VarcharMax _(type.limit).must_equal 2_147_483_647 _(type.precision).must_be_nil @@ -657,7 +657,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test text" _(obj.text).must_equal "test text" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Text _(type.limit).must_equal 2_147_483_647 _(type.precision).must_be_nil @@ -676,7 +676,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "12345678åå" _(obj.nchar_10).must_equal "12345678åå" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::UnicodeChar _(type.limit).must_equal 10 _(type.precision).must_be_nil @@ -696,7 +696,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test nvarchar_50 åå" _(obj.nvarchar_50).must_equal "test nvarchar_50 åå" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::UnicodeVarchar _(type.limit).must_equal 50 _(type.precision).must_be_nil @@ -713,7 +713,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test nvarchar_max åå" _(obj.nvarchar_max).must_equal "test nvarchar_max åå" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::UnicodeVarcharMax _(type.limit).must_equal 2_147_483_647 _(type.precision).must_be_nil @@ -730,7 +730,7 @@ def assert_obj_set_and_save(attribute, value) _(col.default).must_equal "test ntext åå" _(obj.ntext).must_equal "test ntext åå" _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::UnicodeText _(type.limit).must_equal 2_147_483_647 _(type.precision).must_be_nil @@ -751,7 +751,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Binary _(type.limit).must_equal 49 _(type.precision).must_be_nil @@ -772,7 +772,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Varbinary _(type.limit).must_equal 49 _(type.precision).must_be_nil @@ -793,7 +793,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::VarbinaryMax _(type.limit).must_equal 2_147_483_647 _(type.precision).must_be_nil @@ -812,7 +812,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_equal "newid()" - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Uuid _(type.limit).must_be_nil _(type.precision).must_be_nil @@ -837,7 +837,7 @@ def assert_obj_set_and_save(attribute, value) _(col.null).must_equal true _(col.default).must_be_nil _(col.default_function).must_be_nil - type = col.fetch_cast_type(connection) + type = col.cast_type _(type).must_be_instance_of ActiveRecord::ConnectionAdapters::SQLServer::Type::Timestamp _(type.limit).must_be_nil _(type.precision).must_be_nil From 893011f3ee06e74999fa97c9395771163a81dbce Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Mon, 27 Apr 2026 20:03:21 +0100 Subject: [PATCH 08/12] Use schema cache for primary key lookup during insert Ref: https://github.com/rails/rails/commit/3677d34bf988f23207adc98e733b7dbf92e4a3d3 --- .../connection_adapters/sqlserver/database_statements.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver/database_statements.rb b/lib/active_record/connection_adapters/sqlserver/database_statements.rb index 3c8d3d9d9..a3fbe0126 100644 --- a/lib/active_record/connection_adapters/sqlserver/database_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/database_statements.rb @@ -364,7 +364,7 @@ def newsequentialid_function def sql_for_insert(sql, pk, binds, returning) if pk.nil? table_name = query_requires_identity_insert?(sql) - pk = primary_key(table_name) + pk = schema_cache.primary_keys(table_name) end sql = if pk && use_output_inserted? && !database_prefix_remote_server? From 0ea04862d80da6f6274292f9580f0387c8ebfd91 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Mon, 27 Apr 2026 18:57:46 +0100 Subject: [PATCH 09/12] Materialize transactions before instrumentation Ref: https://github.com/rails/rails/commit/8fd2714a35a0b7da9ecd7ffcb843a3b6b4e428cc --- .../connection_adapters/sqlserver/quoting.rb | 2 +- test/cases/coerced_tests.rb | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lib/active_record/connection_adapters/sqlserver/quoting.rb b/lib/active_record/connection_adapters/sqlserver/quoting.rb index 66a094518..ca0369587 100644 --- a/lib/active_record/connection_adapters/sqlserver/quoting.rb +++ b/lib/active_record/connection_adapters/sqlserver/quoting.rb @@ -119,7 +119,7 @@ def quote(value) "0x#{value.hex}" when ActiveRecord::Type::SQLServer::Data value.quoted - when String, ActiveSupport::Multibyte::Chars + when String "N#{super}" else super diff --git a/test/cases/coerced_tests.rb b/test/cases/coerced_tests.rb index 137aa8253..2c75db1b1 100644 --- a/test/cases/coerced_tests.rb +++ b/test/cases/coerced_tests.rb @@ -2824,3 +2824,33 @@ def test_in_batches_loaded_should_unscope_cursor_after_pluck_coerced end end +class TransactionInstrumentationTest < ActiveRecord::TestCase + # SQL Server does not have query for release_savepoint. + coerce_tests! :test_sql_events_do_not_overlap_with_savepoints + def test_sql_events_do_not_overlap_with_savepoints_coerced + events = [] + subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event| + events << event + end + + Topic.transaction do + Topic.count + Topic.transaction(requires_new: true) { Topic.first } + end + + assert_equal 5, events.size + begin_event, count_event, savepoint_event, select_event, commit_event = events + + assert begin_event.payload[:sql].start_with?("BEGIN") + assert count_event.payload[:sql].start_with?("SELECT") + assert savepoint_event.payload[:sql].start_with?("SAVE TRANSACTION") + assert select_event.payload[:sql].start_with?("SELECT") + assert commit_event.payload[:sql].start_with?("COMMIT") + + events.each_cons(2) do |a, b| + assert_operator a.end, :<=, b.time + end + ensure + ActiveSupport::Notifications.unsubscribe(subscriber) + end +end From 73836f525b483a9f0dc666e07ea161bad50d6c29 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Wed, 29 Apr 2026 19:54:51 +0100 Subject: [PATCH 10/12] Batch SQL statements when creating tables Ref: https://github.com/rails/rails/pull/57000/files --- .../sqlserver/schema_statements.rb | 38 ++++++++++--------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index a73bb7b69..48aa532fb 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -10,25 +10,29 @@ def create_table(table_name, **options) res end - def drop_table(*table_names, **options) - table_names.each do |table_name| - # Mimic CASCADE option as best we can. - if options[:force] == :cascade - execute_procedure(:sp_fkeys, pktable_name: table_name).each do |fkdata| - fktable = fkdata["FKTABLE_NAME"] - fkcolmn = fkdata["FKCOLUMN_NAME"] - pktable = fkdata["PKTABLE_NAME"] - pkcolmn = fkdata["PKCOLUMN_NAME"] - remove_foreign_key fktable, name: fkdata["FK_NAME"] - execute "DELETE FROM #{quote_table_name(fktable)} WHERE #{quote_column_name(fkcolmn)} IN ( SELECT #{quote_column_name(pkcolmn)} FROM #{quote_table_name(pktable)} )" - end - end - if options[:if_exists] && version_year < 2016 - execute "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = #{quote(table_name)}) DROP TABLE #{quote_table_name(table_name)}", "SCHEMA" - else - super + def drop_table_sql(table_name, if_exists: nil, force: nil, **) + sqls = [] + + # Mimic CASCADE option as best we can. + if force == :cascade + execute_procedure(:sp_fkeys, pktable_name: table_name).each do |fkdata| + fktable = fkdata["FKTABLE_NAME"] + fkcolmn = fkdata["FKCOLUMN_NAME"] + pktable = fkdata["PKTABLE_NAME"] + pkcolmn = fkdata["PKCOLUMN_NAME"] + remove_foreign_key fktable, name: fkdata["FK_NAME"] + + sqls << "DELETE FROM #{quote_table_name(fktable)} WHERE #{quote_column_name(fkcolmn)} IN ( SELECT #{quote_column_name(pkcolmn)} FROM #{quote_table_name(pktable)} )" end end + + if if_exists && version_year < 2016 + sqls << "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = #{quote(table_name)}) DROP TABLE #{quote_table_name(table_name)}" + else + sqls << super + end + + sqls.join("; ") end def indexes(table_name) From 5eb1348a2cfe3d6e01c33eaabbe73c41f49097f8 Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Thu, 30 Apr 2026 18:44:12 +0100 Subject: [PATCH 11/12] Standardrb --- .../connection_adapters/sqlserver/schema_statements.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb index 48aa532fb..1d58747a1 100644 --- a/lib/active_record/connection_adapters/sqlserver/schema_statements.rb +++ b/lib/active_record/connection_adapters/sqlserver/schema_statements.rb @@ -26,10 +26,10 @@ def drop_table_sql(table_name, if_exists: nil, force: nil, **) end end - if if_exists && version_year < 2016 - sqls << "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = #{quote(table_name)}) DROP TABLE #{quote_table_name(table_name)}" + sqls << if if_exists && version_year < 2016 + "IF EXISTS(SELECT * FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = #{quote(table_name)}) DROP TABLE #{quote_table_name(table_name)}" else - sqls << super + super end sqls.join("; ") From 4f87ce3028343f673ccda0e4ccad96db68c9cabc Mon Sep 17 00:00:00 2001 From: Aidan Haran Date: Fri, 1 May 2026 19:14:13 +0100 Subject: [PATCH 12/12] Fix CI to test against Rails main --- Dockerfile.ci | 2 +- compose.ci.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Dockerfile.ci b/Dockerfile.ci index aad53b3cb..cfecca3a2 100644 --- a/Dockerfile.ci +++ b/Dockerfile.ci @@ -9,6 +9,6 @@ WORKDIR $WORKDIR COPY . $WORKDIR -RUN RAILS_COMMIT=65dc2163e3 bundle install --jobs `expr $(cat /proc/cpuinfo | grep -c "cpu cores") - 1` --retry 3 +RUN RAILS_BRANCH=main bundle install --jobs `expr $(cat /proc/cpuinfo | grep -c "cpu cores") - 1` --retry 3 CMD ["sh"] diff --git a/compose.ci.yaml b/compose.ci.yaml index 12fcae5ce..8242db9b8 100644 --- a/compose.ci.yaml +++ b/compose.ci.yaml @@ -4,7 +4,7 @@ services: ci: environment: - ACTIVERECORD_UNITTEST_HOST=sqlserver - - RAILS_COMMIT=65dc2163e3 + - RAILS_BRANCH=main build: context: . dockerfile: Dockerfile.ci @@ -13,7 +13,7 @@ services: - "sqlserver" standardrb: environment: - - RAILS_COMMIT=65dc2163e3 + - RAILS_BRANCH=main build: context: . dockerfile: Dockerfile.ci