Skip to content

Commit 2ed50ff

Browse files
committed
feat: enhance GoogleTextSearch LINQ filtering with Contains support
- Add Contains() operation support for string properties (Title, Snippet, Link) - Implement intelligent mapping: Contains() -> orTerms for flexible matching - Add 2 new test methods to validate LINQ filtering with Contains and equality - Fix method ambiguity (CS0121) in GoogleTextSearchTests by using explicit TextSearchOptions types - Fix method ambiguity in Google_TextSearch.cs sample by specifying explicit option types - Enhance error messages with clear guidance on supported LINQ patterns and properties This enhancement extends the basic LINQ filtering (equality only) to include string Contains operations, providing more natural and flexible filtering patterns while staying within Google Custom Search API capabilities. All tests passing: 25/25 Google tests (22 existing + 3 new)
1 parent 17f88bf commit 2ed50ff

3 files changed

Lines changed: 119 additions & 11 deletions

File tree

dotnet/samples/Concepts/Search/Google_TextSearch.cs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ public async Task UsingGoogleTextSearchAsync()
2626
var query = "What is the Semantic Kernel?";
2727

2828
// Search and return results as string items
29-
KernelSearchResults<string> stringResults = await textSearch.SearchAsync(query, new() { Top = 4, Skip = 0 });
29+
KernelSearchResults<string> stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4, Skip = 0 });
3030
Console.WriteLine("——— String Results ———\n");
3131
await foreach (string result in stringResults.Results)
3232
{
@@ -35,7 +35,7 @@ public async Task UsingGoogleTextSearchAsync()
3535
}
3636

3737
// Search and return results as TextSearchResult items
38-
KernelSearchResults<TextSearchResult> textResults = await textSearch.GetTextSearchResultsAsync(query, new() { Top = 4, Skip = 4 });
38+
KernelSearchResults<TextSearchResult> textResults = await textSearch.GetTextSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 4 });
3939
Console.WriteLine("\n——— Text Search Results ———\n");
4040
await foreach (TextSearchResult result in textResults.Results)
4141
{
@@ -46,7 +46,7 @@ public async Task UsingGoogleTextSearchAsync()
4646
}
4747

4848
// Search and return results as Google.Apis.CustomSearchAPI.v1.Data.Result items
49-
KernelSearchResults<object> fullResults = await textSearch.GetSearchResultsAsync(query, new() { Top = 4, Skip = 8 });
49+
KernelSearchResults<object> fullResults = await textSearch.GetSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 8 });
5050
Console.WriteLine("\n——— Google Web Page Results ———\n");
5151
await foreach (Google.Apis.CustomSearchAPI.v1.Data.Result result in fullResults.Results)
5252
{
@@ -74,7 +74,7 @@ public async Task UsingGoogleTextSearchWithACustomMapperAsync()
7474
var query = "What is the Semantic Kernel?";
7575

7676
// Search with TextSearchResult textResult type
77-
KernelSearchResults<string> stringResults = await textSearch.SearchAsync(query, new() { Top = 2, Skip = 0 });
77+
KernelSearchResults<string> stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 2, Skip = 0 });
7878
Console.WriteLine("--- Serialized JSON Results ---");
7979
await foreach (string result in stringResults.Results)
8080
{

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

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,60 @@ public async Task GenericGetSearchResultsReturnsSuccessfullyAsync()
318318
}
319319
}
320320

321+
[Fact]
322+
public async Task GenericSearchWithContainsFilterReturnsSuccessfullyAsync()
323+
{
324+
// Arrange
325+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
326+
327+
using var textSearch = new GoogleTextSearch(
328+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
329+
searchEngineId: "SearchEngineId");
330+
331+
// Act - Use generic interface with Contains filtering
332+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?",
333+
new TextSearchOptions<GoogleWebPage>
334+
{
335+
Top = 4,
336+
Skip = 0,
337+
Filter = page => page.Title.Contains("Semantic")
338+
});
339+
340+
// Assert
341+
Assert.NotNull(result);
342+
Assert.NotNull(result.Results);
343+
var resultList = await result.Results.ToListAsync();
344+
Assert.NotNull(resultList);
345+
Assert.Equal(4, resultList.Count);
346+
}
347+
348+
[Fact]
349+
public async Task GenericSearchWithEqualityFilterReturnsSuccessfullyAsync()
350+
{
351+
// Arrange
352+
this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson));
353+
354+
using var textSearch = new GoogleTextSearch(
355+
initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory },
356+
searchEngineId: "SearchEngineId");
357+
358+
// Act - Use generic interface with equality filtering
359+
KernelSearchResults<string> result = await textSearch.SearchAsync("What is the Semantic Kernel?",
360+
new TextSearchOptions<GoogleWebPage>
361+
{
362+
Top = 4,
363+
Skip = 0,
364+
Filter = page => page.DisplayLink == "microsoft.com"
365+
});
366+
367+
// Assert
368+
Assert.NotNull(result);
369+
Assert.NotNull(result.Results);
370+
var resultList = await result.Results.ToListAsync();
371+
Assert.NotNull(resultList);
372+
Assert.Equal(4, resultList.Count);
373+
}
374+
321375
/// <inheritdoc/>
322376
public void Dispose()
323377
{

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

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -148,22 +148,21 @@ private static TextSearchOptions ConvertToLegacyOptions(TextSearchOptions<Google
148148

149149
/// <summary>
150150
/// Converts a LINQ expression to a TextSearchFilter compatible with Google Custom Search API.
151-
/// Only supports simple property equality expressions that map to Google's filter capabilities.
151+
/// Supports property equality expressions and string Contains operations that map to Google's filter capabilities.
152152
/// </summary>
153153
/// <param name="linqExpression">The LINQ expression to convert.</param>
154154
/// <returns>A TextSearchFilter with equivalent filtering.</returns>
155155
/// <exception cref="NotSupportedException">Thrown when the expression cannot be converted to Google filters.</exception>
156156
private static TextSearchFilter ConvertLinqExpressionToGoogleFilter<TRecord>(Expression<Func<TRecord, bool>> linqExpression)
157157
{
158+
// Handle simple equality: record.PropertyName == "value"
158159
if (linqExpression.Body is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal)
159160
{
160-
// Handle simple equality: record.PropertyName == "value"
161161
if (binaryExpr.Left is MemberExpression memberExpr && binaryExpr.Right is ConstantExpression constExpr)
162162
{
163163
string propertyName = memberExpr.Member.Name;
164164
object? value = constExpr.Value;
165165

166-
// Map GoogleWebPage properties to Google API filter names
167166
string? googleFilterName = MapPropertyToGoogleFilter(propertyName);
168167
if (googleFilterName != null && value != null)
169168
{
@@ -172,11 +171,45 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter<TRecord>(Exp
172171
}
173172
}
174173

174+
// Handle string Contains: record.PropertyName.Contains("value")
175+
if (linqExpression.Body is MethodCallExpression methodCall &&
176+
methodCall.Method.Name == "Contains" &&
177+
methodCall.Method.DeclaringType == typeof(string))
178+
{
179+
if (methodCall.Object is MemberExpression memberExpr &&
180+
methodCall.Arguments.Count == 1 &&
181+
methodCall.Arguments[0] is ConstantExpression constExpr)
182+
{
183+
string propertyName = memberExpr.Member.Name;
184+
object? value = constExpr.Value;
185+
186+
string? googleFilterName = MapPropertyToGoogleFilter(propertyName);
187+
if (googleFilterName != null && value != null)
188+
{
189+
// For Contains operations on text fields, use exactTerms or orTerms
190+
if (googleFilterName == "exactTerms")
191+
{
192+
return new TextSearchFilter().Equality("orTerms", value); // More flexible than exactTerms
193+
}
194+
return new TextSearchFilter().Equality(googleFilterName, value);
195+
}
196+
}
197+
}
198+
199+
// Generate helpful error message with supported patterns
200+
var supportedPatterns = new[]
201+
{
202+
"page.Property == \"value\" (exact match)",
203+
"page.Property.Contains(\"text\") (partial match)"
204+
};
205+
206+
var supportedProperties = s_queryParameters.Select(p =>
207+
MapGoogleFilterToProperty(p)).Where(p => p != null).Distinct();
208+
175209
throw new NotSupportedException(
176-
"LINQ expression '" + linqExpression + "' cannot be converted to Google API filters. " +
177-
"Only simple equality expressions like 'page => page.Title == \"example\"' are supported, " +
178-
"and only for properties that map to Google API parameters: " +
179-
string.Join(", ", s_queryParameters));
210+
$"LINQ expression '{linqExpression}' cannot be converted to Google API filters. " +
211+
$"Supported patterns: {string.Join(", ", supportedPatterns)}. " +
212+
$"Supported properties: {string.Join(", ", supportedProperties)}.");
180213
}
181214

182215
/// <summary>
@@ -208,6 +241,27 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter<TRecord>(Exp
208241
};
209242
}
210243

244+
/// <summary>
245+
/// Maps Google Custom Search API filter field names back to example GoogleWebPage property names.
246+
/// Used for generating helpful error messages.
247+
/// </summary>
248+
/// <param name="googleFilterName">The Google API filter name.</param>
249+
/// <returns>An example property name, or null if not mappable.</returns>
250+
private static string? MapGoogleFilterToProperty(string googleFilterName)
251+
{
252+
return googleFilterName switch
253+
{
254+
"siteSearch" => "DisplayLink",
255+
"exactTerms" => "Title",
256+
"filter" => "FileFormat",
257+
"hl" => "HL",
258+
"gl" => "GL",
259+
"cr" => "CR",
260+
"lr" => "LR",
261+
_ => null
262+
};
263+
}
264+
211265
#endregion
212266

213267
/// <inheritdoc/>

0 commit comments

Comments
 (0)