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
7 changes: 7 additions & 0 deletions Postgrest/Interfaces/IPostgrestTable.cs
Original file line number Diff line number Diff line change
Expand Up @@ -370,10 +370,17 @@ IPostgrestTable<TModel> Order(string foreignTable, string column, Constants.Orde
/// <list type="bullet">
/// <item><description>The left side of a comparison must be a Model property (with a `Column` or `PrimaryKey` attribute); the right side a constant, captured variable, or instantiation (`new DateTime(...)`).</description></item>
/// <item><description>Comparisons (`==`, `!=`, `&gt;`, `&gt;=`, `&lt;`, `&lt;=`) can be combined with `&amp;&amp;` and `||`, and `String`/collection `Contains` is supported.</description></item>
/// <item><description>`Contains` translates by which side is the Model: a column containing a constant (`x =&gt; x.Tags.Contains("a")`) becomes a `cs`/`like` filter, while a captured collection containing a column (`x =&gt; ids.Contains(x.Id)`) becomes an `in` filter.</description></item>
/// <item><description>A boolean column can be used as a predicate on its own: `x =&gt; x.IsActive` becomes an `eq` filter and `x =&gt; !x.IsActive` a `not.eq` filter.</description></item>
/// <item><description>Negation (`!`) is translated to a Postgrest `not.` filter: `x =&gt; !(x.Name == "Top Gun")` becomes `not.eq`, and a negated group `x =&gt; !(x.Name == "Top Gun" || x.Id &gt; 5)` becomes `not.or=(...)`.</description></item>
/// <item><description>Null checks translate to Postgrest `is null` filters: `x =&gt; x.Name == null` and `x =&gt; x.Name == null || x.Name == "Top Gun"` both work.</description></item>
/// <item><description>Conditions that never reference the Model (i.e. `x =&gt; localVariable == null`) are evaluated locally: an always-true predicate applies no filter, an always-false one throws.</description></item>
/// </list>
///
/// Comparing two Model columns to each other (`x =&gt; x.StartDate &lt; x.EndDate`) is not supported:
/// Postgrest has no column-to-column filter syntax, so it throws an <see cref="ArgumentException"/> —
/// use a database computed/generated column or an RPC instead.
///
/// Invoking a delegate (`Func&lt;TModel, bool&gt;`) inside the predicate is not supported: a compiled
/// delegate cannot be translated into a Postgrest filter and throws an <see cref="ArgumentException"/>.
/// To apply an optional filter, declare it as an `Expression&lt;Func&lt;TModel, bool&gt;&gt;` and pass it
Expand Down
190 changes: 148 additions & 42 deletions Postgrest/Linq/WhereExpressionVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ namespace Supabase.Postgrest.Linq

{
/// <summary>
/// Helper class for parsing Where linq queries.
/// Helper class for parsing Where linq queries. Supports comparisons, `&amp;&amp;`/`||` groups,
/// `String`/collection `Contains` (including a captured collection containing a column, which becomes
/// an `in` filter), bare boolean columns (`x =&gt; x.IsActive`), null checks (`is null`), and negation
/// (`!`), translating each into the equivalent Postgrest filter; negation wraps its operand in a `not.`
/// filter.
/// </summary>
internal class WhereExpressionVisitor : ExpressionVisitor
{
Expand Down Expand Up @@ -129,6 +133,9 @@ protected override Expression VisitBinary(BinaryExpression node)
throw new ArgumentException(
$"Left side of expression: '{node}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute");

if (ContainsParameter(node.Right))
throw new ArgumentException($"Unable to translate '{node}': Postgrest cannot compare two model columns to each other. Use a database computed/generated column or an RPC for column-to-column comparisons.");

if (node.Right is ConstantExpression rightConstantExpression)
{
HandleConstantExpression(column, op, rightConstantExpression);
Expand All @@ -149,6 +156,44 @@ protected override Expression VisitBinary(BinaryExpression node)
return node;
}

/// <summary>
/// Handles a logical negation (i.e. `x => !(x.Name == "foo")`). The operand is visited as its own
/// branch and the resulting filter is wrapped in a `not.` filter; a locally evaluated operand
/// (i.e. `x => !someLocalBool`) simply flips its boolean value.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
protected override Expression VisitUnary(UnaryExpression node)
{
if (node.NodeType != ExpressionType.Not)
return base.VisitUnary(node);

var (constant, filter) = VisitBranch(node.Operand);

if (constant != null)
ConstantValue = !constant;
else
Filter = new QueryFilter(Operator.Not, filter!);

return node;
}

/// <summary>
/// Handles a boolean column used directly as a predicate (i.e. `x => x.IsActive`, or negated via
/// <see cref="VisitUnary"/> `x => !x.IsActive`), translating it into a `column.eq.true` filter.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
protected override Expression VisitMember(MemberExpression node)
{
if (node.Type != typeof(bool) || !ContainsParameter(node))
return base.VisitMember(node);

Filter = new QueryFilter(GetColumnFromMemberExpression(node), Operator.Equals, true);
return node;
}

/// <summary>
/// Visits one side of an `AND`/`OR` expression, producing either a <see cref="QueryFilter"/> or,
/// when the branch never references the model, its locally evaluated boolean value.
Expand All @@ -175,43 +220,123 @@ protected override Expression VisitBinary(BinaryExpression node)
}

/// <summary>
/// Called when evaluating a method
/// Translates a `Contains` call. Two shapes are supported, distinguished by which side references
/// the model: a model column containing a constant (`x =&gt; x.Tags.Contains("a")` → `cs`/`like`) and
/// a captured collection containing a model column (`x =&gt; ids.Contains(x.Id)` → `in`). The latter
/// works for both instance (`List.Contains`) and static (`Enumerable.Contains`) calls.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
/// <exception cref="NotImplementedException"></exception>
protected override Expression VisitMethodCall(MethodCallExpression node)
{
var obj = node.Object as MemberExpression;
if (node.Method.Name != nameof(Enumerable.Contains))
throw new NotImplementedException("Unsupported method");

var (collection, item) = DeconstructContains(node);
var collectionIsColumn = ContainsParameter(collection);
var itemIsColumn = ContainsParameter(item);
if (collectionIsColumn && !itemIsColumn)
VisitColumnContains(collection, item);
else if (itemIsColumn && !collectionIsColumn)
VisitCapturedContains(collection, item);
else
throw new ArgumentException($"Unable to translate '{node}' into a Postgrest filter. `Contains` is supported as a model column containing a constant (i.e. `x => x.Tags.Contains(\"a\")`) or a captured collection containing a model column (i.e. `x => ids.Contains(x.Id)`).");

if (obj == null)
throw new ArgumentException(
$"Calling context '{node.Object}' is expected to be a member of or derived from `BaseModel`");
return node;
}

var column = GetColumnFromMemberExpression(obj);
/// <summary>
/// Splits a `Contains` call into its collection and item, whether it is an instance call
/// (`collection.Contains(item)`) or a static `Enumerable.Contains(collection, item)`.
/// </summary>
/// <param name="node"></param>
/// <returns></returns>
private static (Expression Collection, Expression Item) DeconstructContains(MethodCallExpression node) =>
node.Object == null ? (node.Arguments[0], node.Arguments[1]) : (node.Object, node.Arguments[0]);

if (column == null)
throw new ArgumentException(
$"Left side of expression: '{node.ToString()}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute");
/// <summary>
/// Translates `x =&gt; x.Column.Contains(value)`: a `cs` filter for a collection column, or a `like`
/// filter for a string column.
/// </summary>
/// <param name="collection"></param>
/// <param name="item"></param>
private void VisitColumnContains(Expression collection, Expression item)
{
var column = ResolveColumn(collection) ?? throw new ArgumentException($"Left side of expression: '{collection}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute");
Filter = collection.Type == typeof(string)
? new QueryFilter(column, Operator.Like, "*" + EvaluateExpression(item) + "*")
: new QueryFilter(column, Operator.Contains, new List<object> { EvaluateExpression(item)! });
}

/// <summary>
/// Translates `x =&gt; capturedCollection.Contains(x.Column)` into an `in` filter, resolving the column
/// from the argument and evaluating the collection locally.
/// </summary>
/// <param name="collection"></param>
/// <param name="item"></param>
private void VisitCapturedContains(Expression collection, Expression item)
{
var column = ResolveColumn(item) ?? throw new ArgumentException($"Argument of expression: '{item}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute");
Filter = new QueryFilter(column, Operator.In, EvaluateCollection(collection));
}

switch (node.Method.Name)
/// <summary>
/// Resolves the column name from an expression that references the model parameter, unwrapping a
/// `Convert` (nullable coercion) or a `Nullable&lt;T&gt;.Value` access along the way.
/// </summary>
/// <param name="expression"></param>
/// <returns></returns>
private string? ResolveColumn(Expression expression) =>
expression switch
{
// Includes String.Contains and IEnumerable.Contains
case nameof(String.Contains):
UnaryExpression { NodeType: ExpressionType.Convert } convert => ResolveColumn(convert.Operand),
MemberExpression member when IsNullableValueAccess(member) => ResolveColumn(member.Expression!),
MemberExpression member => GetColumnFromMemberExpression(member),
_ => null
};

if (typeof(ICollection).IsAssignableFrom(node.Method.DeclaringType))
Filter = new QueryFilter(column, Operator.Contains, GetArgumentValues(node));
else
Filter = new QueryFilter(column, Operator.Like, "*" + GetArgumentValues(node).First() + "*");
/// <summary>
/// True for the `Nullable&lt;T&gt;.Value` access the compiler inserts around a nullable column (i.e.
/// the `.Value` in `x.IntValue!.Value`); its inner expression is the column itself.
/// </summary>
/// <param name="member"></param>
/// <returns></returns>
private static bool IsNullableValueAccess(MemberExpression member) =>
member.Member.Name == "Value" &&
member.Expression != null &&
Nullable.GetUnderlyingType(member.Expression.Type) != null;

break;
default:
throw new NotImplementedException("Unsupported method");
}
/// <summary>
/// Evaluates a parameter-free collection expression locally into the list of values expected by an
/// `in` filter. Conversion wrappers are stripped first so a `T[]` compiled against the span-based
/// `MemoryExtensions.Contains` (i.e. `op_Implicit(array)` to `ReadOnlySpan&lt;T&gt;`) is evaluated as
/// the underlying array rather than an un-invokable ref struct.
/// </summary>
/// <param name="expression"></param>
/// <returns></returns>
/// <exception cref="ArgumentException"></exception>
private static IList EvaluateCollection(Expression expression) =>
EvaluateExpression(UnwrapConversion(expression)) switch
{
IList list => list,
IEnumerable enumerable => enumerable.Cast<object>().ToList(),
_ => throw new ArgumentException($"Expected the collection in '{expression}' to be enumerable so it can be translated into an `in` filter.")
};

return node;
}
/// <summary>
/// Strips `Convert` and implicit/explicit conversion-operator wrappers to reach the underlying value.
/// </summary>
/// <param name="expression"></param>
/// <returns></returns>
private static Expression UnwrapConversion(Expression expression) =>
expression switch
{
UnaryExpression { NodeType: ExpressionType.Convert } convert => UnwrapConversion(convert.Operand),
MethodCallExpression { Method: { Name: "op_Implicit" or "op_Explicit" } } call => UnwrapConversion(call.Arguments[0]),
_ => expression
};

/// <summary>
/// A constant expression parser (i.e. x => x.Id == 5 &lt;- where '5' is the constant)
Expand Down Expand Up @@ -429,24 +554,5 @@ protected override Expression VisitParameter(ParameterExpression node)
return base.VisitParameter(node);
}
}

/// <summary>
/// Gets arguments from a method call expression, (i.e. x => x.Name.Contains("Top")) &lt;- where `Top` is the argument on the called method `Contains`
/// </summary>
/// <param name="methodCall"></param>
/// <returns></returns>
List<object> GetArgumentValues(MethodCallExpression methodCall)
{
var argumentValues = new List<object>();

foreach (var argument in methodCall.Arguments)
{
var lambda = Expression.Lambda(argument);
var func = lambda.Compile();
argumentValues.Add(func.DynamicInvoke());
}

return argumentValues;
}
}
}
14 changes: 10 additions & 4 deletions Postgrest/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -829,10 +829,7 @@ internal KeyValuePair<string, string> PrepareFilter(IPostgrestQueryFilter filter
break;
case Operator.Not:
if (filter.Criteria is QueryFilter notFilter)
{
var prepped = PrepareFilter(notFilter);
return new KeyValuePair<string, string>(prepped.Key, $"not.{prepped.Value}");
}
return NegatePreparedFilter(notFilter, PrepareFilter(notFilter));

break;
case Operator.Like:
Expand Down Expand Up @@ -914,6 +911,15 @@ internal KeyValuePair<string, string> PrepareFilter(IPostgrestQueryFilter filter
return new KeyValuePair<string, string>();
}

/// <summary>
/// Applies a `not.` negation to an already-prepared filter. A negated logical group is expressed as
/// `not.and=(...)` / `not.or=(...)`, with the `not.` prefixing the key; a negated column filter keeps
/// the `not.` on the value (`not.eq.foo`).
/// </summary>
private static KeyValuePair<string, string> NegatePreparedFilter(QueryFilter inner, KeyValuePair<string, string> prepared) =>
inner.Op is Operator.And or Operator.Or
? new KeyValuePair<string, string>($"not.{prepared.Key}", prepared.Value)
: new KeyValuePair<string, string>(prepared.Key, $"not.{prepared.Value}");

/// <inheritdoc />
public void Clear()
Expand Down
15 changes: 15 additions & 0 deletions PostgrestTests/ClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,21 @@ public void TestFiltersNot()
Assert.AreEqual("not.eq.bar", result.Value);
}

[TestMethod("filters: not around a logical group prefixes the key")]
public void GivenNotAroundLogicalGroup_ShouldPrefixKeyWithNot()
{
var client = new Client(BaseUrl);
var group = new QueryFilter(Operator.And, new List<IPostgrestQueryFilter>
{
new QueryFilter("a", Operator.GreaterThanOrEqual, "0"),
new QueryFilter("a", Operator.LessThanOrEqual, "100")
});
var notFilter = new QueryFilter(Operator.Not, group);
var result = ((Table<User>)client.Table<User>()).PrepareFilter(notFilter);
Assert.AreEqual("not.and", result.Key);
Assert.AreEqual("(a.gte.0,a.lte.100)", result.Value);
}

[TestMethod("filters: and & or")]
public void TestFiltersAndOr()
{
Expand Down
Loading
Loading