Skip to content

Commit 9853add

Browse files
authored
Throw on query filters with unsupported method overloads (#47184)
1 parent 3f77ddb commit 9853add

File tree

7 files changed

+128
-23
lines changed

7 files changed

+128
-23
lines changed

sdk/tables/Azure.Data.Tables/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
### Breaking Changes
88

9+
- Calling `TableClient.Query`, `TableClient.QueryAsync`, or `TableClient.CreateQueryFilter` with a filter expression that uses `string.Equals` or `string.Compare` with a `StringComparison` parameter will now throw an exception. This is because the Azure Table service does not support these methods in query filters. Previously the `StringComparison` argument was silently ignored, which can lead to subtle bugs in client code.
10+
911
### Bugs Fixed
1012

1113
### Other Changes

sdk/tables/Azure.Data.Tables/src/Queryable/ExpressionNormalizer.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,11 +162,19 @@ internal Expression VisitMethodCallNoRewrite(MethodCallExpression call)
162162

163163
if (visited.Method.IsStatic && visited.Method.Name == "Equals" && visited.Arguments.Count > 1)
164164
{
165+
if (visited.Arguments.Count > 2)
166+
{
167+
throw new NotSupportedException("string.Equals method with more than two arguments is not supported.");
168+
}
165169
return Expression.Equal(visited.Arguments[0], visited.Arguments[1], false, visited.Method);
166170
}
167171

168172
if (!visited.Method.IsStatic && visited.Method.Name == "Equals" && visited.Arguments.Count > 0)
169173
{
174+
if (visited.Arguments.Count > 1)
175+
{
176+
throw new NotSupportedException("Equals method with more than two arguments is not supported.");
177+
}
170178
return CreateRelationalOperator(ExpressionType.Equal, visited.Object, visited.Arguments[0]);
171179
}
172180

@@ -182,6 +190,10 @@ internal Expression VisitMethodCallNoRewrite(MethodCallExpression call)
182190

183191
if (visited.Method.IsStatic && visited.Method.Name == "Compare" && visited.Arguments.Count > 1 && visited.Method.ReturnType == typeof(int))
184192
{
193+
if (visited.Arguments.Count > 2)
194+
{
195+
throw new NotSupportedException("string.Compare method with more than two arguments is not supported.");
196+
}
185197
return CreateCompareExpression(visited.Arguments[0], visited.Arguments[1]);
186198
}
187199

sdk/tables/Azure.Data.Tables/src/TableClient.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,6 +1041,15 @@ public virtual Response UpdateEntity<T>(T entity, ETag ifMatch, TableUpdateMode
10411041
/// <param name="filter">
10421042
/// Returns only entities that satisfy the specified filter expression.
10431043
/// For example, the following expression would filter entities with a PartitionKey of 'foo': <c>e => e.PartitionKey == "foo"</c>.
1044+
/// <para>
1045+
/// The following string comparison methods are supported as part of a filter expression:
1046+
/// <list type="bullet">
1047+
/// <item><description><see cref="string.Equals(string)"/></description></item>
1048+
/// <item><description><see cref="string.Equals(string, string)"/></description></item>
1049+
/// <item><description><see cref="string.CompareTo(string)"/></description></item>
1050+
/// <item><description><see cref="string.Compare(string, string)"/></description></item>
1051+
/// </list>
1052+
/// </para>
10441053
/// </param>
10451054
/// <param name="maxPerPage">
10461055
/// The maximum number of entities that will be returned per page. If unspecified, the default value is 1000 for storage accounts and is not limited for Cosmos DB table API.
@@ -1079,6 +1088,15 @@ public virtual AsyncPageable<T> QueryAsync<T>(
10791088
/// <param name="filter">
10801089
/// Returns only entities that satisfy the specified filter expression.
10811090
/// For example, the following expression would filter entities with a PartitionKey of 'foo': <c>e => e.PartitionKey == "foo"</c>.
1091+
/// <para>
1092+
/// The following string comparison methods are supported as part of a filter expression:
1093+
/// <list type="bullet">
1094+
/// <item><description><see cref="string.Equals(string)"/></description></item>
1095+
/// <item><description><see cref="string.Equals(string, string)"/></description></item>
1096+
/// <item><description><see cref="string.CompareTo(string)"/></description></item>
1097+
/// <item><description><see cref="string.Compare(string, string)"/></description></item>
1098+
/// </list>
1099+
/// </para>
10821100
/// </param>
10831101
/// <param name="maxPerPage">
10841102
/// The maximum number of entities that will be returned per page. If unspecified, the default value is 1000 for storage accounts and is not limited for Cosmos DB table API.
@@ -1494,7 +1512,18 @@ public virtual Response SetAccessPolicy(IEnumerable<TableSignedIdentifier> table
14941512
/// </summary>
14951513
/// <typeparam name="T">The type of the entity being queried. Typically this will be derived from <see cref="ITableEntity"/>
14961514
/// or <see cref="Dictionary{String, Object}"/>.</typeparam>
1497-
/// <param name="filter">A filter expression.</param>
1515+
/// <param name="filter">A filter expression.
1516+
/// for example: <c>e => e.PartitionKey == "foo"</c>.
1517+
/// <para>
1518+
/// The following string comparison methods are supported as part of a filter expression:
1519+
/// <list type="bullet">
1520+
/// <item><description><see cref="string.Equals(string)"/></description></item>
1521+
/// <item><description><see cref="string.Equals(string, string)"/></description></item>
1522+
/// <item><description><see cref="string.CompareTo(string)"/></description></item>
1523+
/// <item><description><see cref="string.Compare(string, string)"/></description></item>
1524+
/// </list>
1525+
/// </para>
1526+
/// </param>
14981527
/// <returns>The string representation of the filter expression.</returns>
14991528
public static string CreateQueryFilter<T>(Expression<Func<T, bool>> filter) => Bind(filter);
15001529

sdk/tables/Azure.Data.Tables/tests/TableClientQueryExpressionTests.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public class TableClientQueryExpressionTests
2323
private const string TableName = "someTableName";
2424
private const string TableName2 = "otherTableName";
2525
private const string Partition = "partition";
26+
private const string MixedCasePK = "PartitionKey";
2627
private const string Row = "row";
2728
private const int SomeInt = 10;
2829
private const double SomeDouble = 10.10;
@@ -94,6 +95,13 @@ public class TableClientQueryExpressionTests
9495
private static readonly Expression<Func<TableEntity, bool>> s_tableEntExpImplicitBooleanCmpCasted = ent => (bool)ent.GetBoolean("Bool");
9596
private static readonly Expression<Func<TableEntity, bool>> s_tableEntExpImplicitBooleanCmpOr = ent => ent.GetBoolean("Bool").Value || (bool)ent.GetBoolean("Bool");
9697
private static readonly Expression<Func<TableEntity, bool>> s_tableEntExpImplicitBooleanCmpNot = ent => !ent.GetBoolean("Bool").Value;
98+
private static readonly Expression<Func<TableEntity, bool>> s_tableEntExpEquals = ent => ent.PartitionKey.Equals(Partition);
99+
private static readonly Expression<Func<ComplexEntity, bool>> s_CEtableEntExpEquals = ent => ent.String.Equals(Partition);
100+
private static readonly Expression<Func<ComplexEntity, bool>> s_CEtableEntExpStaticEquals = ent => string.Equals(ent.String, Partition);
101+
private static readonly Expression<Func<TableEntity, bool>> s_TEequalsUnsupported = ent => ent.PartitionKey.Equals(Partition, StringComparison.OrdinalIgnoreCase);
102+
private static readonly Expression<Func<TableEntity, bool>> s_TEequalsToLowerUnsupported = ent => ent.PartitionKey.Equals(Partition.ToLower(), StringComparison.OrdinalIgnoreCase);
103+
private static readonly Expression<Func<TableEntity, bool>> s_TEequalsStaticUnsupported = ent => string.Equals(ent.PartitionKey, Partition, StringComparison.OrdinalIgnoreCase);
104+
private static readonly Expression<Func<TableEntity, bool>> s_TECompareStaticUnsupported = ent => string.Compare(ent.PartitionKey, Partition, StringComparison.OrdinalIgnoreCase) > 0;
97105

98106
public static object[] TableEntityExpressionTestCases =
99107
{
@@ -125,7 +133,9 @@ public class TableClientQueryExpressionTests
125133
new object[] { $"not (Bool eq true)", s_complexExpImplicitBooleanCmpNot },
126134
new object[] { $"BoolN eq true", s_complexExpImplicitCastedNullableBooleanCmp },
127135
new object[] { $"(Bool eq true) and (BoolN eq true)", s_complexExpImplicitBooleanCmpAnd },
128-
new object[] { $"(Bool eq true) or (BoolN eq true)", s_complexExpImplicitCastedBooleanCmpOr }
136+
new object[] { $"(Bool eq true) or (BoolN eq true)", s_complexExpImplicitCastedBooleanCmpOr },
137+
new object[] { $"String eq '{Partition}'", s_CEtableEntExpEquals },
138+
new object[] { $"String eq '{Partition}'", s_CEtableEntExpStaticEquals },
129139
};
130140

131141
public static object[] TableItemExpressionTestCases =
@@ -162,7 +172,16 @@ public class TableClientQueryExpressionTests
162172
new object[] { $"Bool eq true", s_tableEntExpImplicitBooleanCmp },
163173
new object[] { $"Bool eq true", s_tableEntExpImplicitBooleanCmpCasted },
164174
new object[] { $"(Bool eq true) or (Bool eq true)", s_tableEntExpImplicitBooleanCmpOr },
165-
new object[] { $"not (Bool eq true)", s_tableEntExpImplicitBooleanCmpNot }
175+
new object[] { $"not (Bool eq true)", s_tableEntExpImplicitBooleanCmpNot },
176+
new object[] { $"PartitionKey eq '{Partition}'", s_tableEntExpEquals },
177+
};
178+
179+
public static object[] UnSupportedTableItemExpressionTestCases =
180+
{
181+
new object[] { s_TEequalsUnsupported },
182+
new object[] { s_TEequalsStaticUnsupported },
183+
new object[] { s_TEequalsToLowerUnsupported },
184+
new object[] { s_TECompareStaticUnsupported },
166185
};
167186

168187
[TestCaseSource(nameof(TableItemExpressionTestCases))]
@@ -191,5 +210,12 @@ public void TestDictionaryTableEntityFilterExpressions(string expectedFilter, Ex
191210

192211
Assert.That(filter, Is.EqualTo(expectedFilter));
193212
}
213+
214+
[TestCaseSource(nameof(UnSupportedTableItemExpressionTestCases))]
215+
[Test]
216+
public void TestTableItemFilterExpressionsUnsupported(Expression<Func<TableEntity, bool>> expression)
217+
{
218+
Assert.Throws<NotSupportedException>(() => TableClient.CreateQueryFilter(expression));
219+
}
194220
}
195221
}

sdk/tables/Azure.Data.Tables/tests/samples/Sample0_Auth.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,7 @@ public async Task TokenCredentialAuth()
108108
#if SNIPPET
109109
new DefaultAzureCredential());
110110
#else
111-
new ClientSecretCredential(
112-
GetVariable("TENANT_ID"),
113-
GetVariable("CLIENT_ID"),
114-
GetVariable("CLIENT_SECRET")));
111+
Credential);
115112
#endif
116113

117114
// Create the table if it doesn't already exist to verify we've successfully authenticated.

sdk/tables/Azure.Data.Tables/tests/samples/Sample8_CustomizingSerialization.cs

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,7 @@ public async Task CustomizeSerialization()
2828
#if SNIPPET
2929
new DefaultAzureCredential());
3030
#else
31-
new ClientSecretCredential(
32-
GetVariable("TENANT_ID"),
33-
GetVariable("CLIENT_ID"),
34-
GetVariable("CLIENT_SECRET")));
31+
Credential);
3532
#endif
3633

3734
// Create the table if it doesn't already exist.

sdk/tables/test-resources.json

Lines changed: 54 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#",
2+
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
33
"contentVersion": "1.0.0.0",
44
"parameters": {
55
"baseName": {
@@ -12,18 +12,18 @@
1212
}
1313
},
1414
"cosmosEndpointSuffix": {
15-
"type": "string",
16-
"defaultValue": "cosmos.azure.com",
17-
"metadata": {
18-
"description": "The url suffix to use when accessing the cosmos data plane."
19-
}
15+
"type": "string",
16+
"defaultValue": "cosmos.azure.com",
17+
"metadata": {
18+
"description": "The url suffix to use when accessing the cosmos data plane."
19+
}
2020
},
2121
"storageEndpointSuffix": {
22-
"type": "string",
23-
"defaultValue": "core.windows.net",
24-
"metadata": {
25-
"description": "The url suffix to use when accessing the storage data plane."
26-
}
22+
"type": "string",
23+
"defaultValue": "core.windows.net",
24+
"metadata": {
25+
"description": "The url suffix to use when accessing the storage data plane."
26+
}
2727
}
2828
},
2929
"variables": {
@@ -48,7 +48,16 @@
4848
"virtualNetworkRules": [],
4949
"ipRules": [],
5050
"defaultAction": "Allow"
51-
}
51+
},
52+
"customCosmosRoleName": "Azure Cosmos DB SDK role for Table Data Plane",
53+
"customCosmosRoleDescription": "Azure Cosmos DB SDK role for Table Data Plane",
54+
"customCosmosRoleActions": [
55+
"Microsoft.DocumentDB/databaseAccounts/readMetadata",
56+
"Microsoft.DocumentDB/databaseAccounts/tables/*",
57+
"Microsoft.DocumentDB/databaseAccounts/tables/containers/*",
58+
"Microsoft.DocumentDB/databaseAccounts/tables/containers/entities/*",
59+
"Microsoft.DocumentDB/databaseAccounts/throughputSettings/read"
60+
]
5261
},
5362
"resources": [
5463
{
@@ -118,6 +127,39 @@
118127
],
119128
"ipRules": []
120129
}
130+
},
131+
{
132+
"dependsOn": [
133+
"[resourceId('Microsoft.DocumentDB/databaseAccounts', variables('primaryAccountName'))]"
134+
],
135+
"type": "Microsoft.DocumentDB/databaseAccounts/tableRoleDefinitions",
136+
"apiVersion": "2024-05-15",
137+
"name": "[concat(variables('primaryAccountName'), '/', guid(variables('customCosmosRoleName')))]",
138+
"properties": {
139+
"roleName": "[variables('customCosmosRoleName')]",
140+
"description": "[variables('customCosmosRoleDescription')]",
141+
"permissions": [
142+
{
143+
"dataActions": "[variables('customCosmosRoleActions')]"
144+
}
145+
],
146+
"assignableScopes": [
147+
"[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', resourceGroup().name, '/providers/Microsoft.DocumentDB/databaseAccounts/', variables('primaryAccountName'))]"
148+
]
149+
}
150+
},
151+
{
152+
"dependsOn": [
153+
"[resourceId('Microsoft.DocumentDB/databaseAccounts/tableRoleDefinitions', variables('primaryAccountName'), guid(variables('customCosmosRoleName')))]"
154+
],
155+
"type": "Microsoft.DocumentDB/databaseAccounts/tableRoleAssignments",
156+
"apiVersion": "2024-05-15",
157+
"name": "[concat(variables('primaryAccountName'), '/', guid(variables('customCosmosRoleName')))]",
158+
"properties": {
159+
"scope": "[concat('/subscriptions/', subscription().subscriptionId, '/resourceGroups/', resourceGroup().name, '/providers/Microsoft.DocumentDB/databaseAccounts/', variables('primaryAccountName'))]",
160+
"roleDefinitionId": "[resourceId('Microsoft.DocumentDB/databaseAccounts/tableRoleDefinitions', variables('primaryAccountName'), guid(variables('customCosmosRoleName')))]",
161+
"principalId": "[parameters('testApplicationOid')]"
162+
}
121163
}
122164
],
123165
"outputs": {

0 commit comments

Comments
 (0)