Skip to content

Commit 90919b0

Browse files
Add LQL function bodies and current setting builtin (#45)
## Summary - add `bodyLql` support for PostgreSQL functions, including YAML validation, DDL generation, and diff idempotence - add PostgreSQL `current_setting('key')` support in LQL policy predicates and function bodies - add broad NAP-shaped tests covering session keys, casts, helper functions, exists pipelines, and real Postgres RLS behavior ## Validation - `dotnet csharpier check .` - `make lint` - `dotnet test Lql/Nimblesite.Lql.Tests --configuration Release --verbosity minimal` - `dotnet test Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj --configuration Release --no-build --verbosity minimal` - `dotnet test Migration/Nimblesite.DataProvider.Migration.Tests/Nimblesite.DataProvider.Migration.Tests.csproj --filter "FullyQualifiedName~RlsCurrentSettingLqlTests|FullyQualifiedName~PostgresFunctionBodyLqlTests|FullyQualifiedName~PostgresFunctionBodyLqlE2ETests" --configuration Release --verbosity minimal` - `dotnet test Sync/Nimblesite.Sync.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Sync/Nimblesite.Sync.SQLite.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Sync/Nimblesite.Sync.Postgres.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Sync/Nimblesite.Sync.Integration.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Sync/Nimblesite.Sync.Http.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Reporting/Nimblesite.Reporting.Tests --configuration Release --no-build --verbosity minimal` - `dotnet test Lql/Nimblesite.Lql.TypeProvider.FSharp.Tests --configuration Release --no-build --verbosity minimal` ## Notes - `make test` was not used for the final pass because the `Nimblesite.DataProvider.Example.Tests` make target hung in nested build startup locally; the same test project passed when rerun directly with `--no-build` earlier in this branch. - `Reporting.Integration.Tests` initially needed the local Playwright Chromium install; after install, the run hit existing `.report-*` DOM timeouts unrelated to the LQL/Migration changes and was stopped.
1 parent ee0ebab commit 90919b0

12 files changed

Lines changed: 1296 additions & 51 deletions

File tree

Lql/Nimblesite.Lql.Core/Parsing/LqlCodeParser.cs

Lines changed: 5 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -122,10 +122,9 @@ public static Result<INode, SqlError> Parse(string lqlCode)
122122

123123
// Check for circular references in let statements
124124
var letStatements = new Dictionary<string, string>();
125-
var definedVariables = new HashSet<string>(); // Track all defined variables
126125
var lines = lqlCode.Split('\n');
127126

128-
// First pass: collect all let statements and defined variables
127+
// First pass: collect all let statements.
129128
foreach (var line in lines)
130129
{
131130
var trimmedLine = line.Trim();
@@ -143,9 +142,6 @@ public static Result<INode, SqlError> Parse(string lqlCode)
143142
var varName = parts[0][4..].Trim(); // Remove "let " prefix
144143
var expression = parts[1].Trim();
145144

146-
// Add to defined variables
147-
definedVariables.Add(varName);
148-
149145
// Extract the first identifier from the expression (before |>)
150146
var pipeIndex = expression.IndexOf("|>", StringComparison.Ordinal);
151147
if (pipeIndex > 0)
@@ -210,31 +206,10 @@ public static Result<INode, SqlError> Parse(string lqlCode)
210206
}
211207
}
212208

213-
// Check for undefined variables (identifiers with underscores that appear as pipeline bases)
214-
// BUT exclude variables that are defined in let statements
215-
if (trimmedLine.Contains("|>", StringComparison.Ordinal))
216-
{
217-
var pipeIndex = trimmedLine.IndexOf("|>", StringComparison.Ordinal);
218-
var beforePipe = trimmedLine[..pipeIndex].Trim();
219-
220-
// Check if the identifier before the pipe contains underscores (indicating it might be an undefined variable)
221-
// BUT only flag it as undefined if it's NOT in our definedVariables set
222-
if (
223-
beforePipe.Contains('_', StringComparison.Ordinal)
224-
&& !beforePipe.Contains('(', StringComparison.Ordinal)
225-
&& !beforePipe.Contains('.', StringComparison.Ordinal)
226-
&& beforePipe.All(c => char.IsLetterOrDigit(c) || c == '_')
227-
&& !definedVariables.Contains(beforePipe)
228-
) // Only flag if NOT defined in let statement
229-
{
230-
return SqlError.WithPosition(
231-
$"Syntax error: Undefined variable '{beforePipe}'",
232-
1,
233-
0,
234-
lqlCode
235-
);
236-
}
237-
}
209+
// Pipeline bases can be table names such as tenant_members. The
210+
// parser cannot distinguish those from variables without schema
211+
// metadata, so undefined-variable validation belongs in a later
212+
// semantic pass with table context.
238213
}
239214

240215
return null;

Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -174,21 +174,20 @@ public void InvalidFilterFunction_ShouldReturnError()
174174
}
175175

176176
[Fact]
177-
public void UndefinedVariable_ShouldReturnError()
177+
public void UnderscorePipelineBase_ShouldParseAsTableName()
178178
{
179179
// Arrange
180180
const string lqlCode = """
181-
undefined_variable |> select(id, name)
181+
tenant_members |> select(id, name)
182182
""";
183183

184184
// Act
185185
var result = LqlStatementConverter.ToStatement(lqlCode);
186186

187187
// Assert
188-
Assert.IsType<Result<LqlStatement, SqlError>.Error<LqlStatement, SqlError>>(result);
189-
var failure = (Result<LqlStatement, SqlError>.Error<LqlStatement, SqlError>)result;
190-
Assert.Contains("Syntax error", failure.Value.Message, StringComparison.Ordinal);
191-
Assert.NotNull(failure.Value.Position);
188+
Assert.IsType<Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>>(result);
189+
var success = (Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>)result;
190+
Assert.NotNull(success.Value);
192191
}
193192

194193
[Fact]
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using Outcome;
2+
using StringError = Outcome.Result<
3+
string,
4+
Nimblesite.DataProvider.Migration.Core.MigrationError
5+
>.Error<string, Nimblesite.DataProvider.Migration.Core.MigrationError>;
6+
using StringOk = Outcome.Result<string, Nimblesite.DataProvider.Migration.Core.MigrationError>.Ok<
7+
string,
8+
Nimblesite.DataProvider.Migration.Core.MigrationError
9+
>;
10+
11+
namespace Nimblesite.DataProvider.Migration.Core;
12+
13+
/// <summary>
14+
/// Transpiles LQL scalar expressions into PostgreSQL SQL-language function bodies.
15+
/// </summary>
16+
public static class LqlFunctionBodyTranspiler
17+
{
18+
/// <summary>
19+
/// Translates a PostgreSQL function <c>bodyLql</c> expression to a SQL function body.
20+
/// </summary>
21+
/// <param name="bodyLql">LQL scalar expression, optionally prefixed with <c>SELECT</c>.</param>
22+
/// <param name="functionName">Function name used in diagnostic messages.</param>
23+
/// <returns>A SQL-language function body beginning with <c>SELECT</c>.</returns>
24+
public static Result<string, MigrationError> TranslatePostgresBody(
25+
string bodyLql,
26+
string functionName
27+
)
28+
{
29+
var expression = StripSelectPrefix(StripTrailingSemicolon(bodyLql.Trim()));
30+
if (string.IsNullOrWhiteSpace(expression))
31+
{
32+
return new StringError(
33+
MigrationError.RlsLqlParse(functionName, "function bodyLql is empty")
34+
);
35+
}
36+
37+
var result = RlsPredicateTranspiler.Translate(
38+
expression,
39+
RlsPlatform.Postgres,
40+
functionName
41+
);
42+
return result switch
43+
{
44+
StringOk ok => new StringOk($"SELECT {ok.Value.Trim()}"),
45+
StringError err => new StringError(err.Value),
46+
};
47+
}
48+
49+
private static string StripTrailingSemicolon(string value) =>
50+
value.EndsWith(';') ? value[..^1].TrimEnd() : value;
51+
52+
private static string StripSelectPrefix(string value)
53+
{
54+
const string select = "select";
55+
if (!value.StartsWith(select, StringComparison.OrdinalIgnoreCase))
56+
{
57+
return value;
58+
}
59+
60+
return value.Length == select.Length || char.IsWhiteSpace(value[select.Length])
61+
? value[select.Length..].TrimStart()
62+
: value;
63+
}
64+
}

0 commit comments

Comments
 (0)