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
35 changes: 5 additions & 30 deletions Lql/Nimblesite.Lql.Core/Parsing/LqlCodeParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,9 @@ public static Result<INode, SqlError> Parse(string lqlCode)

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

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

// Add to defined variables
definedVariables.Add(varName);

// Extract the first identifier from the expression (before |>)
var pipeIndex = expression.IndexOf("|>", StringComparison.Ordinal);
if (pipeIndex > 0)
Expand Down Expand Up @@ -210,31 +206,10 @@ public static Result<INode, SqlError> Parse(string lqlCode)
}
}

// Check for undefined variables (identifiers with underscores that appear as pipeline bases)
// BUT exclude variables that are defined in let statements
if (trimmedLine.Contains("|>", StringComparison.Ordinal))
{
var pipeIndex = trimmedLine.IndexOf("|>", StringComparison.Ordinal);
var beforePipe = trimmedLine[..pipeIndex].Trim();

// Check if the identifier before the pipe contains underscores (indicating it might be an undefined variable)
// BUT only flag it as undefined if it's NOT in our definedVariables set
if (
beforePipe.Contains('_', StringComparison.Ordinal)
&& !beforePipe.Contains('(', StringComparison.Ordinal)
&& !beforePipe.Contains('.', StringComparison.Ordinal)
&& beforePipe.All(c => char.IsLetterOrDigit(c) || c == '_')
&& !definedVariables.Contains(beforePipe)
) // Only flag if NOT defined in let statement
{
return SqlError.WithPosition(
$"Syntax error: Undefined variable '{beforePipe}'",
1,
0,
lqlCode
);
}
}
// Pipeline bases can be table names such as tenant_members. The
// parser cannot distinguish those from variables without schema
// metadata, so undefined-variable validation belongs in a later
// semantic pass with table context.
}

return null;
Expand Down
11 changes: 5 additions & 6 deletions Lql/Nimblesite.Lql.Tests/LqlErrorHandlingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -174,21 +174,20 @@ public void InvalidFilterFunction_ShouldReturnError()
}

[Fact]
public void UndefinedVariable_ShouldReturnError()
public void UnderscorePipelineBase_ShouldParseAsTableName()
{
// Arrange
const string lqlCode = """
undefined_variable |> select(id, name)
tenant_members |> select(id, name)
""";

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

// Assert
Assert.IsType<Result<LqlStatement, SqlError>.Error<LqlStatement, SqlError>>(result);
var failure = (Result<LqlStatement, SqlError>.Error<LqlStatement, SqlError>)result;
Assert.Contains("Syntax error", failure.Value.Message, StringComparison.Ordinal);
Assert.NotNull(failure.Value.Position);
Assert.IsType<Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>>(result);
var success = (Result<LqlStatement, SqlError>.Ok<LqlStatement, SqlError>)result;
Assert.NotNull(success.Value);
}

[Fact]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
using Outcome;
using StringError = Outcome.Result<
string,
Nimblesite.DataProvider.Migration.Core.MigrationError
>.Error<string, Nimblesite.DataProvider.Migration.Core.MigrationError>;
using StringOk = Outcome.Result<string, Nimblesite.DataProvider.Migration.Core.MigrationError>.Ok<
string,
Nimblesite.DataProvider.Migration.Core.MigrationError
>;

namespace Nimblesite.DataProvider.Migration.Core;

/// <summary>
/// Transpiles LQL scalar expressions into PostgreSQL SQL-language function bodies.
/// </summary>
public static class LqlFunctionBodyTranspiler
{
/// <summary>
/// Translates a PostgreSQL function <c>bodyLql</c> expression to a SQL function body.
/// </summary>
/// <param name="bodyLql">LQL scalar expression, optionally prefixed with <c>SELECT</c>.</param>
/// <param name="functionName">Function name used in diagnostic messages.</param>
/// <returns>A SQL-language function body beginning with <c>SELECT</c>.</returns>
public static Result<string, MigrationError> TranslatePostgresBody(
string bodyLql,
string functionName
)
{
var expression = StripSelectPrefix(StripTrailingSemicolon(bodyLql.Trim()));
if (string.IsNullOrWhiteSpace(expression))
{
return new StringError(
MigrationError.RlsLqlParse(functionName, "function bodyLql is empty")
);
}

var result = RlsPredicateTranspiler.Translate(
expression,
RlsPlatform.Postgres,
functionName
);
return result switch
{
StringOk ok => new StringOk($"SELECT {ok.Value.Trim()}"),
StringError err => new StringError(err.Value),
};
}

private static string StripTrailingSemicolon(string value) =>
value.EndsWith(';') ? value[..^1].TrimEnd() : value;

private static string StripSelectPrefix(string value)
{
const string select = "select";
if (!value.StartsWith(select, StringComparison.OrdinalIgnoreCase))
{
return value;
}

return value.Length == select.Length || char.IsWhiteSpace(value[select.Length])
? value[select.Length..].TrimStart()
: value;
}
}
Loading
Loading