Skip to content
Open
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
55 changes: 49 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,32 @@ 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).
///
/// IMPORTANT: This method assumes the input queryString is a raw, URL-encoded query string
/// where special characters in parameter values are encoded (e.g., & is %26, space is %20).
/// It splits on unencoded '&' characters which are parameter separators in the URL standard.
/// If the queryString has already been decoded, this method will not work correctly.
/// </summary>
/// <param name="queryString">Raw URL-encoded query string (e.g., "?$filter=title%20eq%20%27A%26B%27")</param>
/// <param name="parameterName">The parameter name to extract (e.g., "$filter")</param>
/// <returns>The raw encoded value of the parameter, or null if not found</returns>
internal static string? ExtractRawQueryParameter(string queryString, string parameterName)
{
if (string.IsNullOrWhiteSpace(queryString)) return null;

// Split on '&' which are parameter separators in properly URL-encoded query strings.
// Any '&' characters within parameter values will be encoded as %26.
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
/// the double-decoding issue is working correctly.
/// </summary>
[TestMethod]
public async Task FindTestWithFilterContainingSpecialCharacters()
{
await SetupAndRunRestApiTest(
primaryKeyRoute: string.Empty,
queryString: "?$filter=title%20eq%20%27filter%20%26%20test%27",
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 - URL-encoded
await SetupAndRunRestApiTest(
primaryKeyRoute: string.Empty,
queryString: "?$filter=title%20eq%20%27A%2BB%3DC%27",
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%20eq%20%27Tom%20%26%20Jerry%27",
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%20eq%20%27100%25%20Complete%27",
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
Loading