diff --git a/README.md b/README.md index e13367dd39..55ef82964c 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **ksqlDB windows** | JOIN `WITHIN`, window `GRACE PERIOD`, and `EMIT CHANGES`/`FINAL` | | **ClickHouse column selection** | `COLUMNS('regexp')` select items with chained `APPLY`, `EXCEPT`, and `REPLACE` transformers | | **DML** | `INSERT` · `UPDATE` · `UPSERT` · `MERGE` · `DELETE` · `TRUNCATE TABLE` | +| **Oracle RETURNING** | Named output binds (`INTO :name`), JDBC output parameters (`INTO ?`), and `BULK COLLECT INTO` | | **DDL** | `CREATE …` · `ALTER …` · `DROP …` | | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `OPTIMIZE` · `REPAIR TABLE` | diff --git a/src/main/java/net/sf/jsqlparser/statement/ReturningClause.java b/src/main/java/net/sf/jsqlparser/statement/ReturningClause.java index 1236ec4499..51f568f9c3 100644 --- a/src/main/java/net/sf/jsqlparser/statement/ReturningClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/ReturningClause.java @@ -31,11 +31,12 @@ public class ReturningClause extends ArrayList> { /** - * List of output targets like Table or UserVariable + * List of output targets like Table, UserVariable, JdbcNamedParameter or JdbcParameter. */ private final List dataItems; private final List outputAliases; private Keyword keyword; + private boolean bulkCollect; public ReturningClause(Keyword keyword, List> selectItems, List dataItems) { @@ -82,6 +83,15 @@ public List getDataItems() { return dataItems; } + public boolean isBulkCollect() { + return bulkCollect; + } + + public ReturningClause setBulkCollect(boolean bulkCollect) { + this.bulkCollect = bulkCollect; + return this; + } + public List getOutputAliases() { return outputAliases; } @@ -110,7 +120,7 @@ public StringBuilder appendTo(StringBuilder builder, Consumer> ite } if (dataItems != null && !dataItems.isEmpty()) { - builder.append(" INTO "); + builder.append(bulkCollect ? " BULK COLLECT INTO " : " INTO "); for (int i = 0; i < dataItems.size(); i++) { if (i > 0) { builder.append(" ,"); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index bece55b798..6bfc52834b 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2236,8 +2236,15 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + private boolean isBulkCollectIntoAhead() { + return "BULK".equalsIgnoreCase(getToken(1).image) + && "COLLECT".equalsIgnoreCase(getToken(2).image) + && getToken(3).kind == K_INTO; + } + private boolean isAliasAhead() { - if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) { + if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead() + || isBulkCollectIntoAhead()) { return false; } Token t = getToken(1); @@ -7175,6 +7182,7 @@ ReturningClause ReturningClause(): List> selectItems; Object dataItem; List dataItems = null; + boolean bulkCollect = false; } { ( keyword= | keyword= ) @@ -7182,21 +7190,36 @@ ReturningClause ReturningClause(): selectItems = SelectItemsList() [ + LOOKAHEAD({ getToken(1).kind == K_INTO || isBulkCollectIntoAhead() }) + [ LOOKAHEAD({ isBulkCollectIntoAhead() }) + ContextualKeyword("BULK") ContextualKeyword("COLLECT") { bulkCollect = true; } + ] - ( dataItem = Table() | dataItem = UserVariable() ) + dataItem = ReturningIntoTarget() { dataItems = new ArrayList(); dataItems.add(dataItem); } ( "," - ( dataItem = Table() | dataItem = UserVariable() ) { dataItems.add(dataItem); } + dataItem = ReturningIntoTarget() { dataItems.add(dataItem); } )* ] { - return new ReturningClause(keyword.image, selectItems, outputAliases, dataItems); + return new ReturningClause(keyword.image, selectItems, outputAliases, dataItems) + .setBulkCollect(bulkCollect); } } +Object ReturningIntoTarget(): +{ + Object target; +} +{ + ( target=JdbcNamedParameter() | target=JdbcParameter() + | target=Table() | target=UserVariable() ) + { return target; } +} + ReturningReferenceType ReturningReferenceKind(): { String refName; diff --git a/src/test/java/net/sf/jsqlparser/statement/OracleReturningIntoTest.java b/src/test/java/net/sf/jsqlparser/statement/OracleReturningIntoTest.java new file mode 100644 index 0000000000..d6abe7ab12 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/OracleReturningIntoTest.java @@ -0,0 +1,199 @@ +/*- + * #%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 org.junit.jupiter.api.Assertions.*; +import java.util.List; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.JdbcNamedParameter; +import net.sf.jsqlparser.expression.JdbcParameter; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.delete.Delete; +import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.merge.Merge; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.update.Update; +import net.sf.jsqlparser.test.TestUtils; +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.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +class OracleReturningIntoTest { + static Stream dmlStatements() { + return Stream.of("INSERT INTO employees (id, salary) VALUES (1, 100)", + "UPDATE employees SET salary = salary + 100 WHERE id = 1", + "DELETE FROM employees WHERE id = 1", + "MERGE INTO employees e USING changes c ON (e.id = c.id) " + + "WHEN MATCHED THEN UPDATE SET e.salary = c.salary"); + } + + @ParameterizedTest + @MethodSource("dmlStatements") + void preservesNamedOutputBinds(String dml) throws JSQLParserException { + String sql = dml + " RETURNING id, salary INTO :out_id, :out_salary"; + for (Statement statement : parseBoth(sql)) { + ReturningClause clause = returningClause(statement); + assertFalse(clause.isBulkCollect()); + assertEquals(2, clause.getDataItems().size()); + assertEquals("out_id", + assertInstanceOf(JdbcNamedParameter.class, clause.getDataItems().get(0)) + .getName()); + assertEquals("out_salary", + assertInstanceOf(JdbcNamedParameter.class, clause.getDataItems().get(1)) + .getName()); + roundTrip(statement); + } + } + + @ParameterizedTest + @MethodSource("dmlStatements") + void preservesBulkCollectionAndTargets(String dml) throws JSQLParserException { + for (String targets : List.of("ids, salaries", ":ids, :salaries")) { + String sql = dml + " RETURNING id, salary BULK COLLECT INTO " + targets; + for (Statement statement : parseBoth(sql)) { + ReturningClause clause = returningClause(statement); + assertTrue(clause.isBulkCollect()); + assertEquals(2, clause.getDataItems().size()); + assertEquals(targets.startsWith(":"), + clause.getDataItems().get(0) instanceof JdbcNamedParameter); + assertTrue(statement.toString().contains(" BULK COLLECT INTO ")); + roundTrip(statement); + } + } + } + + @Test + void countsJdbcOutputParametersAfterInputParameters() throws JSQLParserException { + for (Statement statement : parseBoth("UPDATE employees SET salary = ? WHERE id = ? " + + "RETURNING id, salary INTO ?, ?")) { + ReturningClause clause = returningClause(statement); + assertFalse(clause.isBulkCollect()); + assertEquals(3, + assertInstanceOf(JdbcParameter.class, clause.getDataItems().get(0)).getIndex()); + assertEquals(4, + assertInstanceOf(JdbcParameter.class, clause.getDataItems().get(1)).getIndex()); + roundTrip(statement); + } + } + + @ParameterizedTest + @ValueSource(strings = {"out_id", "record.out_id", "@out_id"}) + void preservesExistingOutputTargets(String target) throws JSQLParserException { + Statement statement = CCJSqlParserUtil + .parse("INSERT INTO employees (id) VALUES (1) RETURNING id INTO " + target); + Object actual = returningClause(statement).getDataItems().get(0); + Class expectedType = target.startsWith("@") ? UserVariable.class : Table.class; + assertInstanceOf(expectedType, actual); + assertEquals(target, actual.toString()); + roundTrip(statement); + } + + @ParameterizedTest + @ValueSource(strings = {"bulk", "AS bulk", "\"bulk\"", "AS collect"}) + void keepsAliasesBeforeBulkCollect(String alias) throws JSQLParserException { + for (Statement statement : parseBoth("UPDATE employees SET salary = 100 " + + "RETURNING salary " + alias + " BULK COLLECT INTO salaries")) { + ReturningClause clause = returningClause(statement); + assertTrue(clause.isBulkCollect()); + assertEquals(alias.replace("AS ", ""), clause.get(0).getAlias().getName()); + roundTrip(statement); + } + } + + @Test + void keepsBulkAndCollectAsOrdinaryIdentifiersAndAliases() throws JSQLParserException { + PlainSelect select = (PlainSelect) CCJSqlParserUtil + .parse("SELECT bulk, collect, salary bulk, salary collect FROM employees"); + assertEquals("bulk", select.getSelectItem(0).getExpression().toString()); + assertEquals("collect", select.getSelectItem(1).getExpression().toString()); + assertEquals("bulk", select.getSelectItem(2).getAlias().getName()); + assertEquals("collect", select.getSelectItem(3).getAlias().getName()); + roundTrip(select); + } + + @ParameterizedTest + @ValueSource(strings = {"bulk", "collect"}) + void keepsAliasesBeforeOrdinaryInto(String alias) throws JSQLParserException { + for (Statement statement : parseBoth("DELETE FROM employees RETURNING id " + alias + + " INTO out_id")) { + ReturningClause clause = returningClause(statement); + assertFalse(clause.isBulkCollect()); + assertEquals(alias, clause.get(0).getAlias().getName()); + roundTrip(statement); + } + } + + @Test + void allowsChangingCollectionModeThroughTheAst() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse( + "DELETE FROM employees RETURNING id BULK COLLECT INTO ids"); + ReturningClause clause = returningClause(statement); + clause.setBulkCollect(false); + assertFalse(statement.toString().contains("BULK COLLECT")); + roundTrip(statement); + clause.setBulkCollect(true); + assertTrue(statement.toString().contains("BULK COLLECT")); + roundTrip(statement); + } + + @ParameterizedTest + @ValueSource(strings = {"INTO", "INTO :", "INTO :out_id,", "BULK COLLECT INTO", + "BULK COLLECT INTO ids,", "BULK COLLECT", "INTO :out_id + 1"}) + void rejectsIncompleteClauses(String clause) { + String sql = "DELETE FROM employees RETURNING id " + clause; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.ORACLE))); + } + + private static List parseBoth(String sql) throws JSQLParserException { + return List.of(TestUtils.assertSqlCanBeParsedAndDeparsed(sql), + CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.ORACLE))); + } + + private static ReturningClause returningClause(Statement statement) { + if (statement instanceof Insert) { + return ((Insert) statement).getReturningClause(); + } + if (statement instanceof Update) { + return ((Update) statement).getReturningClause(); + } + if (statement instanceof Delete) { + return ((Delete) statement).getReturningClause(); + } + return ((Merge) statement).getReturningClause(); + } + + private static void roundTrip(Statement statement) throws JSQLParserException { + StringBuilder buffer = new StringBuilder(); + statement.accept(new StatementDeParser(buffer), null); + assertEquals(statement.toString(), buffer.toString()); + Statement reparsed = CCJSqlParserUtil.parse(buffer.toString()); + assertEquals(buffer.toString(), reparsed.toString()); + if (!(statement instanceof PlainSelect)) { + ReturningClause before = returningClause(statement); + ReturningClause after = returningClause(reparsed); + assertEquals(before.isBulkCollect(), after.isBulkCollect()); + assertEquals(before.getDataItems().size(), after.getDataItems().size()); + for (int i = 0; i < before.getDataItems().size(); i++) { + assertEquals(before.getDataItems().get(i).getClass(), + after.getDataItems().get(i).getClass()); + assertEquals(before.getDataItems().get(i).toString(), + after.getDataItems().get(i).toString()); + } + } + } +}