From 855efed9ae2fcffbded9a71890cf0fdcf38b97b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:08:02 +0000 Subject: [PATCH 1/6] Initial plan From 8b01458105e92061e79ee5dce40bff148f5a6e7d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:12:48 +0000 Subject: [PATCH 2/6] Implement fix for special characters in filter clause Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- .../RestRequestContexts/RestRequestContext.cs | 6 +++ src/Core/Parsers/RequestParser.cs | 54 +++++++++++++++++-- src/Core/Services/RestService.cs | 2 + 3 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/Core/Models/RestRequestContexts/RestRequestContext.cs b/src/Core/Models/RestRequestContexts/RestRequestContext.cs index 70d6a371b5..e9987730a0 100644 --- a/src/Core/Models/RestRequestContexts/RestRequestContext.cs +++ b/src/Core/Models/RestRequestContexts/RestRequestContext.cs @@ -77,6 +77,12 @@ protected RestRequestContext(string entityName, DatabaseObject dbo) /// public NameValueCollection ParsedQueryString { get; set; } = new(); + /// + /// Raw query string from the HTTP request (URL-encoded). + /// Used to preserve encoding for special characters in query parameters. + /// + public string RawQueryString { get; set; } = string.Empty; + /// /// String holds information needed for pagination. /// Based on request this property may or may not be populated. diff --git a/src/Core/Parsers/RequestParser.cs b/src/Core/Parsers/RequestParser.cs index 6402ce4ecb..867cf23dab 100644 --- a/src/Core/Parsers/RequestParser.cs +++ b/src/Core/Parsers/RequestParser.cs @@ -115,12 +115,22 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv case FILTER_URL: // save the AST that represents the filter for the query // ?$filter= - string filterQueryString = $"?{FILTER_URL}={context.ParsedQueryString[key]}"; - context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); + // Use the raw (URL-encoded) filter value to preserve special characters like & + string? rawFilterValue = ExtractRawQueryParameter(context.RawQueryString, FILTER_URL); + if (rawFilterValue is not null) + { + string filterQueryString = $"?{FILTER_URL}={rawFilterValue}"; + context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); + } break; case SORT_URL: - string sortQueryString = $"?{SORT_URL}={context.ParsedQueryString[key]}"; - (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); + // Use the raw (URL-encoded) orderby value to preserve special characters + string? rawSortValue = ExtractRawQueryParameter(context.RawQueryString, SORT_URL); + if (rawSortValue is not null) + { + string sortQueryString = $"?{SORT_URL}={rawSortValue}"; + (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); + } break; case AFTER_URL: context.After = context.ParsedQueryString[key]; @@ -283,5 +293,41 @@ private static bool IsNull(string value) { return string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", StringComparison.OrdinalIgnoreCase); } + + /// + /// Extracts the raw (URL-encoded) value of a query parameter from a query string. + /// This preserves special characters like & in filter values. + /// + /// The raw query string (e.g., "?$filter=region%20eq%20%27filter%20%26%20test%27") + /// The parameter name to extract (e.g., "$filter") + /// The raw encoded value of the parameter, or null if not found + private static string? ExtractRawQueryParameter(string queryString, string parameterName) + { + if (string.IsNullOrWhiteSpace(queryString)) + { + return null; + } + + // Remove leading '?' if present + string query = queryString.TrimStart('?'); + + // Split by '&' to get individual parameters + string[] parameters = query.Split('&'); + + foreach (string param in parameters) + { + int equalsIndex = param.IndexOf('='); + if (equalsIndex > 0) + { + string key = param.Substring(0, equalsIndex); + if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) + { + return param.Substring(equalsIndex + 1); + } + } + } + + return null; + } } } diff --git a/src/Core/Services/RestService.cs b/src/Core/Services/RestService.cs index 6a2308dd83..a338f9ce3f 100644 --- a/src/Core/Services/RestService.cs +++ b/src/Core/Services/RestService.cs @@ -174,6 +174,7 @@ RequestValidator requestValidator if (!string.IsNullOrWhiteSpace(queryString)) { + context.RawQueryString = queryString; context.ParsedQueryString = HttpUtility.ParseQueryString(queryString); RequestParser.ParseQueryString(context, sqlMetadataProvider); } @@ -277,6 +278,7 @@ private void PopulateStoredProcedureContext( // So, $filter will be treated as any other parameter (inevitably will raise a Bad Request) if (!string.IsNullOrWhiteSpace(queryString)) { + context.RawQueryString = queryString; context.ParsedQueryString = HttpUtility.ParseQueryString(queryString); } From 8f1c4c0a5a47f18adf35c8cc35b828a4f6464ac2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:15:39 +0000 Subject: [PATCH 3/6] Add test data and test case for filter with special characters Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- src/Service.Tests/DatabaseSchema-DwSql.sql | 3 ++- src/Service.Tests/DatabaseSchema-MsSql.sql | 3 ++- src/Service.Tests/DatabaseSchema-MySql.sql | 3 ++- src/Service.Tests/DatabaseSchema-PostgreSql.sql | 3 ++- .../RestApiTests/Find/DwSqlFindApiTests.cs | 6 ++++++ .../RestApiTests/Find/FindApiTestBase.cs | 16 ++++++++++++++++ .../RestApiTests/Find/MsSqlFindApiTests.cs | 6 ++++++ .../RestApiTests/Find/MySqlFindApiTests.cs | 12 ++++++++++++ .../RestApiTests/Find/PostgreSqlFindApiTests.cs | 11 +++++++++++ 9 files changed, 59 insertions(+), 4 deletions(-) diff --git a/src/Service.Tests/DatabaseSchema-DwSql.sql b/src/Service.Tests/DatabaseSchema-DwSql.sql index daed665949..913f4fd9a8 100644 --- a/src/Service.Tests/DatabaseSchema-DwSql.sql +++ b/src/Service.Tests/DatabaseSchema-DwSql.sql @@ -337,7 +337,8 @@ VALUES (1, 'Awesome book', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), (20, 'C:\\LIFE', 1234), -(21, '', 1234); +(21, '', 1234), +(22, 'filter & test', 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 4e87394aee..1228071a1c 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -532,7 +532,8 @@ VALUES (1, 'Awesome book', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), (20, 'C:\\LIFE', 1234), -(21, '', 1234); +(21, '', 1234), +(22, 'filter & test', 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 dda93d86d1..3db6a5db98 100644 --- a/src/Service.Tests/DatabaseSchema-MySql.sql +++ b/src/Service.Tests/DatabaseSchema-MySql.sql @@ -389,7 +389,8 @@ INSERT INTO books(id, title, publisher_id) (18, '[Special Book]', 1234), (19, 'ME\\YOU', 1234), (20, 'C:\\\\LIFE', 1234), - (21, '', 1234); + (21, '', 1234), + (22, 'filter & test', 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 523e96c22f..b3dcab8fa0 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -392,7 +392,8 @@ INSERT INTO books(id, title, publisher_id) (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), (20, 'C:\\LIFE', 1234), - (21, '', 1234); + (21, '', 1234), + (22, 'filter & test', 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/RestApiTests/Find/DwSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/DwSqlFindApiTests.cs index 8c78a27061..503229bf3c 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/DwSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/DwSqlFindApiTests.cs @@ -221,6 +221,12 @@ public class DwSqlFindApiTests : FindApiTestBase $"WHERE (NOT (id < 3) OR id < 4) OR NOT (title = 'Awesome book') " + $"FOR JSON PATH, INCLUDE_NULL_VALUES" }, + { + "FindTestWithFilterContainingSpecialCharacters", + $"SELECT * FROM { _integrationTableName } " + + $"WHERE title = 'filter & test' " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES" + }, { "FindTestWithPrimaryKeyContainingForeignKey", $"SELECT [id], [content] FROM reviews " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs index 483d870d85..e8a1c17a5f 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs @@ -693,6 +693,22 @@ await SetupAndRunRestApiTest( ); } + /// + /// Tests the REST Api for Find operation with a filter containing special characters + /// like ampersand (&) that need to be URL-encoded. This validates that the fix for + /// the double-decoding issue is working correctly. + /// + [TestMethod] + public async Task FindTestWithFilterContainingSpecialCharacters() + { + await SetupAndRunRestApiTest( + primaryKeyRoute: string.Empty, + queryString: "?$filter=title eq 'filter & test'", + entityNameOrPath: _integrationEntityName, + sqlQuery: GetQuery(nameof(FindTestWithFilterContainingSpecialCharacters)) + ); + } + /// /// Tests the REST Api for Find operation where we compare one field /// to the bool returned from another comparison. diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs index 6f43fb2073..d93ebacd43 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs @@ -228,6 +228,12 @@ public class MsSqlFindApiTests : FindApiTestBase $"WHERE (NOT (id < 3) OR id < 4) OR NOT (title = 'Awesome book') " + $"FOR JSON PATH, INCLUDE_NULL_VALUES" }, + { + "FindTestWithFilterContainingSpecialCharacters", + $"SELECT * FROM { _integrationTableName } " + + $"WHERE title = 'filter & test' " + + $"FOR JSON PATH, INCLUDE_NULL_VALUES" + }, { "FindTestWithPrimaryKeyContainingForeignKey", $"SELECT [id], [content] FROM reviews " + diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs index f9a3fdb764..9914beec16 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs @@ -397,6 +397,18 @@ ORDER BY id asc ) AS subq " }, + { + "FindTestWithFilterContainingSpecialCharacters", + @" + SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id)) AS data + FROM ( + SELECT * + FROM " + _integrationTableName + @" + WHERE title = 'filter & test' + ORDER BY id asc + ) AS subq + " + }, { "FindTestWithFilterQueryStringBoolResultFilter", @" diff --git a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs index 9abcfe88c2..7bed112983 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs @@ -411,6 +411,17 @@ SELECT json_agg(to_jsonb(subq)) AS data ORDER BY id asc ) AS subq" }, + { + "FindTestWithFilterContainingSpecialCharacters", + @" + SELECT json_agg(to_jsonb(subq)) AS data + FROM ( + SELECT * + FROM " + _integrationTableName + @" + WHERE title = 'filter & test' + ORDER BY id asc + ) AS subq" + }, { "FindTestWithPrimaryKeyContainingForeignKey", @" From f2d8fbd7273c0b519a13c88e0e1bc07782865976 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:17:43 +0000 Subject: [PATCH 4/6] Add unit tests for RequestParser URL encoding preservation Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- .../UnitTests/RequestParserUnitTests.cs | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/Service.Tests/UnitTests/RequestParserUnitTests.cs diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs new file mode 100644 index 0000000000..82adc885d3 --- /dev/null +++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.DataApiBuilder.Core.Parsers; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.UnitTests +{ + /// + /// Test class for RequestParser utility methods. + /// Specifically tests the ExtractRawQueryParameter method which preserves + /// URL encoding for special characters in query parameters. + /// + [TestClass] + public class RequestParserUnitTests + { + /// + /// Tests that ExtractRawQueryParameter correctly extracts URL-encoded + /// parameter values, preserving special characters like ampersand (&). + /// + [DataTestMethod] + [DataRow("?$filter=region%20eq%20%27filter%20%26%20test%27", "$filter", "region%20eq%20%27filter%20%26%20test%27", DisplayName = "Extract filter with encoded ampersand")] + [DataRow("?$filter=title%20eq%20%27A%20%26%20B%27&$select=id", "$filter", "title%20eq%20%27A%20%26%20B%27", DisplayName = "Extract filter with ampersand and other params")] + [DataRow("?$select=id&$filter=name%20eq%20%27test%27", "$filter", "name%20eq%20%27test%27", DisplayName = "Extract filter when not first parameter")] + [DataRow("?$orderby=name%20asc", "$orderby", "name%20asc", DisplayName = "Extract orderby parameter")] + [DataRow("?param1=value1¶m2=value%26with%26ampersands", "param2", "value%26with%26ampersands", DisplayName = "Extract parameter with multiple ampersands")] + [DataRow("$filter=title%20eq%20%27test%27", "$filter", "title%20eq%20%27test%27", DisplayName = "Extract without leading question mark")] + [DataRow("?$filter=", "$filter", "", DisplayName = "Extract empty filter value")] + public void ExtractRawQueryParameter_PreservesEncoding(string queryString, string parameterName, string expectedValue) + { + // Use reflection to call the private static method + MethodInfo? method = typeof(RequestParser).GetMethod( + "ExtractRawQueryParameter", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "ExtractRawQueryParameter method should exist"); + + string? result = (string?)method.Invoke(null, new object[] { queryString, parameterName }); + + Assert.AreEqual(expectedValue, result, + $"Expected '{expectedValue}' but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); + } + + /// + /// Tests that ExtractRawQueryParameter returns null when parameter is not found. + /// + [DataTestMethod] + [DataRow("?$filter=test", "$orderby", DisplayName = "Parameter not in query string")] + [DataRow("", "$filter", DisplayName = "Empty query string")] + [DataRow(null, "$filter", DisplayName = "Null query string")] + [DataRow("?otherParam=value", "$filter", DisplayName = "Different parameter")] + public void ExtractRawQueryParameter_ReturnsNull_WhenParameterNotFound(string? queryString, string parameterName) + { + // Use reflection to call the private static method + MethodInfo? method = typeof(RequestParser).GetMethod( + "ExtractRawQueryParameter", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "ExtractRawQueryParameter method should exist"); + + string? result = (string?)method.Invoke(null, new object?[] { queryString, parameterName }); + + Assert.IsNull(result, + $"Expected null but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); + } + + /// + /// Tests that ExtractRawQueryParameter handles edge cases correctly. + /// + [DataTestMethod] + [DataRow("?$filter=value&$filter=anothervalue", "$filter", "value", DisplayName = "Multiple same parameters - returns first")] + [DataRow("?$FILTER=value", "$filter", "value", DisplayName = "Case insensitive parameter matching")] + [DataRow("?param=value1&value2", "param", "value1", DisplayName = "Value with unencoded ampersand after parameter")] + public void ExtractRawQueryParameter_HandlesEdgeCases(string queryString, string parameterName, string expectedValue) + { + // Use reflection to call the private static method + MethodInfo? method = typeof(RequestParser).GetMethod( + "ExtractRawQueryParameter", + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.IsNotNull(method, "ExtractRawQueryParameter method should exist"); + + string? result = (string?)method.Invoke(null, new object[] { queryString, parameterName }); + + Assert.AreEqual(expectedValue, result, + $"Expected '{expectedValue}' but got '{result}' for parameter '{parameterName}' in query '{queryString}'"); + } + } +} From 55a1f987895939dcad0051dfd6d2404f3cb393f4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:22:50 +0000 Subject: [PATCH 5/6] Improve ExtractRawQueryParameter with better edge case handling and documentation Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- src/Core/Parsers/RequestParser.cs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/Core/Parsers/RequestParser.cs b/src/Core/Parsers/RequestParser.cs index 867cf23dab..32ed49ab52 100644 --- a/src/Core/Parsers/RequestParser.cs +++ b/src/Core/Parsers/RequestParser.cs @@ -297,6 +297,8 @@ private static bool IsNull(string value) /// /// Extracts the raw (URL-encoded) value of a query parameter from a query string. /// This preserves special characters like & in filter values. + /// This method assumes the query string is properly URL-encoded, where parameter + /// separators are unencoded '&' and special characters within values are encoded (e.g., %26). /// /// The raw query string (e.g., "?$filter=region%20eq%20%27filter%20%26%20test%27") /// The parameter name to extract (e.g., "$filter") @@ -312,17 +314,21 @@ private static bool IsNull(string value) string query = queryString.TrimStart('?'); // Split by '&' to get individual parameters + // This works correctly because in a properly URL-encoded query string: + // - Parameter separators are unencoded '&' + // - Special characters within values are encoded (e.g., '&' becomes '%26') string[] parameters = query.Split('&'); foreach (string param in parameters) { int equalsIndex = param.IndexOf('='); - if (equalsIndex > 0) + if (equalsIndex >= 0) { string key = param.Substring(0, equalsIndex); if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) { - return param.Substring(equalsIndex + 1); + // Return everything after '=', or empty string if '=' is at the end + return equalsIndex < param.Length - 1 ? param.Substring(equalsIndex + 1) : string.Empty; } } } From 7a8f2aaa0ffc2b609d8da0ed71f046b9799477ee Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 23 Jan 2026 21:24:55 +0000 Subject: [PATCH 6/6] Make code more terse and concise Co-authored-by: JerryNixon <1749983+JerryNixon@users.noreply.github.com> --- src/Core/Parsers/RequestParser.cs | 53 ++++++------------------------- 1 file changed, 10 insertions(+), 43 deletions(-) diff --git a/src/Core/Parsers/RequestParser.cs b/src/Core/Parsers/RequestParser.cs index 32ed49ab52..1ed1807e16 100644 --- a/src/Core/Parsers/RequestParser.cs +++ b/src/Core/Parsers/RequestParser.cs @@ -113,24 +113,16 @@ public static void ParseQueryString(RestRequestContext context, ISqlMetadataProv context.FieldsToBeReturned = context.ParsedQueryString[key]!.Split(",").ToList(); break; case FILTER_URL: - // save the AST that represents the filter for the query - // ?$filter= - // Use the raw (URL-encoded) filter value to preserve special characters like & + // Use raw (URL-encoded) filter value to preserve special characters like & string? rawFilterValue = ExtractRawQueryParameter(context.RawQueryString, FILTER_URL); if (rawFilterValue is not null) - { - string filterQueryString = $"?{FILTER_URL}={rawFilterValue}"; - context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); - } + context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause($"?{FILTER_URL}={rawFilterValue}", $"{context.EntityName}.{context.DatabaseObject.FullName}"); break; case SORT_URL: - // Use the raw (URL-encoded) orderby value to preserve special characters + // Use raw (URL-encoded) orderby value to preserve special characters string? rawSortValue = ExtractRawQueryParameter(context.RawQueryString, SORT_URL); if (rawSortValue is not null) - { - string sortQueryString = $"?{SORT_URL}={rawSortValue}"; - (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); - } + (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, $"?{SORT_URL}={rawSortValue}"); break; case AFTER_URL: context.After = context.ParsedQueryString[key]; @@ -296,43 +288,18 @@ private static bool IsNull(string value) /// /// Extracts the raw (URL-encoded) value of a query parameter from a query string. - /// This preserves special characters like & in filter values. - /// This method assumes the query string is properly URL-encoded, where parameter - /// separators are unencoded '&' and special characters within values are encoded (e.g., %26). + /// Preserves special characters like & in filter values (e.g., %26 stays as %26). /// - /// The raw query string (e.g., "?$filter=region%20eq%20%27filter%20%26%20test%27") - /// The parameter name to extract (e.g., "$filter") - /// The raw encoded value of the parameter, or null if not found private static string? ExtractRawQueryParameter(string queryString, string parameterName) { - if (string.IsNullOrWhiteSpace(queryString)) - { - return null; - } - - // Remove leading '?' if present - string query = queryString.TrimStart('?'); + if (string.IsNullOrWhiteSpace(queryString)) return null; - // Split by '&' to get individual parameters - // This works correctly because in a properly URL-encoded query string: - // - Parameter separators are unencoded '&' - // - Special characters within values are encoded (e.g., '&' becomes '%26') - string[] parameters = query.Split('&'); - - foreach (string param in parameters) + foreach (string param in queryString.TrimStart('?').Split('&')) { - int equalsIndex = param.IndexOf('='); - if (equalsIndex >= 0) - { - string key = param.Substring(0, equalsIndex); - if (string.Equals(key, parameterName, StringComparison.OrdinalIgnoreCase)) - { - // Return everything after '=', or empty string if '=' is at the end - return equalsIndex < param.Length - 1 ? param.Substring(equalsIndex + 1) : string.Empty; - } - } + int idx = param.IndexOf('='); + if (idx >= 0 && param.Substring(0, idx).Equals(parameterName, StringComparison.OrdinalIgnoreCase)) + return idx < param.Length - 1 ? param.Substring(idx + 1) : string.Empty; } - return null; } }