From 633641c3dc6730230d2cda54da109dbc7fd81dc8 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 19:11:12 +1100 Subject: [PATCH 1/2] DuckDB: support the ==, ** and ~~ operators and chained signs - `a == b` is `EqualsTo` with the `==` spelling. - `a ** b` is the new `Exponentiation`, above the multiplicative operators and left-associative, as in DuckDB. - `~~`, `~~*`, `~~~`, `!~~` and `!~~*` are `LIKE`, `ILIKE`, `GLOB` and their negations, kept as written. `a ~~ 'x'` used to parse as the regular expression match `a ~ (~'x')`. - `- -1`, `+-+1` and `~ ~a` nest signs. A sign is separated from an operand starting with the same sign so that `- -1` does not deparse as the comment `--1`, nor `~ ~a` as `~~a`. --- .../expression/ExpressionVisitor.java | 7 +++ .../expression/ExpressionVisitorAdapter.java | 6 ++ .../expression/SignedExpression.java | 15 ++++- .../operators/arithmetic/Exponentiation.java | 44 +++++++++++++ .../operators/relational/EqualsTo.java | 5 ++ .../operators/relational/LikeExpression.java | 35 +++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 7 +++ .../util/deparser/ExpressionDeParser.java | 24 ++++++- .../validator/ExpressionValidator.java | 7 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 62 +++++++++++++++---- .../statement/select/DuckDBTest.java | 57 +++++++++++++++++ 11 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/expression/operators/arithmetic/Exponentiation.java diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 69bc473850..450cef6849 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -19,6 +19,7 @@ import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor; import net.sf.jsqlparser.expression.operators.arithmetic.Concat; import net.sf.jsqlparser.expression.operators.arithmetic.Division; +import net.sf.jsqlparser.expression.operators.arithmetic.Exponentiation; import net.sf.jsqlparser.expression.operators.arithmetic.IntegerDivision; import net.sf.jsqlparser.expression.operators.arithmetic.Modulo; import net.sf.jsqlparser.expression.operators.arithmetic.Multiplication; @@ -271,6 +272,12 @@ default void visit(Division division) { T visit(IntegerDivision integerDivision, S context); + T visit(Exponentiation exponentiation, S context); + + default void visit(Exponentiation exponentiation) { + this.visit(exponentiation, null); + } + default void visit(IntegerDivision integerDivision) { this.visit(integerDivision, null); } diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java index 62ab36cae3..9eeb60e4c5 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java @@ -22,6 +22,7 @@ import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor; import net.sf.jsqlparser.expression.operators.arithmetic.Concat; import net.sf.jsqlparser.expression.operators.arithmetic.Division; +import net.sf.jsqlparser.expression.operators.arithmetic.Exponentiation; import net.sf.jsqlparser.expression.operators.arithmetic.IntegerDivision; import net.sf.jsqlparser.expression.operators.arithmetic.Modulo; import net.sf.jsqlparser.expression.operators.arithmetic.Multiplication; @@ -198,6 +199,11 @@ public T visit(IntegerDivision integerDivision, S context) { return visitBinaryExpression(integerDivision, context); } + @Override + public T visit(Exponentiation exponentiation, S context) { + return visitBinaryExpression(exponentiation, context); + } + @Override public T visit(Multiplication multiplication, S context) { return visitBinaryExpression(multiplication, context); diff --git a/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java b/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java index 725d449e89..7814b86b31 100644 --- a/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java @@ -54,7 +54,20 @@ public T accept(ExpressionVisitor expressionVisitor, S context) { @Override public String toString() { - return getSign() + expression.toString(); + String operand = expression.toString(); + return getSign() + (needsSpaceBefore(operand) ? " " : "") + operand; + } + + /** + * Whether the sign must be separated from an operand starting with {@code firstChar}: "- -1" + * must not become the line comment "--1", nor "~ ~a" the LIKE operator "~~". + */ + public boolean needsSpaceBefore(char firstChar) { + return (sign == '-' || sign == '~') && firstChar == sign; + } + + private boolean needsSpaceBefore(String operand) { + return !operand.isEmpty() && needsSpaceBefore(operand.charAt(0)); } public SignedExpression withSign(char sign) { diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/arithmetic/Exponentiation.java b/src/main/java/net/sf/jsqlparser/expression/operators/arithmetic/Exponentiation.java new file mode 100644 index 0000000000..321e014879 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/operators/arithmetic/Exponentiation.java @@ -0,0 +1,44 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression.operators.arithmetic; + +import net.sf.jsqlparser.expression.BinaryExpression; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitor; + +/** DuckDB's {@code a ** b}, {@code a} raised to the power {@code b}. */ +public class Exponentiation extends BinaryExpression { + + public Exponentiation() {} + + public Exponentiation(Expression leftExpression, Expression rightExpression) { + super(leftExpression, rightExpression); + } + + @Override + public T accept(ExpressionVisitor expressionVisitor, S context) { + return expressionVisitor.visit(this, context); + } + + @Override + public String getStringExpression() { + return "**"; + } + + @Override + public Exponentiation withLeftExpression(Expression arg0) { + return (Exponentiation) super.withLeftExpression(arg0); + } + + @Override + public Exponentiation withRightExpression(Expression arg0) { + return (Exponentiation) super.withRightExpression(arg0); + } +} diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/EqualsTo.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/EqualsTo.java index 8f80f1be88..19f238ffbb 100755 --- a/src/main/java/net/sf/jsqlparser/expression/operators/relational/EqualsTo.java +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/EqualsTo.java @@ -18,6 +18,11 @@ public EqualsTo() { super("="); } + /** {@code operator} is {@code =} or DuckDB's {@code ==}. */ + public EqualsTo(String operator) { + super(operator); + } + public EqualsTo(Expression left, Expression right) { this(); setLeftExpression(left); diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java index 92563d7d85..665cc9c356 100644 --- a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java @@ -18,6 +18,7 @@ public class LikeExpression extends BinaryExpression implements SupportsOldOracl private boolean not = false; private boolean useBinary = false; private Expression escapeExpression = null; + private String likeOperator = null; private KeyWord likeKeyWord = KeyWord.LIKE; public boolean isNot() { @@ -48,8 +49,42 @@ public String getStringExpression() { return likeKeyWord.toString(); } + /** DuckDB's and PostgreSQL's operator spelling, e.g. {@code !~~} for {@code NOT LIKE}. */ + public String getLikeOperator() { + return likeOperator; + } + + /** + * Sets the operator spelling and the matching keyword: {@code ~~} is {@code LIKE}, {@code ~~*} + * {@code ILIKE}, {@code ~~~} {@code GLOB}, and a leading {@code !} negates. + */ + public LikeExpression setLikeOperator(String likeOperator) { + boolean negated = likeOperator.startsWith("!"); + String operator = negated ? likeOperator.substring(1) : likeOperator; + switch (operator) { + case "~~": + likeKeyWord = KeyWord.LIKE; + break; + case "~~*": + likeKeyWord = KeyWord.ILIKE; + break; + case "~~~": + likeKeyWord = KeyWord.GLOB; + break; + default: + throw new IllegalArgumentException("Unknown LIKE operator: " + likeOperator); + } + this.not = negated; + this.likeOperator = likeOperator; + return this; + } + @Override public String toString() { + if (likeOperator != null) { + return getLeftExpression() + " " + likeOperator + " " + getRightExpression() + + (escapeExpression != null ? " ESCAPE " + escapeExpression : ""); + } String retval = (oraclePriorPosition == ORACLE_PRIOR_START ? "PRIOR " : "") + getLeftExpression() + " " + (not ? "NOT " : "") + (likeKeyWord == KeyWord.SIMILAR_TO ? "SIMILAR TO" : likeKeyWord) + " " diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 728301e046..486a937a4b 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -27,6 +27,7 @@ import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor; import net.sf.jsqlparser.expression.operators.arithmetic.Concat; import net.sf.jsqlparser.expression.operators.arithmetic.Division; +import net.sf.jsqlparser.expression.operators.arithmetic.Exponentiation; import net.sf.jsqlparser.expression.operators.arithmetic.IntegerDivision; import net.sf.jsqlparser.expression.operators.arithmetic.Modulo; import net.sf.jsqlparser.expression.operators.arithmetic.Multiplication; @@ -663,6 +664,12 @@ public Void visit(IntegerDivision division, S context) { return null; } + @Override + public Void visit(Exponentiation exponentiation, S context) { + visitBinaryExpression(exponentiation); + return null; + } + @Override public Void visit(DoubleValue doubleValue, S context) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index f7f4b187e9..2bd0a166f9 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -93,6 +93,7 @@ import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor; import net.sf.jsqlparser.expression.operators.arithmetic.Concat; import net.sf.jsqlparser.expression.operators.arithmetic.Division; +import net.sf.jsqlparser.expression.operators.arithmetic.Exponentiation; import net.sf.jsqlparser.expression.operators.arithmetic.IntegerDivision; import net.sf.jsqlparser.expression.operators.arithmetic.Modulo; import net.sf.jsqlparser.expression.operators.arithmetic.Multiplication; @@ -225,7 +226,7 @@ public StringBuilder visit(OverlapsCondition overlapsCondition, S context) { @Override public StringBuilder visit(EqualsTo equalsTo, S context) { - deparse(equalsTo, " = ", context); + deparse(equalsTo, " " + equalsTo.getStringExpression() + " ", context); return builder; } @@ -241,6 +242,12 @@ public StringBuilder visit(IntegerDivision division, S context) { return builder; } + @Override + public StringBuilder visit(Exponentiation exponentiation, S context) { + deparse(exponentiation, " ** ", context); + return builder; + } + @Override public StringBuilder visit(DoubleValue doubleValue, S context) { builder.append(doubleValue.toString()); @@ -425,7 +432,12 @@ public StringBuilder visit(FullTextSearch fullTextSearch, S context) { @Override public StringBuilder visit(SignedExpression signedExpression, S context) { builder.append(signedExpression.getSign()); + int operandStart = builder.length(); signedExpression.getExpression().accept(this, context); + if (builder.length() > operandStart + && signedExpression.needsSpaceBefore(builder.charAt(operandStart))) { + builder.insert(operandStart, ' '); + } return builder; } @@ -509,6 +521,16 @@ public StringBuilder visit(JdbcParameter jdbcParameter, S context) { @Override public StringBuilder visit(LikeExpression likeExpression, S context) { + if (likeExpression.getLikeOperator() != null) { + likeExpression.getLeftExpression().accept(this, context); + builder.append(" ").append(likeExpression.getLikeOperator()).append(" "); + likeExpression.getRightExpression().accept(this, context); + if (likeExpression.getEscape() != null) { + builder.append(" ESCAPE "); + likeExpression.getEscape().accept(this, context); + } + return builder; + } String keywordStr = likeExpression.getLikeKeyWord() == LikeExpression.KeyWord.SIMILAR_TO ? " SIMILAR TO" : likeExpression.getLikeKeyWord().toString(); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java index 2e20fd69a1..f42400ff67 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java @@ -90,6 +90,7 @@ import net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor; import net.sf.jsqlparser.expression.operators.arithmetic.Concat; import net.sf.jsqlparser.expression.operators.arithmetic.Division; +import net.sf.jsqlparser.expression.operators.arithmetic.Exponentiation; import net.sf.jsqlparser.expression.operators.arithmetic.IntegerDivision; import net.sf.jsqlparser.expression.operators.arithmetic.Modulo; import net.sf.jsqlparser.expression.operators.arithmetic.Multiplication; @@ -199,6 +200,12 @@ public Void visit(IntegerDivision division, S context) { return null; } + @Override + public Void visit(Exponentiation exponentiation, S context) { + visitBinaryExpression(exponentiation, " ** "); + return null; + } + @Override public Void visit(DoubleValue doubleValue, S context) { // nothing to validate diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 76b0e9c320..43b5838bb6 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -331,6 +331,12 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** DuckDB's and PostgreSQL's {@code ~~}, {@code ~~*}, {@code !~~}, {@code !~~*} and {@code ~~~}. */ + protected static boolean isLikeOperator(Token token) { + return token.image != null + && (token.image.startsWith("~~") || token.image.startsWith("!~~")); + } + protected static boolean isComparisonOperator(Token token) { if (token.image == null || token.image.isEmpty()) { return false; @@ -339,12 +345,13 @@ public class CCJSqlParser extends AbstractJSqlParser { case '>': // >, >= case '=': // =, =* but not => Oracle/PostgreSQL named parameter syntax return !token.image.equals("=>"); - case '~': // ~, ~* - return true; + case '~': // ~, ~* but not the ~~ LIKE operators + return !token.image.startsWith("~~"); case '<': // <, <=, <>, <@, <->, <#>, <=>, <& return true; case '*': return token.image.equals("*="); - case '!': return token.image.startsWith("!~") || token.image.startsWith("!="); + case '!': return (token.image.startsWith("!~") && !token.image.startsWith("!~~")) + || token.image.startsWith("!="); case '@': return token.image.equals("@@") || token.image.equals("@>"); case '?': return true; // ?, ?|, ?& case '-': return true; // -, -# @@ -681,6 +688,7 @@ public class CCJSqlParser extends AbstractJSqlParser { || (op == OP_CONCAT && Dialect.POSTGRESQL.name() .equals(getAsString(Feature.dialect)))) prec = 4; else if (op == OP_SLASH || op == OP_CARET || op == K_DIV) prec = 6; + else if ("**".equals(t.image)) prec = 7; else if (op == S_HASH_OPERATOR) prec = 5; else if (op == OP_CONCAT || op == OP_PIPE || op == OP_LSHIFT || op == OP_RSHIFT) prec = 5; @@ -721,6 +729,7 @@ public class CCJSqlParser extends AbstractJSqlParser { else if (op == S_HASH_OPERATOR) { Intersects r = new Intersects(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_CARET) { net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == K_DIV) { IntegerDivision r = new IntegerDivision(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } + else if ("**".equals(t.image)) { left = new Exponentiation(left, right); } else if (op == OP_CONCAT) { Concat r = new Concat(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_PIPE) { BitwiseOr r = new BitwiseOr(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_LSHIFT) { BitwiseLeftShift r = new BitwiseLeftShift(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } @@ -2324,6 +2333,7 @@ public class CCJSqlParser extends AbstractJSqlParser { */ protected boolean isConditionSuffixAhead() { Token t = getToken(1); + if (isLikeOperator(t)) return true; // Inline isComparisonOperatorAhead for single getToken(1) call if (t.kind == OPENING_BRACKET && getToken(2).image.equals("+")) { return isComparisonOperator(getToken(4)); @@ -10750,6 +10760,7 @@ Expression RegularConditionRHS(Expression leftExpression, int oracleJoinRight) # ">" { result = new GreaterThan(); } | "<" { result = new MinorThan(); } | "=" { result = new EqualsTo(); } + | "==" { result = new EqualsTo("=="); } | token= { result = new GreaterThanEquals(token.image); } | token= { result = new MinorThanEquals(token.image); } | token= { result = new NotEqualsTo(token.image); } @@ -10992,8 +11003,13 @@ Expression LikeExpression(Expression leftExpression) #LikeExpression: Token token; } { - [ { result.setNot(true); } ] ( + ( + token = "~~" | token = "~~*" | token = "~~~" | token = "!~~" | token = "!~~*" + ) { result.setLikeOperator(token.image); } + | + [ { result.setNot(true); } ] + ( token = | token = | token = @@ -11006,7 +11022,8 @@ Expression LikeExpression(Expression leftExpression) #LikeExpression: | token = | token = | token = - ) { result.setLikeKeyWord( LikeExpression.KeyWord.from(token.image)); } + ) { result.setLikeKeyWord( LikeExpression.KeyWord.from(token.image)); } + ) [ LOOKAHEAD(2) {result.setUseBinary(true); } ] rightExpression=SimpleExpression() [ @@ -11441,24 +11458,36 @@ Expression MulChain(): Expression right; } { - retval=PrimaryExpression() + retval=PowerChain() ( LOOKAHEAD(2) ( - "*" right=PrimaryExpression() {{ Multiplication r = new Multiplication(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + "*" right=PowerChain() {{ Multiplication r = new Multiplication(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PrimaryExpression() {{ Division r = new Division(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PowerChain() {{ Division r = new Division(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - "%" right=PrimaryExpression() {{ Modulo r = new Modulo(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + "%" right=PowerChain() {{ Modulo r = new Modulo(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PrimaryExpression() {{ net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PowerChain() {{ net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PrimaryExpression() {{ IntegerDivision r = new IntegerDivision(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PowerChain() {{ IntegerDivision r = new IntegerDivision(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} ) )* { return retval; } } +/** DuckDB's exponentiation, above the multiplicative operators */ +Expression PowerChain(): +{ + Expression retval; + Expression right; +} +{ + retval=PrimaryExpression() + ( LOOKAHEAD(2) "**" right=PrimaryExpression() { retval = new Exponentiation(retval, right); } )* + { return retval; } +} + /** Additive/concat/bitwise — lower precedence */ Expression AddChain(): { @@ -11585,6 +11614,8 @@ Expression PrimaryExpression() #PrimaryExpression: Expression timezoneRightExpr = null; Token token = null; Token sign = null; + Token innerSign; + List innerSigns = new ArrayList(); ColDataType type = null; boolean not = false; boolean exclamationMarkNot = false; @@ -11595,7 +11626,11 @@ Expression PrimaryExpression() #PrimaryExpression: } { [ { not=true; } | "!" { not=true; exclamationMarkNot=true; } ] - [sign="+" | sign="-" | sign="~"] + [ + ( sign="+" | sign="-" | sign="~" ) + // DuckDB's chained signs, e.g. - -1 or +-+1 + ( LOOKAHEAD(1) ( innerSign="+" | innerSign="-" | innerSign="~" ) { innerSigns.add(innerSign); } )* + ] ( { retval = new NullValue(); } @@ -11808,6 +11843,9 @@ Expression PrimaryExpression() #PrimaryExpression: timezoneExpr.setLeftExpression(retval); retval=timezoneExpr; } + for (int i = innerSigns.size() - 1; i >= 0; i--) { + retval = new SignedExpression(innerSigns.get(i).image.charAt(0), retval); + } if (sign != null) { retval = new SignedExpression(sign.image.charAt(0), retval); } 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 831606e76e..8ecff9b211 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -903,4 +903,61 @@ void testTableSampleWithoutOrWithReservoirMethod() throws JSQLParserException { .parse("SELECT a FROM t TABLESAMPLE 10 OFFSET 3"); Assertions.assertEquals("3", select.getOffset().getOffset().toString()); } + + @Test + void testDoubleEqualsOperator() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT a FROM t WHERE a == 1 AND b = 2", true); + Assertions.assertEquals("==", + ((net.sf.jsqlparser.expression.operators.relational.EqualsTo) CCJSqlParserUtil + .parseCondExpression("a == 1")).getStringExpression()); + } + + @Test + void testExponentiationOperator() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT 2 ** 3, 2 * 3 ** 2, -2 ** 2", true); + // above the multiplicative operators, left-associative, below the unary sign + Assertions.assertEquals("Multiplication(2, Exponentiation(3, 2))", + tree(CCJSqlParserUtil.parseExpression("2 * 3 ** 2"))); + Assertions.assertEquals("Exponentiation(Exponentiation(2, 3), 2)", + tree(CCJSqlParserUtil.parseExpression("2 ** 3 ** 2"))); + Assertions.assertEquals("Exponentiation(-2, 2)", + tree(CCJSqlParserUtil.parseExpression("-2 ** 2"))); + } + + @Test + void testLikeOperators() throws JSQLParserException { + for (String operator : new String[] {"~~", "~~*", "~~~", "!~~", "!~~*"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT a FROM t WHERE a " + operator + " 'x%'", true); + } + net.sf.jsqlparser.expression.operators.relational.LikeExpression like = + (net.sf.jsqlparser.expression.operators.relational.LikeExpression) CCJSqlParserUtil + .parseCondExpression("a !~~* 'x%'"); + Assertions.assertEquals( + net.sf.jsqlparser.expression.operators.relational.LikeExpression.KeyWord.ILIKE, + like.getLikeKeyWord()); + Assertions.assertTrue(like.isNot()); + // the regular expression operators are unchanged + Assertions.assertInstanceOf( + net.sf.jsqlparser.expression.operators.relational.RegExpMatchOperator.class, + CCJSqlParserUtil.parseCondExpression("a !~* 'x'")); + } + + @Test + void testChainedSigns() throws JSQLParserException { + TestUtils.assertSqlCanBeParsedAndDeparsed( + "SELECT - -1, +-+1, ++-++-+i, ~ ~a, x - -1 FROM t", + true); + Assertions.assertEquals("- - -a", CCJSqlParserUtil.parseExpression("- - -a").toString()); + } + + private static String tree(net.sf.jsqlparser.expression.Expression expression) { + if (expression instanceof net.sf.jsqlparser.expression.BinaryExpression) { + net.sf.jsqlparser.expression.BinaryExpression binary = + (net.sf.jsqlparser.expression.BinaryExpression) expression; + return expression.getClass().getSimpleName() + "(" + tree(binary.getLeftExpression()) + + ", " + tree(binary.getRightExpression()) + ")"; + } + return expression.toString(); + } } From 8523ca9b071bf48c846aa4052d63bd441831a254 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 19:32:56 +1100 Subject: [PATCH 2/2] Keep the operator spelling of LIKE in step with the model - The `~~` spelling is now derived from the keyword and `NOT`, so `setNot` or `setLikeKeyWord` no longer leave a stale operator, and `PRIOR` and `BINARY` are printed with it. `!~~~` and `null` are rejected. - A sign is also separated from an operand when either is `~`: DuckDB and PostgreSQL read `-~1` as one unknown operator. - `**` is the `OP_POWER` token; the `MulChain` alternative was never reached. - `visit(Exponentiation)` is a default method, so existing `ExpressionVisitor` implementations keep compiling. --- .../expression/ExpressionVisitor.java | 5 +- .../expression/SignedExpression.java | 7 ++- .../operators/relational/LikeExpression.java | 52 +++++++++++++------ .../util/deparser/ExpressionDeParser.java | 20 +++---- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 29 ++++------- .../statement/select/DuckDBTest.java | 26 ++++++++++ 6 files changed, 86 insertions(+), 53 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 450cef6849..6a4fca3acf 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -272,7 +272,10 @@ default void visit(Division division) { T visit(IntegerDivision integerDivision, S context); - T visit(Exponentiation exponentiation, S context); + default T visit(Exponentiation exponentiation, S context) { + visitExpression(exponentiation.getLeftExpression(), context); + return visitExpression(exponentiation.getRightExpression(), context); + } default void visit(Exponentiation exponentiation) { this.visit(exponentiation, null); diff --git a/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java b/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java index 7814b86b31..43c92e29b3 100644 --- a/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/SignedExpression.java @@ -60,10 +60,13 @@ public String toString() { /** * Whether the sign must be separated from an operand starting with {@code firstChar}: "- -1" - * must not become the line comment "--1", nor "~ ~a" the LIKE operator "~~". + * must not become the line comment "--1", nor "~ -a" or "~ ~a" a single operator such as "~-" + * or the LIKE operator "~~", as DuckDB and PostgreSQL read a run of signs containing {@code ~}. */ public boolean needsSpaceBefore(char firstChar) { - return (sign == '-' || sign == '~') && firstChar == sign; + boolean signedOperand = firstChar == '+' || firstChar == '-' || firstChar == '~'; + return sign == '-' && firstChar == '-' || sign == '~' && signedOperand + || firstChar == '~' && (sign == '+' || sign == '-'); } private boolean needsSpaceBefore(String operand) { diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java index 665cc9c356..64d610fa12 100644 --- a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java @@ -18,7 +18,7 @@ public class LikeExpression extends BinaryExpression implements SupportsOldOracl private boolean not = false; private boolean useBinary = false; private Expression escapeExpression = null; - private String likeOperator = null; + private boolean useOperator = false; private KeyWord likeKeyWord = KeyWord.LIKE; public boolean isNot() { @@ -49,23 +49,42 @@ public String getStringExpression() { return likeKeyWord.toString(); } - /** DuckDB's and PostgreSQL's operator spelling, e.g. {@code !~~} for {@code NOT LIKE}. */ + /** + * The operator spelling of this condition when it was written as one and has one, e.g. + * {@code !~~} for {@code NOT LIKE}; otherwise {@code null} and the keyword form is used. + */ public String getLikeOperator() { - return likeOperator; + if (!useOperator) { + return null; + } + switch (likeKeyWord) { + case LIKE: + return not ? "!~~" : "~~"; + case ILIKE: + return not ? "!~~*" : "~~*"; + case GLOB: + return not ? null : "~~~"; + default: + return null; + } } /** - * Sets the operator spelling and the matching keyword: {@code ~~} is {@code LIKE}, {@code ~~*} - * {@code ILIKE}, {@code ~~~} {@code GLOB}, and a leading {@code !} negates. + * Sets the keyword and {@code NOT} from DuckDB's and PostgreSQL's operator spelling: {@code ~~} + * is {@code LIKE}, {@code ~~*} {@code ILIKE}, {@code ~~~} {@code GLOB}, and a leading {@code !} + * negates the first two. The operator is printed while it matches the keyword and {@code NOT}. */ public LikeExpression setLikeOperator(String likeOperator) { - boolean negated = likeOperator.startsWith("!"); - String operator = negated ? likeOperator.substring(1) : likeOperator; - switch (operator) { + if (likeOperator == null) { + throw new IllegalArgumentException("The LIKE operator must not be null"); + } + switch (likeOperator) { case "~~": + case "!~~": likeKeyWord = KeyWord.LIKE; break; case "~~*": + case "!~~*": likeKeyWord = KeyWord.ILIKE; break; case "~~~": @@ -74,21 +93,20 @@ public LikeExpression setLikeOperator(String likeOperator) { default: throw new IllegalArgumentException("Unknown LIKE operator: " + likeOperator); } - this.not = negated; - this.likeOperator = likeOperator; + this.not = likeOperator.startsWith("!"); + this.useOperator = true; return this; } @Override public String toString() { - if (likeOperator != null) { - return getLeftExpression() + " " + likeOperator + " " + getRightExpression() - + (escapeExpression != null ? " ESCAPE " + escapeExpression : ""); - } + String likeOperator = getLikeOperator(); String retval = (oraclePriorPosition == ORACLE_PRIOR_START ? "PRIOR " : "") - + getLeftExpression() + " " + (not ? "NOT " : "") - + (likeKeyWord == KeyWord.SIMILAR_TO ? "SIMILAR TO" : likeKeyWord) + " " - + (useBinary ? "BINARY " : "") + getRightExpression(); + + getLeftExpression() + " " + + (likeOperator != null ? likeOperator + : (not ? "NOT " : "") + + (likeKeyWord == KeyWord.SIMILAR_TO ? "SIMILAR TO" : likeKeyWord)) + + " " + (useBinary ? "BINARY " : "") + getRightExpression(); if (escapeExpression != null) { retval += " ESCAPE " + escapeExpression; } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index 2bd0a166f9..072b584eb4 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -521,16 +521,6 @@ public StringBuilder visit(JdbcParameter jdbcParameter, S context) { @Override public StringBuilder visit(LikeExpression likeExpression, S context) { - if (likeExpression.getLikeOperator() != null) { - likeExpression.getLeftExpression().accept(this, context); - builder.append(" ").append(likeExpression.getLikeOperator()).append(" "); - likeExpression.getRightExpression().accept(this, context); - if (likeExpression.getEscape() != null) { - builder.append(" ESCAPE "); - likeExpression.getEscape().accept(this, context); - } - return builder; - } String keywordStr = likeExpression.getLikeKeyWord() == LikeExpression.KeyWord.SIMILAR_TO ? " SIMILAR TO" : likeExpression.getLikeKeyWord().toString(); @@ -541,10 +531,14 @@ public StringBuilder visit(LikeExpression likeExpression, S context) { } likeExpression.getLeftExpression().accept(this, context); builder.append(" "); - if (likeExpression.isNot()) { - builder.append("NOT "); + if (likeExpression.getLikeOperator() != null) { + builder.append(likeExpression.getLikeOperator()).append(" "); + } else { + if (likeExpression.isNot()) { + builder.append("NOT "); + } + builder.append(keywordStr).append(" "); } - builder.append(keywordStr).append(" "); if (likeExpression.isUseBinary()) { builder.append("BINARY "); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 43b5838bb6..6a8062a318 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -688,7 +688,7 @@ public class CCJSqlParser extends AbstractJSqlParser { || (op == OP_CONCAT && Dialect.POSTGRESQL.name() .equals(getAsString(Feature.dialect)))) prec = 4; else if (op == OP_SLASH || op == OP_CARET || op == K_DIV) prec = 6; - else if ("**".equals(t.image)) prec = 7; + else if (op == OP_POWER) prec = 7; else if (op == S_HASH_OPERATOR) prec = 5; else if (op == OP_CONCAT || op == OP_PIPE || op == OP_LSHIFT || op == OP_RSHIFT) prec = 5; @@ -729,7 +729,7 @@ public class CCJSqlParser extends AbstractJSqlParser { else if (op == S_HASH_OPERATOR) { Intersects r = new Intersects(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_CARET) { net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == K_DIV) { IntegerDivision r = new IntegerDivision(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } - else if ("**".equals(t.image)) { left = new Exponentiation(left, right); } + else if (op == OP_POWER) { left = new Exponentiation(left, right); } else if (op == OP_CONCAT) { Concat r = new Concat(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_PIPE) { BitwiseOr r = new BitwiseOr(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } else if (op == OP_LSHIFT) { BitwiseLeftShift r = new BitwiseLeftShift(); r.setLeftExpression(left); r.setRightExpression(right); left = r; } @@ -3181,6 +3181,7 @@ TOKEN : /* Operators */ | | | +| | | | >"> @@ -11458,36 +11459,24 @@ Expression MulChain(): Expression right; } { - retval=PowerChain() + retval=PrimaryExpression() ( LOOKAHEAD(2) ( - "*" right=PowerChain() {{ Multiplication r = new Multiplication(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + "*" right=PrimaryExpression() {{ Multiplication r = new Multiplication(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PowerChain() {{ Division r = new Division(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PrimaryExpression() {{ Division r = new Division(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - "%" right=PowerChain() {{ Modulo r = new Modulo(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + "%" right=PrimaryExpression() {{ Modulo r = new Modulo(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PowerChain() {{ net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PrimaryExpression() {{ net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor r = new net.sf.jsqlparser.expression.operators.arithmetic.BitwiseXor(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} | - right=PowerChain() {{ IntegerDivision r = new IntegerDivision(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} + right=PrimaryExpression() {{ IntegerDivision r = new IntegerDivision(); r.setLeftExpression(retval); r.setRightExpression(right); retval = r; }} ) )* { return retval; } } -/** DuckDB's exponentiation, above the multiplicative operators */ -Expression PowerChain(): -{ - Expression retval; - Expression right; -} -{ - retval=PrimaryExpression() - ( LOOKAHEAD(2) "**" right=PrimaryExpression() { retval = new Exponentiation(retval, right); } )* - { return retval; } -} - /** Additive/concat/bitwise — lower precedence */ Expression AddChain(): { 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 8ecff9b211..726528dce2 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -951,6 +951,32 @@ void testChainedSigns() throws JSQLParserException { Assertions.assertEquals("- - -a", CCJSqlParserUtil.parseExpression("- - -a").toString()); } + @Test + void testLikeOperatorFollowsTheModel() throws JSQLParserException { + net.sf.jsqlparser.expression.operators.relational.LikeExpression like = + (net.sf.jsqlparser.expression.operators.relational.LikeExpression) CCJSqlParserUtil + .parseCondExpression("a ~~ 'x'"); + like.setNot(true); + Assertions.assertEquals("a !~~ 'x'", like.toString()); + like.setLikeKeyWord( + net.sf.jsqlparser.expression.operators.relational.LikeExpression.KeyWord.SIMILAR_TO); + Assertions.assertEquals("a NOT SIMILAR TO 'x'", like.toString()); + Assertions.assertThrows(IllegalArgumentException.class, () -> like.setLikeOperator("!~~~")); + Assertions.assertThrows(IllegalArgumentException.class, () -> like.setLikeOperator(null)); + } + + @Test + void testMixedSignsStaySeparate() throws JSQLParserException { + for (String expression : new String[] {"- ~1", "~ -1", "~ - ~a", "- ~ -a", "+ ~a"}) { + String deparsed = CCJSqlParserUtil.parseExpression(expression).toString(); + Assertions.assertEquals(expression.replace(" ", ""), + deparsed.replace(" ", "")); + Assertions.assertFalse(deparsed.contains("-~") || deparsed.contains("~-") + || deparsed.contains("~~") || deparsed.contains("+~"), deparsed); + } + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT - ~1, ~ -1, -+1", true); + } + private static String tree(net.sf.jsqlparser.expression.Expression expression) { if (expression instanceof net.sf.jsqlparser.expression.BinaryExpression) { net.sf.jsqlparser.expression.BinaryExpression binary =