diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java index 45c10a5a5..e2dabc783 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java @@ -290,6 +290,9 @@ public T visit(SetOperationList setOpList, S context) { */ @Override public T visit(WithItem withItem, S context) { + if (withItem.getCycleClause() != null) { + withItem.getCycleClause().accept(expressionVisitor, context); + } ParenthesedStatement body = withItem.getParenthesedStatement(); // ParenthesedSelect is a Select and stays on the select path diff --git a/src/main/java/net/sf/jsqlparser/statement/select/WithCycleClause.java b/src/main/java/net/sf/jsqlparser/statement/select/WithCycleClause.java new file mode 100644 index 000000000..5a0cc5155 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/WithCycleClause.java @@ -0,0 +1,129 @@ +/*- + * #%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.statement.select; + +import java.io.Serializable; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.ExpressionVisitor; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.schema.Column; + +/** Cycle detection metadata for a recursive common table expression. */ +public class WithCycleClause implements Serializable { + private ExpressionList cycleColumns; + private String markColumnName; + private Expression markValue; + private Expression markDefault; + private String pathColumnName; + + public ExpressionList getCycleColumns() { + return cycleColumns; + } + + public void setCycleColumns(ExpressionList cycleColumns) { + this.cycleColumns = cycleColumns; + } + + public WithCycleClause withCycleColumns(ExpressionList cycleColumns) { + setCycleColumns(cycleColumns); + return this; + } + + public String getMarkColumnName() { + return markColumnName; + } + + public void setMarkColumnName(String markColumnName) { + this.markColumnName = markColumnName; + } + + public WithCycleClause withMarkColumnName(String markColumnName) { + setMarkColumnName(markColumnName); + return this; + } + + public Expression getMarkValue() { + return markValue; + } + + public void setMarkValue(Expression markValue) { + this.markValue = markValue; + } + + public WithCycleClause withMarkValue(Expression markValue) { + setMarkValue(markValue); + return this; + } + + public Expression getMarkDefault() { + return markDefault; + } + + public void setMarkDefault(Expression markDefault) { + this.markDefault = markDefault; + } + + public WithCycleClause withMarkDefault(Expression markDefault) { + setMarkDefault(markDefault); + return this; + } + + public String getPathColumnName() { + return pathColumnName; + } + + public void setPathColumnName(String pathColumnName) { + this.pathColumnName = pathColumnName; + } + + public WithCycleClause withPathColumnName(String pathColumnName) { + setPathColumnName(pathColumnName); + return this; + } + + public void accept(ExpressionVisitor visitor, S context) { + if (cycleColumns != null) { + cycleColumns.forEach(column -> column.accept(visitor, context)); + } + if (markValue != null) { + markValue.accept(visitor, context); + } + if (markDefault != null) { + markDefault.accept(visitor, context); + } + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + builder.append("CYCLE "); + if (cycleColumns != null) { + for (int i = 0; i < cycleColumns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionPrinter.accept(cycleColumns.get(i)); + } + } + builder.append(" SET ").append(markColumnName); + if (markValue != null) { + builder.append(" TO "); + expressionPrinter.accept(markValue); + builder.append(" DEFAULT "); + expressionPrinter.accept(markDefault); + } + return builder.append(" USING ").append(pathColumnName); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, expression -> builder.append(expression)).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java b/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java index 01605c8a9..767f96846 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java @@ -9,6 +9,9 @@ */ package net.sf.jsqlparser.statement.select; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; + import java.io.Serializable; import java.util.ArrayList; import java.util.Collection; @@ -28,6 +31,7 @@ public class WithItem implements Serializable { private List> withItemList; private WithFunctionDeclaration withFunctionDeclaration; private WithSearchClause searchClause; + private WithCycleClause cycleClause; private boolean recursive = false; private boolean usingNot = false; private boolean materialized = false; @@ -149,6 +153,32 @@ public WithItem withSearchClause(WithSearchClause searchClause) { return this; } + public WithCycleClause getCycleClause() { + return cycleClause; + } + + public void setCycleClause(WithCycleClause cycleClause) { + this.cycleClause = cycleClause; + } + + public WithItem withCycleClause(WithCycleClause cycleClause) { + setCycleClause(cycleClause); + return this; + } + + public StringBuilder appendRecursiveClausesTo(StringBuilder builder, + Consumer expressionPrinter) { + if (searchClause != null) { + builder.append(" "); + searchClause.appendTo(builder, expressionPrinter); + } + if (cycleClause != null) { + builder.append(" "); + cycleClause.appendTo(builder, expressionPrinter); + } + return builder; + } + @Override public String toString() { StringBuilder builder = new StringBuilder(); @@ -174,9 +204,7 @@ public String toString() { : "MATERIALIZED "); } builder.append(statement); - if (searchClause != null) { - builder.append(" ").append(searchClause); - } + appendRecursiveClausesTo(builder, expression -> builder.append(expression)); } return builder.toString(); } diff --git a/src/main/java/net/sf/jsqlparser/statement/select/WithSearchClause.java b/src/main/java/net/sf/jsqlparser/statement/select/WithSearchClause.java index 84de6e223..75f7a1949 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/WithSearchClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/WithSearchClause.java @@ -9,6 +9,9 @@ */ package net.sf.jsqlparser.statement.select; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; + import java.io.Serializable; import java.util.Collection; @@ -86,15 +89,23 @@ public WithSearchClause withSequenceColumnName(String sequenceColumnName) { return this; } + public StringBuilder appendTo(StringBuilder builder, + Consumer expressionPrinter) { + builder.append("SEARCH ").append(searchOrder).append(" FIRST BY "); + if (searchColumns != null) { + for (int i = 0; i < searchColumns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + expressionPrinter.accept(searchColumns.get(i)); + } + } + return builder.append(" SET ").append(sequenceColumnName); + } + @Override public String toString() { - return new StringBuilder() - .append("SEARCH ") - .append(searchOrder) - .append(" FIRST BY ") - .append(Select.getStringList(searchColumns)) - .append(" SET ") - .append(sequenceColumnName) - .toString(); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, expression -> builder.append(expression)).toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 4cda25ae2..ab239721d 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -328,6 +328,9 @@ public Void visit(WithItem withItem, S context) { if (withItem.getAlias() != null) { otherItemNames.add(withItem.getAlias().getName()); } + if (withItem.getCycleClause() != null) { + withItem.getCycleClause().accept(this, context); + } // dispatch any ParenthesedStatement payload (Select, Delete, Update, Insert) withItem.accept((StatementVisitor) this, context); return null; diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index b506b0b27..d82f3fda3 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -902,9 +902,8 @@ public StringBuilder visit(WithItem withItem, S context) { StatementDeParser statementDeParser = new StatementDeParser((ExpressionDeParser) expressionVisitor, this, builder); statementDeParser.deParse(withItem.getParenthesedStatement()); - if (withItem.getSearchClause() != null) { - builder.append(" ").append(withItem.getSearchClause()); - } + withItem.appendRecursiveClausesTo(builder, + expression -> expression.accept(expressionVisitor, context)); } else { builder.append(withItem.getWithFunctionDeclaration().toString()); } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java index b9f22aada..e60ea6817 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java @@ -390,6 +390,9 @@ public Void visit(WithItem withItem, S context) { if (isNotEmpty(withItem.getWithItemList())) { withItem.getWithItemList().forEach(wi -> wi.accept(this, context)); } + if (withItem.getCycleClause() != null) { + withItem.getCycleClause().accept(getValidator(ExpressionValidator.class), context); + } withItem.getSelect().accept((SelectVisitor) this, context); return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index fbff17c98..a1961a5dd 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -6279,6 +6279,7 @@ WithItem WithItem() #WithItem: WithFunctionDeclaration withFunctionDeclaration = null; ParenthesedStatement statement = null; WithSearchClause withSearchClause = null; + WithCycleClause withCycleClause = null; WithItem withItem; } { @@ -6312,6 +6313,7 @@ WithItem WithItem() #WithItem: ) ) [ withSearchClause = WithSearchClause() { withItem.setSearchClause(withSearchClause); } ] + [ withCycleClause = WithCycleClause() { withItem.setCycleClause(withCycleClause); } ] { return withItem; } @@ -6342,6 +6344,24 @@ WithSearchClause WithSearchClause() #WithSearchClause: } } +WithCycleClause WithCycleClause() #WithCycleClause: +{ + WithCycleClause cycle = new WithCycleClause(); + ExpressionList columns = new ExpressionList(); + String name; + Expression value; +} +{ + + name=RelObjectName() { columns.add(new Column(name)); } + ( "," name=RelObjectName() { columns.add(new Column(name)); } )* + name=RelObjectName() { cycle.setMarkColumnName(name); } + [ value=Expression() { cycle.setMarkValue(value); } + value=Expression() { cycle.setMarkDefault(value); } ] + name=RelObjectName() { cycle.setPathColumnName(name); } + { return cycle.withCycleColumns(columns); } +} + WithFunctionDeclaration WithFunctionDeclaration() #WithFunctionDeclaration: { String functionName; diff --git a/src/test/java/net/sf/jsqlparser/statement/select/WithCycleClauseTest.java b/src/test/java/net/sf/jsqlparser/statement/select/WithCycleClauseTest.java new file mode 100644 index 000000000..3a59ab90e --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/WithCycleClauseTest.java @@ -0,0 +1,120 @@ +/*- + * #%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.statement.select; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class WithCycleClauseTest { + private static final String CTE = "WITH RECURSIVE walk(id, parent) AS " + + "(SELECT id, parent FROM nodes UNION ALL " + + "SELECT n.id, n.parent FROM nodes n JOIN walk w ON n.parent = w.id) "; + + @ParameterizedTest + @ValueSource(strings = { + "CYCLE id SET is_cycle USING path", + "CYCLE id, parent SET is_cycle TO 'Y' DEFAULT 'N' USING path", + "CYCLE id SET is_cycle TO 1 DEFAULT 0 USING path", + "CYCLE id SET is_cycle TO true DEFAULT false USING path", + "SEARCH DEPTH FIRST BY id SET seq CYCLE id SET is_cycle USING path", + "SEARCH BREADTH FIRST BY id, parent SET seq CYCLE id, parent SET is_cycle USING path" + }) + void parsesRecursiveCycleVariants(String clause) throws Exception { + Select select = (Select) TestUtils.assertSqlCanBeParsedAndDeparsed( + CTE + clause + " SELECT * FROM walk", true); + assertNotNull(select.getWithItemsList().get(0).getCycleClause()); + assertEquals(select.toString(), CCJSqlParserUtil.parse(select.toString()).toString()); + } + + @Test + void retainsQuotedNamesAndTypedMarkValues() throws Exception { + Select select = (Select) CCJSqlParserUtil.parse(CTE + + "CYCLE id, parent SET \"Cycle\" TO 'yes' DEFAULT 'no' USING \"Path\" SELECT * FROM walk"); + WithCycleClause cycle = select.getWithItemsList().get(0).getCycleClause(); + assertEquals(2, cycle.getCycleColumns().size()); + assertEquals("parent", cycle.getCycleColumns().get(1).getColumnName()); + assertEquals("\"Cycle\"", cycle.getMarkColumnName()); + assertEquals("yes", ((StringValue) cycle.getMarkValue()).getValue()); + assertEquals("no", ((StringValue) cycle.getMarkDefault()).getValue()); + assertEquals("\"Path\"", cycle.getPathColumnName()); + } + + @Test + void preservesImplicitBooleanDefaultsAndMultipleCtes() throws Exception { + Select select = (Select) TestUtils.assertSqlCanBeParsedAndDeparsed(CTE + + "CYCLE id SET is_cycle USING path, chosen AS (SELECT * FROM walk) SELECT * FROM chosen", + true); + assertNull(select.getWithItemsList().get(0).getCycleClause().getMarkValue()); + assertNull(select.getWithItemsList().get(0).getCycleClause().getMarkDefault()); + assertNull(select.getWithItemsList().get(1).getCycleClause()); + } + + @ParameterizedTest + @ValueSource(strings = {"CYCLE id SET flag", "CYCLE id SET flag TO 'Y' USING path", + "CYCLE id SET flag DEFAULT 'N' USING path", "CYCLE SET flag USING path"}) + void rejectsIncompleteCycleClauses(String clause) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(CTE + clause + " SELECT * FROM walk")); + } + + @Test + void visitsCycleExpressions() throws Exception { + Select select = (Select) CCJSqlParserUtil.parse(CTE + + "CYCLE id SET flag TO 'Y' DEFAULT 'N' USING path SELECT * FROM walk"); + List values = new ArrayList<>(); + select.accept(new SelectVisitorAdapter(new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + values.add(value.getValue()); + return null; + } + }), null); + assertEquals(List.of("Y", "N"), values); + } + + @Test + void sharesSearchAndCycleRenderingWithExpressionDeparser() throws Exception { + Select select = (Select) CCJSqlParserUtil.parse(CTE + + "SEARCH DEPTH FIRST BY id SET seq CYCLE id SET flag TO 'Y' DEFAULT 'N' USING path SELECT * FROM walk"); + StringBuilder sql = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(Column column, S context) { + getBuilder().append("changed_").append(column.getColumnName()); + return getBuilder(); + } + + @Override + public StringBuilder visit(StringValue value, S context) { + getBuilder().append("'changed_").append(value.getValue()).append("'"); + return getBuilder(); + } + }; + select.accept(new StatementDeParser(expressions, new SelectDeParser(), sql), null); + assertTrue(sql.toString().contains( + "SEARCH DEPTH FIRST BY changed_id SET seq CYCLE changed_id SET flag TO 'changed_Y' DEFAULT 'changed_N' USING path"), + sql.toString()); + } +}