Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>.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<FieldValue>();
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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 = "<Pending>", Scope = "member", Target = "~M:Elastic.SemanticKernel.Connectors.Elasticsearch.ElasticsearchFilterTranslator.TryBindProperty(System.Linq.Expressions.Expression,Microsoft.Extensions.VectorData.ProviderServices.PropertyModel@)~System.Boolean")]
87 changes: 86 additions & 1 deletion test/VectorData.ConformanceTests/Filter/BasicFilterTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright (c) Microsoft. All rights reserved.
// Copyright (c) Microsoft. All rights reserved.

using System.Linq.Expressions;
using Microsoft.Extensions.VectorData;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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<string> { "x", "z" };

return this.TestFilterAsync(
r => r.StringList.Any(t => tagTexts.Contains(t)),
r => ((List<string>)r["StringList"]!).Any(t => tagTexts.Contains(t)));
}

[ConditionalFact]
public virtual Task Any_with_Contains_no_match()
{
var tagTexts = new List<string> { "unknown", "missing" };

return this.TestFilterAsync(
r => r.StringList.Any(t => tagTexts.Contains(t)),
r => ((List<string>)r["StringList"]!).Any(t => tagTexts.Contains(t)),
expectZeroResults: true);
}

[ConditionalFact]
public virtual Task Any_with_Contains_single_value()
{
var tagTexts = new List<string> { "x" };

return this.TestFilterAsync(
r => r.StringList.Any(t => tagTexts.Contains(t)),
r => ((List<string>)r["StringList"]!).Any(t => tagTexts.Contains(t)));
}

[ConditionalFact]
public virtual Task Any_combined_with_And()
{
var tagTexts = new List<string> { "x", "z" };

return this.TestFilterAsync(
r => r.StringList.Any(t => tagTexts.Contains(t)) && r.Int == 8,
r => ((List<string>)r["StringList"]!).Any(t => tagTexts.Contains(t)) && (int)r["Int"] == 8);
}

[ConditionalFact]
public virtual Task Any_combined_with_Or()
{
var tagTexts = new List<string> { "unknown" };

return this.TestFilterAsync(
r => r.StringList.Any(t => tagTexts.Contains(t)) || r.String == "foo",
r => ((List<string>)r["StringList"]!).Any(t => tagTexts.Contains(t)) || r["String"] == "foo");
}

#endregion Any

#region Variable types

[ConditionalFact]
Expand Down