Skip to content

Commit 5581287

Browse files
authored
fix: null-reference crash when a Where predicate null-checks a captured value (#122)
* fix: null-reference crash when a Where predicate null-checks a captured value A LINQ predicate like `x => filterPredicate == null || filterPredicate(x)` crashed with a NullReferenceException on `Get`: branches the visitor could not translate (i.e. a delegate invocation) were combined with the null-forgiving operator, putting a literal null into the nested filter criteria that `PrepareFilter` later dereferenced. The visitor now tracks the lambda parameter and evaluates branches that never reference the model locally, folding them through `||`/`&&` with C#'s short-circuit semantics. The reported idiom therefore works: when the captured delegate is null the predicate is always true and `Where` applies no filter. Branches that do reference the model but cannot be translated (a compiled delegate is opaque) now throw a descriptive ArgumentException pointing at the conditional-query workaround, as does an always-false predicate (silently matching every row would be dangerous for `Update`/`Delete`). Also moves the `== null` -> `is.null` / `!= null` -> `not.is.null` conversion from `Table.Where` (top level only) into the visitor so nested null checks like `x => x.Name == null || x.Name == "foo"` no longer emit an invalid `eq.` filter, and guards `PrepareFilter` against null nested filters supplied through the public QueryFilter constructor. Fixes supabase-community/supabase-csharp#192 * refactor: remove comments * refactor: extract LINQ Where tests * docs: update Where predicate documentation * refactor: code cleanup
1 parent e6527e3 commit 5581287

5 files changed

Lines changed: 348 additions & 132 deletions

File tree

Postgrest/Interfaces/IPostgrestTable.cs

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -352,20 +352,41 @@ IPostgrestTable<TModel> Order(string foreignTable, string column, Constants.Orde
352352
IPostgrestTable<TModel> Select(Expression<Func<TModel, object[]>> predicate);
353353

354354
/// <summary>
355-
/// Filter a query based on a predicate function.
356-
///
355+
/// Filter a query based on a predicate expression. The expression is translated into a
356+
/// Postgrest filter and evaluated server-side - it is never executed as C# code.
357+
///
357358
/// Note: Chaining multiple <see cref="Where(Expression{Func{TModel, bool}})"/> calls will
358359
/// be parsed as an "AND" query.
359-
///
360+
///
360361
/// Examples:
361362
/// `Table&lt;Movie&gt;().Where(x =&gt; x.Name == "Top Gun").Get();`
362363
/// `Table&lt;Movie&gt;().Where(x =&gt; x.Name == "Top Gun" || x.Name == "Mad Max").Get();`
363364
/// `Table&lt;Movie&gt;().Where(x =&gt; x.Name.Contains("Gun")).Get();`
364365
/// `Table&lt;Movie&gt;().Where(x =&gt; x.CreatedAt &lt;= new DateTime(2022, 08, 21)).Get();`
365366
/// `Table&lt;Movie&gt;().Where(x =&gt; x.Id > 5 &amp;&amp; x.Name.Contains("Max")).Get();`
366367
/// </summary>
368+
/// <remarks>
369+
/// Supported predicate shapes:
370+
/// <list type="bullet">
371+
/// <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>
372+
/// <item><description>Comparisons (`==`, `!=`, `&gt;`, `&gt;=`, `&lt;`, `&lt;=`) can be combined with `&amp;&amp;` and `||`, and `String`/collection `Contains` is supported.</description></item>
373+
/// <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>
374+
/// <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>
375+
/// </list>
376+
///
377+
/// Invoking a delegate (`Func&lt;TModel, bool&gt;`) inside the predicate is not supported: a compiled
378+
/// delegate cannot be translated into a Postgrest filter and throws an <see cref="ArgumentException"/>.
379+
/// To apply an optional filter, declare it as an `Expression&lt;Func&lt;TModel, bool&gt;&gt;` and pass it
380+
/// conditionally:
381+
/// <code>
382+
/// var query = client.Table&lt;Movie&gt;();
383+
/// if (optionalFilter != null)
384+
/// query = query.Where(optionalFilter);
385+
/// </code>
386+
/// </remarks>
367387
/// <param name="predicate"></param>
368388
/// <returns></returns>
389+
/// <exception cref="ArgumentException">The predicate contains an expression that cannot be translated into a Postgrest filter, or never matches any row.</exception>
369390
IPostgrestTable<TModel> Where(Expression<Func<TModel, bool>> predicate);
370391

371392
/// <summary>

Postgrest/Linq/WhereExpressionVisitor.cs

Lines changed: 159 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,45 @@ namespace Supabase.Postgrest.Linq
1919
/// </summary>
2020
internal class WhereExpressionVisitor : ExpressionVisitor
2121
{
22+
private ParameterExpression? parameter;
23+
24+
public WhereExpressionVisitor() { }
25+
26+
private WhereExpressionVisitor(ParameterExpression? parameter) =>
27+
this.parameter = parameter;
28+
2229
/// <summary>
2330
/// The filter resulting from this Visitor, capable of producing nested filters.
2431
/// </summary>
2532
public QueryFilter? Filter { get; private set; }
2633

34+
/// <summary>
35+
/// Set instead of <see cref="Filter"/> when the predicate (or the visited branch of it) never
36+
/// references the model and was instead evaluated locally to a boolean
37+
/// (i.e. `x => filterPredicate == null || filterPredicate(x)` where `filterPredicate` is null).
38+
/// </summary>
39+
public bool? ConstantValue { get; private set; }
40+
41+
/// <inheritdoc />
42+
protected override Expression VisitLambda<T>(Expression<T> node)
43+
{
44+
parameter ??= node.Parameters.FirstOrDefault();
45+
46+
// A predicate that never references the model (i.e. `x => localVariable == null`) can't be
47+
// translated into a filter - it is evaluated locally instead.
48+
if (node.Body.Type == typeof(bool) && !ContainsParameter(node.Body))
49+
{
50+
ConstantValue = (bool)EvaluateExpression(node.Body)!;
51+
return node;
52+
}
53+
54+
return base.VisitLambda(node);
55+
}
56+
2757
/// <summary>
2858
/// An entry point that will be used to populate <see cref="Filter"/>.
29-
///
30-
/// Invoked like:
59+
///
60+
/// Invoked like:
3161
/// `Table&lt;Movies&gt;().Where(x => x.Name == "Top Gun").Get();`
3262
/// </summary>
3363
/// <param name="node"></param>
@@ -44,29 +74,52 @@ protected override Expression VisitBinary(BinaryExpression node)
4474
case ExpressionType.Or:
4575
case ExpressionType.AndAlso:
4676
case ExpressionType.OrElse:
47-
var leftVisitor = new WhereExpressionVisitor();
48-
leftVisitor.Visit(node.Left);
77+
var shortCircuitValue = node.NodeType is ExpressionType.Or or ExpressionType.OrElse;
4978

50-
var rightVisitor = new WhereExpressionVisitor();
51-
rightVisitor.Visit(node.Right);
79+
var (leftConstant, leftFilter) = VisitBranch(node.Left);
80+
81+
// Follow C#'s short-circuit semantics: `true || anything` and `false && anything`
82+
// never evaluate their right side (i.e. `filterPredicate == null || filterPredicate(x)`).
83+
if (leftConstant == shortCircuitValue)
84+
{
85+
ConstantValue = leftConstant;
86+
return node;
87+
}
88+
89+
var (rightConstant, rightFilter) = VisitBranch(node.Right);
90+
91+
// A non-short-circuiting constant (`false ||` / `true &&`) reduces to the other side.
92+
if (leftConstant != null)
93+
{
94+
ConstantValue = rightConstant;
95+
Filter = rightFilter;
96+
return node;
97+
}
98+
99+
if (rightConstant != null)
100+
{
101+
if (rightConstant == shortCircuitValue)
102+
ConstantValue = rightConstant;
103+
else
104+
Filter = leftFilter;
105+
106+
return node;
107+
}
52108

53109
Filter = new QueryFilter(op,
54-
new List<IPostgrestQueryFilter> { leftVisitor.Filter!, rightVisitor.Filter! });
110+
new List<IPostgrestQueryFilter> { leftFilter!, rightFilter! });
55111

56112
return node;
57113
}
58114

59115
// Otherwise, the base case.
60116

61-
var left = Visit(node.Left);
62-
var right = Visit(node.Right);
63-
64117
string? column = null;
65-
if (left is MemberExpression leftMember)
118+
if (node.Left is MemberExpression leftMember)
66119
{
67120
column = GetColumnFromMemberExpression(leftMember);
68121
} //To handle properly if it's a Convert ExpressionType generally with nullable properties
69-
else if (left is UnaryExpression leftUnary && leftUnary.NodeType == ExpressionType.Convert &&
122+
else if (node.Left is UnaryExpression leftUnary && leftUnary.NodeType == ExpressionType.Convert &&
70123
leftUnary.Operand is MemberExpression leftOperandMember)
71124
{
72125
column = GetColumnFromMemberExpression(leftOperandMember);
@@ -76,26 +129,51 @@ protected override Expression VisitBinary(BinaryExpression node)
76129
throw new ArgumentException(
77130
$"Left side of expression: '{node}' is expected to be property with a ColumnAttribute or PrimaryKeyAttribute");
78131

79-
if (right is ConstantExpression rightConstant)
132+
if (node.Right is ConstantExpression rightConstantExpression)
80133
{
81-
HandleConstantExpression(column, op, rightConstant);
134+
HandleConstantExpression(column, op, rightConstantExpression);
82135
}
83-
else if (right is MemberExpression memberExpression)
136+
else if (node.Right is MemberExpression memberExpression)
84137
{
85138
HandleMemberExpression(column, op, memberExpression);
86139
}
87-
else if (right is NewExpression newExpression)
140+
else if (node.Right is NewExpression newExpression)
88141
{
89142
HandleNewExpression(column, op, newExpression);
90143
}
91-
else if (right is UnaryExpression unaryExpression)
144+
else if (node.Right is UnaryExpression unaryExpression)
92145
{
93146
HandleUnaryExpression(column, op, unaryExpression);
94147
}
95148

96149
return node;
97150
}
98151

152+
/// <summary>
153+
/// Visits one side of an `AND`/`OR` expression, producing either a <see cref="QueryFilter"/> or,
154+
/// when the branch never references the model, its locally evaluated boolean value.
155+
/// </summary>
156+
/// <param name="expression"></param>
157+
/// <returns></returns>
158+
/// <exception cref="ArgumentException"></exception>
159+
private (bool? Constant, QueryFilter? Filter) VisitBranch(Expression expression)
160+
{
161+
if (expression.Type == typeof(bool) && !ContainsParameter(expression))
162+
return ((bool)EvaluateExpression(expression)!, null);
163+
164+
var visitor = new WhereExpressionVisitor(parameter);
165+
visitor.Visit(expression);
166+
167+
if (visitor.ConstantValue != null)
168+
return (visitor.ConstantValue, null);
169+
170+
if (visitor.Filter == null)
171+
throw new ArgumentException(
172+
$"Unable to translate expression '{expression}' into a Postgrest filter. If the condition depends on values that are not model columns (i.e. invoking a delegate), evaluate it outside of `Where` and build the query conditionally instead.");
173+
174+
return (null, visitor.Filter);
175+
}
176+
99177
/// <summary>
100178
/// Called when evaluating a method
101179
/// </summary>
@@ -143,15 +221,7 @@ protected override Expression VisitMethodCall(MethodCallExpression node)
143221
/// <param name="constantExpression"></param>
144222
private void HandleConstantExpression(string column, Operator op, ConstantExpression constantExpression)
145223
{
146-
if (constantExpression.Type.IsEnum)
147-
{
148-
var enumValue = constantExpression.Value;
149-
Filter = new QueryFilter(column, op, enumValue);
150-
}
151-
else
152-
{
153-
Filter = new QueryFilter(column, op, constantExpression.Value);
154-
}
224+
Filter = BuildFilter(column, op, constantExpression.Value);
155225
}
156226

157227
/// <summary>
@@ -162,7 +232,29 @@ private void HandleConstantExpression(string column, Operator op, ConstantExpres
162232
/// <param name="memberExpression"></param>
163233
private void HandleMemberExpression(string column, Operator op, MemberExpression memberExpression)
164234
{
165-
Filter = new QueryFilter(column, op, GetMemberExpressionValue(memberExpression));
235+
Filter = BuildFilter(column, op, GetMemberExpressionValue(memberExpression));
236+
}
237+
238+
/// <summary>
239+
/// Builds a filter from a column, operator and (possibly null) criterion, translating null
240+
/// equality checks (i.e. `x => x.Name == null`) into the `IS NULL`/`IS NOT NULL` filters
241+
/// Postgrest expects — at any nesting depth.
242+
/// </summary>
243+
/// <param name="column"></param>
244+
/// <param name="op"></param>
245+
/// <param name="value"></param>
246+
private static QueryFilter BuildFilter(string column, Operator op, object? value)
247+
{
248+
if (value != null)
249+
return new QueryFilter(column, op, value);
250+
251+
return op switch
252+
{
253+
Operator.Equals => new QueryFilter(column, Operator.Is, QueryFilter.NullVal),
254+
Operator.NotEqual => new QueryFilter(column, Operator.Not,
255+
new QueryFilter(column, Operator.Is, QueryFilter.NullVal)),
256+
_ => new QueryFilter(column, op, value)
257+
};
166258
}
167259

168260
/// <summary>
@@ -298,6 +390,46 @@ private Operator GetMappedOperator(Expression node)
298390
};
299391
}
300392

393+
/// <summary>
394+
/// Checks if an expression references the model parameter of the `Where` predicate.
395+
/// Expressions that don't (i.e. `filterPredicate == null`) can be evaluated locally
396+
/// instead of being translated into a filter.
397+
/// </summary>
398+
/// <param name="expression"></param>
399+
/// <returns></returns>
400+
private bool ContainsParameter(Expression expression)
401+
{
402+
var finder = new ParameterFinder(parameter);
403+
finder.Visit(expression);
404+
return finder.Found;
405+
}
406+
407+
/// <summary>
408+
/// Evaluates an expression that doesn't reference the model locally.
409+
/// </summary>
410+
/// <param name="expression"></param>
411+
/// <returns></returns>
412+
private static object? EvaluateExpression(Expression expression) =>
413+
Expression.Lambda(expression).Compile().DynamicInvoke();
414+
415+
private class ParameterFinder : ExpressionVisitor
416+
{
417+
private readonly ParameterExpression? _target;
418+
419+
public ParameterFinder(ParameterExpression? target) =>
420+
_target = target;
421+
422+
public bool Found { get; private set; }
423+
424+
protected override Expression VisitParameter(ParameterExpression node)
425+
{
426+
if (_target == null || node == _target)
427+
Found = true;
428+
429+
return base.VisitParameter(node);
430+
}
431+
}
432+
301433
/// <summary>
302434
/// 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`
303435
/// </summary>

Postgrest/Table.cs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -355,17 +355,18 @@ public IPostgrestTable<TModel> Where(Expression<Func<TModel, bool>> predicate)
355355
var visitor = new WhereExpressionVisitor();
356356
visitor.Visit(predicate);
357357

358+
if (visitor.ConstantValue == true)
359+
return this;
360+
361+
if (visitor.ConstantValue == false)
362+
throw new ArgumentException(
363+
"The supplied predicate always evaluates to false, so no row would ever match. Evaluate the condition outside of `Where` and build the query conditionally instead.");
364+
358365
if (visitor.Filter == null)
359366
throw new ArgumentException(
360367
"Unable to parse the supplied predicate, did you return a predicate where each left hand of the condition is a Model property?");
361368

362-
if (visitor.Filter.Op == Operator.Equals && visitor.Filter.Criteria == null)
363-
_filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal));
364-
else if (visitor.Filter.Op == Operator.NotEqual && visitor.Filter.Criteria == null)
365-
_filters.Add(new QueryFilter(visitor.Filter.Property!, Operator.Not,
366-
new QueryFilter(visitor.Filter.Property!, Operator.Is, QueryFilter.NullVal)));
367-
else
368-
_filters.Add(visitor.Filter);
369+
_filters.Add(visitor.Filter);
369370

370371
return this;
371372
}
@@ -800,7 +801,13 @@ internal KeyValuePair<string, string> PrepareFilter(IPostgrestQueryFilter filter
800801
{
801802
var list = new List<KeyValuePair<string, string>>();
802803
foreach (var subFilter in subFilters)
804+
{
805+
if (subFilter == null)
806+
throw new ArgumentException(
807+
$"Expected all filters supplied to a `{filter.Op}` filter to be non-null.");
808+
803809
list.Add(PrepareFilter(subFilter));
810+
}
804811

805812
foreach (var preppedFilter in list)
806813
strBuilder.Append($"{preppedFilter.Key}.{preppedFilter.Value},");

0 commit comments

Comments
 (0)