diff --git a/src/Core/Resolvers/QueryExecutor.cs b/src/Core/Resolvers/QueryExecutor.cs index 908c1bb1e8..97e2f7e8d4 100644 --- a/src/Core/Resolvers/QueryExecutor.cs +++ b/src/Core/Resolvers/QueryExecutor.cs @@ -740,6 +740,12 @@ internal int StreamCharData(DbDataReader dbDataReader, long availableSize, Strin // else we throw exception. ValidateSize(availableSize, resultFieldSize); + // If the cell is empty, don't append anything to the resultJsonString and return 0. + if (resultFieldSize == 0) + { + return 0; + } + char[] buffer = new char[resultFieldSize]; // read entire field into buffer and reduce available size. @@ -766,6 +772,13 @@ internal int StreamByteData(DbDataReader dbDataReader, long availableSize, int o // else we throw exception. ValidateSize(availableSize, resultFieldSize); + // If the cell is empty, set resultBytes to an empty array and return 0. + if (resultFieldSize == 0) + { + resultBytes = Array.Empty(); + return 0; + } + resultBytes = new byte[resultFieldSize]; dbDataReader.GetBytes(ordinal: ordinal, dataOffset: 0, buffer: resultBytes, bufferOffset: 0, length: resultBytes.Length); diff --git a/src/Service.Tests/DatabaseSchema-DwSql.sql b/src/Service.Tests/DatabaseSchema-DwSql.sql index 300ef7ff32..daed665949 100644 --- a/src/Service.Tests/DatabaseSchema-DwSql.sql +++ b/src/Service.Tests/DatabaseSchema-DwSql.sql @@ -336,7 +336,8 @@ VALUES (1, 'Awesome book', 1234), (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), -(20, 'C:\\LIFE', 1234); +(20, 'C:\\LIFE', 1234), +(21, '', 1234); INSERT INTO book_website_placements(id, book_id, price) VALUES (1, 1, 100), (2, 2, 50), (3, 3, 23), (4, 5, 33); diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 3605b2628a..4e87394aee 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -531,7 +531,8 @@ VALUES (1, 'Awesome book', 1234), (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), -(20, 'C:\\LIFE', 1234); +(20, 'C:\\LIFE', 1234), +(21, '', 1234); SET IDENTITY_INSERT books OFF SET IDENTITY_INSERT books_mm ON diff --git a/src/Service.Tests/DatabaseSchema-MySql.sql b/src/Service.Tests/DatabaseSchema-MySql.sql index f746bc063a..dda93d86d1 100644 --- a/src/Service.Tests/DatabaseSchema-MySql.sql +++ b/src/Service.Tests/DatabaseSchema-MySql.sql @@ -388,7 +388,8 @@ INSERT INTO books(id, title, publisher_id) (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\\YOU', 1234), - (20, 'C:\\\\LIFE', 1234); + (20, 'C:\\\\LIFE', 1234), + (21, '', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126); diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index 14615707b1..523e96c22f 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -391,7 +391,8 @@ INSERT INTO books(id, title, publisher_id) (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), - (20, 'C:\\LIFE', 1234); + (20, 'C:\\LIFE', 1234), + (21, '', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126);; diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index c9207a6672..745d5eade3 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -257,7 +257,7 @@ public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyD string currentDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); JsonDocument currentResult = JsonDocument.Parse(currentDbResponse); - Assert.AreEqual(currentResult.RootElement.GetProperty("maxId").GetInt64(), 20); + Assert.AreEqual(currentResult.RootElement.GetProperty("maxId").GetInt64(), 21); JsonElement graphQLResponse = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); // Stored Procedure didn't return anything @@ -266,7 +266,7 @@ public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyD // check to verify new element is inserted string updatedDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); JsonDocument updatedResult = JsonDocument.Parse(updatedDbResponse); - Assert.AreEqual(updatedResult.RootElement.GetProperty("maxId").GetInt64(), 19); + Assert.AreEqual(updatedResult.RootElement.GetProperty("maxId").GetInt64(), 20); } public async Task InsertMutationOnTableWithTriggerWithNonAutoGenPK(string dbQuery) diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs index e7e18d6090..33db8f8b49 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs @@ -84,95 +84,101 @@ public async Task RequestMaxUsingNegativeOne() } }"; - // this resultset represents all books in the db. - JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); string expected = @"{ - ""items"": [ + ""items"": [ { - ""id"": 1, - ""title"": ""Awesome book"" + ""id"": 1, + ""title"": ""Awesome book"" }, { - ""id"": 2, - ""title"": ""Also Awesome book"" + ""id"": 2, + ""title"": ""Also Awesome book"" }, { - ""id"": 3, - ""title"": ""Great wall of china explained"" + ""id"": 3, + ""title"": ""Great wall of china explained"" }, { - ""id"": 4, - ""title"": ""US history in a nutshell"" + ""id"": 4, + ""title"": ""US history in a nutshell"" }, { - ""id"": 5, - ""title"": ""Chernobyl Diaries"" + ""id"": 5, + ""title"": ""Chernobyl Diaries"" }, { - ""id"": 6, - ""title"": ""The Palace Door"" + ""id"": 6, + ""title"": ""The Palace Door"" }, { - ""id"": 7, - ""title"": ""The Groovy Bar"" + ""id"": 7, + ""title"": ""The Groovy Bar"" }, { - ""id"": 8, - ""title"": ""Time to Eat"" + ""id"": 8, + ""title"": ""Time to Eat"" }, { - ""id"": 9, - ""title"": ""Policy-Test-01"" + ""id"": 9, + ""title"": ""Policy-Test-01"" }, { - ""id"": 10, - ""title"": ""Policy-Test-02"" + ""id"": 10, + ""title"": ""Policy-Test-02"" }, { - ""id"": 11, - ""title"": ""Policy-Test-04"" + ""id"": 11, + ""title"": ""Policy-Test-04"" }, { - ""id"": 12, - ""title"": ""Time to Eat 2"" + ""id"": 12, + ""title"": ""Time to Eat 2"" + }, + { + ""id"": 13, + ""title"": ""Before Sunrise"" }, { - ""id"": 13, - ""title"": ""Before Sunrise"" + ""id"": 14, + ""title"": ""Before Sunset"" }, { - ""id"": 14, - ""title"": ""Before Sunset"" + ""id"": 15, + ""title"": ""SQL_CONN"" }, { - ""id"": 15, - ""title"": ""SQL_CONN"" + ""id"": 16, + ""title"": ""SOME%CONN"" }, { - ""id"": 16, - ""title"": ""SOME%CONN"" + ""id"": 17, + ""title"": ""CONN%_CONN"" }, { - ""id"": 17, - ""title"": ""CONN%_CONN"" + ""id"": 18, + ""title"": ""[Special Book]"" }, { - ""id"": 18, - ""title"": ""[Special Book]"" + ""id"": 19, + ""title"": ""ME\\YOU"" }, { - ""id"": 19, - ""title"": ""ME\\YOU"" + ""id"": 20, + ""title"": ""C:\\\\LIFE"" }, { - ""id"": 20, - ""title"": ""C:\\\\LIFE"" + ""id"": 21, + ""title"": """" } - ], - ""endCursor"": null, - ""hasNextPage"": false + ], + ""endCursor"": null, + ""hasNextPage"": false }"; + // Note: The max page size is 21 for MsSql and 20 for all other data sources, so when using -1 + // this resultset represents all books in the db. + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } @@ -196,91 +202,96 @@ public async Task RequestNoParamFullConnection() }"; JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = @"{ - ""items"": [ + ""items"": [ { - ""id"": 1, - ""title"": ""Awesome book"" + ""id"": 1, + ""title"": ""Awesome book"" }, { - ""id"": 2, - ""title"": ""Also Awesome book"" + ""id"": 2, + ""title"": ""Also Awesome book"" }, { - ""id"": 3, - ""title"": ""Great wall of china explained"" + ""id"": 3, + ""title"": ""Great wall of china explained"" }, { - ""id"": 4, - ""title"": ""US history in a nutshell"" + ""id"": 4, + ""title"": ""US history in a nutshell"" }, { - ""id"": 5, - ""title"": ""Chernobyl Diaries"" + ""id"": 5, + ""title"": ""Chernobyl Diaries"" }, { - ""id"": 6, - ""title"": ""The Palace Door"" + ""id"": 6, + ""title"": ""The Palace Door"" }, { - ""id"": 7, - ""title"": ""The Groovy Bar"" + ""id"": 7, + ""title"": ""The Groovy Bar"" }, { - ""id"": 8, - ""title"": ""Time to Eat"" + ""id"": 8, + ""title"": ""Time to Eat"" }, { - ""id"": 9, - ""title"": ""Policy-Test-01"" + ""id"": 9, + ""title"": ""Policy-Test-01"" }, { - ""id"": 10, - ""title"": ""Policy-Test-02"" + ""id"": 10, + ""title"": ""Policy-Test-02"" }, { - ""id"": 11, - ""title"": ""Policy-Test-04"" + ""id"": 11, + ""title"": ""Policy-Test-04"" }, { - ""id"": 12, - ""title"": ""Time to Eat 2"" + ""id"": 12, + ""title"": ""Time to Eat 2"" }, { - ""id"": 13, - ""title"": ""Before Sunrise"" + ""id"": 13, + ""title"": ""Before Sunrise"" }, { - ""id"": 14, - ""title"": ""Before Sunset"" + ""id"": 14, + ""title"": ""Before Sunset"" }, { - ""id"": 15, - ""title"": ""SQL_CONN"" + ""id"": 15, + ""title"": ""SQL_CONN"" }, { - ""id"": 16, - ""title"": ""SOME%CONN"" + ""id"": 16, + ""title"": ""SOME%CONN"" }, { - ""id"": 17, - ""title"": ""CONN%_CONN"" + ""id"": 17, + ""title"": ""CONN%_CONN"" }, { - ""id"": 18, - ""title"": ""[Special Book]"" + ""id"": 18, + ""title"": ""[Special Book]"" }, { - ""id"": 19, - ""title"": ""ME\\YOU"" + ""id"": 19, + ""title"": ""ME\\YOU"" }, { - ""id"": 20, - ""title"": ""C:\\\\LIFE"" + ""id"": 20, + ""title"": ""C:\\\\LIFE"" + }, + { + ""id"": 21, + ""title"": """" } - ], - ""endCursor"": null, - ""hasNextPage"": false + ], + ""endCursor"": null, + ""hasNextPage"": false }"; SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs index 00930103c7..1d90a4c6f1 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs @@ -268,6 +268,22 @@ SELECT title FROM books await QueryWithSingleColumnPrimaryKey(msSqlQuery); } + [TestMethod] + public virtual async Task QueryWithEmptyStringResult() + { + string graphQLQueryName = "book_by_pk"; + string graphQLQuery = @"{ + book_by_pk(id: 21) { + title + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + string title = actual.GetProperty("title").GetString(); + Assert.AreEqual("", title); + } + [TestMethod] public async Task QueryWithSingleColumnPrimaryKeyAndMappings() { diff --git a/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs b/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs index c574db540f..984b252727 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs @@ -20,7 +20,7 @@ public class DwSqlDeleteApiTests : DeleteApiTestBase { "DeleteOneWithStoredProcedureTest", $"SELECT [id] FROM { _integrationTableName } " + - $"WHERE id = 20" + $"WHERE id = 21" } }; #region Test Fixture Setup diff --git a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs index 13f4d31cf2..cf8a1f6fc5 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs @@ -29,7 +29,7 @@ public class MsSqlDeleteApiTests : DeleteApiTestBase // This query is used to confirm that the item no longer exists, not the // actual delete query. $"SELECT [id] FROM { _integrationTableName } " + - $"WHERE id = 20 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + $"WHERE id = 21 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" } }; #region Test Fixture Setup diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index 908b7019c4..2b62c6b444 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -623,6 +623,51 @@ public void ValidateStreamingLogicForStoredProcedures(int readDataLoops, bool ex } } + /// + /// Makes sure the stream logic handles cells with empty strings correctly. + /// + [DataTestMethod, TestCategory(TestCategory.MSSQL)] + public void ValidateStreamingLogicForEmptyCellsAsync() + { + TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(Cors: null, Authentication: null, MaxResponseSizeMB: 5) + ), + Entities: new(new Dictionary())); + + RuntimeConfigProvider runtimeConfigProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(runtimeConfig); + + Mock>> queryExecutorLogger = new(); + Mock httpContextAccessor = new(); + DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(runtimeConfigProvider); + + // Instantiate the MsSqlQueryExecutor and Setup parameters for the query + MsSqlQueryExecutor msSqlQueryExecutor = new(runtimeConfigProvider, dbExceptionParser, queryExecutorLogger.Object, httpContextAccessor.Object); + + Mock dbDataReader = new(); + dbDataReader.Setup(d => d.HasRows).Returns(true); + + // Make sure GetChars returns 0 when buffer is null + dbDataReader.Setup(x => x.GetChars(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())).Returns(0); + + // Make sure available size is set to > 0 + int availableSize = (int)runtimeConfig.MaxResponseSizeMB() * 1024 * 1024; + + // Stream char data should not return an exception + availableSize -= msSqlQueryExecutor.StreamCharData( + dbDataReader: dbDataReader.Object, availableSize: availableSize, resultJsonString: new(), ordinal: 0); + + Assert.AreEqual(availableSize, (int)runtimeConfig.MaxResponseSizeMB() * 1024 * 1024); + } + [TestCleanup] public void CleanupAfterEachTest() {