diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java index 08afe5129..d07d9179a 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java @@ -737,6 +737,12 @@ default void visit(RangeExpression rangeExpression) { this.visit(rangeExpression, null); } + T visit(TernaryExpression ternaryExpression, S context); + + default void visit(TernaryExpression ternaryExpression) { + this.visit(ternaryExpression, null); + } + T visit(TSQLLeftJoin tsqlLeftJoin, S context); default void visit(TSQLLeftJoin tsqlLeftJoin) { diff --git a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java index 432c16354..4727e93e8 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java @@ -822,6 +822,12 @@ public T visit(RangeExpression rangeExpression, S context) { rangeExpression.getEndExpression()); } + @Override + public T visit(TernaryExpression ternaryExpression, S context) { + return visitExpressions(ternaryExpression, context, ternaryExpression.getCondition(), + ternaryExpression.getThenExpression(), ternaryExpression.getElseExpression()); + } + @Override public T visit(TSQLLeftJoin tsqlLeftJoin, S context) { return visitBinaryExpression(tsqlLeftJoin, context); diff --git a/src/main/java/net/sf/jsqlparser/expression/TernaryExpression.java b/src/main/java/net/sf/jsqlparser/expression/TernaryExpression.java new file mode 100644 index 000000000..3d67d4ea3 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/expression/TernaryExpression.java @@ -0,0 +1,68 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2025 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.expression; + +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The C-style ternary conditional operator {@code condition ? thenExpression : elseExpression}, + * supported for instance by ClickHouse as an alias for {@code if(condition, then, else)}. + */ +public class TernaryExpression extends ASTNodeAccessImpl implements Expression { + private Expression condition; + private Expression thenExpression; + private Expression elseExpression; + + public TernaryExpression() {} + + public TernaryExpression(Expression condition, Expression thenExpression, + Expression elseExpression) { + this.condition = condition; + this.thenExpression = thenExpression; + this.elseExpression = elseExpression; + } + + public Expression getCondition() { + return condition; + } + + public TernaryExpression setCondition(Expression condition) { + this.condition = condition; + return this; + } + + public Expression getThenExpression() { + return thenExpression; + } + + public TernaryExpression setThenExpression(Expression thenExpression) { + this.thenExpression = thenExpression; + return this; + } + + public Expression getElseExpression() { + return elseExpression; + } + + public TernaryExpression setElseExpression(Expression elseExpression) { + this.elseExpression = elseExpression; + return this; + } + + @Override + public String toString() { + return condition + " ? " + thenExpression + " : " + elseExpression; + } + + @Override + public T accept(ExpressionVisitor expressionVisitor, S context) { + return expressionVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 4b643742a..99ee4eacc 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -249,6 +249,14 @@ public Void visit(RangeExpression rangeExpression, S context) { return null; } + @Override + public Void visit(TernaryExpression ternaryExpression, S context) { + ternaryExpression.getCondition().accept(this, context); + ternaryExpression.getThenExpression().accept(this, context); + ternaryExpression.getElseExpression().accept(this, context); + return null; + } + /** * Main entry for this Tool class. A list of found tables is returned. */ 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 e50ccad6a..2767f5e23 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -62,6 +62,7 @@ import net.sf.jsqlparser.expression.OverlapsCondition; import net.sf.jsqlparser.expression.PostgresNamedFunctionParameter; import net.sf.jsqlparser.expression.RangeExpression; +import net.sf.jsqlparser.expression.TernaryExpression; import net.sf.jsqlparser.expression.RowConstructor; import net.sf.jsqlparser.expression.RowGetExpression; import net.sf.jsqlparser.expression.SignedExpression; @@ -818,6 +819,16 @@ public StringBuilder visit(RangeExpression rangeExpression, S context) { return builder; } + @Override + public StringBuilder visit(TernaryExpression ternaryExpression, S context) { + ternaryExpression.getCondition().accept(this, context); + builder.append(" ? "); + ternaryExpression.getThenExpression().accept(this, context); + builder.append(" : "); + ternaryExpression.getElseExpression().accept(this, context); + return builder; + } + @Override public StringBuilder visit(Column tableColumn, S context) { final Table table = tableColumn.getTable(); @@ -1045,6 +1056,10 @@ public void visit(RangeExpression rangeExpression) { visit(rangeExpression, null); } + public void visit(TernaryExpression ternaryExpression) { + visit(ternaryExpression, null); + } + public void visit(Column tableColumn) { visit(tableColumn, null); } 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 a18a21ab7..128fc7d92 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 @@ -56,6 +56,7 @@ import net.sf.jsqlparser.expression.OverlapsCondition; import net.sf.jsqlparser.expression.PostgresNamedFunctionParameter; import net.sf.jsqlparser.expression.RangeExpression; +import net.sf.jsqlparser.expression.TernaryExpression; import net.sf.jsqlparser.expression.RowConstructor; import net.sf.jsqlparser.expression.RowGetExpression; import net.sf.jsqlparser.expression.SignedExpression; @@ -1153,6 +1154,14 @@ public Void visit(RangeExpression rangeExpression, S context) { return null; } + @Override + public Void visit(TernaryExpression ternaryExpression, S context) { + ternaryExpression.getCondition().accept(this, context); + ternaryExpression.getThenExpression().accept(this, context); + ternaryExpression.getElseExpression().accept(this, context); + return null; + } + @Override public Void visit(TSQLLeftJoin tsqlLeftJoin, S context) { tsqlLeftJoin.getLeftExpression().accept(this, context); @@ -1310,6 +1319,10 @@ public void visit(RangeExpression rangeExpression) { visit(rangeExpression, null); } + public void visit(TernaryExpression ternaryExpression) { + visit(ternaryExpression, null); + } + public void visit(TSQLLeftJoin tsqlLeftJoin) { visit(tsqlLeftJoin, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 088492931..99e62720f 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -101,6 +101,10 @@ public class CCJSqlParser extends AbstractJSqlParser { return this; } + // depth of open ClickHouse-style ternary then-branches: a ":" directly after the + // then-branch closes the ternary and must not be taken as the JSON path operator + private int ternaryThenBranchDepth = 0; + private void linkAST(ASTNodeAccess access, Node node) { access.setASTNode(node); node.jjtSetValue(access); @@ -182,6 +186,11 @@ public class CCJSqlParser extends AbstractJSqlParser { // comparison operator follows after "(" "+" ")" return isComparisonOperator(getToken(4)); } + if ("?".equals(token.image) && isTernaryAhead()) { + // ClickHouse ternary `cond ? then : else` — the `?` must not be taken + // as the PostgreSQL JSON operator here, it is consumed by prattExpressionRest + return false; + } return isComparisonOperator(token); } catch (Exception e) { return false; @@ -212,6 +221,52 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** + * True when the pending "?" starts a ClickHouse-style ternary conditional + * {@code cond ? then : else} rather than the PostgreSQL JSON operator: + * a standalone ":" closes the then-branch at the same nesting depth before + * any expression boundary (",", ";", EOF, an unbalanced closing bracket or a + * clause keyword such as FROM/WHERE). + */ + protected boolean isTernaryAhead() { + try { + int depth = 0; + for (int i = 2; ; i++) { + Token t = getToken(i); + if (t == null || t.kind == EOF) { + return false; + } + String image = t.image; + if ("(".equals(image) || "[".equals(image)) { + depth++; + } else if (")".equals(image) || "]".equals(image)) { + if (depth == 0) { + return false; + } + depth--; + } else if (depth == 0) { + if (":".equals(image)) { + return true; + } + if (",".equals(image) || ";".equals(image)) { + return false; + } + switch (t.kind) { + case K_SELECT: case K_FROM: case K_WHERE: case K_GROUP: + case K_HAVING: case K_ORDER: case K_LIMIT: case K_UNION: + case K_INTERSECT: case K_EXCEPT: case K_MINUS: case K_INTO: + case K_VALUES: case K_FETCH: case K_OFFSET: + return false; + default: + break; + } + } + } + } catch (Exception e) { + return false; + } + } + /** * Tokens that have dedicated branches in PrimaryExpression AFTER the Function branch. @@ -328,12 +383,35 @@ public class CCJSqlParser extends AbstractJSqlParser { */ protected Expression prattExpressionRest(Expression left, int minPrec) throws ParseException { while (!interrupted) { - int op = getToken(1).kind; + Token t = getToken(1); + int op = t.kind; + boolean ternary = "?".equals(t.image) && isTernaryAhead(); int prec; if (op == K_AND || op == OP_DOUBLEAND) prec = 4; else if (op == K_XOR || op == K_OR) prec = 2; + else if (ternary) prec = 2; else break; if (prec < minPrec) break; + if (ternary) { + // ClickHouse-style ternary conditional: cond ? then : else + // Right-associative: both branches parse at the full expression + // level (prec 2), nested ternaries are absorbed by the else-branch. + jj_consume_token(op, t.image); + Expression thenExpression; + ternaryThenBranchDepth++; + try { + thenExpression = prattExpressionRest(Condition(), 2); + } finally { + ternaryThenBranchDepth--; + } + if (!":".equals(getToken(1).image)) { + throw new ParseException("Expected ':' closing the ternary conditional operator"); + } + jj_consume_token(getToken(1).kind, getToken(1).image); + Expression elseExpression = prattExpressionRest(Condition(), 2); + left = new TernaryExpression(left, thenExpression, elseExpression); + continue; + } jj_consume_token(op, getToken(1).image); // +1 makes OR/AND/XOR left-associative Expression right = prattExpressionRest(Condition(), prec + 1); @@ -920,6 +998,9 @@ public class CCJSqlParser extends AbstractJSqlParser { if (t.kind == OPENING_BRACKET && getToken(2).image.equals("+")) { return isComparisonOperator(getToken(4)); } + // ClickHouse ternary `cond ? then : else` — the `?` must not be taken as the + // PostgreSQL JSON operator, it is consumed by prattExpressionRest instead + if ("?".equals(t.image) && isTernaryAhead()) return false; if (isComparisonOperator(t)) return true; switch (t.kind) { // Each suffix's start token: @@ -7980,7 +8061,7 @@ Expression PrimaryExpression() #PrimaryExpression: // Check for JSON operands [ - LOOKAHEAD(2) ( + LOOKAHEAD(2, { ternaryThenBranchDepth == 0 || !":".equals(getToken(1).image) }) ( LOOKAHEAD(2) ( token="->" | @@ -8310,7 +8391,7 @@ JsonExpression JsonExpression(Expression expr, List 0 ? 'y' : 'n' FROM t", true); + + Select select = (Select) CCJSqlParserUtil.parse("SELECT x > 0 ? 'y' : 'n' FROM t"); + TernaryExpression ternary = (TernaryExpression) ((PlainSelect) select).getSelectItem(0) + .getExpression(); + Assertions.assertTrue(ternary.getCondition() instanceof ComparisonOperator); + Assertions.assertTrue(ternary.getThenExpression() instanceof StringValue); + Assertions.assertTrue(ternary.getElseExpression() instanceof StringValue); + } + + @ParameterizedTest + @ValueSource(strings = { + // select items, where, order by, group by, having + "SELECT a ? b : c FROM t", + "SELECT * FROM t WHERE a ? b : c", + "SELECT * FROM t WHERE a ? b : c ORDER BY x ? y : z", + "SELECT id FROM t GROUP BY v ? 1 : 0 HAVING count(*) > 1", + // function arguments, nested in parentheses and subqueries + "SELECT abs(a ? b : c) FROM t", + "SELECT (a ? b : c) FROM t", + "SELECT * FROM (SELECT a ? b : c AS v FROM t) x", + // dml statements + "UPDATE t SET v = a ? b : c", + "INSERT INTO t VALUES (a ? b : c)", + "DELETE FROM t WHERE a ? b : c", + // joins + "SELECT * FROM t1 JOIN t2 ON t1.a ? t1.b : t2.c", + // branches with arithmetic or boolean operators + "SELECT x > 0 ? 1 + 2 : 3 * 4 FROM t", + "SELECT a ? b OR c : d AND e FROM t", + "SELECT a IS NULL ? 'x' : y FROM t", + // jdbc parameters as branches + "SELECT a ? ? : c FROM t", + "SELECT a ? b : ? FROM t", + "SELECT * FROM t WHERE x = ? AND y ? z : w", + // case expression inside a branch + "SELECT a ? CASE WHEN b THEN c ELSE d END : e FROM t" + }) + void testTernaryInVariousContexts(String sqlStr) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testPrecedenceBindsLooserThanBooleanOperators() throws JSQLParserException { + Select select = (Select) CCJSqlParserUtil + .parse("SELECT * FROM t WHERE a OR b ? c : d"); + Expression where = ((PlainSelect) select).getWhere(); + Assertions.assertTrue(where instanceof TernaryExpression); + Assertions.assertTrue(((TernaryExpression) where).getCondition() instanceof OrExpression); + + select = (Select) CCJSqlParserUtil.parse("SELECT * FROM t WHERE a AND b ? c : d"); + where = ((PlainSelect) select).getWhere(); + Assertions.assertTrue(where instanceof TernaryExpression); + Assertions.assertTrue(((TernaryExpression) where).getCondition() instanceof AndExpression); + + select = (Select) CCJSqlParserUtil.parse("SELECT * FROM t WHERE a ? b : c OR d"); + where = ((PlainSelect) select).getWhere(); + Assertions.assertTrue(where instanceof TernaryExpression); + Assertions.assertTrue( + ((TernaryExpression) where).getElseExpression() instanceof OrExpression); + } + + @Test + void testRightAssociativeNesting() throws JSQLParserException { + // a ? b : c ? d : e ==> a ? b : (c ? d : e) + Select select = (Select) CCJSqlParserUtil.parse("SELECT * FROM t WHERE a ? b : c ? d : e"); + TernaryExpression ternary = (TernaryExpression) ((PlainSelect) select).getWhere(); + Assertions.assertTrue(ternary.getThenExpression() instanceof Column); + Assertions.assertTrue(ternary.getElseExpression() instanceof TernaryExpression); + + // a ? b ? c : d : e ==> a ? (b ? c : d) : e + select = (Select) CCJSqlParserUtil.parse("SELECT * FROM t WHERE a ? b ? c : d : e"); + ternary = (TernaryExpression) ((PlainSelect) select).getWhere(); + Assertions.assertTrue(ternary.getThenExpression() instanceof TernaryExpression); + Assertions.assertTrue(ternary.getElseExpression() instanceof Column); + } + + @ParameterizedTest + @ValueSource(strings = { + // PostgreSQL JSON operators must keep working + "SELECT col ? 'key' FROM t", + "SELECT col ?| 'key' FROM t", + "SELECT col ?& 'key' FROM t", + "SELECT * FROM t WHERE col ? 'key' AND x = 1", + // JSON path operator must keep working + "SELECT col -> 'key' FROM t", + // JDBC parameters must keep working + "SELECT * FROM t WHERE x = ?", + "SELECT * FROM t WHERE x = ?5", + "SELECT * FROM t WHERE x = ? AND y = ?", + "SELECT * FROM t LIMIT ?", + // array ranges and casts are unaffected + "SELECT ARRAY[1:3]", + "SELECT CAST(a AS CHAR)" + }) + void testUnrelatedSyntaxUnaffected(String sqlStr) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } +}