diff --git a/Postgrest/Interfaces/IPostgrestTable.cs b/Postgrest/Interfaces/IPostgrestTable.cs index 6849854..0ba51aa 100644 --- a/Postgrest/Interfaces/IPostgrestTable.cs +++ b/Postgrest/Interfaces/IPostgrestTable.cs @@ -370,10 +370,17 @@ IPostgrestTable Order(string foreignTable, string column, Constants.Orde /// /// 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(...)`). /// Comparisons (`==`, `!=`, `>`, `>=`, `<`, `<=`) can be combined with `&&` and `||`, and `String`/collection `Contains` is supported. + /// `Contains` translates by which side is the Model: a column containing a constant (`x => x.Tags.Contains("a")`) becomes a `cs`/`like` filter, while a captured collection containing a column (`x => ids.Contains(x.Id)`) becomes an `in` filter. + /// A boolean column can be used as a predicate on its own: `x => x.IsActive` becomes an `eq` filter and `x => !x.IsActive` a `not.eq` filter. + /// Negation (`!`) is translated to a Postgrest `not.` filter: `x => !(x.Name == "Top Gun")` becomes `not.eq`, and a negated group `x => !(x.Name == "Top Gun" || x.Id > 5)` becomes `not.or=(...)`. /// Null checks translate to Postgrest `is null` filters: `x => x.Name == null` and `x => x.Name == null || x.Name == "Top Gun"` both work. /// Conditions that never reference the Model (i.e. `x => localVariable == null`) are evaluated locally: an always-true predicate applies no filter, an always-false one throws. /// /// + /// Comparing two Model columns to each other (`x => x.StartDate < x.EndDate`) is not supported: + /// Postgrest has no column-to-column filter syntax, so it throws an — + /// use a database computed/generated column or an RPC instead. + /// /// Invoking a delegate (`Func<TModel, bool>`) inside the predicate is not supported: a compiled /// delegate cannot be translated into a Postgrest filter and throws an . /// To apply an optional filter, declare it as an `Expression<Func<TModel, bool>>` and pass it diff --git a/Postgrest/Linq/WhereExpressionVisitor.cs b/Postgrest/Linq/WhereExpressionVisitor.cs index 1e2da08..c05df47 100644 --- a/Postgrest/Linq/WhereExpressionVisitor.cs +++ b/Postgrest/Linq/WhereExpressionVisitor.cs @@ -15,7 +15,11 @@ namespace Supabase.Postgrest.Linq { /// - /// Helper class for parsing Where linq queries. + /// Helper class for parsing Where linq queries. Supports comparisons, `&&`/`||` groups, + /// `String`/collection `Contains` (including a captured collection containing a column, which becomes + /// an `in` filter), bare boolean columns (`x => x.IsActive`), null checks (`is null`), and negation + /// (`!`), translating each into the equivalent Postgrest filter; negation wraps its operand in a `not.` + /// filter. /// internal class WhereExpressionVisitor : ExpressionVisitor { @@ -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); @@ -149,6 +156,44 @@ protected override Expression VisitBinary(BinaryExpression node) return node; } + /// + /// 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. + /// + /// + /// + /// + 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; + } + + /// + /// Handles a boolean column used directly as a predicate (i.e. `x => x.IsActive`, or negated via + /// `x => !x.IsActive`), translating it into a `column.eq.true` filter. + /// + /// + /// + 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; + } + /// /// Visits one side of an `AND`/`OR` expression, producing either a or, /// when the branch never references the model, its locally evaluated boolean value. @@ -175,7 +220,10 @@ protected override Expression VisitBinary(BinaryExpression node) } /// - /// 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 => x.Tags.Contains("a")` → `cs`/`like`) and + /// a captured collection containing a model column (`x => ids.Contains(x.Id)` → `in`). The latter + /// works for both instance (`List.Contains`) and static (`Enumerable.Contains`) calls. /// /// /// @@ -183,35 +231,112 @@ protected override Expression VisitBinary(BinaryExpression node) /// 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); + /// + /// 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)`. + /// + /// + /// + 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"); + /// + /// Translates `x => x.Column.Contains(value)`: a `cs` filter for a collection column, or a `like` + /// filter for a string column. + /// + /// + /// + 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 { EvaluateExpression(item)! }); + } + + /// + /// Translates `x => capturedCollection.Contains(x.Column)` into an `in` filter, resolving the column + /// from the argument and evaluating the collection locally. + /// + /// + /// + 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) + /// + /// Resolves the column name from an expression that references the model parameter, unwrapping a + /// `Convert` (nullable coercion) or a `Nullable<T>.Value` access along the way. + /// + /// + /// + 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() + "*"); + /// + /// True for the `Nullable<T>.Value` access the compiler inserts around a nullable column (i.e. + /// the `.Value` in `x.IntValue!.Value`); its inner expression is the column itself. + /// + /// + /// + 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"); - } + /// + /// 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<T>`) is evaluated as + /// the underlying array rather than an un-invokable ref struct. + /// + /// + /// + /// + private static IList EvaluateCollection(Expression expression) => + EvaluateExpression(UnwrapConversion(expression)) switch + { + IList list => list, + IEnumerable enumerable => enumerable.Cast().ToList(), + _ => throw new ArgumentException($"Expected the collection in '{expression}' to be enumerable so it can be translated into an `in` filter.") + }; - return node; - } + /// + /// Strips `Convert` and implicit/explicit conversion-operator wrappers to reach the underlying value. + /// + /// + /// + 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 + }; /// /// A constant expression parser (i.e. x => x.Id == 5 <- where '5' is the constant) @@ -429,24 +554,5 @@ protected override Expression VisitParameter(ParameterExpression node) return base.VisitParameter(node); } } - - /// - /// Gets arguments from a method call expression, (i.e. x => x.Name.Contains("Top")) <- where `Top` is the argument on the called method `Contains` - /// - /// - /// - List GetArgumentValues(MethodCallExpression methodCall) - { - var argumentValues = new List(); - - foreach (var argument in methodCall.Arguments) - { - var lambda = Expression.Lambda(argument); - var func = lambda.Compile(); - argumentValues.Add(func.DynamicInvoke()); - } - - return argumentValues; - } } } \ No newline at end of file diff --git a/Postgrest/Table.cs b/Postgrest/Table.cs index 680c875..de3bfc7 100644 --- a/Postgrest/Table.cs +++ b/Postgrest/Table.cs @@ -829,10 +829,7 @@ internal KeyValuePair PrepareFilter(IPostgrestQueryFilter filter break; case Operator.Not: if (filter.Criteria is QueryFilter notFilter) - { - var prepped = PrepareFilter(notFilter); - return new KeyValuePair(prepped.Key, $"not.{prepped.Value}"); - } + return NegatePreparedFilter(notFilter, PrepareFilter(notFilter)); break; case Operator.Like: @@ -914,6 +911,15 @@ internal KeyValuePair PrepareFilter(IPostgrestQueryFilter filter return new KeyValuePair(); } + /// + /// 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`). + /// + private static KeyValuePair NegatePreparedFilter(QueryFilter inner, KeyValuePair prepared) => + inner.Op is Operator.And or Operator.Or + ? new KeyValuePair($"not.{prepared.Key}", prepared.Value) + : new KeyValuePair(prepared.Key, $"not.{prepared.Value}"); /// public void Clear() diff --git a/PostgrestTests/ClientTests.cs b/PostgrestTests/ClientTests.cs index 4a0b8d6..9c37fd1 100644 --- a/PostgrestTests/ClientTests.cs +++ b/PostgrestTests/ClientTests.cs @@ -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 + { + new QueryFilter("a", Operator.GreaterThanOrEqual, "0"), + new QueryFilter("a", Operator.LessThanOrEqual, "100") + }); + var notFilter = new QueryFilter(Operator.Not, group); + var result = ((Table)client.Table()).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() { diff --git a/PostgrestTests/LinqWhereTests.cs b/PostgrestTests/LinqWhereTests.cs index 30008e7..f96e299 100644 --- a/PostgrestTests/LinqWhereTests.cs +++ b/PostgrestTests/LinqWhereTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Microsoft.VisualStudio.TestTools.UnitTesting; using Supabase.Postgrest; @@ -11,12 +12,11 @@ namespace PostgrestTests public class LinqWhereTests { private const string BaseUrl = "http://localhost:54321/rest/v1"; + private Client client = new Client(BaseUrl); [TestMethod("Linq: Where")] public async Task TestLinqWhere() { - var client = new Client(BaseUrl); - // Test boolean equality var query1 = await client.Table() .Where(x => x.Id == "ea07bd86-a507-4c68-9545-b848bfe74c90") @@ -110,7 +110,6 @@ await client.Table() [TestMethod("Linq: Where with a null-check on an absent delegate applies no filter (supabase-csharp#192)")] public void GivenNullDelegate_ShouldApplyNoFilter() { - var client = new Client(BaseUrl); var requestModel = new UserRequestModel(); var table = client.Table().Where(x => requestModel.FilterPredicate == null || requestModel.FilterPredicate(x)); Assert.AreEqual($"{BaseUrl}/users", table.GenerateUrl()); @@ -119,7 +118,6 @@ public void GivenNullDelegate_ShouldApplyNoFilter() [TestMethod("Linq: Where with a null-check on a present delegate throws a descriptive exception (supabase-csharp#192)")] public void GivenNonNullDelegate_ShouldThrowArgumentException() { - var client = new Client(BaseUrl); var requestModel = new UserRequestModel { FilterPredicate = u => u.Username == "supabot" }; var exception = Assert.ThrowsException(() => client.Table().Where(x => requestModel.FilterPredicate == null || requestModel.FilterPredicate(x))); StringAssert.Contains(exception.Message, "Unable to translate expression"); @@ -128,7 +126,6 @@ public void GivenNonNullDelegate_ShouldThrowArgumentException() [TestMethod("Linq: Where with an always-false predicate throws a descriptive exception (supabase-csharp#192)")] public void GivenAlwaysFalsePredicate_ShouldThrowArgumentException() { - var client = new Client(BaseUrl); var requestModel = new UserRequestModel(); var exception = Assert.ThrowsException(() => client.Table().Where(x => requestModel.FilterPredicate != null && requestModel.FilterPredicate(x))); StringAssert.Contains(exception.Message, "always evaluates to false"); @@ -137,12 +134,98 @@ public void GivenAlwaysFalsePredicate_ShouldThrowArgumentException() [TestMethod("Linq: Where translates a nested null-check to `is.null` (supabase-csharp#192)")] public void GivenNullCheckInsideOrPredicate_WhereTranslateToIsNullFilter() { - var client = new Client(BaseUrl); var table = client.Table().Where(x => x.Catchphrase == null || x.Catchphrase == "fat cat"); var urlEncodedIsNullFilter = "or=(catchphrase.is.null%2ccatchphrase.eq.fat+cat)"; Assert.AreEqual($"{BaseUrl}/users?{urlEncodedIsNullFilter}", table.GenerateUrl()); } + [TestMethod("Linq: Where negates an equality predicate into a `not.eq` filter")] + public void GivenNegatedEqualityPredicate_ShouldGenerateNotEqFilter() + { + var table = client.Table().Where(x => !(x.Username == "supabot")); + Assert.AreEqual($"{BaseUrl}/users?username=not.eq.supabot", table.GenerateUrl()); + } + + [TestMethod("Linq: Where negates a null-check into a `not.is.null` filter")] + public void GivenNegatedNullCheckPredicate_ShouldGenerateNotIsNullFilter() + { + var table = client.Table().Where(x => !(x.Catchphrase == null)); + Assert.AreEqual($"{BaseUrl}/users?catchphrase=not.is.null", table.GenerateUrl()); + } + + [TestMethod("Linq: Where negates a grouped predicate into a `not.`-wrapped logical filter")] + public void GivenNegatedGroupedPredicate_ShouldGenerateNotWrappedLogicalFilter() + { + var table = client.Table().Where(x => !(x.Catchphrase == "fat cat" || x.Username == "supabot")); + var urlEncodedNotOrFilter = "not.or=(catchphrase.eq.fat+cat%2cusername.eq.supabot)"; + Assert.AreEqual($"{BaseUrl}/users?{urlEncodedNotOrFilter}", table.GenerateUrl()); + } + + [TestMethod("Linq: Where negates a string `Contains` into a `not.like` filter")] + public void GivenNegatedStringContainsPredicate_ShouldGenerateNotLikeFilter() + { + var table = client.Table().Where(x => !x.Username!.Contains("supa")); + Assert.AreEqual($"{BaseUrl}/users?username=not.like.*supa*", table.GenerateUrl()); + } + + [TestMethod("Linq: Where translates a captured list `Contains(column)` into an `in` filter")] + public void GivenCapturedListContainsColumn_ShouldGenerateInFilter() + { + var values = new List { "a", "b" }; + var table = client.Table().Where(x => values.Contains(x.StringValue!)); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?string_value=in.(\"a\"%2c\"b\")", table.GenerateUrl()); + } + + [TestMethod("Linq: Where translates a captured array `Contains(column)` into an `in` filter")] + public void GivenCapturedArrayContainsColumn_ShouldGenerateInFilter() + { + var values = new[] { 1, 2 }; + var table = client.Table().Where(x => values.Contains(x.IntValue!.Value)); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?int_value=in.(\"1\"%2c\"2\")", table.GenerateUrl()); + } + + [TestMethod("Linq: Where still translates a column list `Contains(constant)` into a `cs` filter")] + public void GivenColumnListContainsConstant_ShouldStillGenerateContainsFilter() + { + var table = client.Table().Where(x => x.ListOfStrings!.Contains("set")); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?list_of_strings=cs.{{set}}", table.GenerateUrl()); + } + + [TestMethod("Linq: Where still translates a column string `Contains(constant)` into a `like` filter")] + public void GivenColumnStringContainsConstant_ShouldStillGenerateLikeFilter() + { + var table = client.Table().Where(x => x.StringValue!.Contains("foo")); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?string_value=like.*foo*", table.GenerateUrl()); + } + + [TestMethod("Linq: Where translates a bare boolean member into an `eq` filter")] + public void GivenBareBooleanMemberPredicate_ShouldGenerateEqTrueFilter() + { + var table = client.Table().Where(x => x.BooleanValue); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?bool_value=eq.True", table.GenerateUrl()); + } + + [TestMethod("Linq: Where translates a negated boolean member into a `not.eq` filter")] + public void GivenNegatedBooleanMemberPredicate_ShouldGenerateNotEqTrueFilter() + { + var table = client.Table().Where(x => !x.BooleanValue); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?bool_value=not.eq.True", table.GenerateUrl()); + } + + [TestMethod("Linq: Where translates a boolean member inside an AND into a nested filter")] + public void GivenBooleanMemberInsideAndPredicate_ShouldGenerateNestedFilter() + { + var table = client.Table().Where(x => x.BooleanValue && x.IntValue > 3); + Assert.AreEqual($"{BaseUrl}/kitchen_sink?and=(bool_value.eq.True%2cint_value.gt.3)", table.GenerateUrl()); + } + + [TestMethod("Linq: Where throws a descriptive exception when comparing two columns")] + public void GivenColumnComparedToColumnPredicate_ShouldThrowDescriptiveArgumentException() + { + var exception = Assert.ThrowsException(() => client.Table().Where(x => x.DateTimeValue < x.DateTimeValue1)); + StringAssert.Contains(exception.Message, "cannot compare two model columns"); + } + private class UserRequestModel { public Func? FilterPredicate { get; set; }