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..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); @@ -11430,6 +11457,9 @@ 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({ !interrupted && isDuckDBLambdaAhead() }) retval=DuckDBLambdaExpression() + | LOOKAHEAD(2, {!interrupted}) retval=Column() [ LOOKAHEAD( "(" "+" ")" ("," | ")") ) @@ -13263,6 +13293,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..a18c26665 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,56 @@ 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 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); + } }