Skip to content
Merged
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 @@ -31,8 +31,8 @@ internal string Translate(LambdaExpression lambdaExpression, VectorStoreRecordMo
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

this.Translate(preprocessedExpression);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal BsonDocument Translate(LambdaExpression lambdaExpression, VectorStoreRe
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

return this.Translate(preprocessedExpression);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using Microsoft.Extensions.VectorData.ConnectorSupport;
using Microsoft.Extensions.VectorData.ConnectorSupport.Filter;
Expand All @@ -31,10 +29,10 @@ internal class AzureCosmosDBNoSqlFilterTranslator
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = false };
var preprocessedExpression = preprocessor.Visit(lambdaExpression);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = true };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

this.Translate(lambdaExpression.Body);
this.Translate(preprocessedExpression);

return (this._sql.ToString(), this._parameters);
}
Expand All @@ -51,6 +49,10 @@ private void Translate(Expression? node)
this.TranslateConstant(constant);
return;

case QueryParameterExpression { Name: var name, Value: var value }:
this.TranslateQueryParameter(name, value);
return;

case MemberExpression member:
this.TranslateMember(member);
return;
Expand Down Expand Up @@ -143,34 +145,13 @@ private void TranslateConstant(ConstantExpression constant)

private void TranslateMember(MemberExpression memberExpression)
{
switch (memberExpression)
if (this.TryBindProperty(memberExpression, out var property))
{
case var _ when this.TryBindProperty(memberExpression, out var property):
this.GeneratePropertyAccess(property);
return;

// Identify captured lambda variables, translate to Cosmos parameters (@foo, @bar...)
case var _ when TryGetCapturedValue(memberExpression, out var name, out var value):
// Duplicate parameter name, create a new parameter with a different name
// TODO: Share the same parameter when it references the same captured value
if (this._parameters.ContainsKey(name))
{
var baseName = name;
var i = 0;
do
{
name = baseName + (i++);
} while (this._parameters.ContainsKey(name));
}

name = '@' + name;
this._parameters.Add(name, value);
this._sql.Append(name);
return;

default:
throw new NotSupportedException($"Member access for '{memberExpression.Member.Name}' is unsupported - only member access over the filter parameter are supported");
this.GeneratePropertyAccess(property);
return;
}

throw new NotSupportedException($"Member access for '{memberExpression.Member.Name}' is unsupported - only member access over the filter parameter are supported");
}

private void TranslateNewArray(NewArrayExpression newArray)
Expand Down Expand Up @@ -264,6 +245,13 @@ private void TranslateUnary(UnaryExpression unary)
}
}

protected void TranslateQueryParameter(string name, object? value)
{
name = '@' + name;
this._parameters.Add(name, value);
this._sql.Append(name);
}

protected virtual void GeneratePropertyAccess(VectorStoreRecordPropertyModel property)
=> this._sql.Append(AzureCosmosDBNoSQLConstants.ContainerAlias).Append("[\"").Append(property.StorageName).Append("\"]");

Expand Down Expand Up @@ -312,20 +300,4 @@ private bool TryBindProperty(Expression expression, [NotNullWhen(true)] out Vect

return true;
}

private static bool TryGetCapturedValue(Expression expression, [NotNullWhen(true)] out string? name, out object? value)
{
if (expression is MemberExpression { Expression: ConstantExpression constant, Member: FieldInfo fieldInfo }
&& constant.Type.Attributes.HasFlag(TypeAttributes.NestedPrivate)
&& Attribute.IsDefined(constant.Type, typeof(CompilerGeneratedAttribute), inherit: true))
{
name = fieldInfo.Name;
value = fieldInfo.GetValue(constant.Value);
return true;
}

name = null;
value = null;
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ internal void Translate(bool appendWhere)
this._sql.Append("WHERE ");
}

var preprocessor = new FilterTranslationPreprocessor { TransformCapturedVariablesToQueryParameterExpressions = true };
var preprocessedExpression = preprocessor.Visit(this._lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = true };
var preprocessedExpression = preprocessor.Preprocess(this._lambdaExpression.Body);

this.Translate(preprocessedExpression, isSearchCondition: true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal BsonDocument Translate(LambdaExpression lambdaExpression, VectorStoreRe
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

return this.Translate(preprocessedExpression);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,8 @@ internal Metadata Translate(LambdaExpression lambdaExpression, VectorStoreRecord
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

return this.Translate(preprocessedExpression);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal Filter Translate(LambdaExpression lambdaExpression, VectorStoreRecordMo
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

return this.Translate(preprocessedExpression);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ internal string Translate(LambdaExpression lambdaExpression, VectorStoreRecordMo
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

this.Translate(preprocessedExpression);
return this._filter.ToString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ internal string Translate(LambdaExpression lambdaExpression, VectorStoreRecordMo
Debug.Assert(lambdaExpression.Parameters.Count == 1);
this._recordParameter = lambdaExpression.Parameters[0];

var preprocessor = new FilterTranslationPreprocessor { InlineCapturedVariables = true };
var preprocessedExpression = preprocessor.Visit(lambdaExpression.Body);
var preprocessor = new FilterTranslationPreprocessor { SupportsParameterization = false };
var preprocessedExpression = preprocessor.Preprocess(lambdaExpression.Body);

this.Translate(preprocessedExpression);
return this._filter.ToString();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;

namespace Microsoft.Extensions.VectorData.ConnectorSupport.Filter;

Expand All @@ -15,40 +14,112 @@ namespace Microsoft.Extensions.VectorData.ConnectorSupport.Filter;
[Experimental("MEVD9001")]
public class FilterTranslationPreprocessor : ExpressionVisitor
{
private List<string>? _parameterNames;

/// <summary>
/// Whether to inline captured variables in the filter expression (when the database doesn't support parameters).
/// Whether the connector supports parameterization.
/// </summary>
public bool InlineCapturedVariables { get; init; }
/// <remarks>
/// If <see langword="false"/>, the visitor will inline captured variables and constant member accesses as simple constant nodes.
/// If <see langword="true"/>, these will instead be replaced with <see cref="QueryParameterExpression"/> nodes.
/// </remarks>
public required bool SupportsParameterization { get; init; }

/// <summary>
/// Whether to transform captured variables in the filter expression to <see cref="QueryParameterExpression"/> (when the database supports parameters).
/// Preprocesses the filter expression before translation.
/// </summary>
public bool TransformCapturedVariablesToQueryParameterExpressions { get; init; }
public Expression Preprocess(Expression node)
{
if (this.SupportsParameterization)
{
this._parameterNames = [];
}

return this.Visit(node);
}

/// <inheritdoc />
protected override Expression VisitMember(MemberExpression node)
{
// This identifies compiler-generated closure types which contain captured variables.
var visited = (MemberExpression)base.VisitMember(node);

// This identifies field and property access over constants, which can be evaluated immediately.
// This covers captured variables, since those are actually member accesses over compiled-generated closure types:
// var x = 8;
// _ = await collection.SearchAsync(vector, top: 3, new() { Filter = r => r.Int == x });
//
// This also covers member variables:
// _ = await collection.SearchAsync(vector, top: 3, new() { Filter = r => r.Int == this._x });
// ... as "this" here is represented by a ConstantExpression node in the tree.
//
// Some databases - mostly relational ones - support out-of-band parameters which can be referenced via placeholders
// from the query itself. For those databases, we transform the captured variable to QueryParameterExpression (this simplifies things for those
// from the query itself. For those databases, we transform the member access to QueryParameterExpression (this simplifies things for those
// connectors, and centralizes the pattern matching in a single centralized place).
// For databases which don't support parameters, we simply inline the captured variable as a constant in the tree, so that translators don't
// even need to be aware of the captured variable.
// For all other databases, we simply inline the captured variable as a constant in the tree.
if (node is MemberExpression { Expression: ConstantExpression constant, Member: FieldInfo fieldInfo }
&& constant.Type.Attributes.HasFlag(TypeAttributes.NestedPrivate)
&& Attribute.IsDefined(constant.Type, typeof(CompilerGeneratedAttribute), inherit: true))
// For databases which don't support parameters, we simply inline the evaluated member access as a constant in the tree, so that translators don't
// even need to be aware of it.

// Evaluate the MemberExpression to get the actual value, either for instance members (expression is a ConstantExpression) or for
// static members (expression is null).
object? baseValue;
switch (visited.Expression)
{
// Member access over constant (i.e. instance members)
case ConstantExpression { Value: var v }:
baseValue = v;
break;

// Member constant over null (i.e. static members)
case null:
baseValue = null;
break;

// Member constant over something that has already been parameterized (i.e. nested member access, e.g. r=> r.Int == this.SomeWrapper.Something)
case QueryParameterExpression p:
baseValue = p.Value;

// The previous parameter is getting replaced by the new one we're creating here, so remove its name from the list of parameter names.
this._parameterNames!.Remove(p.Name);
break;

default:
return visited;
}

object? evaluatedValue;

var memberInfo = visited.Member;

switch (memberInfo)
{
case FieldInfo fieldInfo:
evaluatedValue = fieldInfo.GetValue(baseValue);
break;
case PropertyInfo propertyInfo:
evaluatedValue = propertyInfo.GetValue(baseValue);
break;
default:
return visited;
}

// Inline the evaluated value (if the connector doesn't support parameterization, or if the field is readonly),
if (!this.SupportsParameterization)
{
return Expression.Constant(evaluatedValue, visited.Type);
}

// Otherwise, transform the node to a QueryParameterExpression which the connector will then translate to a parameter (e.g. SqlParameter).

// TODO: Share the same parameter when it references the same captured value

// Make sure parameter names are unique.
var origName = memberInfo.Name;
var name = origName;
for (var i = 0; this._parameterNames!.Contains(name); i++)
{
return (this.InlineCapturedVariables, this.TransformCapturedVariablesToQueryParameterExpressions) switch
{
(true, false) => Expression.Constant(fieldInfo.GetValue(constant.Value), node.Type),
(false, true) => new QueryParameterExpression(fieldInfo.Name, fieldInfo.GetValue(constant.Value), node.Type),

(true, true) => throw new InvalidOperationException("InlineCapturedVariables and TransformCapturedVariablesToQueryParameterExpressions cannot both be true."),
(false, false) => base.VisitMember(node)
};
name = $"{origName}_{i}";
}
this._parameterNames.Add(name);

return base.VisitMember(node);
return new QueryParameterExpression(name, evaluatedValue, visited.Type);
}
}
Loading