Skip to content

Commit 55c13bf

Browse files
authored
feat: support Oracle RETURNING INTO binds and bulk collection (#2846)
1 parent 0472f95 commit 55c13bf

4 files changed

Lines changed: 239 additions & 6 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J
142142
| **ksqlDB windows** | JOIN `WITHIN`, window `GRACE PERIOD`, and `EMIT CHANGES`/`FINAL` |
143143
| **ClickHouse column selection** | `COLUMNS('regexp')` select items with chained `APPLY`, `EXCEPT`, and `REPLACE` transformers |
144144
| **DML** | `INSERT` · `UPDATE` · `UPSERT` · `MERGE` · `DELETE` · `TRUNCATE TABLE` |
145+
| **Oracle RETURNING** | Named output binds (`INTO :name`), JDBC output parameters (`INTO ?`), and `BULK COLLECT INTO` |
145146
| **DDL** | `CREATE …` · `ALTER …` · `DROP …` |
146147
| **Oracle vector types** | `VECTOR(*, *)` · `VECTOR(*, FLOAT32)` · `VECTOR(3, *)`, including dense and sparse storage formats |
147148
| **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` |

‎src/main/java/net/sf/jsqlparser/statement/ReturningClause.java‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@
3131

3232
public class ReturningClause extends ArrayList<SelectItem<?>> {
3333
/**
34-
* List of output targets like Table or UserVariable
34+
* List of output targets like Table, UserVariable, JdbcNamedParameter or JdbcParameter.
3535
*/
3636
private final List<Object> dataItems;
3737
private final List<ReturningOutputAlias> outputAliases;
3838
private Keyword keyword;
39+
private boolean bulkCollect;
3940

4041
public ReturningClause(Keyword keyword, List<SelectItem<?>> selectItems,
4142
List<Object> dataItems) {
@@ -82,6 +83,15 @@ public List<?> getDataItems() {
8283
return dataItems;
8384
}
8485

86+
public boolean isBulkCollect() {
87+
return bulkCollect;
88+
}
89+
90+
public ReturningClause setBulkCollect(boolean bulkCollect) {
91+
this.bulkCollect = bulkCollect;
92+
return this;
93+
}
94+
8595
public List<ReturningOutputAlias> getOutputAliases() {
8696
return outputAliases;
8797
}
@@ -110,7 +120,7 @@ public StringBuilder appendTo(StringBuilder builder, Consumer<SelectItem<?>> ite
110120
}
111121

112122
if (dataItems != null && !dataItems.isEmpty()) {
113-
builder.append(" INTO ");
123+
builder.append(bulkCollect ? " BULK COLLECT INTO " : " INTO ");
114124
for (int i = 0; i < dataItems.size(); i++) {
115125
if (i > 0) {
116126
builder.append(" ,");

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2236,8 +2236,15 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
22362236
}
22372237
}
22382238

2239+
private boolean isBulkCollectIntoAhead() {
2240+
return "BULK".equalsIgnoreCase(getToken(1).image)
2241+
&& "COLLECT".equalsIgnoreCase(getToken(2).image)
2242+
&& getToken(3).kind == K_INTO;
2243+
}
2244+
22392245
private boolean isAliasAhead() {
2240-
if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) {
2246+
if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()
2247+
|| isBulkCollectIntoAhead()) {
22412248
return false;
22422249
}
22432250
Token t = getToken(1);
@@ -7175,28 +7182,44 @@ ReturningClause ReturningClause():
71757182
List<SelectItem<?>> selectItems;
71767183
Object dataItem;
71777184
List<Object> dataItems = null;
7185+
boolean bulkCollect = false;
71787186
}
71797187
{
71807188
( keyword=<K_RETURNING> | keyword=<K_RETURN> )
71817189
[ LOOKAHEAD(2) outputAliases = ReturningOutputAliasList() ]
71827190
selectItems = SelectItemsList()
71837191

71847192
[
7193+
LOOKAHEAD({ getToken(1).kind == K_INTO || isBulkCollectIntoAhead() })
7194+
[ LOOKAHEAD({ isBulkCollectIntoAhead() })
7195+
ContextualKeyword("BULK") ContextualKeyword("COLLECT") { bulkCollect = true; }
7196+
]
71857197
<K_INTO>
7186-
( dataItem = Table() | dataItem = UserVariable() )
7198+
dataItem = ReturningIntoTarget()
71877199
{ dataItems = new ArrayList<Object>(); dataItems.add(dataItem); }
71887200

71897201
(
71907202
","
7191-
( dataItem = Table() | dataItem = UserVariable() ) { dataItems.add(dataItem); }
7203+
dataItem = ReturningIntoTarget() { dataItems.add(dataItem); }
71927204
)*
71937205
]
71947206

71957207
{
7196-
return new ReturningClause(keyword.image, selectItems, outputAliases, dataItems);
7208+
return new ReturningClause(keyword.image, selectItems, outputAliases, dataItems)
7209+
.setBulkCollect(bulkCollect);
71977210
}
71987211
}
71997212

7213+
Object ReturningIntoTarget():
7214+
{
7215+
Object target;
7216+
}
7217+
{
7218+
( target=JdbcNamedParameter() | target=JdbcParameter()
7219+
| target=Table() | target=UserVariable() )
7220+
{ return target; }
7221+
}
7222+
72007223
ReturningReferenceType ReturningReferenceKind():
72017224
{
72027225
String refName;
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement;
11+
12+
import static org.junit.jupiter.api.Assertions.*;
13+
import java.util.List;
14+
import java.util.stream.Stream;
15+
import net.sf.jsqlparser.JSQLParserException;
16+
import net.sf.jsqlparser.expression.JdbcNamedParameter;
17+
import net.sf.jsqlparser.expression.JdbcParameter;
18+
import net.sf.jsqlparser.expression.UserVariable;
19+
import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect;
20+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
21+
import net.sf.jsqlparser.schema.Table;
22+
import net.sf.jsqlparser.statement.delete.Delete;
23+
import net.sf.jsqlparser.statement.insert.Insert;
24+
import net.sf.jsqlparser.statement.merge.Merge;
25+
import net.sf.jsqlparser.statement.select.PlainSelect;
26+
import net.sf.jsqlparser.statement.update.Update;
27+
import net.sf.jsqlparser.test.TestUtils;
28+
import net.sf.jsqlparser.util.deparser.StatementDeParser;
29+
import org.junit.jupiter.api.Test;
30+
import org.junit.jupiter.params.ParameterizedTest;
31+
import org.junit.jupiter.params.provider.MethodSource;
32+
import org.junit.jupiter.params.provider.ValueSource;
33+
34+
class OracleReturningIntoTest {
35+
static Stream<String> dmlStatements() {
36+
return Stream.of("INSERT INTO employees (id, salary) VALUES (1, 100)",
37+
"UPDATE employees SET salary = salary + 100 WHERE id = 1",
38+
"DELETE FROM employees WHERE id = 1",
39+
"MERGE INTO employees e USING changes c ON (e.id = c.id) "
40+
+ "WHEN MATCHED THEN UPDATE SET e.salary = c.salary");
41+
}
42+
43+
@ParameterizedTest
44+
@MethodSource("dmlStatements")
45+
void preservesNamedOutputBinds(String dml) throws JSQLParserException {
46+
String sql = dml + " RETURNING id, salary INTO :out_id, :out_salary";
47+
for (Statement statement : parseBoth(sql)) {
48+
ReturningClause clause = returningClause(statement);
49+
assertFalse(clause.isBulkCollect());
50+
assertEquals(2, clause.getDataItems().size());
51+
assertEquals("out_id",
52+
assertInstanceOf(JdbcNamedParameter.class, clause.getDataItems().get(0))
53+
.getName());
54+
assertEquals("out_salary",
55+
assertInstanceOf(JdbcNamedParameter.class, clause.getDataItems().get(1))
56+
.getName());
57+
roundTrip(statement);
58+
}
59+
}
60+
61+
@ParameterizedTest
62+
@MethodSource("dmlStatements")
63+
void preservesBulkCollectionAndTargets(String dml) throws JSQLParserException {
64+
for (String targets : List.of("ids, salaries", ":ids, :salaries")) {
65+
String sql = dml + " RETURNING id, salary BULK COLLECT INTO " + targets;
66+
for (Statement statement : parseBoth(sql)) {
67+
ReturningClause clause = returningClause(statement);
68+
assertTrue(clause.isBulkCollect());
69+
assertEquals(2, clause.getDataItems().size());
70+
assertEquals(targets.startsWith(":"),
71+
clause.getDataItems().get(0) instanceof JdbcNamedParameter);
72+
assertTrue(statement.toString().contains(" BULK COLLECT INTO "));
73+
roundTrip(statement);
74+
}
75+
}
76+
}
77+
78+
@Test
79+
void countsJdbcOutputParametersAfterInputParameters() throws JSQLParserException {
80+
for (Statement statement : parseBoth("UPDATE employees SET salary = ? WHERE id = ? "
81+
+ "RETURNING id, salary INTO ?, ?")) {
82+
ReturningClause clause = returningClause(statement);
83+
assertFalse(clause.isBulkCollect());
84+
assertEquals(3,
85+
assertInstanceOf(JdbcParameter.class, clause.getDataItems().get(0)).getIndex());
86+
assertEquals(4,
87+
assertInstanceOf(JdbcParameter.class, clause.getDataItems().get(1)).getIndex());
88+
roundTrip(statement);
89+
}
90+
}
91+
92+
@ParameterizedTest
93+
@ValueSource(strings = {"out_id", "record.out_id", "@out_id"})
94+
void preservesExistingOutputTargets(String target) throws JSQLParserException {
95+
Statement statement = CCJSqlParserUtil
96+
.parse("INSERT INTO employees (id) VALUES (1) RETURNING id INTO " + target);
97+
Object actual = returningClause(statement).getDataItems().get(0);
98+
Class<?> expectedType = target.startsWith("@") ? UserVariable.class : Table.class;
99+
assertInstanceOf(expectedType, actual);
100+
assertEquals(target, actual.toString());
101+
roundTrip(statement);
102+
}
103+
104+
@ParameterizedTest
105+
@ValueSource(strings = {"bulk", "AS bulk", "\"bulk\"", "AS collect"})
106+
void keepsAliasesBeforeBulkCollect(String alias) throws JSQLParserException {
107+
for (Statement statement : parseBoth("UPDATE employees SET salary = 100 "
108+
+ "RETURNING salary " + alias + " BULK COLLECT INTO salaries")) {
109+
ReturningClause clause = returningClause(statement);
110+
assertTrue(clause.isBulkCollect());
111+
assertEquals(alias.replace("AS ", ""), clause.get(0).getAlias().getName());
112+
roundTrip(statement);
113+
}
114+
}
115+
116+
@Test
117+
void keepsBulkAndCollectAsOrdinaryIdentifiersAndAliases() throws JSQLParserException {
118+
PlainSelect select = (PlainSelect) CCJSqlParserUtil
119+
.parse("SELECT bulk, collect, salary bulk, salary collect FROM employees");
120+
assertEquals("bulk", select.getSelectItem(0).getExpression().toString());
121+
assertEquals("collect", select.getSelectItem(1).getExpression().toString());
122+
assertEquals("bulk", select.getSelectItem(2).getAlias().getName());
123+
assertEquals("collect", select.getSelectItem(3).getAlias().getName());
124+
roundTrip(select);
125+
}
126+
127+
@ParameterizedTest
128+
@ValueSource(strings = {"bulk", "collect"})
129+
void keepsAliasesBeforeOrdinaryInto(String alias) throws JSQLParserException {
130+
for (Statement statement : parseBoth("DELETE FROM employees RETURNING id " + alias
131+
+ " INTO out_id")) {
132+
ReturningClause clause = returningClause(statement);
133+
assertFalse(clause.isBulkCollect());
134+
assertEquals(alias, clause.get(0).getAlias().getName());
135+
roundTrip(statement);
136+
}
137+
}
138+
139+
@Test
140+
void allowsChangingCollectionModeThroughTheAst() throws JSQLParserException {
141+
Statement statement = CCJSqlParserUtil.parse(
142+
"DELETE FROM employees RETURNING id BULK COLLECT INTO ids");
143+
ReturningClause clause = returningClause(statement);
144+
clause.setBulkCollect(false);
145+
assertFalse(statement.toString().contains("BULK COLLECT"));
146+
roundTrip(statement);
147+
clause.setBulkCollect(true);
148+
assertTrue(statement.toString().contains("BULK COLLECT"));
149+
roundTrip(statement);
150+
}
151+
152+
@ParameterizedTest
153+
@ValueSource(strings = {"INTO", "INTO :", "INTO :out_id,", "BULK COLLECT INTO",
154+
"BULK COLLECT INTO ids,", "BULK COLLECT", "INTO :out_id + 1"})
155+
void rejectsIncompleteClauses(String clause) {
156+
String sql = "DELETE FROM employees RETURNING id " + clause;
157+
assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql));
158+
assertThrows(JSQLParserException.class,
159+
() -> CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.ORACLE)));
160+
}
161+
162+
private static List<Statement> parseBoth(String sql) throws JSQLParserException {
163+
return List.of(TestUtils.assertSqlCanBeParsedAndDeparsed(sql),
164+
CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.ORACLE)));
165+
}
166+
167+
private static ReturningClause returningClause(Statement statement) {
168+
if (statement instanceof Insert) {
169+
return ((Insert) statement).getReturningClause();
170+
}
171+
if (statement instanceof Update) {
172+
return ((Update) statement).getReturningClause();
173+
}
174+
if (statement instanceof Delete) {
175+
return ((Delete) statement).getReturningClause();
176+
}
177+
return ((Merge) statement).getReturningClause();
178+
}
179+
180+
private static void roundTrip(Statement statement) throws JSQLParserException {
181+
StringBuilder buffer = new StringBuilder();
182+
statement.accept(new StatementDeParser(buffer), null);
183+
assertEquals(statement.toString(), buffer.toString());
184+
Statement reparsed = CCJSqlParserUtil.parse(buffer.toString());
185+
assertEquals(buffer.toString(), reparsed.toString());
186+
if (!(statement instanceof PlainSelect)) {
187+
ReturningClause before = returningClause(statement);
188+
ReturningClause after = returningClause(reparsed);
189+
assertEquals(before.isBulkCollect(), after.isBulkCollect());
190+
assertEquals(before.getDataItems().size(), after.getDataItems().size());
191+
for (int i = 0; i < before.getDataItems().size(); i++) {
192+
assertEquals(before.getDataItems().get(i).getClass(),
193+
after.getDataItems().get(i).getClass());
194+
assertEquals(before.getDataItems().get(i).toString(),
195+
after.getDataItems().get(i).toString());
196+
}
197+
}
198+
}
199+
}

0 commit comments

Comments
 (0)