From e86fa61567b9ad520955968f2e282d0a8048abad Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 10:28:05 +1100 Subject: [PATCH 1/2] Support DuckDB's `lambda x, y : expr` syntax DuckDB's recommended lambda syntax, `lambda x, y : expr`, had no rule: `lambda x : x + 1` only parsed by accident, as a column named `lambda` followed by a JSON path, and a condition body (`lambda x : x > 0`, `lambda x : x IS NOT NULL`) or several parameters (`lambda acc, x : acc + x`) were rejected. These are the common forms in `list_filter` and `list_reduce`. `DuckDBLambdaExpression()` parses it into a `LambdaExpression` flagged with `isLambdaKeyword()`, which deparses it as written. `LAMBDA` is a non-reserved keyword, so it remains usable as a name. --- .../expression/LambdaExpression.java | 16 ++++++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 27 +++++++++++++ .../statement/select/DuckDBTest.java | 40 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/src/main/java/net/sf/jsqlparser/expression/LambdaExpression.java b/src/main/java/net/sf/jsqlparser/expression/LambdaExpression.java index 397a4a432..2b992173f 100644 --- a/src/main/java/net/sf/jsqlparser/expression/LambdaExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/LambdaExpression.java @@ -22,6 +22,7 @@ public class LambdaExpression extends ASTNodeAccessImpl implements Expression { private List identifiers; private Expression expression; private boolean parenthesized; + private boolean lambdaKeyword; public LambdaExpression(String identifier, Expression expression) { this.identifiers = Collections.singletonList(identifier); @@ -53,6 +54,16 @@ public LambdaExpression setParenthesized(boolean parenthesized) { return this; } + /** Whether it was written with DuckDB's lambda keyword: {@code lambda x, y : expr}. */ + public boolean isLambdaKeyword() { + return lambdaKeyword; + } + + public LambdaExpression setLambdaKeyword(boolean lambdaKeyword) { + this.lambdaKeyword = lambdaKeyword; + return this; + } + public List getIdentifiers() { return identifiers; } @@ -76,6 +87,11 @@ public StringBuilder appendTo(StringBuilder builder) { } public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + if (lambdaKeyword) { + builder.append("lambda ").append(String.join(", ", identifiers)).append(" : "); + expressionPrinter.accept(expression); + return builder; + } if (identifiers.size() == 1 && !parenthesized) { builder.append(identifiers.get(0)); } else { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 50d98ed83..c5b42782a 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2601,6 +2601,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -11430,6 +11431,10 @@ Expression PrimaryExpression() #PrimaryExpression: | LOOKAHEAD({ !interrupted && isCharacterSetIntroducedStringAhead() }) retval=CharacterSetIntroducedString() + // DuckDB's lambda x, y : expr, before Column() which would take LAMBDA as a column name + | LOOKAHEAD( RelObjectName() ( "," RelObjectName() )* ":", {!interrupted} ) + retval=DuckDBLambdaExpression() + | LOOKAHEAD(2, {!interrupted}) retval=Column() [ LOOKAHEAD( "(" "+" ")" ("," | ")") ) @@ -13263,6 +13268,28 @@ LambdaExpression LambdaExpression() #LambdaExpression: } } +/** + * DuckDB's lambda syntax, lambda x, y : expr, equivalent to (x, y) -> expr. + */ +LambdaExpression DuckDBLambdaExpression() #LambdaExpression: +{ + List identifiers = new ArrayList(); + String s; + Expression expression; + LambdaExpression lambdaExpression; +} +{ + s = RelObjectName() { identifiers.add(s); } + ( "," s = RelObjectName() { identifiers.add(s); } )* + ":" + expression = Expression() + { + lambdaExpression = new LambdaExpression(identifiers, expression).setLambdaKeyword(true); + linkAST(lambdaExpression,jjtThis); + return lambdaExpression; + } +} + RowPatternFunction RowPatternFunction() #RowPatternFunction: { RowPatternFunction.EvaluationMode mode; diff --git a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java index 68e98f101..4e0c65caa 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -10,6 +10,8 @@ package net.sf.jsqlparser.statement.select; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.LambdaExpression; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.insert.Insert; @@ -30,6 +32,8 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.Arrays; + public class DuckDBTest { @Test @@ -689,4 +693,40 @@ void testRecursiveCteWithoutUsingKey() throws JSQLParserException { Assertions.assertNull(select.getWithItemsList().get(0).getUsingKeyExpressions()); } + + @Test + void testLambdaKeyword() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_transform(l, lambda x : x + 1) FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT list_filter(l, lambda x : x > 0) FROM t", + true); + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_filter(l, lambda x : x IS NOT NULL AND x <> 'a') FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_reduce(l, lambda acc, x : acc + x) FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_filter([(CASE WHEN a THEN 1 END)], lambda x : x IS NOT NULL) FROM t", + true); + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_transform(l, lambda x : list_filter(x, lambda y : y > 1)) FROM t", + true); + } + + @Test + void testLambdaKeywordExpression() throws JSQLParserException { + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT list_reduce(l, lambda acc, x : acc + x) FROM t", true); + Function function = select.getSelectItem(0).getExpression(Function.class); + LambdaExpression lambda = (LambdaExpression) function.getParameters().get(1); + + Assertions.assertTrue(lambda.isLambdaKeyword()); + Assertions.assertEquals(Arrays.asList("acc", "x"), lambda.getIdentifiers()); + } + + @Test + void testLambdaAsIdentifier() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT lambda FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT lambda x, y FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT t.lambda FROM lambda t", true); + } } From f574d965edfc3f1e8361a4e1819f638796f0266b Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 11:36:24 +1100 Subject: [PATCH 2/2] Keep `lambda` a plain identifier outside DuckDB lambdas As a keyword token, LAMBDA stopped parsing where only plain identifiers are accepted: `CAST(a AS lambda)`, `COLLATE lambda`, `CREATE TABLE t (a lambda)`, `ALTER TABLE t DROP COLUMN lambda`, `ALTER TABLE t RENAME lambda TO x`. `isDuckDBLambdaAhead()` recognises `lambda name [, name ...] :` contextually, as `isKeywordAhead()` does for other words, so no token is needed. --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 33 ++++++++++++++++--- .../statement/select/DuckDBTest.java | 16 +++++++++ 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c5b42782a..1c4810ccd 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1599,6 +1599,33 @@ public class CCJSqlParser extends AbstractJSqlParser { catch (IllegalArgumentException exception) { throw new ParseException("Unknown " + type.getSimpleName() + ": " + value); } } + /** + * DuckDB's lambda x, y : expr. LAMBDA stays a plain identifier, so a column, type or alias + * named lambda keeps parsing: only LAMBDA followed by names separated by commas and a ":" is + * a lambda. + */ + private boolean isDuckDBLambdaAhead() { + if (getToken(1).kind != S_IDENTIFIER || !isKeywordAhead("LAMBDA")) { + return false; + } + int offset = 2; + while (true) { + int kind = getToken(offset).kind; + if (kind != S_IDENTIFIER && kind != S_QUOTED_IDENTIFIER && kind != DATA_TYPE + && (kind < MIN_NON_RESERVED_WORD || kind > MAX_NON_RESERVED_WORD)) { + return false; + } + String next = getToken(offset + 1).image; + if (":".equals(next)) { + return true; + } + if (!",".equals(next)) { + return false; + } + offset += 2; + } + } + private boolean isKeywordAhead(String keyword) { Token token = getToken(1); return token.kind != EOF && token.image != null && keyword.equalsIgnoreCase(token.image); @@ -2601,7 +2628,6 @@ String NonReservedWord() : | tk= | tk= | tk= - | tk= | tk= | tk= | tk= @@ -11432,8 +11458,7 @@ Expression PrimaryExpression() #PrimaryExpression: retval=CharacterSetIntroducedString() // DuckDB's lambda x, y : expr, before Column() which would take LAMBDA as a column name - | LOOKAHEAD( RelObjectName() ( "," RelObjectName() )* ":", {!interrupted} ) - retval=DuckDBLambdaExpression() + | LOOKAHEAD({ !interrupted && isDuckDBLambdaAhead() }) retval=DuckDBLambdaExpression() | LOOKAHEAD(2, {!interrupted}) retval=Column() [ @@ -13279,7 +13304,7 @@ LambdaExpression DuckDBLambdaExpression() #LambdaExpression: LambdaExpression lambdaExpression; } { - s = RelObjectName() { identifiers.add(s); } + s = RelObjectName() { identifiers.add(s); } ( "," s = RelObjectName() { identifiers.add(s); } )* ":" expression = Expression() diff --git a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java index 4e0c65caa..a18c26665 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -723,10 +723,26 @@ void testLambdaKeywordExpression() throws JSQLParserException { Assertions.assertEquals(Arrays.asList("acc", "x"), lambda.getIdentifiers()); } + @Test + void testLambdaKeywordFollowedByArguments() throws JSQLParserException { + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT f(l, lambda x : x + 1, 2) FROM t", true); + Function function = select.getSelectItem(0).getExpression(Function.class); + + Assertions.assertEquals(3, function.getParameters().size()); + Assertions.assertEquals("x + 1", + ((LambdaExpression) function.getParameters().get(1)).getExpression().toString()); + } + @Test void testLambdaAsIdentifier() throws JSQLParserException { TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT lambda FROM t", true); TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT lambda x, y FROM t", true); TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT t.lambda FROM lambda t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT CAST(a AS lambda) FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT 'a' COLLATE lambda FROM t", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("CREATE TABLE t (a lambda)", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("ALTER TABLE t DROP COLUMN lambda", true); + TestUtils.assertSqlCanBeParsedAndDeparsed("ALTER TABLE t RENAME lambda TO x", true); } }