From f5069d62ef201f82430141ad0d162aa825854f3d Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 11 Sep 2026 05:33:21 +0000 Subject: [PATCH] Support PostgreSQL DO wrappers with preserved routine body literals --- .../sf/jsqlparser/parser/feature/Feature.java | 2 +- .../sf/jsqlparser/statement/DoStatement.java | 78 +++++++++++ .../statement/StatementFeatureVisitor.java | 7 + .../statement/StatementVisitor.java | 8 ++ .../statement/StatementVisitorAdapter.java | 6 + .../sf/jsqlparser/util/TablesNamesFinder.java | 7 + .../util/deparser/StatementDeParser.java | 6 + .../validation/feature/PostgresqlVersion.java | 1 + .../validator/StatementValidator.java | 7 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 23 ++++ src/site/sphinx/usage.rst | 11 ++ .../jsqlparser/statement/DoStatementTest.java | 127 ++++++++++++++++++ 12 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/DoStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/DoStatementTest.java diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index d7a90bed9..9d2131555 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -457,7 +457,7 @@ public enum Feature { */ oracleBlock, - execute, executeExec, executeCall, executeExecute, + execute, executeExec, executeCall, executeExecute, doStatement, /** * SQL "EXECUTE" statement is allowed diff --git a/src/main/java/net/sf/jsqlparser/statement/DoStatement.java b/src/main/java/net/sf/jsqlparser/statement/DoStatement.java new file mode 100644 index 000000000..c595ea1a3 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/DoStatement.java @@ -0,0 +1,78 @@ +/*- + * #%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; + +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.StringValue; + +/** A PostgreSQL anonymous routine. The language-specific body remains a string literal. */ +public class DoStatement implements Statement { + private StringValue code; + private String language; + private boolean languageBeforeCode; + + public StringValue getCode() { + return code; + } + + public void setCode(StringValue code) { + this.code = code; + } + + public DoStatement withCode(StringValue code) { + setCode(code); + return this; + } + + /** Returns the explicit language, or null when omitted. */ + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } + + public DoStatement withLanguage(String language) { + setLanguage(language); + return this; + } + + public boolean isLanguageBeforeCode() { + return languageBeforeCode; + } + + public void setLanguageBeforeCode(boolean languageBeforeCode) { + this.languageBeforeCode = languageBeforeCode; + } + + public StringBuilder appendTo(StringBuilder builder, Consumer codePrinter) { + builder.append("DO "); + if (languageBeforeCode && language != null) { + builder.append("LANGUAGE ").append(language).append(' '); + } + codePrinter.accept(code); + if (!languageBeforeCode && language != null) { + builder.append(" LANGUAGE ").append(language); + } + return builder; + } + + @Override + public T accept(StatementVisitor visitor, S context) { + return visitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, value -> builder.append(value)).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 4112ad6f8..7f13f5efc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -598,6 +598,13 @@ public Void visit(Execute execute, S context) { return null; } + @Override + public Void visit(DoStatement statement, S context) { + analysis.claimTopLevel(); + analysis.opaque("DO"); + return null; + } + @Override public Void visit(CreateFunctionalStatement createFunctionalStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 2cf0985e7..2001533ae 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -210,6 +210,14 @@ default void visit(Statements statements) { T visit(Execute execute, S context); + default T visit(DoStatement statement, S context) { + return null; + } + + default void visit(DoStatement statement) { + this.visit(statement, null); + } + default void visit(Execute execute) { this.visit(execute, null); } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 1c013d3e2..e633adebb 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -423,6 +423,12 @@ public T visit(Execute execute, S context) { return null; } + @Override + public T visit(DoStatement statement, S context) { + expressionVisitor.visitExpression(statement.getCode(), context); + return null; + } + @Override public T visit(LockStatement lock, S context) { diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 26f569661..18c12c1ba 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -91,6 +91,7 @@ import net.sf.jsqlparser.statement.CreateFunctionalStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.DoStatement; import net.sf.jsqlparser.statement.ExplainStatement; import net.sf.jsqlparser.statement.IfElseStatement; import net.sf.jsqlparser.statement.OutputClause; @@ -1564,6 +1565,12 @@ public Void visit(CreateIndex createIndex, S context) { return null; } + @Override + public Void visit(DoStatement statement, S context) { + throwUnsupported(statement); + return null; + } + @Override public void visit(CreateIndex createIndex) { StatementVisitor.super.visit(createIndex); 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 8bc3cb2d4..d85dbddd2 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -38,6 +38,7 @@ import net.sf.jsqlparser.statement.CreateFunctionalStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.DoStatement; import net.sf.jsqlparser.statement.ExplainStatement; import net.sf.jsqlparser.statement.IfElseStatement; import net.sf.jsqlparser.statement.PurgeStatement; @@ -151,6 +152,11 @@ public StringBuilder visit(CreateIndex createIndex, S context) { return builder; } + @Override + public StringBuilder visit(DoStatement statement, S context) { + return statement.appendTo(builder, code -> code.accept(expressionDeParser, context)); + } + @Override public StringBuilder visit(CreateTable createTable, S context) { CreateTableDeParser createTableDeParser = new CreateTableDeParser(this, builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index e1404a6f3..d0f00f166 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -27,6 +27,7 @@ public enum PostgresqlVersion implements Version { EnumSet.of(// supported if used with jdbc Feature.jdbcParameter, Feature.jdbcNamedParameter, // expressions + Feature.doStatement, Feature.exprLike, Feature.exprSimilarTo, // https://www.postgresql.org/docs/current/sql-select.html Feature.select, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index ef4655c46..282e30172 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -35,6 +35,7 @@ import net.sf.jsqlparser.statement.Block; import net.sf.jsqlparser.statement.Commit; import net.sf.jsqlparser.statement.CreateFunctionalStatement; +import net.sf.jsqlparser.statement.DoStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.statement.ExplainStatement; @@ -232,6 +233,12 @@ public Void visit(Execute execute, S context) { return null; } + @Override + public Void visit(DoStatement statement, S context) { + validateFeature(Feature.doStatement); + return null; + } + @Override public Void visit(SetStatement set, S context) { getValidator(SetStatementValidator.class).validate(set); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e8a6d6d19..ed99ad565 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2764,6 +2764,9 @@ Statement SingleStatement() : | stm = Execute() | + LOOKAHEAD({ getToken(1).kind == K_DO && Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) + stm = DoStatement() + | stm = Set() | stm = Reset() @@ -2883,6 +2886,26 @@ OracleBlock.ExceptionHandler OracleExceptionHandler(): body=OracleBlockStatements() { handler.setStatements(body); return handler; } } +DoStatement DoStatement(): +{ + DoStatement statement = new DoStatement(); + String language; + Token code; +} +{ + + ( + LOOKAHEAD({ isKeywordAhead("LANGUAGE") }) + AccessKeyword("LANGUAGE") language=RelObjectName() code= + { statement.setLanguage(language); statement.setLanguageBeforeCode(true); } + | + code= + [ LOOKAHEAD({ isKeywordAhead("LANGUAGE") }) AccessKeyword("LANGUAGE") + language=RelObjectName() { statement.setLanguage(language); } ] + ) + { statement.setCode(new StringValue(code.image)); return statement; } +} + Block Block() #Block : { Statements stmts = new Statements(); List list = new ArrayList(); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 35bdc082a..b3e8e8a58 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -766,6 +766,17 @@ uses the existing ``Update`` model's ``fromItem`` and ``joins`` properties. Table discovery and metadata validation recognize a target alias declared in that FROM clause. Other dialects retain the existing FROM-after-SET syntax. +``Dialect.POSTGRESQL`` enables ``DO [LANGUAGE name] code [LANGUAGE name]``, +with the language clause allowed once, before or after the body. +``DoStatement.getCode()`` is a ``StringValue`` that preserves the literal's +quotes, dollar tag and body text. The optional language and its position have +separate properties; an omitted language remains unspecified in the AST. +The body is language-specific source, not a parsed PL/pgSQL statement tree. +Expression visitors can inspect or replace the body literal. Feature analysis +reports ``OPAQUE``; table discovery rejects this statement because the body's +table accesses are unknown. Validation checks the ``doStatement`` capability, +without validating the procedural language inside the literal. + With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and ``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()`` for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words diff --git a/src/test/java/net/sf/jsqlparser/statement/DoStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/DoStatementTest.java new file mode 100644 index 000000000..a38c0b615 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/DoStatementTest.java @@ -0,0 +1,127 @@ +/*- + * #%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; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DoStatementTest { + @ParameterizedTest + @ValueSource(strings = {"DO $$BEGIN NULL; END$$", "DO LANGUAGE plpgsql $$BEGIN NULL; END$$", + "DO $body$BEGIN NULL; END$body$ LANGUAGE plpgsql", "DO 'BEGIN NULL; END'", + "DO LANGUAGE plpython3u 'print(1)'"}) + void roundTripsBodyAndLanguagePosition(String sql) throws Exception { + DoStatement statement = (DoStatement) assertSqlCanBeParsedAndDeparsed(sql, false, + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), + p -> p.withDialect(Dialect.POSTGRESQL)).toString()); + assertEquals(sql.startsWith("DO LANGUAGE"), statement.isLanguageBeforeCode()); + assertEquals( + sql.contains("LANGUAGE") ? sql.contains("plpython") ? "plpython3u" : "plpgsql" + : null, + statement.getLanguage()); + assertTrue(statement.getFeatures().isOpaque()); + assertTrue(statement.getFeatures().mayModifyData()); + assertThrows(UnsupportedOperationException.class, + () -> new TablesNamesFinder().getTables(statement)); + } + + @Test + void preservesProceduralBodyAndFollowingStatementsIssue1946() throws Exception { + String body = "$$\nBEGIN\n IF NOT EXISTS (SELECT 1 FROM comm.permission_operation) THEN\n" + + " INSERT INTO comm.permission_operation (permission_operation_id) VALUES (1) " + + "ON CONFLICT (permission_operation_id) DO NOTHING;\n END IF;\nEND $$"; + Statements statements = CCJSqlParserUtil.parseStatements("DO " + body + "; SELECT 1;", + p -> p.withDialect(Dialect.POSTGRESQL)); + assertEquals(2, statements.size()); + assertEquals(body, ((DoStatement) statements.get(0)).getCode().toString()); + assertEquals("SELECT 1", statements.get(1).toString()); + } + + @Test + void supportsBodyVisitorsAndAstEdits() throws Exception { + DoStatement statement = (DoStatement) CCJSqlParserUtil.parse("DO $$BEGIN NULL; END$$", + p -> p.withDialect(Dialect.POSTGRESQL)); + List bodies = new ArrayList<>(); + statement.accept(new StatementVisitorAdapter<>(new SelectVisitorAdapter<>( + new ExpressionVisitorAdapter() { + @Override + public Void visit(StringValue value, S context) { + assertEquals("context", context); + bodies.add(value.toString()); + return null; + } + })), "context"); + assertEquals(List.of("$$BEGIN NULL; END$$"), bodies); + statement.withCode(new StringValue("$new$BEGIN PERFORM 1; END$new$")) + .withLanguage("plpgsql"); + statement.setLanguageBeforeCode(true); + assertEquals("DO LANGUAGE plpgsql $new$BEGIN PERFORM 1; END$new$", statement.toString()); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(StringValue value, S context) { + return getBuilder().append("$$BEGIN NULL; END$$"); + } + }; + statement.accept(new StatementDeParser(expressions, new SelectDeParser(), output)); + assertEquals("DO LANGUAGE plpgsql $$BEGIN NULL; END$$", output.toString()); + } + + @Test + void gatesDialectAndRejectsMalformedWrappers() throws Exception { + String sql = "DO $$BEGIN NULL; END$$"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + for (Dialect dialect : Dialect.values()) { + if (dialect != Dialect.POSTGRESQL) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect))); + } + } + for (String malformed : List.of("DO", "DO LANGUAGE plpgsql", "DO 123", + "DO LANGUAGE plpgsql $$x$$ LANGUAGE plpgsql", "DO $tag$unterminated")) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(malformed, + p -> p.withDialect(Dialect.POSTGRESQL))); + } + } + + @Test + void validatesWrapperCapabilityWithoutClaimingBodyValidation() { + FeatureConfiguration config = new FeatureConfiguration() + .setValue(Feature.dialect, Dialect.POSTGRESQL.name()); + assertTrue(new Validation(config, List.of(PostgresqlVersion.V10), + "DO $$arbitrary language body$$") + .validate().isEmpty()); + assertFalse(new Validation(config, List.of(new FeaturesAllowed(Feature.select)), + "DO $$arbitrary language body$$").validate().isEmpty()); + } +}