Skip to content
Open
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
30f5852
Oracle returning simple attempt, to improve later
radovanradic Dec 26, 2025
60ff1da
Oracle returning simple attempt, to improve later
radovanradic Dec 26, 2025
3511f55
Oracle returning simple attempt, to improve later
radovanradic Dec 27, 2025
484bc2d
Oracle returning simple attempt, to improve later
radovanradic Dec 27, 2025
135a847
Oracle returning simple attempt, to improve later
radovanradic Dec 27, 2025
110445f
Cleanup and remove obsolete code, for now support only insert returning
radovanradic Dec 28, 2025
25c2d49
Oracle update returning entity supported
radovanradic Dec 28, 2025
fcd899c
Oracle update returning both entity and single field supported
radovanradic Dec 29, 2025
808febc
Undo formatting changes.
radovanradic Dec 29, 2025
81e20e2
Support delete returning for Oracle
radovanradic Dec 30, 2025
1f11c39
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Dec 30, 2025
7a925b8
Cleanup and minor improvements.
radovanradic Dec 30, 2025
b2b17f8
Cleanup space on github runners
radovanradic Dec 30, 2025
1d26088
Revert formatting and updated some docs/comments.
radovanradic Dec 30, 2025
ee48ae4
Minor improvement and more tests
radovanradic Dec 31, 2025
589a9c9
Minor improvement and code cleanup
radovanradic Dec 31, 2025
afa1e23
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Dec 31, 2025
6015d39
Fix Sonar reported issue
radovanradic Dec 31, 2025
44a5ce3
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Jan 12, 2026
4e5cea4
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Jan 16, 2026
eab27fd
Remove added lines
radovanradic Jan 16, 2026
2441863
Support Oracle RETURNING for custom methods/queries.
radovanradic Jan 18, 2026
baffbaa
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Jan 18, 2026
0a585b7
Fix test
radovanradic Jan 18, 2026
3bdeb7f
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Jan 21, 2026
47ff92d
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Feb 3, 2026
7a655d5
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Feb 10, 2026
b6ffeaf
Remove blank line
radovanradic Feb 10, 2026
77776c8
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Mar 9, 2026
2414442
Checkstyle fix
radovanradic Mar 9, 2026
97625ba
Merge remote-tracking branch 'origin/5.0.x' into radovanradic/oracle-…
radovanradic Mar 17, 2026
48ccd1e
Fix review feedback for Oracle RETURNING INTO support (#3762)
Copilot Mar 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions config/checkstyle/custom-suppressions.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@

<suppressions>
<suppress checks="FileLength" files=".*AbstractSqlLikeQueryBuilder.*" />
<suppress checks="FileLength" files="SqlQueryBuilder.java" />
<suppress files="[\\/]generated-src[\\/]" checks="[a-zA-Z0-9]*"/>
</suppressions>
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/*
* Copyright 2017-2025 original authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.micronaut.data.jdbc.mapper;

import io.micronaut.core.annotation.Experimental;
import io.micronaut.core.annotation.Internal;
import io.micronaut.core.convert.ConversionService;
import io.micronaut.data.exceptions.DataAccessException;
import io.micronaut.data.model.DataType;
import io.micronaut.data.runtime.mapper.ResultReader;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

import java.math.BigDecimal;
import java.sql.CallableStatement;
import java.sql.Time;
import java.util.Date;
import java.util.Map;

/**
* A result reader that uses the column name to retrieve the column index and then delegates to
* {@link ColumnIndexCallableResultReader} to read the value.
*
* @author radovanradic
* @since 5.0
*/
@Internal
@Experimental
public final class ColumnNameByIndexCallableResultReader implements ResultReader<CallableStatement, String> {

private final ColumnIndexCallableResultReader delegate;
private final Map<String, Integer> columnIndexesByName;

/**
* Constructs a new instance of ColumnNameByIndexCallableResultReader.
*
* @param delegate The delegate {@link ColumnIndexCallableResultReader} to use for reading values
* @param columnIndexesByName The map of column indexes by names used as out parameters for the callable statement
*/
public ColumnNameByIndexCallableResultReader(ColumnIndexCallableResultReader delegate, Map<String, Integer> columnIndexesByName) {
this.delegate = delegate;
this.columnIndexesByName = columnIndexesByName;
}

@Override
public ConversionService getConversionService() {
return delegate.getConversionService();
}

private Integer getIndex(String columnName) {
if (columnIndexesByName.containsKey(columnName)) {
return columnIndexesByName.get(columnName);
}
throw new DataAccessException("Column name not found: " + columnName);
}

@Nullable
@Override
public Object readDynamic(@NonNull CallableStatement cs, @NonNull String index, @NonNull DataType dataType) {
return delegate.readDynamic(cs, getIndex(index), dataType);
}

@Override
public boolean next(CallableStatement cs) {
return delegate.next(cs);
}

@Override
public <T> T convertRequired(@NonNull Object value, Class<T> type) {
return delegate.convertRequired(value, type);
}

@Override
@Nullable
public Date readTimestamp(CallableStatement cs, String index) {
return delegate.readTimestamp(cs, getIndex(index));
}

@Override
@Nullable
public Time readTime(CallableStatement cs, String index) {
return delegate.readTime(cs, getIndex(index));
}

@Override
public long readLong(CallableStatement cs, String name) {
return delegate.readLong(cs, getIndex(name));
}

@Override
public char readChar(CallableStatement cs, String name) {
return delegate.readChar(cs, getIndex(name));
}

@Override
@Nullable
public Date readDate(CallableStatement cs, String name) {
return delegate.readDate(cs, getIndex(name));
}

@Nullable
@Override
public String readString(CallableStatement cs, String name) {
return delegate.readString(cs, getIndex(name));
}

@Override
public int readInt(CallableStatement cs, String name) {
return delegate.readInt(cs, getIndex(name));
}

@Override
public boolean readBoolean(CallableStatement cs, String name) {
return delegate.readBoolean(cs, getIndex(name));
}

@Override
public float readFloat(CallableStatement cs, String name) {
return delegate.readFloat(cs, getIndex(name));
}

@Override
public byte readByte(CallableStatement cs, String name) {
return delegate.readByte(cs, getIndex(name));
}

@Override
public short readShort(CallableStatement cs, String name) {
return delegate.readShort(cs, getIndex(name));
}

@Override
public double readDouble(CallableStatement cs, String name) {
return delegate.readDouble(cs, getIndex(name));
}

@Override
@Nullable
public BigDecimal readBigDecimal(CallableStatement cs, String name) {
return delegate.readBigDecimal(cs, getIndex(name));
}

@Override
public byte[] readBytes(CallableStatement cs, String name) {
return delegate.readBytes(cs, getIndex(name));
}

@Override
@Nullable
public <T> T getRequiredValue(CallableStatement cs, String name, Class<T> type) throws DataAccessException {
return delegate.getRequiredValue(cs, getIndex(name), type);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@
import io.micronaut.context.annotation.Parameter;
import io.micronaut.core.annotation.AnnotationMetadata;
import io.micronaut.core.annotation.Internal;
import io.micronaut.data.jdbc.mapper.ColumnNameByIndexCallableResultReader;
import io.micronaut.data.model.runtime.QueryOutParameterBinding;
import io.micronaut.data.runtime.mapper.sql.SqlJsonColumnReader;
import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;
import io.micronaut.core.beans.BeanProperty;
Expand Down Expand Up @@ -119,6 +122,7 @@
import java.util.Collection;
import java.util.EnumMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.NoSuchElementException;
Expand Down Expand Up @@ -403,6 +407,35 @@
}

private <T, R> List<R> findAll(Connection connection, SqlPreparedQuery<T, R> preparedQuery, boolean applyPageable) {
if (preparedQuery.getDialect() == Dialect.ORACLE && (
preparedQuery.getOperationType() == StoredQuery.OperationType.INSERT_RETURNING ||
preparedQuery.getOperationType() == StoredQuery.OperationType.UPDATE_RETURNING ||
preparedQuery.getOperationType() == StoredQuery.OperationType.DELETE_RETURNING)) {
try (CallableStatement cs = connection.prepareCall(preparedQuery.getQuery())) {
preparedQuery.bindParameters(new JdbcParameterBinder(connection, cs, preparedQuery));
OutParameterContext outCtx = registerOracleReturningOutParameters(cs, preparedQuery);
int inCount = outCtx.inCount();
List<String> columnNames = outCtx.columnNames();
cs.execute();
boolean isEntityResult = preparedQuery.getResultDataType() == DataType.ENTITY;
if (isEntityResult) {
SqlResultEntityTypeMapper mapper = getSqlResultEntityTypeMapper(preparedQuery.getPersistentEntity(), columnNames, inCount);

Check warning on line 422 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2O&open=AZt0K2jzWBqEuua6pi2O&pullRequest=3669
return List.of((R) mapper.readEntity(cs));
}
// Otherwise single field
List<R> result = new ArrayList<>();
result.add(
(R) columnIndexCallableResultReader.readDynamic(
cs,
inCount + 1,
preparedQuery.getResultDataType()
)
);
return result;
} catch (SQLException e) {
throw new DataAccessException("Error executing Oracle SQL RETURNING: " + e.getMessage(), e);
}
}
try (PreparedStatement ps = prepareStatement(connection::prepareStatement, preparedQuery, !applyPageable, false)) {
preparedQuery.bindParameters(new JdbcParameterBinder(connection, ps, preparedQuery));
return findAll(preparedQuery, ps);
Expand All @@ -411,6 +444,29 @@
}
}

/**
* Creates a {@link SqlResultEntityTypeMapper} instance to map the results from a {@link CallableStatement} to an entity.
* Currently used for INSERT/UPDATE/DELETE ... RETURNING for Oracle dialect.
*
* @param persistentEntity the persistent entity to be mapped
* @param columnNames the column names of the result set
* @param inCount the number of input parameters in the statement
* @return a {@link SqlResultEntityTypeMapper} instance
*/
private SqlResultEntityTypeMapper getSqlResultEntityTypeMapper(RuntimePersistentEntity<?> persistentEntity, List<String> columnNames, int inCount) {

Check warning on line 456 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2P&open=AZt0K2jzWBqEuua6pi2P&pullRequest=3669
Map<String, Integer> columnIndexesByName = new LinkedHashMap<>(columnNames.size());

Check warning on line 457 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this call to the constructor with the better suited static method LinkedHashMap.newLinkedHashMap(int numMappings)

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2S&open=AZt0K2jzWBqEuua6pi2S&pullRequest=3669
int pos = inCount;
for (String columnName : columnNames) {
columnIndexesByName.put(columnName, ++pos);
}
ColumnNameByIndexCallableResultReader resultReader = new ColumnNameByIndexCallableResultReader(columnIndexCallableResultReader,
columnIndexesByName);
SqlJsonColumnReader<CallableStatement> reader = jsonMapper != null ? () -> jsonMapper : null;
SqlResultEntityTypeMapper mapper = new SqlResultEntityTypeMapper<>(persistentEntity, resultReader,

Check warning on line 465 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2Q&open=AZt0K2jzWBqEuua6pi2Q&pullRequest=3669
Set.of(), reader, conversionService);

Check warning on line 466 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Immediately return this expression instead of assigning it to the temporary variable "mapper".

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2N&open=AZt0K2jzWBqEuua6pi2N&pullRequest=3669
return mapper;
}

private <T, R> List<R> findAll(SqlStoredQuery<T, R> sqlStoredQuery, PreparedStatement ps) throws SQLException {
try (ResultSet rs = ps.executeQuery()) {
return findAll(sqlStoredQuery, rs);
Expand Down Expand Up @@ -1114,11 +1170,30 @@
return fallbackMapper.apply(sqlException);
}

private OutParameterContext registerOracleReturningOutParameters(CallableStatement cs, SqlStoredQuery<?, ?> query) throws SQLException {
int inCount = query.getQueryBindings().size();
List<QueryOutParameterBinding> outParams = query.getOutParameterBindings();
if (CollectionUtils.isEmpty(outParams)) {
throw new DataAccessException("Missing OUT parameter metadata for Oracle RETURNING. SqlQueryBuilder must attach QueryOutParameterBinding list.");
}
int pos = inCount;
List<String> columnNames = new ArrayList<>(outParams.size());
for (QueryOutParameterBinding outParam : outParams) {
int sqlType = JdbcQueryStatement.findSqlType(outParam.dataType(), jdbcConfiguration.getDialect());
cs.registerOutParameter(++pos, sqlType);
columnNames.add(outParam.name());
}
return new OutParameterContext(inCount, columnNames);
}

@Override
public boolean isSupportsBatchInsert(JdbcOperationContext jdbcOperationContext, RuntimePersistentEntity<?> persistentEntity) {
return isSupportsBatchInsert(persistentEntity, jdbcOperationContext.dialect);
}

private record OutParameterContext(int inCount, List<String> columnNames) {
}

private final class JdbcParameterBinder implements BindableParametersStoredQuery.Binder {

private final SqlStoredQuery<?, ?> sqlStoredQuery;
Expand Down Expand Up @@ -1271,6 +1346,27 @@
}

private void executeReturning() {
// For Oracle, RETURNING is expressed via PL/SQL block with INTO placeholders.
// Use CallableStatement and OUT parameter to capture the generated id and out field parameters.
if (ctx.dialect == Dialect.ORACLE) {
try (CallableStatement cs = ctx.connection.prepareCall(storedQuery.getQuery())) {
storedQuery.bindParameters(new JdbcParameterBinder(ctx.connection, cs, storedQuery),
ctx.invocationContext, entity, previousValues);
OutParameterContext outCtx = registerOracleReturningOutParameters(cs, storedQuery);
int inCount = outCtx.inCount();
List<String> columnNames = outCtx.columnNames();
rowsUpdated = cs.executeUpdate();
SqlResultEntityTypeMapper mapper = getSqlResultEntityTypeMapper(persistentEntity, columnNames, inCount);

Check warning on line 1359 in data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Provide the parametrized type for this generic.

See more on https://sonarcloud.io/project/issues?id=micronaut-projects_micronaut-data&issues=AZt0K2jzWBqEuua6pi2R&open=AZt0K2jzWBqEuua6pi2R&pullRequest=3669
entity = (T) mapper.readEntity(cs);

// Trigger post-load on the entity
entity = DefaultJdbcRepositoryOperations.this.triggerPostLoad(entity, persistentEntity, ctx.annotationMetadata);
return;
} catch (SQLException e) {
throw new DataAccessException("Error executing Oracle SQL RETURNING: " + e.getMessage(), e);
}
}
// Default path (e.g. Postgres) uses PreparedStatement and maps a result set
try (PreparedStatement ps = ctx.connection.prepareStatement(storedQuery.getQuery())) {
storedQuery.bindParameters(new JdbcParameterBinder(ctx.connection, ps, storedQuery), ctx.invocationContext, entity, previousValues);
List<T> result = (List<T>) findAll(storedQuery, ps);
Expand Down Expand Up @@ -1490,5 +1586,4 @@

private record ConnectionContext(Connection connection, boolean needsToBeClosed) {
}

}
Loading
Loading