Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/Core/Models/RestRequestContexts/RestRequestContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,12 @@ protected RestRequestContext(string entityName, DatabaseObject dbo)
/// </summary>
public NameValueCollection ParsedQueryString { get; set; } = new();

/// <summary>
/// Raw query string from the HTTP request (URL-encoded).
/// Used to preserve encoding for special characters in query parameters.
/// </summary>
public string RawQueryString { get; set; } = string.Empty;

/// <summary>
/// String holds information needed for pagination.
/// Based on request this property may or may not be populated.
Expand Down
31 changes: 25 additions & 6 deletions src/Core/Parsers/RequestParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -113,14 +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=<filter clause using microsoft api guidelines>
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 (rawFilterValue is not null)
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 (rawSortValue is not null)
(context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = GenerateOrderByLists(context, sqlMetadataProvider, $"?{SORT_URL}={rawSortValue}");
break;
case AFTER_URL:
context.After = context.ParsedQueryString[key];
Expand Down Expand Up @@ -283,5 +285,22 @@ private static bool IsNull(string value)
{
return string.IsNullOrWhiteSpace(value) || string.Equals(value, "null", StringComparison.OrdinalIgnoreCase);
}

/// <summary>
/// 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).
/// </summary>
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;
}
}
}
2 changes: 2 additions & 0 deletions src/Core/Services/RestService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ RequestValidator requestValidator

if (!string.IsNullOrWhiteSpace(queryString))
{
context.RawQueryString = queryString;
context.ParsedQueryString = HttpUtility.ParseQueryString(queryString);
RequestParser.ParseQueryString(context, sqlMetadataProvider);
}
Expand Down Expand Up @@ -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);
}

Expand Down
3 changes: 2 additions & 1 deletion src/Service.Tests/DatabaseSchema-DwSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/Service.Tests/DatabaseSchema-MySql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/Service.Tests/DatabaseSchema-PostgreSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
16 changes: 16 additions & 0 deletions src/Service.Tests/SqlTests/RestApiTests/Find/FindApiTestBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -693,6 +693,22 @@ await SetupAndRunRestApiTest(
);
}

/// <summary>
/// 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.
/// </summary>
[TestMethod]
public async Task FindTestWithFilterContainingSpecialCharacters()
{
await SetupAndRunRestApiTest(
primaryKeyRoute: string.Empty,
queryString: "?$filter=title eq 'filter & test'",
entityNameOrPath: _integrationEntityName,
sqlQuery: GetQuery(nameof(FindTestWithFilterContainingSpecialCharacters))
);
}

/// <summary>
/// Tests the REST Api for Find operation where we compare one field
/// to the bool returned from another comparison.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
@"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
@"
Expand Down
90 changes: 90 additions & 0 deletions src/Service.Tests/UnitTests/RequestParserUnitTests.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Test class for RequestParser utility methods.
/// Specifically tests the ExtractRawQueryParameter method which preserves
/// URL encoding for special characters in query parameters.
/// </summary>
[TestClass]
public class RequestParserUnitTests
{
/// <summary>
/// Tests that ExtractRawQueryParameter correctly extracts URL-encoded
/// parameter values, preserving special characters like ampersand (&).
/// </summary>
[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&param2=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}'");
}

/// <summary>
/// Tests that ExtractRawQueryParameter returns null when parameter is not found.
/// </summary>
[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}'");
}

/// <summary>
/// Tests that ExtractRawQueryParameter handles edge cases correctly.
/// </summary>
[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}'");
}
}
}
Loading