From 7da1d65f984d3cb706f7dd120e96cb38a83a6660 Mon Sep 17 00:00:00 2001 From: viogroza Date: Fri, 18 Sep 2026 14:22:39 +0300 Subject: [PATCH 1/5] Database: Fix NullReferenceException in ExecuteNonQuery with ContinueOnError=True [STUD-81504] ExecuteNonQuery swallowed the exception via ConnectionHelper.HandleException when ContinueOnError=True, but then unconditionally accessed affectedRecords.Result, which was still null since it's only assigned on a successful ExecuteCommand call. Add the same null guard ExecuteQuery.cs already has for the identical pattern. Co-Authored-By: Claude Sonnet 5 --- .../ExecuteNonQuery.cs | 2 ++ .../SqliteIntegrationTests.cs | 17 +++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs index 5c07f9529..c39f15dbd 100644 --- a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs +++ b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs @@ -84,6 +84,8 @@ protected async override Task> ExecuteAsync(Asy } var result = new Action(asyncCodeActivityContext => { + if (affectedRecords == null) return; + AffectedRecords.Set(asyncCodeActivityContext, affectedRecords.Result); ConnectionHelper.SetOutputParameters(asyncCodeActivityContext, Parameters, affectedRecords.ParametersBind); }); diff --git a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs index 7132a25c6..79a8352db 100644 --- a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs +++ b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs @@ -320,6 +320,23 @@ public void DatabaseTransaction_InternalConnection_OutputNotBound_IsDisposed() } } + [Fact, TestPriority(12)] + public void ExecuteNonQuery_ContinueOnError_SwallowsExceptionInsteadOfThrowingNullReferenceException() + { + var activity = new ExecuteNonQuery + { + ExistingDbConnection = new InArgument(_ => _fixture.Connection), + Sql = new InArgument("SELECT * FROM ThisTableDoesNotExist_Probe"), + ContinueOnError = new InArgument(true), + AffectedRecords = new OutArgument() + }; + + var outputs = WorkflowInvoker.Invoke(activity, TimeSpan.FromSeconds(30)); + + var affected = (int)outputs[nameof(ExecuteNonQuery.AffectedRecords)]; + Assert.Equal(0, affected); + } + private static string NewTempDbPath() => Path.Combine(Path.GetTempPath(), $"uipath_sqlite_txn_{Guid.NewGuid():N}.db"); From 285b72c34e8ef818ab4d7d34bddce2fc880c4137 Mon Sep 17 00:00:00 2001 From: viogroza Date: Mon, 21 Sep 2026 11:19:37 +0300 Subject: [PATCH 2/5] Database: Reset AffectedRecords to 0 on swallowed ContinueOnError failure Initializing affectedRecords to an empty DBExecuteCommandResult instead of null makes ExecuteNonQuery match InsertDataTable/BulkInsert/BulkUpdate, which all reset their output to 0 rather than leaving a stale value from a prior successful run when ContinueOnError swallows an exception. The now-unreachable null guard is removed, and a regression test pins the reset behavior using a pre-seeded variable so the assertion can't pass by coincidence of OutArgument's default value. Addresses review feedback on PR #600 (STUD-81504). Co-Authored-By: Claude Sonnet 5 --- .../ExecuteNonQuery.cs | 4 +- .../SqliteIntegrationTests.cs | 42 ++++++++++++++++--- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs index c39f15dbd..e7e5ad31b 100644 --- a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs +++ b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs @@ -47,7 +47,7 @@ protected async override Task> ExecuteAsync(Asy string sql = string.Empty; int? commandTimeoutMs = TimeoutMS.Expression is null ? (int?)null : TimeoutMS.Get(context); DatabaseConnection existingConnection = null; - DBExecuteCommandResult affectedRecords = null; + DBExecuteCommandResult affectedRecords = new DBExecuteCommandResult(); if (commandTimeoutMs.HasValue && commandTimeoutMs.Value < 0) { throw new ArgumentException(Resources.TimeoutMSException, nameof(TimeoutMS)); @@ -84,8 +84,6 @@ protected async override Task> ExecuteAsync(Asy } var result = new Action(asyncCodeActivityContext => { - if (affectedRecords == null) return; - AffectedRecords.Set(asyncCodeActivityContext, affectedRecords.Result); ConnectionHelper.SetOutputParameters(asyncCodeActivityContext, Parameters, affectedRecords.ParametersBind); }); diff --git a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs index 79a8352db..bce04993f 100644 --- a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs +++ b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs @@ -257,7 +257,7 @@ public void DatabaseTransaction_InternalConnection_OutputBound_IsNotDisposed() try { var connVar = new Variable(); - var capture = new CaptureConnection { Input = new InArgument(connVar) }; + var capture = new CaptureValue { Input = new InArgument(connVar) }; var workflow = new Sequence { @@ -337,6 +337,36 @@ public void ExecuteNonQuery_ContinueOnError_SwallowsExceptionInsteadOfThrowingNu Assert.Equal(0, affected); } + [Fact, TestPriority(13)] + public void ExecuteNonQuery_ContinueOnError_ResetsAffectedRecordsInsteadOfLeavingStaleValue() + { + // Pre-seed the bound variable with a value the activity did NOT produce, so the + // assertion can only pass if ExecuteNonQuery actively resets it to 0 on the swallowed + // failure path, not because an unbound OutArgument happens to default to 0. + var affectedVar = new Variable { Default = 42 }; + var capture = new CaptureValue { Input = new InArgument(affectedVar) }; + + var workflow = new Sequence + { + Variables = { affectedVar }, + Activities = + { + new ExecuteNonQuery + { + ExistingDbConnection = new InArgument(_ => _fixture.Connection), + Sql = new InArgument("SELECT * FROM ThisTableDoesNotExist_Probe"), + ContinueOnError = new InArgument(true), + AffectedRecords = new OutArgument(affectedVar) + }, + capture + } + }; + + WorkflowInvoker.Invoke(workflow, TimeSpan.FromSeconds(30)); + + Assert.Equal(0, capture.Value); + } + private static string NewTempDbPath() => Path.Combine(Path.GetTempPath(), $"uipath_sqlite_txn_{Guid.NewGuid():N}.db"); @@ -360,13 +390,13 @@ private static DataTable BuildPeopleTable(params (int Id, string Name, int Age)[ } /// - /// Reads a argument at the end of a workflow body and exposes - /// it to the test, so the connection's post-scope state can be asserted. + /// Reads an argument at the end of a workflow body and exposes it to the test, so a + /// variable's post-scope state can be asserted. /// - private sealed class CaptureConnection : CodeActivity + private sealed class CaptureValue : CodeActivity { - public InArgument Input { get; set; } - public DatabaseConnection Value { get; private set; } + public InArgument Input { get; set; } + public T Value { get; private set; } protected override void Execute(CodeActivityContext context) { From d94a499f0698de15a39f4ef67b058aae9f6bf188 Mon Sep 17 00:00:00 2001 From: viogroza Date: Tue, 22 Sep 2026 10:34:57 +0300 Subject: [PATCH 3/5] Database: Fix SonarCloud reliability/maintainability findings in ExecuteNonQuery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pass cancellationToken through to Task.Run so the command respects workflow cancellation instead of always running to completion (S8949). - Drop the invalid paramName from the TimeoutMS ArgumentException — TimeoutMS is a property, not a parameter of ExecuteAsync, so nameof(TimeoutMS) never matched a real parameter (S3928/CA2208). - Seal DBExecuteCommandResult, a private class with no derived types (S3260). Co-Authored-By: Claude Sonnet 5 --- .../Database/UiPath.Database.Activities/ExecuteNonQuery.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs index e7e5ad31b..853a48591 100644 --- a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs +++ b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs @@ -50,7 +50,7 @@ protected async override Task> ExecuteAsync(Asy DBExecuteCommandResult affectedRecords = new DBExecuteCommandResult(); if (commandTimeoutMs.HasValue && commandTimeoutMs.Value < 0) { - throw new ArgumentException(Resources.TimeoutMSException, nameof(TimeoutMS)); + throw new ArgumentException(Resources.TimeoutMSException); } TimeSpan? commandTimeout = commandTimeoutMs.HasValue ? TimeSpan.FromMilliseconds(commandTimeoutMs.Value) : (TimeSpan?)null; Dictionary parameters = null; @@ -66,7 +66,7 @@ protected async override Task> ExecuteAsync(Asy parameters = ConnectionHelper.BuildParameters(Parameters, context); ConnectionHelper.ConnectionValidation(existingConnection, connSecureString, connString, provName); // create the action for doing the actual work - affectedRecords = await Task.Run(() => ExecuteCommand(connString, connSecureString, provName, sql, parameters, commandTimeout)); + affectedRecords = await Task.Run(() => ExecuteCommand(connString, connSecureString, provName, sql, parameters, commandTimeout), cancellationToken); } catch (Exception ex) { @@ -110,7 +110,7 @@ private DBExecuteCommandResult ExecuteCommand(string connString, SecureString co return new DBExecuteCommandResult(DbConnection.Execute(sql, parameters, commandTimeout, CommandType), parameters); } - private class DBExecuteCommandResult + private sealed class DBExecuteCommandResult { public int Result { get; } public Dictionary ParametersBind { get; } From 087134a3e879bdbb7ff4ff4c71b3b28889ae17f5 Mon Sep 17 00:00:00 2001 From: viogroza Date: Tue, 22 Sep 2026 11:19:47 +0300 Subject: [PATCH 4/5] Database: Add test coverage for ExecuteNonQuery negative TimeoutMS validation The Sonar-fix commit touched the ArgumentException throw for a negative TimeoutMS, which put that line into the PR's new-code coverage window with zero existing test hits and dropped new-code coverage below the 80% quality gate threshold. Add a regression test for that validation path. Co-Authored-By: Claude Sonnet 5 --- .../SqliteIntegrationTests.cs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs index bce04993f..86036bef3 100644 --- a/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs +++ b/Activities/Database/UiPath.Database.Tests/SqliteIntegrationTests.cs @@ -367,6 +367,20 @@ public void ExecuteNonQuery_ContinueOnError_ResetsAffectedRecordsInsteadOfLeavin Assert.Equal(0, capture.Value); } + [Fact, TestPriority(14)] + public void ExecuteNonQuery_NegativeTimeoutMS_ThrowsArgumentException() + { + var activity = new ExecuteNonQuery + { + ExistingDbConnection = new InArgument(_ => _fixture.Connection), + Sql = new InArgument("SELECT 1"), + TimeoutMS = new InArgument(-1), + AffectedRecords = new OutArgument() + }; + + Assert.Throws(() => WorkflowInvoker.Invoke(activity, TimeSpan.FromSeconds(30))); + } + private static string NewTempDbPath() => Path.Combine(Path.GetTempPath(), $"uipath_sqlite_txn_{Guid.NewGuid():N}.db"); From c7c6628242607fb7d92a2c085bab7457403edaba Mon Sep 17 00:00:00 2001 From: viogroza Date: Thu, 24 Sep 2026 14:44:22 +0300 Subject: [PATCH 5/5] Database: Preserve ParamName on TimeoutMS ArgumentException Restores nameof(TimeoutMS) as the ArgumentException paramName, keeping ExecuteNonQuery consistent with ExecuteQuery, BulkInsert, InsertDataTable, and BulkUpdate, which all still pass it. The Sonar CA2208 finding this was dropped for applies identically to those siblings, none of which were touched by this PR, so fixing it only here created an inconsistency rather than resolving the underlying issue. Co-Authored-By: Claude Sonnet 5 --- .../Database/UiPath.Database.Activities/ExecuteNonQuery.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs index 853a48591..ed828b933 100644 --- a/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs +++ b/Activities/Database/UiPath.Database.Activities/ExecuteNonQuery.cs @@ -50,7 +50,7 @@ protected async override Task> ExecuteAsync(Asy DBExecuteCommandResult affectedRecords = new DBExecuteCommandResult(); if (commandTimeoutMs.HasValue && commandTimeoutMs.Value < 0) { - throw new ArgumentException(Resources.TimeoutMSException); + throw new ArgumentException(Resources.TimeoutMSException, nameof(TimeoutMS)); } TimeSpan? commandTimeout = commandTimeoutMs.HasValue ? TimeSpan.FromMilliseconds(commandTimeoutMs.Value) : (TimeSpan?)null; Dictionary parameters = null;