From b0026743120c205bb5ad863aff61a921a3de63cb Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 18 Sep 2026 23:39:38 +0900 Subject: [PATCH] fix: avoid recursion when deparsing statement lists --- .../net/sf/jsqlparser/statement/Block.java | 8 +- .../jsqlparser/statement/IfElseStatement.java | 18 +- .../sf/jsqlparser/statement/Statements.java | 30 ++- .../util/deparser/StatementDeParser.java | 21 +- src/site/sphinx/usage.rst | 14 + .../deparser/StatementListDeParserTest.java | 248 ++++++++++++++++++ 6 files changed, 310 insertions(+), 29 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/util/deparser/StatementListDeParserTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/Block.java b/src/main/java/net/sf/jsqlparser/statement/Block.java index 83b9a23474..f609e58262 100644 --- a/src/main/java/net/sf/jsqlparser/statement/Block.java +++ b/src/main/java/net/sf/jsqlparser/statement/Block.java @@ -9,6 +9,8 @@ */ package net.sf.jsqlparser.statement; +import java.util.function.Consumer; + public class Block implements Statement { private boolean hasSemicolonAfterEnd = false; @@ -36,9 +38,13 @@ public T accept(StatementVisitor statementVisitor, S context) { } public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer statementsPrinter) { builder.append("BEGIN\n"); if (statements != null) { - builder.append(statements); + statementsPrinter.accept(statements); } builder.append("END"); if (hasSemicolonAfterEnd) { diff --git a/src/main/java/net/sf/jsqlparser/statement/IfElseStatement.java b/src/main/java/net/sf/jsqlparser/statement/IfElseStatement.java index 848c886ab7..c9e6a6f266 100644 --- a/src/main/java/net/sf/jsqlparser/statement/IfElseStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/IfElseStatement.java @@ -11,6 +11,7 @@ package net.sf.jsqlparser.statement; import java.util.Objects; +import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; @@ -65,12 +66,21 @@ public void setUsingSemicolonForIfStatement(boolean usingSemicolonForIfStatement } public StringBuilder appendTo(StringBuilder builder) { - builder.append("IF ").append(condition).append(" ").append(ifStatement) - .append(usingSemicolonForIfStatement ? ";" : ""); + return appendTo(builder, builder::append, builder::append); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter, + Consumer statementPrinter) { + builder.append("IF "); + expressionPrinter.accept(condition); + builder.append(' '); + statementPrinter.accept(ifStatement); + builder.append(usingSemicolonForIfStatement ? ";" : ""); if (elseStatement != null) { - builder.append(" ELSE ").append(elseStatement) - .append(usingSemicolonForElseStatement ? ";" : ""); + builder.append(" ELSE "); + statementPrinter.accept(elseStatement); + builder.append(usingSemicolonForElseStatement ? ";" : ""); } return builder; } diff --git a/src/main/java/net/sf/jsqlparser/statement/Statements.java b/src/main/java/net/sf/jsqlparser/statement/Statements.java index 0c8571c357..b4d6e4f09d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/Statements.java +++ b/src/main/java/net/sf/jsqlparser/statement/Statements.java @@ -12,6 +12,7 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +import java.util.function.Consumer; public class Statements extends ArrayList implements Serializable { @@ -34,17 +35,30 @@ public E get(Class type, int index) { return type.cast(get(index)); } - @Override - public String toString() { - StringBuilder b = new StringBuilder(); + /** + * Appends each statement using the supplied renderer and the list's separators. Blocks and + * IF/ELSE statements retain their own semicolon settings. Null entries produced by error + * recovery retain the legacy "null;" placeholder without invoking the renderer. + */ + public StringBuilder appendTo(StringBuilder builder, Consumer statementPrinter) { for (Statement stmt : this) { - // IfElseStatements and Blocks control the Semicolons by themselves - if (stmt instanceof IfElseStatement || stmt instanceof Block) { - b.append(stmt).append("\n"); + if (stmt == null) { + builder.append("null"); } else { - b.append(stmt).append(";\n"); + statementPrinter.accept(stmt); + } + // IfElseStatements and Blocks control the Semicolons by themselves + if (!(stmt instanceof IfElseStatement || stmt instanceof Block)) { + builder.append(';'); } + builder.append('\n'); } - return b.toString(); + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index ac5490fa0a..ffd5fe900c 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -344,8 +344,7 @@ public StringBuilder visit(AlterEvent alterEvent, S context) { @Override public StringBuilder visit(Statements statements, S context) { - statements.accept(this, context); - return builder; + return statements.appendTo(builder, statement -> statement.accept(this, context)); } @Override @@ -435,18 +434,7 @@ public StringBuilder visit(ShowTablesStatement showTables, S context) { @Override public StringBuilder visit(Block block, S context) { - builder.append("BEGIN\n"); - if (block.getStatements() != null) { - for (Statement stmt : block.getStatements()) { - stmt.accept(this, context); - builder.append(";\n"); - } - } - builder.append("END"); - if (block.hasSemicolonAfterEnd()) { - builder.append(";"); - } - return builder; + return block.appendTo(builder, statements -> statements.accept(this, context)); } @Override @@ -563,8 +551,9 @@ public StringBuilder visit(AlterSession alterSession, S context) { @Override public StringBuilder visit(IfElseStatement ifElseStatement, S context) { - ifElseStatement.appendTo(builder); - return builder; + return ifElseStatement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context), + statement -> statement.accept(this, context)); } @Override diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 2e70489dec..84a501fb72 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -690,6 +690,20 @@ The object model works in both directions. Build the tree from Java and print it Assertions.assertEquals(expectedSQLStr, builder.toString()); +The same visitor can render an entire statement list: + +.. code-block:: java + + Statements statements = CCJSqlParserUtil.parseStatements("SELECT 1; SELECT 2;"); + StringBuilder script = new StringBuilder(); + statements.accept(new StatementDeParser(script), null); + Assertions.assertEquals("SELECT 1;\nSELECT 2;\n", script.toString()); + +Statement lists and nested blocks share the separator policy used by +``Statements.toString()``. Blocks and ``IF/ELSE`` statements retain their own +semicolon settings. Custom deparsers receive each child statement and the +``IF/ELSE`` condition through the visitor API, with the supplied context. + ODBC timestamp intervals ============================== diff --git a/src/test/java/net/sf/jsqlparser/util/deparser/StatementListDeParserTest.java b/src/test/java/net/sf/jsqlparser/util/deparser/StatementListDeParserTest.java new file mode 100644 index 0000000000..b6015da492 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/deparser/StatementListDeParserTest.java @@ -0,0 +1,248 @@ +/*- + * #%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.util.deparser; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.operators.relational.EqualsTo; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Block; +import net.sf.jsqlparser.statement.IfElseStatement; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.select.Select; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class StatementListDeParserTest { + @Test + void rendersEmptyListIntoExistingBuilder() { + StringBuilder output = new StringBuilder("prefix"); + Statements statements = new Statements(); + assertSame(output, statements.accept(new StatementDeParser(output), null)); + assertEquals("prefix", output.toString()); + assertEquals("", statements.toString()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "SELECT 1 | SELECT 1;", + "SELECT 1; SELECT 2; | SELECT 1; SELECT 2;", + ";;SELECT 1;;; SELECT 2;; | SELECT 1; SELECT 2;" + }) + void rendersEveryStatementWithSeparators(String sql, String expected) throws Exception { + Statements statements = CCJSqlParserUtil.parseStatements(sql); + StringBuilder output = new StringBuilder(); + assertSame(output, statements.accept(new StatementDeParser(output), null)); + assertEquals(expected.replace("; ", ";\n") + "\n", output.toString()); + assertEquals(statements.toString(), output.toString()); + assertRoundTrip(statements, output.toString(), null); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TABLE t (id integer); INSERT INTO t VALUES (1); SELECT id FROM t;", + "BEGIN SELECT 1; END;", + "BEGIN SELECT 1; END", + "SELECT 0; BEGIN SELECT 1; END;", + "IF 1 = 1 SELECT 2; SELECT 3;", + "IF 1 = 1 SELECT 2; ELSE SELECT 3; SELECT 4;", + "IF 1 = 1 BEGIN SELECT 2; END ELSE BEGIN SELECT 3; END; SELECT 4;", + "IF 1 = 1 BEGIN SELECT 2; SELECT 3; END;" + }) + void preservesCompoundStatementBoundaries(String sql) throws Exception { + Statements statements = CCJSqlParserUtil.parseStatements(sql); + String output = render(statements); + assertEquals(statements.toString(), output); + assertRoundTrip(statements, output, null); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void blockOwnsItsEndSemicolon(boolean terminated) throws Exception { + Block block = new Block().withStatements(CCJSqlParserUtil.parseStatements("SELECT 1;")); + block.setSemicolonAfterEnd(terminated); + Statements statements = new Statements(); + statements.add(block); + statements.add(CCJSqlParserUtil.parse("SELECT 2")); + String expected = "BEGIN\nSELECT 1;\nEND" + (terminated ? ";" : "") + "\nSELECT 2;\n"; + assertEquals(expected, render(statements)); + assertEquals(expected, statements.toString()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void rendersBlocksWithoutStatements(boolean terminated) { + Block block = new Block(); + block.setSemicolonAfterEnd(terminated); + Statements statements = new Statements(); + statements.add(block); + String expected = "BEGIN\nEND" + (terminated ? ";" : "") + "\n"; + assertEquals(expected, render(statements)); + block.setStatements(new Statements()); + assertEquals(expected, render(statements)); + } + + @Test + void nestedBlocksDoNotAddSeparatorsAfterTerminatedChildren() throws Exception { + Statements body = CCJSqlParserUtil.parseStatements("IF 1 = 1 SELECT 2; ELSE SELECT 3;"); + Block inner = new Block().withStatements(CCJSqlParserUtil.parseStatements("SELECT 4;")); + inner.setSemicolonAfterEnd(true); + body.add(inner); + Block outer = new Block().withStatements(body); + outer.setSemicolonAfterEnd(true); + Statements statements = new Statements(); + statements.add(outer); + statements.add(CCJSqlParserUtil.parse("SELECT 5")); + String expected = "BEGIN\nIF 1 = 1 SELECT 2; ELSE SELECT 3;\n" + + "BEGIN\nSELECT 4;\nEND;\nEND;\nSELECT 5;\n"; + assertEquals(expected, render(statements)); + assertEquals(expected, statements.toString()); + } + + @ParameterizedTest + @CsvSource({"false,false", "false,true", "true,false", "true,true"}) + void ifElseOwnsItsBranchSemicolons(boolean thenTerminated, boolean elseTerminated) + throws Exception { + IfElseStatement conditional = new IfElseStatement( + CCJSqlParserUtil.parseCondExpression("1 = 1"), CCJSqlParserUtil.parse("SELECT 2")); + conditional.setElseStatement(CCJSqlParserUtil.parse("SELECT 3")); + conditional.setUsingSemicolonForIfStatement(thenTerminated); + conditional.setUsingSemicolonForElseStatement(elseTerminated); + Statements statements = new Statements(); + statements.add(conditional); + String expected = "IF 1 = 1 SELECT 2" + (thenTerminated ? ";" : "") + + " ELSE SELECT 3" + (elseTerminated ? ";" : "") + "\n"; + assertEquals(expected, render(statements)); + assertEquals(expected, statements.toString()); + } + + @Test + void appliesCustomVisitorsInsideBranchesAndNestedListsWithContext() throws Exception { + Statements statements = CCJSqlParserUtil.parseStatements( + "IF flag = 1 BEGIN SELECT 2; END ELSE BEGIN SELECT 3; END; SELECT 4;"); + String original = statements.toString(); + Object context = new Object(); + List visitedConditions = new ArrayList<>(); + List visitedValues = new ArrayList<>(); + List