From 16ec4db9cdb30b54f3ac339fa80266629e226135 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Fri, 31 Oct 2025 01:19:14 -0700 Subject: [PATCH 1/9] Change ITextSearch.GetSearchResultsAsync to return KernelSearchResults - Change interface return type from KernelSearchResults to KernelSearchResults - Update VectorStoreTextSearch implementation with new GetResultsAsTRecordAsync helper - Keep GetResultsAsRecordAsync for legacy ITextSearch interface backward compatibility - Update 3 unit tests to use strongly-typed DataModel instead of object Benefits: - Improved type safety - no more casting required - Better IntelliSense and developer experience - Zero breaking changes to legacy ITextSearch interface - All 19 unit tests pass This is Part 2.1 of the Issue #10456 multi-PR chain, refining the API . --- .../Data/TextSearch/ITextSearch.cs | 4 +-- .../Data/TextSearch/VectorStoreTextSearch.cs | 26 +++++++++++++++++-- .../Data/VectorStoreTextSearchTests.cs | 15 ++++++----- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs b/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs index 57da1a9ec677..e955af86bc6c 100644 --- a/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs +++ b/dotnet/src/SemanticKernel.Abstractions/Data/TextSearch/ITextSearch.cs @@ -36,12 +36,12 @@ Task> GetTextSearchResultsAsync( CancellationToken cancellationToken = default); /// - /// Perform a search for content related to the specified query and return values representing the search results. + /// Perform a search for content related to the specified query and return strongly-typed values representing the search results. /// /// What to search for. /// Options used when executing a text search. /// The to monitor for cancellation requests. The default is . - Task> GetSearchResultsAsync( + Task> GetSearchResultsAsync( string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default); diff --git a/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs b/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs index 121ff9b6c7bb..f1b18483c43a 100644 --- a/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs +++ b/dotnet/src/SemanticKernel.Core/Data/TextSearch/VectorStoreTextSearch.cs @@ -213,11 +213,11 @@ Task> ITextSearch.GetTextSearchRe } /// - Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) + Task> ITextSearch.GetSearchResultsAsync(string query, TextSearchOptions? searchOptions, CancellationToken cancellationToken) { var searchResponse = this.ExecuteVectorSearchAsync(query, searchOptions, cancellationToken); - return Task.FromResult(new KernelSearchResults(this.GetResultsAsRecordAsync(searchResponse, cancellationToken))); + return Task.FromResult(new KernelSearchResults(this.GetResultsAsTRecordAsync(searchResponse, cancellationToken))); } #region private @@ -367,6 +367,28 @@ private async IAsyncEnumerable GetResultsAsRecordAsync(IAsyncEnumerable< } } + /// + /// Return the search results as strongly-typed instances. + /// + /// Response containing the records matching the query. + /// Cancellation token + private async IAsyncEnumerable GetResultsAsTRecordAsync(IAsyncEnumerable>? searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null) + { + yield break; + } + + await foreach (var result in searchResponse.WithCancellation(cancellationToken).ConfigureAwait(false)) + { + if (result.Record is not null) + { + yield return result.Record; + await Task.Yield(); + } + } + } + /// /// Return the search results as instances of . /// diff --git a/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs b/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs index 8dd095710c06..75f4b090590e 100644 --- a/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs +++ b/dotnet/src/SemanticKernel.UnitTests/Data/VectorStoreTextSearchTests.cs @@ -78,12 +78,14 @@ public async Task CanGetSearchResultAsync() { // Arrange. var sut = await CreateVectorStoreTextSearchAsync(); + ITextSearch typeSafeInterface = sut; // Act. - KernelSearchResults searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 }); + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 2, Skip = 0 }); var results = await searchResults.Results.ToListAsync(); Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.IsType(result)); } [Fact] @@ -117,12 +119,14 @@ public async Task CanGetSearchResultsWithEmbeddingGeneratorAsync() { // Arrange. var sut = await CreateVectorStoreTextSearchWithEmbeddingGeneratorAsync(); + ITextSearch typeSafeInterface = sut; // Act. - KernelSearchResults searchResults = await sut.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 2, Skip = 0 }); + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 2, Skip = 0 }); var results = await searchResults.Results.ToListAsync(); Assert.Equal(2, results.Count); + Assert.All(results, result => Assert.IsType(result)); } #pragma warning disable CS0618 // VectorStoreTextSearch with ITextEmbeddingGenerationService is obsolete @@ -270,17 +274,16 @@ public async Task LinqGetSearchResultsAsync() Filter = r => r.Tag == "Even" }; - KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync( + KernelSearchResults searchResults = await typeSafeInterface.GetSearchResultsAsync( "What is the Semantic Kernel?", searchOptions); var results = await searchResults.Results.ToListAsync(); - // Assert - Results should be DataModel objects with Tag == "Even" + // Assert - Results should be strongly-typed DataModel objects with Tag == "Even" Assert.NotEmpty(results); Assert.All(results, result => { - var dataModel = Assert.IsType(result); - Assert.Equal("Even", dataModel.Tag); + Assert.Equal("Even", result.Tag); // Direct property access - no cast needed! }); } From 59a5878d91d99f65deccb6a041858aa679f256dc Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Sat, 27 Sep 2025 20:55:59 -0700 Subject: [PATCH 2/9] feat: Modernize GoogleTextSearch with ITextSearch interface --- .../Plugins.Web/Google/GoogleTextSearch.cs | 167 +++++++++++++++++- .../Plugins.Web/Google/GoogleWebPage.cs | 103 +++++++++++ 2 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 38b2a705ed42..7db7b64c8312 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -18,7 +20,7 @@ namespace Microsoft.SemanticKernel.Plugins.Web.Google; /// A Google Text Search implementation that can be used to perform searches using the Google Web Search API. /// #pragma warning disable CS0618 // ITextSearch is obsolete - this class provides backward compatibility -public sealed class GoogleTextSearch : ITextSearch, IDisposable +public sealed class GoogleTextSearch : ITextSearch, ITextSearch, IDisposable #pragma warning restore CS0618 { /// @@ -89,6 +91,127 @@ public async Task> SearchAsync(string query, TextSea return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } + #region ITextSearch Implementation + + /// + public async Task> GetSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + { + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsGoogleWebPageAsync(searchResponse, cancellationToken).Cast(), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + public async Task> GetTextSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + { + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsTextSearchResultAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + public async Task> SearchAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + { + var legacyOptions = ConvertToLegacyOptions(searchOptions); + var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); + + long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; + + return new KernelSearchResults(this.GetResultsAsStringAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); + } + + /// + /// Converts generic TextSearchOptions with LINQ filtering to legacy TextSearchOptions. + /// Attempts to translate simple LINQ expressions to Google API filters where possible. + /// + /// The generic search options with LINQ filtering. + /// Legacy TextSearchOptions with equivalent filtering. + private static TextSearchOptions ConvertToLegacyOptions(TextSearchOptions? genericOptions) + { + if (genericOptions == null) + { + return new TextSearchOptions(); + } + + return new TextSearchOptions + { + Top = genericOptions.Top, + Skip = genericOptions.Skip, + IncludeTotalCount = genericOptions.IncludeTotalCount, + Filter = genericOptions.Filter != null ? ConvertLinqExpressionToGoogleFilter(genericOptions.Filter) : null + }; + } + + /// + /// Converts a LINQ expression to a TextSearchFilter compatible with Google Custom Search API. + /// Only supports simple property equality expressions that map to Google's filter capabilities. + /// + /// The LINQ expression to convert. + /// A TextSearchFilter with equivalent filtering. + /// Thrown when the expression cannot be converted to Google filters. + private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Expression> linqExpression) + { + if (linqExpression.Body is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) + { + // Handle simple equality: record.PropertyName == "value" + if (binaryExpr.Left is MemberExpression memberExpr && binaryExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + + // Map GoogleWebPage properties to Google API filter names + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + return new TextSearchFilter().Equality(googleFilterName, value); + } + } + } + + throw new NotSupportedException( + "LINQ expression '" + linqExpression + "' cannot be converted to Google API filters. " + + "Only simple equality expressions like 'page => page.Title == \"example\"' are supported, " + + "and only for properties that map to Google API parameters: " + + string.Join(", ", s_queryParameters)); + } + + /// + /// Maps GoogleWebPage property names to Google Custom Search API filter field names. + /// + /// The GoogleWebPage property name. + /// The corresponding Google API filter name, or null if not mappable. + private static string? MapPropertyToGoogleFilter(string propertyName) + { + return propertyName.ToUpperInvariant() switch + { + // Map GoogleWebPage properties to Google API equivalents + "LINK" => "siteSearch", // Maps to site search + "DISPLAYLINK" => "siteSearch", // Maps to site search + "TITLE" => "exactTerms", // Exact title match + "SNIPPET" => "exactTerms", // Exact content match + + // Direct API parameters mapped from GoogleWebPage metadata properties + "FILEFORMAT" => "filter", // File format filtering + "MIME" => "filter", // MIME type filtering + + // Locale/Language parameters (if we extend GoogleWebPage) + "HL" => "hl", // Interface language + "GL" => "gl", // Geolocation + "CR" => "cr", // Country restrict + "LR" => "lr", // Language restrict + + _ => null // Property not mappable to Google filters + }; + } + + #endregion + /// public void Dispose() { @@ -235,6 +358,25 @@ private async IAsyncEnumerable GetResultsAsStringAsync(global::Google.Ap } } + /// + /// Return the search results as instances of . + /// + /// Google search response + /// Cancellation token + private async IAsyncEnumerable GetResultsAsGoogleWebPageAsync(global::Google.Apis.CustomSearchAPI.v1.Data.Search searchResponse, [EnumeratorCancellation] CancellationToken cancellationToken) + { + if (searchResponse is null || searchResponse.Items is null) + { + yield break; + } + + foreach (var item in searchResponse.Items) + { + yield return ConvertToGoogleWebPage(item); + await Task.Yield(); + } + } + /// /// Return the search results as instances of . /// @@ -266,6 +408,29 @@ private async IAsyncEnumerable GetResultsAsStringAsync(global::Google.Ap }; } + /// + /// Converts a Google CustomSearchAPI Result to a GoogleWebPage instance. + /// + /// The Google search result to convert. + /// A GoogleWebPage with mapped properties. + private static GoogleWebPage ConvertToGoogleWebPage(global::Google.Apis.CustomSearchAPI.v1.Data.Result googleResult) + { + return new GoogleWebPage + { + Title = googleResult.Title, + Link = googleResult.Link, + Snippet = googleResult.Snippet, + DisplayLink = googleResult.DisplayLink, + FormattedUrl = googleResult.FormattedUrl, + HtmlFormattedUrl = googleResult.HtmlFormattedUrl, + HtmlSnippet = googleResult.HtmlSnippet, + HtmlTitle = googleResult.HtmlTitle, + Mime = googleResult.Mime, + FileFormat = googleResult.FileFormat, + Labels = googleResult.Labels?.Select(l => l.Name).ToArray() + }; + } + /// /// Default implementation which maps from a to a /// diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs new file mode 100644 index 000000000000..c7af4618b77d --- /dev/null +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.SemanticKernel.Plugins.Web.Google; + +/// +/// Defines a webpage result from Google Custom Search API. +/// +public sealed class GoogleWebPage +{ + /// + /// Only allow creation within this package. + /// + [JsonConstructorAttribute] + internal GoogleWebPage() + { + } + + /// + /// The title of the webpage. + /// + /// + /// Use this title along with Link to create a hyperlink that when clicked takes the user to the webpage. + /// + [JsonPropertyName("title")] + public string? Title { get; set; } + + /// + /// The URL to the webpage. + /// + /// + /// Use this URL along with Title to create a hyperlink that when clicked takes the user to the webpage. + /// + [JsonPropertyName("link")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? Link { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// A snippet of text from the webpage that describes its contents. + /// + [JsonPropertyName("snippet")] + public string? Snippet { get; set; } + + /// + /// The formatted URL display string. + /// + /// + /// The URL is meant for display purposes only and may not be well formed. + /// + [JsonPropertyName("displayLink")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? DisplayLink { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// The MIME type of the result. + /// + [JsonPropertyName("mime")] + public string? Mime { get; set; } + + /// + /// The file format of the result. + /// + [JsonPropertyName("fileFormat")] + public string? FileFormat { get; set; } + + /// + /// The HTML title of the webpage. + /// + [JsonPropertyName("htmlTitle")] + public string? HtmlTitle { get; set; } + + /// + /// The HTML snippet of the webpage. + /// + [JsonPropertyName("htmlSnippet")] + public string? HtmlSnippet { get; set; } + + /// + /// The formatted URL of the webpage. + /// + [JsonPropertyName("formattedUrl")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? FormattedUrl { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// The HTML-formatted URL of the webpage. + /// + [JsonPropertyName("htmlFormattedUrl")] +#pragma warning disable CA1056 // URI-like properties should not be strings + public string? HtmlFormattedUrl { get; set; } +#pragma warning restore CA1056 // URI-like properties should not be strings + + /// + /// Labels associated with the webpage. + /// + [JsonPropertyName("labels")] + public IReadOnlyList? Labels { get; set; } +} From 9bf70fb44b36b1ac88914763595de6856819dd36 Mon Sep 17 00:00:00 2001 From: alzarei Date: Sun, 28 Sep 2025 07:53:55 +0000 Subject: [PATCH 3/9] fix: resolve method ambiguity in GoogleTextSearchTests and add generic interface tests - Fix CS0121 compilation errors by explicitly specifying TextSearchOptions instead of new() - Add 3 comprehensive tests for ITextSearch generic interface: * GenericSearchAsyncReturnsSuccessfullyAsync * GenericGetTextSearchResultsReturnsSuccessfullyAsync * GenericGetSearchResultsReturnsSuccessfullyAsync - All 22 Google tests now pass (19 legacy + 3 generic) - Validates both backward compatibility and new type-safe functionality --- .../Web/Google/GoogleTextSearchTests.cs | 93 ++++++++++++++++++- 1 file changed, 88 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index 38a497eac9d1..a15357f34932 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -55,7 +55,7 @@ public async Task SearchReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -81,7 +81,7 @@ public async Task GetTextSearchResultsReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); // Assert Assert.NotNull(result); @@ -109,7 +109,7 @@ public async Task GetSearchResultsReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 10, Skip = 0 }); + KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); // Assert Assert.NotNull(results); @@ -140,7 +140,7 @@ public async Task SearchWithCustomStringMapperReturnsSuccessfullyAsync() options: new() { StringMapper = new TestTextSearchStringMapper() }); // Act - KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -169,7 +169,7 @@ public async Task GetTextSearchResultsWithCustomResultMapperReturnsSuccessfullyA options: new() { ResultMapper = new TestTextSearchResultMapper() }); // Act - KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new() { Top = 4, Skip = 0 }); + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); // Assert Assert.NotNull(result); @@ -237,6 +237,89 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync() Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message); } + [Fact] + public async Task GenericSearchAsyncReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 4, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + foreach (var stringResult in resultList) + { + Assert.NotEmpty(stringResult); + } + } + + [Fact] + public async Task GenericGetTextSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults result = await textSearch.GetTextSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + foreach (var textSearchResult in resultList) + { + Assert.NotNull(textSearchResult.Name); + Assert.NotNull(textSearchResult.Value); + Assert.NotNull(textSearchResult.Link); + } + } + + [Fact] + public async Task GenericGetSearchResultsReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + // Create an ITextSearch instance using Google search + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with GoogleWebPage + KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); + + // Assert + Assert.NotNull(results); + Assert.NotNull(results.Results); + var resultList = await results.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + foreach (GoogleWebPage result in resultList.Cast()) + { + Assert.NotNull(result.Title); + Assert.NotNull(result.Snippet); + Assert.NotNull(result.Link); + Assert.NotNull(result.DisplayLink); + } + } + /// public void Dispose() { From 7f4cdade5310a4ba8163787aadebf2e413bbf30a Mon Sep 17 00:00:00 2001 From: alzarei Date: Wed, 1 Oct 2025 07:05:04 +0000 Subject: [PATCH 4/9] 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) --- .../Concepts/Search/Google_TextSearch.cs | 8 +-- .../Web/Google/GoogleTextSearchTests.cs | 54 +++++++++++++++ .../Plugins.Web/Google/GoogleTextSearch.cs | 68 +++++++++++++++++-- 3 files changed, 119 insertions(+), 11 deletions(-) diff --git a/dotnet/samples/Concepts/Search/Google_TextSearch.cs b/dotnet/samples/Concepts/Search/Google_TextSearch.cs index a77f65bcfbc3..2fe41cdedc80 100644 --- a/dotnet/samples/Concepts/Search/Google_TextSearch.cs +++ b/dotnet/samples/Concepts/Search/Google_TextSearch.cs @@ -26,7 +26,7 @@ public async Task UsingGoogleTextSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results as string items - KernelSearchResults stringResults = await textSearch.SearchAsync(query, new() { Top = 4, Skip = 0 }); + KernelSearchResults stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4, Skip = 0 }); Console.WriteLine("——— String Results ———\n"); await foreach (string result in stringResults.Results) { @@ -35,7 +35,7 @@ public async Task UsingGoogleTextSearchAsync() } // Search and return results as TextSearchResult items - KernelSearchResults textResults = await textSearch.GetTextSearchResultsAsync(query, new() { Top = 4, Skip = 4 }); + KernelSearchResults textResults = await textSearch.GetTextSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 4 }); Console.WriteLine("\n——— Text Search Results ———\n"); await foreach (TextSearchResult result in textResults.Results) { @@ -46,7 +46,7 @@ public async Task UsingGoogleTextSearchAsync() } // Search and return results as Google.Apis.CustomSearchAPI.v1.Data.Result items - KernelSearchResults fullResults = await textSearch.GetSearchResultsAsync(query, new() { Top = 4, Skip = 8 }); + KernelSearchResults fullResults = await textSearch.GetSearchResultsAsync(query, new TextSearchOptions { Top = 4, Skip = 8 }); Console.WriteLine("\n——— Google Web Page Results ———\n"); await foreach (Google.Apis.CustomSearchAPI.v1.Data.Result result in fullResults.Results) { @@ -74,7 +74,7 @@ public async Task UsingGoogleTextSearchWithACustomMapperAsync() var query = "What is the Semantic Kernel?"; // Search with TextSearchResult textResult type - KernelSearchResults stringResults = await textSearch.SearchAsync(query, new() { Top = 2, Skip = 0 }); + KernelSearchResults stringResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 2, Skip = 0 }); Console.WriteLine("--- Serialized JSON Results ---"); await foreach (string result in stringResults.Results) { diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index a15357f34932..ceaada5b2e8c 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -320,6 +320,60 @@ public async Task GenericGetSearchResultsReturnsSuccessfullyAsync() } } + [Fact] + public async Task GenericSearchWithContainsFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with Contains filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title.Contains("Semantic") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithEqualityFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with equality filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + /// public void Dispose() { diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 7db7b64c8312..27d3d71df8fe 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -150,22 +150,21 @@ private static TextSearchOptions ConvertToLegacyOptions(TextSearchOptions /// Converts a LINQ expression to a TextSearchFilter compatible with Google Custom Search API. - /// Only supports simple property equality expressions that map to Google's filter capabilities. + /// Supports property equality expressions and string Contains operations that map to Google's filter capabilities. /// /// The LINQ expression to convert. /// A TextSearchFilter with equivalent filtering. /// Thrown when the expression cannot be converted to Google filters. private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Expression> linqExpression) { + // Handle simple equality: record.PropertyName == "value" if (linqExpression.Body is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) { - // Handle simple equality: record.PropertyName == "value" if (binaryExpr.Left is MemberExpression memberExpr && binaryExpr.Right is ConstantExpression constExpr) { string propertyName = memberExpr.Member.Name; object? value = constExpr.Value; - // Map GoogleWebPage properties to Google API filter names string? googleFilterName = MapPropertyToGoogleFilter(propertyName); if (googleFilterName != null && value != null) { @@ -174,11 +173,45 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp } } + // Handle string Contains: record.PropertyName.Contains("value") + if (linqExpression.Body is MethodCallExpression methodCall && + methodCall.Method.Name == "Contains" && + methodCall.Method.DeclaringType == typeof(string)) + { + if (methodCall.Object is MemberExpression memberExpr && + methodCall.Arguments.Count == 1 && + methodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + // For Contains operations on text fields, use exactTerms or orTerms + if (googleFilterName == "exactTerms") + { + return new TextSearchFilter().Equality("orTerms", value); // More flexible than exactTerms + } + return new TextSearchFilter().Equality(googleFilterName, value); + } + } + } + + // Generate helpful error message with supported patterns + var supportedPatterns = new[] + { + "page.Property == \"value\" (exact match)", + "page.Property.Contains(\"text\") (partial match)" + }; + + var supportedProperties = s_queryParameters.Select(p => + MapGoogleFilterToProperty(p)).Where(p => p != null).Distinct(); + throw new NotSupportedException( - "LINQ expression '" + linqExpression + "' cannot be converted to Google API filters. " + - "Only simple equality expressions like 'page => page.Title == \"example\"' are supported, " + - "and only for properties that map to Google API parameters: " + - string.Join(", ", s_queryParameters)); + $"LINQ expression '{linqExpression}' cannot be converted to Google API filters. " + + $"Supported patterns: {string.Join(", ", supportedPatterns)}. " + + $"Supported properties: {string.Join(", ", supportedProperties)}."); } /// @@ -210,6 +243,27 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp }; } + /// + /// Maps Google Custom Search API filter field names back to example GoogleWebPage property names. + /// Used for generating helpful error messages. + /// + /// The Google API filter name. + /// An example property name, or null if not mappable. + private static string? MapGoogleFilterToProperty(string googleFilterName) + { + return googleFilterName switch + { + "siteSearch" => "DisplayLink", + "exactTerms" => "Title", + "filter" => "FileFormat", + "hl" => "HL", + "gl" => "GL", + "cr" => "CR", + "lr" => "LR", + _ => null + }; + } + #endregion /// From ae09dc26d73a13689e19e968a184a1f154b1852d Mon Sep 17 00:00:00 2001 From: alzarei Date: Fri, 3 Oct 2025 06:46:11 +0000 Subject: [PATCH 5/9] feat(plugins): enhance GoogleTextSearch with advanced LINQ filtering - Add ITextSearch interface implementation - Support equality, contains, NOT operations, and compound AND expressions - Map LINQ expressions to Google Custom Search API parameters - Add GoogleWebPage strongly-typed model for search results - Support FileFormat filtering via Google's fileType parameter - Add comprehensive test coverage (29 tests) for all filtering patterns - Include practical examples demonstrating enhanced filtering capabilities - Maintain backward compatibility with existing ITextSearch interface Resolves enhanced LINQ filtering requirements for Google Text Search plugin. --- .../Web/Google/GoogleTextSearchTests.cs | 139 +++++++++++++++- .../Plugins.Web/Google/GoogleTextSearch.cs | 152 +++++++++++++++++- 2 files changed, 284 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index ceaada5b2e8c..1d956721615b 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -234,7 +234,7 @@ public async Task DoesNotBuildsUriForInvalidQueryParameterAsync() // Act && Assert var e = await Assert.ThrowsAsync(async () => await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", searchOptions)); - Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message); + Assert.Equal("Unknown equality filter clause field name 'fooBar', must be one of cr,dateRestrict,exactTerms,excludeTerms,fileType,filter,gl,hl,linkSite,lr,orTerms,rights,siteSearch (Parameter 'searchOptions')", e.Message); } [Fact] @@ -336,7 +336,7 @@ public async Task GenericSearchWithContainsFilterReturnsSuccessfullyAsync() { Top = 4, Skip = 0, - Filter = page => page.Title.Contains("Semantic") + Filter = page => page.Title != null && page.Title.Contains("Semantic") }); // Assert @@ -374,6 +374,141 @@ public async Task GenericSearchWithEqualityFilterReturnsSuccessfullyAsync() Assert.Equal(4, resultList.Count); } + [Fact] + public async Task GenericSearchWithNotEqualFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with NOT EQUAL filtering (excludes terms) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != "Deprecated" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithNotContainsFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with NOT Contains filtering (excludes terms) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && !page.Title.Contains("deprecated") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithFileFormatFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with FileFormat filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithCompoundAndFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with compound AND filtering + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") && page.DisplayLink != null && page.DisplayLink.Contains("microsoft") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + + [Fact] + public async Task GenericSearchWithComplexCompoundFilterReturnsSuccessfullyAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use generic interface with complex compound filtering (equality + contains + exclusion) + KernelSearchResults result = await textSearch.SearchAsync("What is the Semantic Kernel?", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && page.Title != null && page.Title.Contains("AI") && page.Snippet != null && !page.Snippet.Contains("deprecated") + }); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Results); + var resultList = await result.Results.ToListAsync(); + Assert.NotNull(resultList); + Assert.Equal(4, resultList.Count); + } + /// public void Dispose() { diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 27d3d71df8fe..9859fbd09a18 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -150,13 +150,22 @@ private static TextSearchOptions ConvertToLegacyOptions(TextSearchOptions /// Converts a LINQ expression to a TextSearchFilter compatible with Google Custom Search API. - /// Supports property equality expressions and string Contains operations that map to Google's filter capabilities. + /// Supports property equality expressions, string Contains operations, NOT operations (inequality and negation), + /// and compound AND expressions that map to Google's filter capabilities. /// /// The LINQ expression to convert. /// A TextSearchFilter with equivalent filtering. /// Thrown when the expression cannot be converted to Google filters. private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Expression> linqExpression) { + // Handle compound AND expressions: expr1 && expr2 + if (linqExpression.Body is BinaryExpression andExpr && andExpr.NodeType == ExpressionType.AndAlso) + { + var filter = new TextSearchFilter(); + CollectAndCombineFilters(andExpr, filter); + return filter; + } + // Handle simple equality: record.PropertyName == "value" if (linqExpression.Body is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) { @@ -173,6 +182,45 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp } } + // Handle inequality (NOT): record.PropertyName != "value" + if (linqExpression.Body is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) + { + if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + + // Map to excludeTerms for text fields + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + return new TextSearchFilter().Equality("excludeTerms", value); + } + } + } + + // Handle NOT expressions: !record.PropertyName.Contains("value") + if (linqExpression.Body is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) + { + if (unaryExpr.Operand is MethodCallExpression notMethodCall && + notMethodCall.Method.Name == "Contains" && + notMethodCall.Method.DeclaringType == typeof(string)) + { + if (notMethodCall.Object is MemberExpression memberExpr && + notMethodCall.Arguments.Count == 1 && + notMethodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + + // Map to excludeTerms for text fields + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + return new TextSearchFilter().Equality("excludeTerms", value); + } + } + } + } + // Handle string Contains: record.PropertyName.Contains("value") if (linqExpression.Body is MethodCallExpression methodCall && methodCall.Method.Name == "Contains" && @@ -202,7 +250,10 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp var supportedPatterns = new[] { "page.Property == \"value\" (exact match)", - "page.Property.Contains(\"text\") (partial match)" + "page.Property != \"value\" (exclude)", + "page.Property.Contains(\"text\") (partial match)", + "!page.Property.Contains(\"text\") (exclude partial)", + "page.Prop1 == \"val1\" && page.Prop2.Contains(\"val2\") (compound AND)" }; var supportedProperties = s_queryParameters.Select(p => @@ -214,6 +265,93 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp $"Supported properties: {string.Join(", ", supportedProperties)}."); } + /// + /// Recursively collects and combines filters from compound AND expressions. + /// + /// The expression to process. + /// The filter to accumulate results into. + private static void CollectAndCombineFilters(Expression expression, TextSearchFilter filter) + { + if (expression is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.AndAlso) + { + // Recursively process both sides of the AND + CollectAndCombineFilters(binaryExpr.Left, filter); + CollectAndCombineFilters(binaryExpr.Right, filter); + } + else if (expression is BinaryExpression equalExpr && equalExpr.NodeType == ExpressionType.Equal) + { + // Handle equality + if (equalExpr.Left is MemberExpression memberExpr && equalExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + filter.Equality(googleFilterName, value); + } + } + } + else if (expression is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) + { + // Handle inequality (exclusion) + if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + filter.Equality("excludeTerms", value); + } + } + } + else if (expression is MethodCallExpression methodCall && + methodCall.Method.Name == "Contains" && + methodCall.Method.DeclaringType == typeof(string)) + { + // Handle Contains + if (methodCall.Object is MemberExpression memberExpr && + methodCall.Arguments.Count == 1 && + methodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) + { + if (googleFilterName == "exactTerms") + { + filter.Equality("orTerms", value); + } + else + { + filter.Equality(googleFilterName, value); + } + } + } + } + else if (expression is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) + { + // Handle NOT Contains + if (unaryExpr.Operand is MethodCallExpression notMethodCall && + notMethodCall.Method.Name == "Contains" && + notMethodCall.Method.DeclaringType == typeof(string)) + { + if (notMethodCall.Object is MemberExpression memberExpr && + notMethodCall.Arguments.Count == 1 && + notMethodCall.Arguments[0] is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) + { + filter.Equality("excludeTerms", value); + } + } + } + } + } + /// /// Maps GoogleWebPage property names to Google Custom Search API filter field names. /// @@ -230,7 +368,7 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp "SNIPPET" => "exactTerms", // Exact content match // Direct API parameters mapped from GoogleWebPage metadata properties - "FILEFORMAT" => "filter", // File format filtering + "FILEFORMAT" => "fileType", // File type/extension filtering "MIME" => "filter", // MIME type filtering // Locale/Language parameters (if we extend GoogleWebPage) @@ -255,7 +393,10 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp { "siteSearch" => "DisplayLink", "exactTerms" => "Title", - "filter" => "FileFormat", + "orTerms" => "Title", + "excludeTerms" => "Title", + "fileType" => "FileFormat", + "filter" => "Mime", "hl" => "HL", "gl" => "GL", "cr" => "CR", @@ -286,7 +427,7 @@ public void Dispose() private static readonly ITextSearchResultMapper s_defaultResultMapper = new DefaultTextSearchResultMapper(); // See https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list - private static readonly string[] s_queryParameters = ["cr", "dateRestrict", "exactTerms", "excludeTerms", "filter", "gl", "hl", "linkSite", "lr", "orTerms", "rights", "siteSearch"]; + private static readonly string[] s_queryParameters = ["cr", "dateRestrict", "exactTerms", "excludeTerms", "fileType", "filter", "gl", "hl", "linkSite", "lr", "orTerms", "rights", "siteSearch"]; private delegate void SetSearchProperty(CseResource.ListRequest search, string value); @@ -295,6 +436,7 @@ public void Dispose() { "DATERESTRICT", (search, value) => search.DateRestrict = value }, { "EXACTTERMS", (search, value) => search.ExactTerms = value }, { "EXCLUDETERMS", (search, value) => search.ExcludeTerms = value }, + { "FILETYPE", (search, value) => search.FileType = value }, { "FILTER", (search, value) => search.Filter = value }, { "GL", (search, value) => search.Gl = value }, { "HL", (search, value) => search.Hl = value }, From 56ec38384287675525c096b9fa169d8a81556923 Mon Sep 17 00:00:00 2001 From: alzarei Date: Fri, 3 Oct 2025 08:57:23 +0000 Subject: [PATCH 6/9] feat: Add comprehensive LINQ filtering examples and fix method ambiguity - Add UsingGoogleTextSearchWithEnhancedLinqFilteringAsync method to Google_TextSearch.cs * Demonstrates 6 practical LINQ filtering patterns * Includes equality, contains, NOT operations, FileFormat, compound AND examples * Shows real-world usage of ITextSearch interface - Fix method ambiguity in Step1_Web_Search.cs * Explicitly specify TextSearchOptions type instead of target-typed new() * Resolves CS0121 compilation error when both legacy and generic interfaces implemented * Maintains tutorial clarity for getting started guide These enhancements complete the sample code demonstrating the new LINQ filtering capabilities while ensuring all existing tutorials continue to compile correctly. --- .../Concepts/Search/Google_TextSearch.cs | 107 ++++++++++++++++++ .../Step1_Web_Search.cs | 4 +- 2 files changed, 109 insertions(+), 2 deletions(-) diff --git a/dotnet/samples/Concepts/Search/Google_TextSearch.cs b/dotnet/samples/Concepts/Search/Google_TextSearch.cs index 2fe41cdedc80..749405422faf 100644 --- a/dotnet/samples/Concepts/Search/Google_TextSearch.cs +++ b/dotnet/samples/Concepts/Search/Google_TextSearch.cs @@ -107,6 +107,113 @@ public async Task UsingGoogleTextSearchWithASiteSearchFilterAsync() } } + /// + /// Show how to use enhanced LINQ filtering with GoogleTextSearch including Contains, NOT, FileType, and compound AND expressions. + /// + [Fact] + public async Task UsingGoogleTextSearchWithEnhancedLinqFilteringAsync() + { + // Create an ITextSearch instance using Google search + var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = TestConfiguration.Google.ApiKey, HttpClientFactory = new CustomHttpClientFactory(this.Output) }, + searchEngineId: TestConfiguration.Google.SearchEngineId); + + var query = "Semantic Kernel AI"; + + // Example 1: Simple equality filtering + Console.WriteLine("——— Example 1: Equality Filter (DisplayLink) ———\n"); + var equalityOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }; + var equalityResults = await textSearch.SearchAsync(query, equalityOptions); + await foreach (string result in equalityResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 2: Contains filtering + Console.WriteLine("\n——— Example 2: Contains Filter (Title) ———\n"); + var containsOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("AI") + }; + var containsResults = await textSearch.SearchAsync(query, containsOptions); + await foreach (string result in containsResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 3: NOT Contains filtering (exclusion) + Console.WriteLine("\n——— Example 3: NOT Contains Filter (Exclude 'deprecated') ———\n"); + var notContainsOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && !page.Title.Contains("deprecated") + }; + var notContainsResults = await textSearch.SearchAsync(query, notContainsOptions); + await foreach (string result in notContainsResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 4: FileFormat filtering + Console.WriteLine("\n——— Example 4: FileFormat Filter (PDF files) ———\n"); + var fileFormatOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }; + var fileFormatResults = await textSearch.SearchAsync(query, fileFormatOptions); + await foreach (string result in fileFormatResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 5: Compound AND filtering (multiple conditions) + Console.WriteLine("\n——— Example 5: Compound AND Filter (Title + Site) ———\n"); + var compoundOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") && + page.DisplayLink != null && page.DisplayLink.Contains("microsoft") + }; + var compoundResults = await textSearch.SearchAsync(query, compoundOptions); + await foreach (string result in compoundResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + + // Example 6: Complex compound filtering (equality + contains + exclusion) + Console.WriteLine("\n——— Example 6: Complex Compound Filter (FileFormat + Contains + NOT Contains) ———\n"); + var complexOptions = new TextSearchOptions + { + Top = 2, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && + page.Title != null && page.Title.Contains("AI") && + page.Snippet != null && !page.Snippet.Contains("deprecated") + }; + var complexResults = await textSearch.SearchAsync(query, complexOptions); + await foreach (string result in complexResults.Results) + { + Console.WriteLine(result); + Console.WriteLine(new string('—', HorizontalRuleLength)); + } + } + #region private private const int HorizontalRuleLength = 80; diff --git a/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs b/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs index fe33e7f7da10..1d4fe23a3eee 100644 --- a/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs +++ b/dotnet/samples/GettingStartedWithTextSearch/Step1_Web_Search.cs @@ -25,7 +25,7 @@ public async Task BingSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results - KernelSearchResults searchResults = await textSearch.SearchAsync(query, new() { Top = 4 }); + KernelSearchResults searchResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4 }); await foreach (string result in searchResults.Results) { Console.WriteLine(result); @@ -46,7 +46,7 @@ public async Task GoogleSearchAsync() var query = "What is the Semantic Kernel?"; // Search and return results - KernelSearchResults searchResults = await textSearch.SearchAsync(query, new() { Top = 4 }); + KernelSearchResults searchResults = await textSearch.SearchAsync(query, new TextSearchOptions { Top = 4 }); await foreach (string result in searchResults.Results) { Console.WriteLine(result); From 606ce140f917132a828529bf749bcce3dd7069fa Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Thu, 16 Oct 2025 22:37:00 -0700 Subject: [PATCH 7/9] Address PR4 reviewer feedback 1. Added LINQ Filter Verification Tests - Added 7 test methods verifying LINQ expressions produce correct Google API URLs - Tests cover equality, contains, inequality, compound filters with URL validation - Expanded test suite from 29 to 36 tests (all passing) - Addresses reviewer comment: 'Some tests to verify the filter url that is created from the different linq expressions would be good' 2. Fixed Documentation Standards - Updated all property summaries in GoogleWebPage.cs to use 'Gets or sets the' convention - Applied to all 11 properties following .NET documentation standards - Addresses reviewer comment: 'These property summaries should start with Gets or sets the to conform to the documentation standard' 3. Performance Optimization - Added static readonly s_supportedPatterns array to avoid allocations in error paths - Moved error messages from inline array allocation to static field - Addresses reviewer comment: 'Consider making this a static field on the class. No need to allocate a new array of strings for each failed invocation' 4. Code Consolidation - Extracted shared LINQ processing logic into helper methods - Eliminated duplication between ConvertLinqExpressionToGoogleFilter and CollectAndCombineFilters - Applied DRY principles throughout LINQ expression processing - Addresses reviewer comment: 'This code seems very similar to that in CollectAndCombineFilters. Can this be consolidated?' Validation: - Build: Release configuration successful - Tests: 36/36 passing - Format: dotnet format compliance verified - Regression: All existing functionality preserved Note: API design question about return type consistency deferred for architectural discussion --- .../Web/Google/GoogleTextSearchTests.cs | 195 ++++++++++++ .../Plugins.Web/Google/GoogleTextSearch.cs | 277 +++++++++--------- .../Plugins.Web/Google/GoogleWebPage.cs | 22 +- 3 files changed, 338 insertions(+), 156 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index 1d956721615b..2b6fdebc46c5 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -509,6 +509,201 @@ public async Task GenericSearchWithComplexCompoundFilterReturnsSuccessfullyAsync Assert.Equal(4, resultList.Count); } + #region LINQ Filter Verification Tests + // These tests verify that LINQ expressions produce correct Google API URL parameters + // Addressing reviewer feedback: "Some tests to verify the filter url that is created from the different linq expressions would be good" + + [Fact] + public async Task LinqEqualityFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ equality filter for DisplayLink + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" + }); + + // Assert - Verify URL contains correct siteSearch parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("siteSearch=microsoft.com", absoluteUri); + Assert.Contains("siteSearchFilter=i", absoluteUri); + } + + [Fact] + public async Task LinqFileFormatEqualityFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ equality filter for FileFormat + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" + }); + + // Assert - Verify URL contains correct fileType parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("fileType=pdf", absoluteUri); + } + + [Fact] + public async Task LinqContainsFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ Contains filter for Title + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != null && page.Title.Contains("Semantic") + }); + + // Assert - Verify URL contains correct orTerms parameter (Contains uses orTerms for flexibility) + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("orTerms=Semantic", absoluteUri); + } + + [Fact] + public async Task LinqNotEqualFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ NOT Equal filter for Title + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Title != "deprecated" + }); + + // Assert - Verify URL contains correct excludeTerms parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("excludeTerms=deprecated", absoluteUri); + } + + [Fact] + public async Task LinqNotContainsFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ NOT Contains filter for Snippet + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.Snippet != null && !page.Snippet.Contains("outdated") + }); + + // Assert - Verify URL contains correct excludeTerms parameter + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("excludeTerms=outdated", absoluteUri); + } + + [Fact] + public async Task LinqCompoundAndFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ compound AND filter + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.DisplayLink == "microsoft.com" && page.FileFormat == "pdf" + }); + + // Assert - Verify URL contains both parameters + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("siteSearch=microsoft.com", absoluteUri); + Assert.Contains("siteSearchFilter=i", absoluteUri); + Assert.Contains("fileType=pdf", absoluteUri); + } + + [Fact] + public async Task LinqComplexCompoundFilterProducesCorrectApiUrlAsync() + { + // Arrange + this._messageHandlerStub.AddJsonResponse(File.ReadAllText(WhatIsTheSKResponseJson)); + + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act - Use LINQ complex compound filter (equality + contains + exclusion) + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => page.FileFormat == "pdf" && + page.Title != null && page.Title.Contains("AI") && + page.Snippet != null && !page.Snippet.Contains("deprecated") + }); + + // Assert - Verify URL contains all expected parameters + var requestUris = this._messageHandlerStub.RequestUris; + Assert.Single(requestUris); + var absoluteUri = requestUris[0]!.AbsoluteUri; + Assert.Contains("fileType=pdf", absoluteUri); + Assert.Contains("orTerms=AI", absoluteUri); // Contains uses orTerms for flexibility + Assert.Contains("excludeTerms=deprecated", absoluteUri); + } + + #endregion + /// public void Dispose() { diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 9859fbd09a18..29837b4f5dc9 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -166,102 +166,20 @@ private static TextSearchFilter ConvertLinqExpressionToGoogleFilter(Exp return filter; } - // Handle simple equality: record.PropertyName == "value" - if (linqExpression.Body is BinaryExpression binaryExpr && binaryExpr.NodeType == ExpressionType.Equal) + // Handle simple expressions using the shared processing logic + var textSearchFilter = new TextSearchFilter(); + if (TryProcessSingleExpression(linqExpression.Body, textSearchFilter)) { - if (binaryExpr.Left is MemberExpression memberExpr && binaryExpr.Right is ConstantExpression constExpr) - { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - - string? googleFilterName = MapPropertyToGoogleFilter(propertyName); - if (googleFilterName != null && value != null) - { - return new TextSearchFilter().Equality(googleFilterName, value); - } - } - } - - // Handle inequality (NOT): record.PropertyName != "value" - if (linqExpression.Body is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) - { - if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) - { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - - // Map to excludeTerms for text fields - if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) - { - return new TextSearchFilter().Equality("excludeTerms", value); - } - } - } - - // Handle NOT expressions: !record.PropertyName.Contains("value") - if (linqExpression.Body is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) - { - if (unaryExpr.Operand is MethodCallExpression notMethodCall && - notMethodCall.Method.Name == "Contains" && - notMethodCall.Method.DeclaringType == typeof(string)) - { - if (notMethodCall.Object is MemberExpression memberExpr && - notMethodCall.Arguments.Count == 1 && - notMethodCall.Arguments[0] is ConstantExpression constExpr) - { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - - // Map to excludeTerms for text fields - if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) - { - return new TextSearchFilter().Equality("excludeTerms", value); - } - } - } - } - - // Handle string Contains: record.PropertyName.Contains("value") - if (linqExpression.Body is MethodCallExpression methodCall && - methodCall.Method.Name == "Contains" && - methodCall.Method.DeclaringType == typeof(string)) - { - if (methodCall.Object is MemberExpression memberExpr && - methodCall.Arguments.Count == 1 && - methodCall.Arguments[0] is ConstantExpression constExpr) - { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - - string? googleFilterName = MapPropertyToGoogleFilter(propertyName); - if (googleFilterName != null && value != null) - { - // For Contains operations on text fields, use exactTerms or orTerms - if (googleFilterName == "exactTerms") - { - return new TextSearchFilter().Equality("orTerms", value); // More flexible than exactTerms - } - return new TextSearchFilter().Equality(googleFilterName, value); - } - } + return textSearchFilter; } // Generate helpful error message with supported patterns - var supportedPatterns = new[] - { - "page.Property == \"value\" (exact match)", - "page.Property != \"value\" (exclude)", - "page.Property.Contains(\"text\") (partial match)", - "!page.Property.Contains(\"text\") (exclude partial)", - "page.Prop1 == \"val1\" && page.Prop2.Contains(\"val2\") (compound AND)" - }; - var supportedProperties = s_queryParameters.Select(p => MapGoogleFilterToProperty(p)).Where(p => p != null).Distinct(); throw new NotSupportedException( $"LINQ expression '{linqExpression}' cannot be converted to Google API filters. " + - $"Supported patterns: {string.Join(", ", supportedPatterns)}. " + + $"Supported patterns: {string.Join(", ", s_supportedPatterns)}. " + $"Supported properties: {string.Join(", ", supportedProperties)}."); } @@ -278,78 +196,141 @@ private static void CollectAndCombineFilters(Expression expression, TextSearchFi CollectAndCombineFilters(binaryExpr.Left, filter); CollectAndCombineFilters(binaryExpr.Right, filter); } - else if (expression is BinaryExpression equalExpr && equalExpr.NodeType == ExpressionType.Equal) + else { - // Handle equality - if (equalExpr.Left is MemberExpression memberExpr && equalExpr.Right is ConstantExpression constExpr) + // Process individual expression using shared logic + TryProcessSingleExpression(expression, filter); + } + } + + /// + /// Shared logic to process a single LINQ expression and add appropriate filters. + /// Consolidates duplicate code between ConvertLinqExpressionToGoogleFilter and CollectAndCombineFilters. + /// + /// The expression to process. + /// The filter to add results to. + /// True if the expression was successfully processed, false otherwise. + private static bool TryProcessSingleExpression(Expression expression, TextSearchFilter filter) + { + // Handle equality: record.PropertyName == "value" + if (expression is BinaryExpression equalExpr && equalExpr.NodeType == ExpressionType.Equal) + { + return TryProcessEqualityExpression(equalExpr, filter); + } + + // Handle inequality (NOT): record.PropertyName != "value" + if (expression is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) + { + return TryProcessInequalityExpression(notEqualExpr, filter); + } + + // Handle string Contains: record.PropertyName.Contains("value") + if (expression is MethodCallExpression methodCall && + methodCall.Method.Name == "Contains" && + methodCall.Method.DeclaringType == typeof(string)) + { + return TryProcessContainsExpression(methodCall, filter); + } + + // Handle NOT expressions: !record.PropertyName.Contains("value") + if (expression is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) + { + return TryProcessNotExpression(unaryExpr, filter); + } + + return false; + } + + /// + /// Processes equality expressions: record.PropertyName == "value" + /// + private static bool TryProcessEqualityExpression(BinaryExpression equalExpr, TextSearchFilter filter) + { + if (equalExpr.Left is MemberExpression memberExpr && equalExpr.Right is ConstantExpression constExpr) + { + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - string? googleFilterName = MapPropertyToGoogleFilter(propertyName); - if (googleFilterName != null && value != null) - { - filter.Equality(googleFilterName, value); - } + filter.Equality(googleFilterName, value); + return true; } } - else if (expression is BinaryExpression notEqualExpr && notEqualExpr.NodeType == ExpressionType.NotEqual) + return false; + } + + /// + /// Processes inequality expressions: record.PropertyName != "value" + /// + private static bool TryProcessInequalityExpression(BinaryExpression notEqualExpr, TextSearchFilter filter) + { + if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) { - // Handle inequality (exclusion) - if (notEqualExpr.Left is MemberExpression memberExpr && notEqualExpr.Right is ConstantExpression constExpr) + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + // Map to excludeTerms for text fields + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) - { - filter.Equality("excludeTerms", value); - } + filter.Equality("excludeTerms", value); + return true; } } - else if (expression is MethodCallExpression methodCall && - methodCall.Method.Name == "Contains" && - methodCall.Method.DeclaringType == typeof(string)) + return false; + } + + /// + /// Processes Contains expressions: record.PropertyName.Contains("value") + /// + private static bool TryProcessContainsExpression(MethodCallExpression methodCall, TextSearchFilter filter) + { + if (methodCall.Object is MemberExpression memberExpr && + methodCall.Arguments.Count == 1 && + methodCall.Arguments[0] is ConstantExpression constExpr) { - // Handle Contains - if (methodCall.Object is MemberExpression memberExpr && - methodCall.Arguments.Count == 1 && - methodCall.Arguments[0] is ConstantExpression constExpr) + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + string? googleFilterName = MapPropertyToGoogleFilter(propertyName); + if (googleFilterName != null && value != null) { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - string? googleFilterName = MapPropertyToGoogleFilter(propertyName); - if (googleFilterName != null && value != null) + // For Contains operations on text fields, use exactTerms or orTerms + if (googleFilterName == "exactTerms") { - if (googleFilterName == "exactTerms") - { - filter.Equality("orTerms", value); - } - else - { - filter.Equality(googleFilterName, value); - } + filter.Equality("orTerms", value); // More flexible than exactTerms + } + else + { + filter.Equality(googleFilterName, value); } + return true; } } - else if (expression is UnaryExpression unaryExpr && unaryExpr.NodeType == ExpressionType.Not) + return false; + } + + /// + /// Processes NOT expressions: !record.PropertyName.Contains("value") + /// + private static bool TryProcessNotExpression(UnaryExpression unaryExpr, TextSearchFilter filter) + { + if (unaryExpr.Operand is MethodCallExpression notMethodCall && + notMethodCall.Method.Name == "Contains" && + notMethodCall.Method.DeclaringType == typeof(string)) { - // Handle NOT Contains - if (unaryExpr.Operand is MethodCallExpression notMethodCall && - notMethodCall.Method.Name == "Contains" && - notMethodCall.Method.DeclaringType == typeof(string)) + if (notMethodCall.Object is MemberExpression memberExpr && + notMethodCall.Arguments.Count == 1 && + notMethodCall.Arguments[0] is ConstantExpression constExpr) { - if (notMethodCall.Object is MemberExpression memberExpr && - notMethodCall.Arguments.Count == 1 && - notMethodCall.Arguments[0] is ConstantExpression constExpr) + string propertyName = memberExpr.Member.Name; + object? value = constExpr.Value; + if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) { - string propertyName = memberExpr.Member.Name; - object? value = constExpr.Value; - if (propertyName.ToUpperInvariant() is "TITLE" or "SNIPPET" && value != null) - { - filter.Equality("excludeTerms", value); - } + filter.Equality("excludeTerms", value); + return true; } } } + return false; } /// @@ -413,10 +394,6 @@ public void Dispose() this._search.Dispose(); } - #region private - - private const int MaxCount = 10; - private readonly ILogger _logger; private readonly CustomSearchAPIService _search; private readonly string? _searchEngineId; @@ -426,9 +403,20 @@ public void Dispose() private static readonly ITextSearchStringMapper s_defaultStringMapper = new DefaultTextSearchStringMapper(); private static readonly ITextSearchResultMapper s_defaultResultMapper = new DefaultTextSearchResultMapper(); + private const int MaxCount = 10; + // See https://developers.google.com/custom-search/v1/reference/rest/v1/cse/list private static readonly string[] s_queryParameters = ["cr", "dateRestrict", "exactTerms", "excludeTerms", "fileType", "filter", "gl", "hl", "linkSite", "lr", "orTerms", "rights", "siteSearch"]; + // Performance optimization: Static error message arrays to avoid allocations in error paths + private static readonly string[] s_supportedPatterns = [ + "page.Property == \"value\" (exact match)", + "page.Property != \"value\" (exclude)", + "page.Property.Contains(\"text\") (partial match)", + "!page.Property.Contains(\"text\") (exclude partial)", + "page.Prop1 == \"val1\" && page.Prop2.Contains(\"val2\") (compound AND)" + ]; + private delegate void SetSearchProperty(CseResource.ListRequest search, string value); private static readonly Dictionary s_searchPropertySetters = new() { @@ -460,7 +448,7 @@ public void Dispose() var count = searchOptions.Top; var offset = searchOptions.Skip; - if (count is <= 0 or > MaxCount) + if (count <= 0 || count > MaxCount) { throw new ArgumentOutOfRangeException(nameof(searchOptions), count, $"{nameof(searchOptions)}.Count value must be must be greater than 0 and less than or equals 10."); } @@ -660,5 +648,4 @@ public TextSearchResult MapFromResultToTextSearchResult(object result) return new TextSearchResult(googleResult.Snippet) { Name = googleResult.Title, Link = googleResult.Link }; } } - #endregion } diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs index c7af4618b77d..8eab2153d27b 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleWebPage.cs @@ -19,7 +19,7 @@ internal GoogleWebPage() } /// - /// The title of the webpage. + /// Gets or sets the title of the webpage. /// /// /// Use this title along with Link to create a hyperlink that when clicked takes the user to the webpage. @@ -28,7 +28,7 @@ internal GoogleWebPage() public string? Title { get; set; } /// - /// The URL to the webpage. + /// Gets or sets the URL to the webpage. /// /// /// Use this URL along with Title to create a hyperlink that when clicked takes the user to the webpage. @@ -39,13 +39,13 @@ internal GoogleWebPage() #pragma warning restore CA1056 // URI-like properties should not be strings /// - /// A snippet of text from the webpage that describes its contents. + /// Gets or sets a snippet of text from the webpage that describes its contents. /// [JsonPropertyName("snippet")] public string? Snippet { get; set; } /// - /// The formatted URL display string. + /// Gets or sets the formatted URL display string. /// /// /// The URL is meant for display purposes only and may not be well formed. @@ -56,31 +56,31 @@ internal GoogleWebPage() #pragma warning restore CA1056 // URI-like properties should not be strings /// - /// The MIME type of the result. + /// Gets or sets the MIME type of the result. /// [JsonPropertyName("mime")] public string? Mime { get; set; } /// - /// The file format of the result. + /// Gets or sets the file format of the result. /// [JsonPropertyName("fileFormat")] public string? FileFormat { get; set; } /// - /// The HTML title of the webpage. + /// Gets or sets the HTML title of the webpage. /// [JsonPropertyName("htmlTitle")] public string? HtmlTitle { get; set; } /// - /// The HTML snippet of the webpage. + /// Gets or sets the HTML snippet of the webpage. /// [JsonPropertyName("htmlSnippet")] public string? HtmlSnippet { get; set; } /// - /// The formatted URL of the webpage. + /// Gets or sets the formatted URL of the webpage. /// [JsonPropertyName("formattedUrl")] #pragma warning disable CA1056 // URI-like properties should not be strings @@ -88,7 +88,7 @@ internal GoogleWebPage() #pragma warning restore CA1056 // URI-like properties should not be strings /// - /// The HTML-formatted URL of the webpage. + /// Gets or sets the HTML-formatted URL of the webpage. /// [JsonPropertyName("htmlFormattedUrl")] #pragma warning disable CA1056 // URI-like properties should not be strings @@ -96,7 +96,7 @@ internal GoogleWebPage() #pragma warning restore CA1056 // URI-like properties should not be strings /// - /// Labels associated with the webpage. + /// Gets or sets labels associated with the webpage. /// [JsonPropertyName("labels")] public IReadOnlyList? Labels { get; set; } From cbd12657e3f121039f2728d3c7a5f744634cee9e Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Wed, 29 Oct 2025 01:03:48 -0700 Subject: [PATCH 8/9] 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 --- .../Web/Google/GoogleTextSearchTests.cs | 29 ++++++++++++ .../Plugins.Web/Google/GoogleTextSearch.cs | 45 ++++++++++++++++--- 2 files changed, 69 insertions(+), 5 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index 2b6fdebc46c5..91c66b14c76f 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -702,6 +702,35 @@ await textSearch.SearchAsync("test", Assert.Contains("excludeTerms=deprecated", absoluteUri); } + [Fact] + public async Task CollectionContainsFilterThrowsNotSupportedExceptionAsync() + { + // Arrange + using var textSearch = new GoogleTextSearch( + initializer: new() { ApiKey = "ApiKey", HttpClientFactory = this._clientFactory }, + searchEngineId: "SearchEngineId"); + + // Act & Assert - Collection Contains (both Enumerable.Contains and MemoryExtensions.Contains) + // This same code resolves differently based on C# language version: + // - C# 13 and earlier: Enumerable.Contains (LINQ extension method) + // - C# 14 and later: MemoryExtensions.Contains (span-based optimization) + // Our implementation handles both identically - both throw NotSupportedException + string[] sites = ["microsoft.com", "github.com"]; + var exception = await Assert.ThrowsAsync(async () => + await textSearch.SearchAsync("test", + new TextSearchOptions + { + Top = 4, + Skip = 0, + Filter = page => sites.Contains(page.DisplayLink!) + })); + + // Verify exception message is clear and actionable + Assert.Contains("Collection Contains filters", exception.Message); + Assert.Contains("not supported by Google Custom Search API", exception.Message); + Assert.Contains("OR logic", exception.Message); + } + #endregion /// diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 29837b4f5dc9..944d2d0dd091 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -224,12 +224,31 @@ private static bool TryProcessSingleExpression(Expression expression, TextSearch return TryProcessInequalityExpression(notEqualExpr, filter); } - // Handle string Contains: record.PropertyName.Contains("value") - if (expression is MethodCallExpression methodCall && - methodCall.Method.Name == "Contains" && - methodCall.Method.DeclaringType == typeof(string)) + // Handle Contains method calls + if (expression is MethodCallExpression methodCall && methodCall.Method.Name == "Contains") { - return TryProcessContainsExpression(methodCall, filter); + // String.Contains (instance method) - supported for substring search + if (methodCall.Method.DeclaringType == typeof(string)) + { + return TryProcessContainsExpression(methodCall, filter); + } + + // Collection Contains (static methods) - NOT supported due to Google API limitations + // This handles both Enumerable.Contains (C# 13-) and MemoryExtensions.Contains (C# 14+) + // User's C# language version determines which method is resolved, but both are unsupported + if (methodCall.Object == null) // Static method + { + // Enumerable.Contains or MemoryExtensions.Contains + if (methodCall.Method.DeclaringType == typeof(Enumerable) || + (methodCall.Method.DeclaringType == typeof(MemoryExtensions) && IsMemoryExtensionsContains(methodCall))) + { + throw new NotSupportedException( + "Collection Contains filters (e.g., array.Contains(page.Property)) are not supported by Google Custom Search API. " + + "Google's search operators do not support OR logic across multiple values. " + + "Consider either: (1) performing multiple separate searches for each value, or " + + "(2) retrieving broader results and filtering on the client side."); + } + } } // Handle NOT expressions: !record.PropertyName.Contains("value") @@ -241,6 +260,22 @@ private static bool TryProcessSingleExpression(Expression expression, TextSearch return false; } + /// + /// Checks if a method call expression is MemoryExtensions.Contains. + /// This handles C# 14's "first-class spans" feature where collection.Contains(item) resolves to + /// MemoryExtensions.Contains instead of Enumerable.Contains. + /// + private static bool IsMemoryExtensionsContains(MethodCallExpression methodExpr) + { + // MemoryExtensions.Contains has 2-3 parameters (source, value, optional comparer) + // We only support the case without a comparer (or with null comparer) + return methodExpr.Method.Name == nameof(MemoryExtensions.Contains) && + methodExpr.Arguments.Count >= 2 && + methodExpr.Arguments.Count <= 3 && + (methodExpr.Arguments.Count == 2 || + (methodExpr.Arguments.Count == 3 && methodExpr.Arguments[2] is ConstantExpression { Value: null })); + } + /// /// Processes equality expressions: record.PropertyName == "value" /// From d8c45b51870d73ac1e79df222cfe7a9a7ff6e6f9 Mon Sep 17 00:00:00 2001 From: Alexander Zarei Date: Sun, 2 Nov 2025 19:06:45 -0800 Subject: [PATCH 9/9] Return TRecord instead of object in GoogleTextSearch.GetSearchResultsAsync Changes: - Update GoogleTextSearch.GetSearchResultsAsync to return KernelSearchResults instead of KernelSearchResults - Remove wasteful .Cast() call - Update test to use strongly-typed GoogleWebPage instead of casting from object Benefits: - Eliminates runtime casting overhead - Provides compile-time type safety - Improves IntelliSense for consumers This change aligns with PR #13318 interface fix that changed ITextSearch.GetSearchResultsAsync to return KernelSearchResults. --- .../Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs | 4 ++-- dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs index 91c66b14c76f..5eeb12c61c43 100644 --- a/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs +++ b/dotnet/src/Plugins/Plugins.UnitTests/Web/Google/GoogleTextSearchTests.cs @@ -303,7 +303,7 @@ public async Task GenericGetSearchResultsReturnsSuccessfullyAsync() searchEngineId: "SearchEngineId"); // Act - Use generic interface with GoogleWebPage - KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); + KernelSearchResults results = await textSearch.GetSearchResultsAsync("What is the Semantic Kernel?", new TextSearchOptions { Top = 10, Skip = 0 }); // Assert Assert.NotNull(results); @@ -311,7 +311,7 @@ public async Task GenericGetSearchResultsReturnsSuccessfullyAsync() var resultList = await results.Results.ToListAsync(); Assert.NotNull(resultList); Assert.Equal(4, resultList.Count); - foreach (GoogleWebPage result in resultList.Cast()) + foreach (GoogleWebPage result in resultList) { Assert.NotNull(result.Title); Assert.NotNull(result.Snippet); diff --git a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs index 944d2d0dd091..c450e4f0d4e5 100644 --- a/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs +++ b/dotnet/src/Plugins/Plugins.Web/Google/GoogleTextSearch.cs @@ -94,14 +94,14 @@ public async Task> SearchAsync(string query, TextSea #region ITextSearch Implementation /// - public async Task> GetSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) + public async Task> GetSearchResultsAsync(string query, TextSearchOptions? searchOptions = null, CancellationToken cancellationToken = default) { var legacyOptions = ConvertToLegacyOptions(searchOptions); var searchResponse = await this.ExecuteSearchAsync(query, legacyOptions, cancellationToken).ConfigureAwait(false); long? totalCount = searchOptions?.IncludeTotalCount == true ? long.Parse(searchResponse.SearchInformation.TotalResults) : null; - return new KernelSearchResults(this.GetResultsAsGoogleWebPageAsync(searchResponse, cancellationToken).Cast(), totalCount, GetResultsMetadata(searchResponse)); + return new KernelSearchResults(this.GetResultsAsGoogleWebPageAsync(searchResponse, cancellationToken), totalCount, GetResultsMetadata(searchResponse)); } ///