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
108 changes: 108 additions & 0 deletions Lql/Nimblesite.Lql.Core/Parsing/LqlToAstVisitor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,19 @@ private static string ProcessComparisonToSql(
{
return ProcessCaseExpressionToSql(expr.caseExpr(), lambdaScope);
}

// expr matched the `IDENT '(' argList? ')'` branch — a bare
// function call (e.g. is_member(u, t)) used as a boolean predicate
// inside a lambda body without an explicit comparison operator.
// GitHub issue #40: NAP needs SECURITY DEFINER fn calls to
// survive LQL transpilation; rewriting via exists() loses
// SECURITY DEFINER semantics. Emit the call verbatim so RLS
// policy bodies can call user-defined Postgres functions.
if (expr.IDENT() != null && expr.ChildCount >= 3)
{
return ProcessFnCallExprToSql(expr, lambdaScope);
}

// No fallback - fail hard if expr type is not handled
throw new SqlErrorException(
CreateSqlErrorStatic(
Expand Down Expand Up @@ -706,6 +719,101 @@ context as ParserRuleContext
);
}

/// <summary>
/// Process an expr that matched the <c>IDENT '(' argList? ')'</c> branch
/// (a function call) into SQL text. Lambda-scope-aware so qualified
/// idents like <c>p.tenant_id</c> strip the <c>p.</c> prefix when
/// <c>p</c> is bound. Implements GitHub issue #40 — RLS policies must
/// be able to call user-defined SECURITY DEFINER functions verbatim.
/// </summary>
private static string ProcessFnCallExprToSql(
LqlParser.ExprContext expr,
HashSet<string>? lambdaScope
)
{
var fnName = expr.IDENT().GetText();
var args = expr.argList();
if (args == null)
{
return $"{fnName}()";
}
var argTexts = args.arg().Select(a => ProcessFnCallArgToSql(a, lambdaScope)).ToList();
return $"{fnName}({string.Join(", ", argTexts)})";
}

private static string ProcessFnCallArgToSql(
LqlParser.ArgContext arg,
HashSet<string>? lambdaScope
)
{
// arg grammar matches `columnAlias` first when the arg is a
// qualified identifier like c.tenant_id. The columnAlias rule
// itself wraps qualifiedIdent / IDENT / arithmeticExpr / functionCall.
// Walk through to find the actual shape and apply lambda-scope
// stripping for qualified idents.
if (arg.columnAlias() != null)
{
var ca = arg.columnAlias();
if (ca.qualifiedIdent() != null)
{
return ProcessQualifiedIdentifierToSql(ca.qualifiedIdent(), lambdaScope);
}
if (ca.arithmeticExpr() != null)
{
return ProcessArithmeticExpressionToSql(ca.arithmeticExpr(), lambdaScope);
}
if (ca.functionCall() != null)
{
return ExtractFunctionCall(ca.functionCall());
}
if (ca.IDENT() != null && ca.IDENT().Length > 0)
{
return ca.IDENT()[0].GetText();
}
}
if (arg.arithmeticExpr() != null)
{
return ProcessArithmeticExpressionToSql(arg.arithmeticExpr(), lambdaScope);
}
if (arg.functionCall() != null)
{
return ExtractFunctionCall(arg.functionCall());
}
if (arg.expr() != null)
{
var inner = arg.expr();
if (inner.IDENT() != null && inner.ChildCount >= 3)
{
return ProcessFnCallExprToSql(inner, lambdaScope);
}
if (inner.qualifiedIdent() != null)
{
return ProcessQualifiedIdentifierToSql(inner.qualifiedIdent(), lambdaScope);
}
if (inner.IDENT() != null)
{
return inner.IDENT().GetText();
}
if (inner.STRING() != null)
{
return inner.STRING().GetText();
}
if (inner.INT() != null)
{
return inner.INT().GetText();
}
if (inner.DECIMAL() != null)
{
return inner.DECIMAL().GetText();
}
}
if (arg.comparison() != null)
{
return ProcessComparisonToSql(arg.comparison(), lambdaScope);
}
return ExtractIdentifier(arg);
}

/// <summary>
/// Processes a qualified identifier to SQL text, removing lambda variable prefixes.
/// </summary>
Expand Down
119 changes: 119 additions & 0 deletions Lql/Nimblesite.Lql.Tests/LqlFnCallInLambdaTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
using Nimblesite.Lql.Postgres;
using Nimblesite.Sql.Model;
using Xunit;

namespace Nimblesite.Lql.Tests;

// Coverage for ProcessFnCallExprToSql + ProcessFnCallArgToSql added in
// LqlToAstVisitor for GitHub issues #40/#41 (NAP RLS bare fn calls in
// lambda bodies, e.g. exists(parent |> filter(fn(p) => p.id = id and
// is_member(app_user_id(), p.tenant_id)))).

/// <summary>
/// Targeted unit tests for the bare-function-call branch of LQL's lambda
/// body and its argument-shape handling. These shapes are not exercised by
/// the file-based fixture tests but are required for RLS predicates that
/// call SECURITY DEFINER functions.
/// </summary>
public sealed class LqlFnCallInLambdaTests
{
private static string ToPg(string lql)
{
var stmt = LqlStatementConverter.ToStatement(lql);
Assert.True(
stmt is Outcome.Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>,
stmt is Outcome.Result<LqlStatement, SqlError>.Error<LqlStatement, SqlError> e
? e.Value.Message
: "expected Ok"
);
var ok = (Outcome.Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>)stmt;
var result = ok.Value.ToPostgreSql();
Assert.True(
result is Outcome.Result<string, SqlError>.Ok<string, SqlError>,
result is Outcome.Result<string, SqlError>.Error<string, SqlError> e2
? e2.Value.Message
: "expected transpile Ok"
);
return ((Outcome.Result<string, SqlError>.Ok<string, SqlError>)result).Value;
}

[Fact]
public void Lambda_BareFnCall_NoArgs_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => some_fn())");
Assert.Contains("some_fn()", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_StringArgs_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => is_member('a', 'b'))");
Assert.Contains("is_member('a', 'b')", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_QualifiedIdentArg_StripsLambdaPrefix()
{
// x is the lambda var -> x.tenant_id should emit as 'tenant_id'.
var sql = ToPg("t |> filter(fn(x) => is_member('u', x.tenant_id))");
Assert.Contains("is_member('u', tenant_id)", sql, StringComparison.Ordinal);
Assert.DoesNotContain("x.tenant_id", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_NestedFnCallArg_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => is_member(app_user_id(), app_tenant_id()))");
// Outer fn is emitted via ProcessFnCallExprToSql (lowercase preserved).
// Nested fn args go through ExtractFunctionCall which uppercases the
// function name -- Postgres treats unquoted names as case-insensitive
// so APP_USER_ID() and app_user_id() resolve to the same function.
Assert.Contains("is_member(", sql, StringComparison.Ordinal);
Assert.Contains("APP_USER_ID()", sql, StringComparison.Ordinal);
Assert.Contains("APP_TENANT_ID()", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_AndCombinationWithBareFnCall_ParsesAndEmits()
{
// The right-hand side of AND is a bare fn call -- must not raise
// 'Unsupported expr type in comparison'.
var sql = ToPg(
"t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' and is_member('u', x.tenant_id))"
);
Assert.Contains("AND", sql, StringComparison.Ordinal);
Assert.Contains("is_member(", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_OrCombinationWithBareFnCall_ParsesAndEmits()
{
var sql = ToPg(
"t |> filter(fn(x) => x.id = '00000000-0000-0000-0000-000000000000' or is_member('u', x.tenant_id))"
);
Assert.Contains("OR", sql, StringComparison.Ordinal);
Assert.Contains("is_member(", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_IntArg_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => has_role(42))");
Assert.Contains("has_role(42)", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_DecimalArg_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => has_balance(1.5))");
Assert.Contains("has_balance(1.5)", sql, StringComparison.Ordinal);
}

[Fact]
public void Lambda_BareFnCall_IdentArg_PassesThrough()
{
var sql = ToPg("t |> filter(fn(x) => some_fn(other_col))");
Assert.Contains("some_fn(", sql, StringComparison.Ordinal);
Assert.Contains("other_col", sql, StringComparison.Ordinal);
}
}
Loading
Loading