Skip to content
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
45 changes: 39 additions & 6 deletions src/Core/Parsers/RequestParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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 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];
Expand Down Expand Up @@ -283,5 +299,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
6 changes: 5 additions & 1 deletion src/Service.Tests/DatabaseSchema-DwSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
6 changes: 5 additions & 1 deletion src/Service.Tests/DatabaseSchema-MsSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion src/Service.Tests/DatabaseSchema-MySql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/Service.Tests/DatabaseSchema-PostgreSql.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 " +
Expand Down
63 changes: 63 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,69 @@ 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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there other special characters that we could test?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added 3 additional integration test methods in commit cffedaa testing special characters: +, = (A+B=C), ampersand in different context (Tom & Jerry), and % (100% Complete). Test data added across all database schemas.

/// 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 with filters containing various special characters
/// that need URL encoding: plus (+), equals (=), and percent (%).
/// Validates that multiple types of special characters are handled correctly.
/// </summary>
[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))
);
}

/// <summary>
/// Tests the REST Api for Find operation with a filter containing ampersand
/// in a different context to ensure robustness across various data patterns.
/// </summary>
[TestMethod]
public async Task FindTestWithFilterContainingAmpersandInPhrase()
{
await SetupAndRunRestApiTest(
primaryKeyRoute: string.Empty,
queryString: "?$filter=title eq 'Tom & Jerry'",
entityNameOrPath: _integrationEntityName,
sqlQuery: GetQuery(nameof(FindTestWithFilterContainingAmpersandInPhrase))
);
}

/// <summary>
/// Tests the REST Api for Find operation with a filter containing percent sign (%)
/// which has special meaning in URL encoding and SQL LIKE patterns.
/// </summary>
[TestMethod]
public async Task FindTestWithFilterContainingPercentSign()
{
await SetupAndRunRestApiTest(
primaryKeyRoute: string.Empty,
queryString: "?$filter=title eq '100% Complete'",
entityNameOrPath: _integrationEntityName,
sqlQuery: GetQuery(nameof(FindTestWithFilterContainingPercentSign))
);
}

/// <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,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 " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
@"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
@"
Expand Down
Loading