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..8d47110322 100644
--- a/src/Core/Parsers/RequestParser.cs
+++ b/src/Core/Parsers/RequestParser.cs
@@ -113,14 +113,30 @@ 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=
- string filterQueryString = $"?{FILTER_URL}={context.ParsedQueryString[key]}";
- context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}");
+ // Use raw (URL-encoded) filter value to preserve special characters like &
+ string? rawFilterValue = ExtractRawQueryParameter(context.RawQueryString, FILTER_URL);
+ // If key exists in ParsedQueryString but not in RawQueryString, something is wrong
+ if (rawFilterValue is null)
+ {
+ throw new DataApiBuilderException(
+ message: $"Unable to extract {FILTER_URL} parameter from query string.",
+ statusCode: HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
+ }
+ context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause($"?{FILTER_URL}={rawFilterValue}", $"{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 raw (URL-encoded) orderby value to preserve special characters
+ string? rawSortValue = ExtractRawQueryParameter(context.RawQueryString, SORT_URL);
+ // If key exists in ParsedQueryString but not in RawQueryString, something is wrong
+ if (rawSortValue is null)
+ {
+ throw new DataApiBuilderException(
+ message: $"Unable to extract {SORT_URL} parameter from query string.",
+ statusCode: HttpStatusCode.BadRequest,
+ subStatusCode: DataApiBuilderException.SubStatusCodes.BadRequest);
+ }
+ (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, $"?{SORT_URL}={rawSortValue}");
break;
case AFTER_URL:
context.After = context.ParsedQueryString[key];
@@ -283,5 +299,22 @@ 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.
+ /// Preserves special characters like & in filter values (e.g., %26 stays as %26).
+ ///
+ private static string? ExtractRawQueryParameter(string queryString, string parameterName)
+ {
+ if (string.IsNullOrWhiteSpace(queryString)) return null;
+
+ foreach (string param in queryString.TrimStart('?').Split('&'))
+ {
+ 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;
+ }
}
}
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);
}
diff --git a/src/Service.Tests/DatabaseSchema-DwSql.sql b/src/Service.Tests/DatabaseSchema-DwSql.sql
index daed665949..f20402aa16 100644
--- a/src/Service.Tests/DatabaseSchema-DwSql.sql
+++ b/src/Service.Tests/DatabaseSchema-DwSql.sql
@@ -337,7 +337,11 @@ 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),
+(23, 'A+B=C', 1234),
+(24, 'Tom & Jerry', 1234),
+(25, '100% Complete', 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..ac2db67f94 100644
--- a/src/Service.Tests/DatabaseSchema-MsSql.sql
+++ b/src/Service.Tests/DatabaseSchema-MsSql.sql
@@ -532,7 +532,11 @@ 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),
+(23, 'A+B=C', 1234),
+(24, 'Tom & Jerry', 1234),
+(25, '100% Complete', 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..7b45879e13 100644
--- a/src/Service.Tests/DatabaseSchema-MySql.sql
+++ b/src/Service.Tests/DatabaseSchema-MySql.sql
@@ -389,7 +389,11 @@ 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),
+ (23, 'A+B=C', 1234),
+ (24, 'Tom & Jerry', 1234),
+ (25, '100% Complete', 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..adc37cb151 100644
--- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql
+++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql
@@ -392,7 +392,11 @@ 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),
+ (23, 'A+B=C', 1234),
+ (24, 'Tom & Jerry', 1234),
+ (25, '100% Complete', 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..a37a44c36f 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/Find/DwSqlFindApiTests.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/Find/DwSqlFindApiTests.cs
@@ -221,6 +221,30 @@ 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"
+ },
+ {
+ "FindTestWithFilterContainingMultipleSpecialCharacters",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = 'A+B=C' " +
+ $"FOR JSON PATH, INCLUDE_NULL_VALUES"
+ },
+ {
+ "FindTestWithFilterContainingAmpersandInPhrase",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = 'Tom & Jerry' " +
+ $"FOR JSON PATH, INCLUDE_NULL_VALUES"
+ },
+ {
+ "FindTestWithFilterContainingPercentSign",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = '100% Complete' " +
+ $"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..96321fcb47 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs
@@ -693,6 +693,69 @@ 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 with filters containing various special characters
+ /// that need URL encoding: plus (+), equals (=), and percent (%).
+ /// Validates that multiple types of special characters are handled correctly.
+ ///
+ [TestMethod]
+ public async Task FindTestWithFilterContainingMultipleSpecialCharacters()
+ {
+ // Test with plus and equals signs
+ await SetupAndRunRestApiTest(
+ primaryKeyRoute: string.Empty,
+ queryString: "?$filter=title eq 'A+B=C'",
+ entityNameOrPath: _integrationEntityName,
+ sqlQuery: GetQuery(nameof(FindTestWithFilterContainingMultipleSpecialCharacters))
+ );
+ }
+
+ ///
+ /// Tests the REST Api for Find operation with a filter containing ampersand
+ /// in a different context to ensure robustness across various data patterns.
+ ///
+ [TestMethod]
+ public async Task FindTestWithFilterContainingAmpersandInPhrase()
+ {
+ await SetupAndRunRestApiTest(
+ primaryKeyRoute: string.Empty,
+ queryString: "?$filter=title eq 'Tom & Jerry'",
+ entityNameOrPath: _integrationEntityName,
+ sqlQuery: GetQuery(nameof(FindTestWithFilterContainingAmpersandInPhrase))
+ );
+ }
+
+ ///
+ /// Tests the REST Api for Find operation with a filter containing percent sign (%)
+ /// which has special meaning in URL encoding and SQL LIKE patterns.
+ ///
+ [TestMethod]
+ public async Task FindTestWithFilterContainingPercentSign()
+ {
+ await SetupAndRunRestApiTest(
+ primaryKeyRoute: string.Empty,
+ queryString: "?$filter=title eq '100% Complete'",
+ entityNameOrPath: _integrationEntityName,
+ sqlQuery: GetQuery(nameof(FindTestWithFilterContainingPercentSign))
+ );
+ }
+
///
/// 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..a8b4817e36 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MsSqlFindApiTests.cs
@@ -228,6 +228,30 @@ 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"
+ },
+ {
+ "FindTestWithFilterContainingMultipleSpecialCharacters",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = 'A+B=C' " +
+ $"FOR JSON PATH, INCLUDE_NULL_VALUES"
+ },
+ {
+ "FindTestWithFilterContainingAmpersandInPhrase",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = 'Tom & Jerry' " +
+ $"FOR JSON PATH, INCLUDE_NULL_VALUES"
+ },
+ {
+ "FindTestWithFilterContainingPercentSign",
+ $"SELECT * FROM { _integrationTableName } " +
+ $"WHERE title = '100% Complete' " +
+ $"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..a158b62beb 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/Find/MySqlFindApiTests.cs
@@ -397,6 +397,54 @@ 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
+ "
+ },
+ {
+ "FindTestWithFilterContainingMultipleSpecialCharacters",
+ @"
+ SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = 'A+B=C'
+ ORDER BY id asc
+ ) AS subq
+ "
+ },
+ {
+ "FindTestWithFilterContainingAmpersandInPhrase",
+ @"
+ SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = 'Tom & Jerry'
+ ORDER BY id asc
+ ) AS subq
+ "
+ },
+ {
+ "FindTestWithFilterContainingPercentSign",
+ @"
+ SELECT JSON_ARRAYAGG(JSON_OBJECT('id', id, 'title', title, 'publisher_id', publisher_id)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = '100% Complete'
+ 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..ff9a407eaa 100644
--- a/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs
+++ b/src/Service.Tests/SqlTests/RestApiTests/Find/PostgreSqlFindApiTests.cs
@@ -411,6 +411,50 @@ 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"
+ },
+ {
+ "FindTestWithFilterContainingMultipleSpecialCharacters",
+ @"
+ SELECT json_agg(to_jsonb(subq)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = 'A+B=C'
+ ORDER BY id asc
+ ) AS subq"
+ },
+ {
+ "FindTestWithFilterContainingAmpersandInPhrase",
+ @"
+ SELECT json_agg(to_jsonb(subq)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = 'Tom & Jerry'
+ ORDER BY id asc
+ ) AS subq"
+ },
+ {
+ "FindTestWithFilterContainingPercentSign",
+ @"
+ SELECT json_agg(to_jsonb(subq)) AS data
+ FROM (
+ SELECT *
+ FROM " + _integrationTableName + @"
+ WHERE title = '100% Complete'
+ ORDER BY id asc
+ ) AS subq"
+ },
{
"FindTestWithPrimaryKeyContainingForeignKey",
@"
diff --git a/src/Service.Tests/UnitTests/RequestParserUnitTests.cs b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
new file mode 100644
index 0000000000..d1ffed1ce3
--- /dev/null
+++ b/src/Service.Tests/UnitTests/RequestParserUnitTests.cs
@@ -0,0 +1,99 @@
+// 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")]
+ [DataRow("?$filter=name%20eq%20%27test%3D123%27", "$filter", "name%20eq%20%27test%3D123%27", DisplayName = "Extract filter with encoded equals sign (=)")]
+ [DataRow("?$filter=url%20eq%20%27http%3A%2F%2Fexample.com%3Fkey%3Dvalue%27", "$filter", "url%20eq%20%27http%3A%2F%2Fexample.com%3Fkey%3Dvalue%27", DisplayName = "Extract filter with encoded URL (: / ?)")]
+ [DataRow("?$filter=text%20eq%20%27A%2BB%27", "$filter", "text%20eq%20%27A%2BB%27", DisplayName = "Extract filter with encoded plus sign (+)")]
+ [DataRow("?$filter=value%20eq%20%2750%25%27", "$filter", "value%20eq%20%2750%25%27", DisplayName = "Extract filter with encoded percent sign (%)")]
+ [DataRow("?$filter=tag%20eq%20%27%23hashtag%27", "$filter", "tag%20eq%20%27%23hashtag%27", DisplayName = "Extract filter with encoded hash (#)")]
+ [DataRow("?$filter=expr%20eq%20%27a%3Cb%3Ed%27", "$filter", "expr%20eq%20%27a%3Cb%3Ed%27", DisplayName = "Extract filter with encoded less-than and greater-than (< >)")]
+ 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:
+ /// - Duplicate parameters (returns first occurrence)
+ /// - Case-insensitive parameter name matching
+ /// - Malformed query strings with unencoded ampersands
+ ///
+ [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}'");
+ }
+ }
+}