Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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)
Comment thread
RubenCerna2079 marked this conversation as resolved.
Outdated
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)
Comment thread
JerryNixon marked this conversation as resolved.
Outdated
(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
Comment thread
JerryNixon marked this conversation as resolved.
Outdated
/// 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
Comment thread
Aniruddh25 marked this conversation as resolved.
Outdated
) 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")]
Comment thread
JerryNixon marked this conversation as resolved.
Outdated
[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.
Comment thread
JerryNixon marked this conversation as resolved.
Outdated
/// </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