Skip to content

Commit 9466ecb

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 8f333d2 commit 9466ecb

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
@@ -700,6 +700,35 @@ await textSearch.SearchAsync("test",
700700
Assert.Contains("excludeTerms=deprecated", absoluteUri);
701701
}
702702

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

705734
/// <inheritdoc/>

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

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

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

233252
// Handle NOT expressions: !record.PropertyName.Contains("value")
@@ -239,6 +258,22 @@ private static bool TryProcessSingleExpression(Expression expression, TextSearch
239258
return false;
240259
}
241260

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

0 commit comments

Comments
 (0)