Skip to content

Commit 96a241b

Browse files
committed
Add explicit handling for collection Contains filters in LINQ expressions
Enhance GoogleTextSearch LINQ filter processing to explicitly detect and reject collection Contains operations (e.g., array.Contains(page.Property)) with a clear, actionable error message. Changes: - Added IsMemoryExtensionsContains helper method to detect C# 14+ span-based Contains resolution - Enhanced TryProcessSingleExpression to distinguish between: * String.Contains (supported for substring search) * Enumerable.Contains / MemoryExtensions.Contains (not supported - now explicitly rejected) - Throws NotSupportedException with guidance on alternatives when collection Contains is detected - Handles both C# 13- (Enumerable.Contains) and C# 14+ (MemoryExtensions.Contains) resolution paths Test Coverage: - Added CollectionContainsFilterThrowsNotSupportedExceptionAsync test - Verifies exception is thrown for collection Contains operations - Validates error message contains actionable guidance about OR logic limitations - Ensures consistent behavior across C# language versions (13 vs 14 Contains resolution) Rationale: Google Custom Search API does not support OR logic across multiple values. Collection Contains filters would require OR semantics that cannot be expressed via Google's query parameters. This change provides clear, early feedback to developers attempting to use unsupported filter patterns. Related to reviewer feedback on LINQ expression filter validation and C# 14 compatibility. Fixes #10456
1 parent 0c1fd78 commit 96a241b

File tree

2 files changed

+69
-5
lines changed

2 files changed

+69
-5
lines changed

dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,35 @@ await textSearch.SearchAsync("test",
702702
Assert.Contains("excludeTerms=deprecated", absoluteUri);
703703
}
704704

705+
[Fact]
706+
public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync()
707+
{
708+
// Arrange
709+
using var textSearch = new GoogleTextSearch(
710+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
711+
searchEngineId: "SearchEngineId");
712+
713+
// Act & Assert - Collection Contains (both Enumerable.Contains and MemoryExtensions.Contains)
714+
// This same code resolves differently based on C# language version:
715+
// - C# 13 and earlier: Enumerable.Contains (LINQ extension method)
716+
// - C# 14 and later: MemoryExtensions.Contains (span-based optimization)
717+
// Our implementation handles both identically - both throw NotSupportedException
718+
string[] sites = ["microsoft.com", "github.com"];
719+
var exception = await Assert.ThrowsAsync<NotSupportedException>(async () =>
720+
await textSearch.SearchAsync("test",
721+
new TextSearchOptions<GoogleWebPage>
722+
{
723+
Top = 4,
724+
Skip = 0,
725+
Filter = page => sites.Contains(page.DisplayLink!)
726+
}));
727+
728+
// Verify exception message is clear and actionable
729+
Assert.Contains("Collection Contains filters", exception.Message);
730+
Assert.Contains("not supported by Google Custom Search API", exception.Message);
731+
Assert.Contains("OR logic", exception.Message);
732+
}
733+
705734
#endregion
706735

707736
/// <inheritdoc/>

dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -224,12 +224,31 @@ private static bool TryProcessSingleExpression(Expression expression, TextSearch
224224
return TryProcessInequalityExpression(notEqualExpr, filter);
225225
}
226226

227-
// Handle string Contains: record.PropertyName.Contains("value")
228-
if (expression is MethodCallExpression methodCall &&
229-
methodCall.Method.Name == "Contains" &&
230-
methodCall.Method.DeclaringType == typeof(string))
227+
// Handle Contains method calls
228+
if (expression is MethodCallExpression methodCall && methodCall.Method.Name == "Contains")
231229
{
232-
return TryProcessContainsExpression(methodCall, filter);
230+
// String.Contains (instance method) - supported for substring search
231+
if (methodCall.Method.DeclaringType == typeof(string))
232+
{
233+
return TryProcessContainsExpression(methodCall, filter);
234+
}
235+
236+
// Collection Contains (static methods) - NOT supported due to Google API limitations
237+
// This handles both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+)
238+
// User's C# language version determines which method is resolved, but both are unsupported
239+
if (methodCall.Object == null) // Static method
240+
{
241+
// Enumerable.Contains or MemoryExtensions.Contains
242+
if (methodCall.Method.DeclaringType == typeof(Enumerable) ||
243+
(methodCall.Method.DeclaringType == typeof(MemoryExtensions) && IsMemoryExtensionsContains(methodCall)))
244+
{
245+
throw new NotSupportedException(
246+
"Collection Contains filters (e.g., array.Contains(page.Property)) are not supported by Google Custom Search API. " +
247+
"Google's search operators do not support OR logic across multiple values. " +
248+
"Consider either: (1) performing multiple separate searches for each value, or " +
249+
"(2) retrieving broader results and filtering on the client side.");
250+
}
251+
}
233252
}
234253

235254
// Handle NOT expressions: !record.PropertyName.Contains("value")
@@ -241,6 +260,22 @@ private static bool TryProcessSingleExpression(Expression expression, TextSearch
241260
return false;
242261
}
243262

263+
/// <summary>
264+
/// Checks if a method call expression is MemoryExtensions.Contains.
265+
/// This handles C# 14's "first-class spans" feature where collection.Contains(item) resolves to
266+
/// MemoryExtensions.Contains instead of Enumerable.Contains.
267+
/// </summary>
268+
private static bool IsMemoryExtensionsContains(MethodCallExpression methodExpr)
269+
{
270+
// MemoryExtensions.Contains has 2-3 parameters (source, value, optional comparer)
271+
// We only support the case without a comparer (or with null comparer)
272+
return methodExpr.Method.Name == nameof(MemoryExtensions.Contains) &&
273+
methodExpr.Arguments.Count >= 2 &&
274+
methodExpr.Arguments.Count <= 3 &&
275+
(methodExpr.Arguments.Count == 2 ||
276+
(methodExpr.Arguments.Count == 3 && methodExpr.Arguments[2] is ConstantExpression { Value: null }));
277+
}
278+
244279
/// <summary>
245280
/// Processes equality expressions: record.PropertyName == "value"
246281
/// </summary>

0 commit comments

Comments
 (0)