diff --git a/Elastic.SemanticKernel.Connectors.Elasticsearch/ElasticsearchFilterTranslator.cs b/Elastic.SemanticKernel.Connectors.Elasticsearch/ElasticsearchFilterTranslator.cs index 941bb1f..5935d4f 100644 --- a/Elastic.SemanticKernel.Connectors.Elasticsearch/ElasticsearchFilterTranslator.cs +++ b/Elastic.SemanticKernel.Connectors.Elasticsearch/ElasticsearchFilterTranslator.cs @@ -149,10 +149,97 @@ private Query TranslateMethodCall(MethodCallExpression methodCall) when (declaringType.GetGenericTypeDefinition() == typeof(List<>)) => TranslateContains(source, item), - _ => throw new NotSupportedException($"Unsupported method call: {methodCall.Method.DeclaringType?.Name}.{methodCall.Method.Name}.") + // ✅ String.Contains() support + { + Method.Name: nameof(string.Contains), + Object: { } instance, + Arguments: [ConstantExpression { Value: string substring }] + } when instance.Type == typeof(string) + => TranslateStringContains(instance, substring), + + // Enumerable.Any() support + { + Method.Name: nameof(Enumerable.Any), + Arguments: [var source, LambdaExpression predicate] + } anyCall + when (anyCall.Method.DeclaringType == typeof(Enumerable)) + => TranslateAny(source, predicate), + + _ => throw new NotSupportedException( + $"Unsupported method call: {methodCall.Method.DeclaringType?.Name}.{methodCall.Method.Name}.") + }; + } + + + private Query TranslateStringContains(Expression instance, string substring) + { + if (!TryBindProperty(instance, out var property)) + { + throw new NotSupportedException("Unsupported property in String.Contains expression."); + } + return new WildcardQuery(property.StorageName) + { + Wildcard = $"*{substring.ToLower(System.Globalization.CultureInfo.InvariantCulture)}*" }; } + private Query TranslateAny(Expression source, LambdaExpression predicate) + { + // Handle: r => r.TagNames.Any(t => externalList.Contains(t)) + // This translates to checking if any value in the field array matches any value in the external list + + if (predicate.Body is not MethodCallExpression innerMethodCall) + { + throw new NotSupportedException("Only method calls are supported inside Any()."); + } + + // Check if it's a Contains call + if (innerMethodCall.Method.Name != nameof(Enumerable.Contains)) + { + throw new NotSupportedException($"Only Contains() is supported inside Any(), got {innerMethodCall.Method.Name}."); + } + + // Get the field being checked (e.g., TagNames) + if (!TryBindProperty(source, out var fieldProperty)) + { + throw new NotSupportedException("Unsupported source property in Any() expression."); + } + + // Pattern 1: externalList.Contains(t) - where innerMethodCall is extension method + if (innerMethodCall.Method.DeclaringType == typeof(Enumerable) && + innerMethodCall.Arguments.Count == 2 && + innerMethodCall.Arguments[0] is ConstantExpression { Value: IEnumerable externalList } && + innerMethodCall.Arguments[1] is ParameterExpression lambdaParam && + lambdaParam == predicate.Parameters[0]) + { + return BuildTermsQuery(fieldProperty.StorageName, externalList); + } + + // Pattern 2: externalList.Contains(t) - where innerMethodCall is instance method (List.Contains) + if (innerMethodCall.Method.DeclaringType is { IsGenericType: true } declaringType && + declaringType.GetGenericTypeDefinition() == typeof(List<>) && + innerMethodCall.Object is ConstantExpression { Value: IEnumerable externalList2 } && + innerMethodCall.Arguments.Count == 1 && + innerMethodCall.Arguments[0] is ParameterExpression lambdaParam2 && + lambdaParam2 == predicate.Parameters[0]) + { + return BuildTermsQuery(fieldProperty.StorageName, externalList2); + } + + throw new NotSupportedException("Unsupported Any() expression structure. Expected pattern: collection.Any(item => externalList.Contains(item))"); + + static Query BuildTermsQuery(string fieldName, IEnumerable values) + { + var fieldValues = new List(); + foreach (var value in values) + { + fieldValues.Add(FieldValueFactory.FromValue(value)); + } + + return new TermsQuery(fieldName, new TermsQueryField(fieldValues.ToArray())); + } + } + private Query TranslateContains(Expression source, Expression item) { switch (source) diff --git a/Elastic.SemanticKernel.Connectors.Elasticsearch/GlobalSuppressions.cs b/Elastic.SemanticKernel.Connectors.Elasticsearch/GlobalSuppressions.cs new file mode 100644 index 0000000..4104cf8 --- /dev/null +++ b/Elastic.SemanticKernel.Connectors.Elasticsearch/GlobalSuppressions.cs @@ -0,0 +1,8 @@ +// This file is used by Code Analysis to maintain SuppressMessage +// attributes that are applied to this project. +// Project-level suppressions either have no target or are given +// a specific target and scoped to a namespace, type, member, etc. + +using System.Diagnostics.CodeAnalysis; + +[assembly: SuppressMessage("Style", "IDE0003:Remove qualification", Justification = "", Scope = "member", Target = "~M:Elastic.SemanticKernel.Connectors.Elasticsearch.ElasticsearchFilterTranslator.TryBindProperty(System.Linq.Expressions.Expression,Microsoft.Extensions.VectorData.ProviderServices.PropertyModel@)~System.Boolean")] diff --git a/test/VectorData.ConformanceTests/Filter/BasicFilterTests.cs b/test/VectorData.ConformanceTests/Filter/BasicFilterTests.cs index 85fa89d..a906375 100644 --- a/test/VectorData.ConformanceTests/Filter/BasicFilterTests.cs +++ b/test/VectorData.ConformanceTests/Filter/BasicFilterTests.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft. All rights reserved. +// Copyright (c) Microsoft. All rights reserved. using System.Linq.Expressions; using Microsoft.Extensions.VectorData; @@ -283,6 +283,7 @@ public virtual Task Not_over_bool() r => !r.Bool, r => !(bool)r["Bool"]); + [ConditionalFact] public virtual Task Not_over_bool_And_Comparison() => this.TestFilterAsync( @@ -335,6 +336,90 @@ public virtual Task Contains_over_captured_string_array() #endregion Contains + #region String.Contains + + [ConditionalFact] + public virtual Task StringContains_with_substring() + => this.TestFilterAsync( + r => r.String != null && r.String.Contains("fo"), + r => r["String"] != null && ((string)r["String"]!).Contains("fo")); + + [ConditionalFact] + public virtual Task StringContains_with_full_match() + => this.TestFilterAsync( + r => r.String != null && r.String.Contains("foo"), + r => r["String"] != null && ((string)r["String"]!).Contains("foo")); + + [ConditionalFact] + public virtual Task StringContains_no_match() + => this.TestFilterAsync( + r => r.String != null && r.String.Contains("xyz"), + r => r["String"] != null && ((string)r["String"]!).Contains("xyz"), + expectZeroResults: true); + + [ConditionalFact] + public virtual Task StringContains_with_special_characters() + => this.TestFilterAsync( + r => r.String != null && r.String.Contains("specia"), + r => r["String"] != null && ((string)r["String"]!).Contains("specia")); + + #endregion String.Contains + + #region Any + + [ConditionalFact] + public virtual Task Any_with_Contains_using_List() + { + var tagTexts = new List { "x", "z" }; + + return this.TestFilterAsync( + r => r.StringList.Any(t => tagTexts.Contains(t)), + r => ((List)r["StringList"]!).Any(t => tagTexts.Contains(t))); + } + + [ConditionalFact] + public virtual Task Any_with_Contains_no_match() + { + var tagTexts = new List { "unknown", "missing" }; + + return this.TestFilterAsync( + r => r.StringList.Any(t => tagTexts.Contains(t)), + r => ((List)r["StringList"]!).Any(t => tagTexts.Contains(t)), + expectZeroResults: true); + } + + [ConditionalFact] + public virtual Task Any_with_Contains_single_value() + { + var tagTexts = new List { "x" }; + + return this.TestFilterAsync( + r => r.StringList.Any(t => tagTexts.Contains(t)), + r => ((List)r["StringList"]!).Any(t => tagTexts.Contains(t))); + } + + [ConditionalFact] + public virtual Task Any_combined_with_And() + { + var tagTexts = new List { "x", "z" }; + + return this.TestFilterAsync( + r => r.StringList.Any(t => tagTexts.Contains(t)) && r.Int == 8, + r => ((List)r["StringList"]!).Any(t => tagTexts.Contains(t)) && (int)r["Int"] == 8); + } + + [ConditionalFact] + public virtual Task Any_combined_with_Or() + { + var tagTexts = new List { "unknown" }; + + return this.TestFilterAsync( + r => r.StringList.Any(t => tagTexts.Contains(t)) || r.String == "foo", + r => ((List)r["StringList"]!).Any(t => tagTexts.Contains(t)) || r["String"] == "foo"); + } + + #endregion Any + #region Variable types [ConditionalFact]