diff --git a/config/checkstyle/custom-suppressions.xml b/config/checkstyle/custom-suppressions.xml index bd8daa69090..3c3825067e8 100644 --- a/config/checkstyle/custom-suppressions.xml +++ b/config/checkstyle/custom-suppressions.xml @@ -6,5 +6,6 @@ + diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/mapper/ColumnNameByIndexCallableResultReader.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/mapper/ColumnNameByIndexCallableResultReader.java new file mode 100644 index 00000000000..d11b71284e7 --- /dev/null +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/mapper/ColumnNameByIndexCallableResultReader.java @@ -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 { + + private final ColumnIndexCallableResultReader delegate; + private final Map 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 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 convertRequired(@NonNull Object value, Class 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 getRequiredValue(CallableStatement cs, String name, Class type) throws DataAccessException { + return delegate.getRequiredValue(cs, getIndex(name), type); + } + +} diff --git a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java index 5ab2d361636..b609a932628 100644 --- a/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java +++ b/data-jdbc/src/main/java/io/micronaut/data/jdbc/operations/DefaultJdbcRepositoryOperations.java @@ -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; @@ -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; @@ -403,6 +407,37 @@ private R findOne(Connection connection, SqlPreparedQuery preparedQ } private List findAll(Connection connection, SqlPreparedQuery 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 columnNames = outCtx.columnNames(); + cs.execute(); + boolean isEntityResult = preparedQuery.getResultDataType() == DataType.ENTITY; + if (isEntityResult) { + SqlResultEntityTypeMapper mapper = getSqlResultEntityTypeMapper(preparedQuery.getPersistentEntity(), columnNames, inCount); + T entity = (T) mapper.readEntity(cs); + entity = triggerPostLoad(entity, (RuntimePersistentEntity) preparedQuery.getPersistentEntity(), preparedQuery.getAnnotationMetadata()); + return List.of((R) entity); + } + // Otherwise single field + List 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); @@ -411,6 +446,29 @@ private List findAll(Connection connection, SqlPreparedQuery pre } } + /** + * 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 columnNames, int inCount) { + Map columnIndexesByName = new LinkedHashMap<>(columnNames.size()); + int pos = inCount; + for (String columnName : columnNames) { + columnIndexesByName.put(columnName, ++pos); + } + ColumnNameByIndexCallableResultReader resultReader = new ColumnNameByIndexCallableResultReader(columnIndexCallableResultReader, + columnIndexesByName); + SqlJsonColumnReader reader = jsonMapper != null ? () -> jsonMapper : null; + SqlResultEntityTypeMapper mapper = new SqlResultEntityTypeMapper<>(persistentEntity, resultReader, + Set.of(), reader, conversionService); + return mapper; + } + private List findAll(SqlStoredQuery sqlStoredQuery, PreparedStatement ps) throws SQLException { try (ResultSet rs = ps.executeQuery()) { return findAll(sqlStoredQuery, rs); @@ -1114,11 +1172,30 @@ private DataAccessException sqlExceptionToDataAccessException(SQLException sqlEx return fallbackMapper.apply(sqlException); } + private OutParameterContext registerOracleReturningOutParameters(CallableStatement cs, SqlStoredQuery query) throws SQLException { + int inCount = query.getQueryBindings().size(); + List 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 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 columnNames) { + } + private final class JdbcParameterBinder implements BindableParametersStoredQuery.Binder { private final SqlStoredQuery sqlStoredQuery; @@ -1271,6 +1348,27 @@ protected void execute() throws SQLException { } 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 columnNames = outCtx.columnNames(); + rowsUpdated = cs.executeUpdate(); + SqlResultEntityTypeMapper mapper = getSqlResultEntityTypeMapper(persistentEntity, columnNames, inCount); + 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 result = (List) findAll(storedQuery, ps); @@ -1490,5 +1588,4 @@ public Connection getConnection() { private record ConnectionContext(Connection connection, boolean needsToBeClosed) { } - } diff --git a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXERepositorySpec.groovy b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXERepositorySpec.groovy index 335711bd0a5..9998afddc2b 100644 --- a/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXERepositorySpec.groovy +++ b/data-jdbc/src/test/groovy/io/micronaut/data/jdbc/oraclexe/OracleXERepositorySpec.groovy @@ -16,12 +16,16 @@ package io.micronaut.data.jdbc.oraclexe import groovy.transform.Memoized +import io.micronaut.data.tck.entities.Address +import io.micronaut.data.tck.entities.Restaurant import io.micronaut.data.tck.entities.Book import io.micronaut.data.tck.entities.Face import io.micronaut.data.tck.jdbc.entities.IntervalEntity import io.micronaut.data.tck.repositories.* import io.micronaut.data.tck.tests.AbstractRepositorySpec -import spock.lang.PendingFeature + +import java.time.LocalDateTime +import java.time.temporal.ChronoUnit import java.time.Duration import java.time.Period @@ -45,6 +49,11 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes return context.getBean(OracleXEBookRepository) } + @Memoized + OracleRestaurantRepository getRestaurantRepository() { + return context.getBean(OracleRestaurantRepository) + } + @Memoized @Override GenreRepository getGenreRepository() { @@ -238,7 +247,6 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes cleanupBooks() } - @PendingFeature void "test update returning book"() { given: setupBooks() @@ -251,7 +259,6 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes newBook.title == "Xyz" } - @PendingFeature void "test update returning book title"() { given: setupBooks() @@ -264,7 +271,6 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes bookRepository.findById(book.id).get().title == "Xyz" } - @PendingFeature void "test update returning book title 2"() { given: setupBooks() @@ -276,7 +282,6 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes bookRepository.findById(book.id).get().title == "Xyz" } - @PendingFeature void "test update returning book title 3"() { given: setupBooks() @@ -386,4 +391,131 @@ class OracleXERepositorySpec extends AbstractRepositorySpec implements OracleTes then: count == 2 } + + void "test insert returning book"() { + given: + setupBooks() + def existing = bookRepository.findByTitle("Pet Cemetery") + def bookToCreate = new Book(title: "My book ORA", totalPages: 321, author: existing.author) + when: + def newBook = bookRepository.saveReturning(bookToCreate) + then: + newBook.id + !newBook.is(bookToCreate) + // lifecycle events + bookToCreate.prePersist == 1 + newBook.postLoad == 1 + newBook.postPersist == 1 + // verify persisted + bookRepository.findById(newBook.id).get().title == "My book ORA" + bookRepository.findByTitle("My book ORA") + } + + void "test insert returning books"() { + given: + setupBooks() + def book = bookRepository.findByTitle("Pet Cemetery") + + def booksToCreate = List.of( + new Book(title: "My book 1", totalPages: 123, author: book.author), + new Book(title: "My book 2", totalPages: 123, author: book.author), + new Book(title: "My book 3", totalPages: 123, author: book.author), + ) + when: + def newBooks = bookRepository.saveReturning( + booksToCreate + ) + then: + newBooks.size() == 3 + newBooks[0].id + !newBooks[0].is(booksToCreate[0]) + newBooks[0].title == "My book 1" + newBooks[1].title == "My book 2" + newBooks[2].title == "My book 3" + def newBook = newBooks[0] + bookRepository.findById(newBook.id).get().title == "My book 1" + bookRepository.findByTitle("My book 1") + booksToCreate.forEach { + assert it.prePersist == 1 + } + newBooks.forEach { + assert it.postLoad == 1 + assert it.postPersist == 1 + } + } + + void "test delete returning book"() { + given: + setupBooks() + when: + def book = bookRepository.findByTitle("Pet Cemetery") + Book deletedBook = bookRepository.deleteReturning(book) + then: + deletedBook.id == book.id + deletedBook.title == book.title + deletedBook.postLoad == 1 + } + + void "test delete returning title book"() { + given: + setupBooks() + when: + def book = bookRepository.findByTitle("Pet Cemetery") + String deletedTitle = bookRepository.deleteReturningTitle(book) + then: + deletedTitle == book.title + bookRepository.findById(book.id).isEmpty() + } + + void "test insert returning restaurant with embedded fields"() { + given: + def restaurantToCreate = new Restaurant("Una", new Address("Main", "21002")) + when: + def newRestaurant = restaurantRepository.saveReturning(restaurantToCreate) + then: + newRestaurant.id + !newRestaurant.is(restaurantToCreate) + newRestaurant.address + newRestaurant.address.street == "Main" + // verify persisted + restaurantRepository.findById(newRestaurant.id).get().name == "Una" + cleanup: + restaurantRepository.deleteAll() + } + + void "test custom insert/update/delete returning book(s) with @Query"() { + given: + setupBooks() + def existing = bookRepository.findByTitle("Pet Cemetery") + when: + def one = bookRepository.customInsertReturningBook(existing.author.id, null, "CI one", 111, null, LocalDateTime.now()) + then: + one + one.id + one.title == "CI one" + when: + def current = LocalDateTime.now() + def updated = bookRepository.customUpdateReturning(one.id, "CI one - updated", 110, current) + then: + updated.title == "CI one - updated" + updated.totalPages == 110 + updated.lastUpdated.truncatedTo(ChronoUnit.MILLIS) == current.truncatedTo(ChronoUnit.MILLIS) + when: + def title = bookRepository.customDeleteReturningTitle(updated.id) + then: + title == "CI one - updated" + !bookRepository.findById(updated.id).present + when: + def many = bookRepository.customInsertReturningBooks(existing.author.id, null, "CI many", 112, null, LocalDateTime.now()) + then: + many + many.size() >= 1 + when: + def onlyTitle = bookRepository.customInsertReturningTitle(existing.author.id, null, "CI title", 113, null, LocalDateTime.now()) + then: + onlyTitle == "CI title" + bookRepository.findByTitle("CI title") + cleanup: + bookRepository.deleteAll() + } } diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleRestaurantRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleRestaurantRepository.java new file mode 100644 index 00000000000..a82c5c820a0 --- /dev/null +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleRestaurantRepository.java @@ -0,0 +1,12 @@ +package io.micronaut.data.jdbc.oraclexe; + +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.tck.entities.Restaurant; +import io.micronaut.data.tck.repositories.RestaurantRepository; + +@JdbcRepository(dialect = Dialect.ORACLE) +public interface OracleRestaurantRepository extends RestaurantRepository { + + Restaurant saveReturning(Restaurant restaurant); +} diff --git a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEBookRepository.java b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEBookRepository.java index de6cf9ebfaf..fa9e2ab1626 100644 --- a/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEBookRepository.java +++ b/data-jdbc/src/test/java/io/micronaut/data/jdbc/oraclexe/OracleXEBookRepository.java @@ -15,6 +15,7 @@ */ package io.micronaut.data.jdbc.oraclexe; +import io.micronaut.data.annotation.Id; import org.jspecify.annotations.NonNull; import io.micronaut.data.annotation.Expandable; import io.micronaut.data.annotation.Query; @@ -28,6 +29,8 @@ import io.micronaut.data.tck.repositories.BookRepository; import org.jspecify.annotations.Nullable; + +import java.time.LocalDateTime; import java.util.Collection; import java.util.List; @@ -61,11 +64,62 @@ public OracleXEBookRepository(OracleXEAuthorRepository authorRepository) { @ClientInfo.Attribute(name = "OCSID.ACTION", value = "INSERT") public abstract @NonNull Book save(@NonNull Book book); - // public abstract Book updateReturning(Book book); -// -// public abstract String updateReturningTitle(Book book); -// -// public abstract String updateReturningTitle(@Id Long id, String title); -// -// public abstract String updateByIdReturningTitle(Long id, String title); + public abstract Book saveReturning(Book book); + + public abstract List saveReturning(List books); + + public abstract Book updateReturning(Book book); + + public abstract String updateReturningTitle(Book book); + + public abstract String updateReturningTitle(@Id Long id, String title); + + public abstract String updateByIdReturningTitle(Long id, String title); + + public abstract Book deleteReturning(Book book); + + public abstract String deleteReturningTitle(Book book); + + @Query(""" + INSERT INTO book (author_id,genre_id,title,total_pages,publisher_id,last_updated) + VALUES (:authorId, :genderId, :title, :totalPages, :publisherId, :lastUpdated) + RETURNING * + """) + public abstract List customInsertReturningBooks(Long authorId, + @Nullable Long genderId, + String title, + int totalPages, + @Nullable Long publisherId, + LocalDateTime lastUpdated); + + @Query(""" + INSERT INTO "BOOK" ("AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED") + VALUES (:authorId, :genreId, :title, :totalPages, :publisherId, :lastUpdated) + RETURNING * + """) + public abstract Book customInsertReturningBook(Long authorId, + @Nullable Long genreId, + String title, + int totalPages, + @Nullable Long publisherId, + LocalDateTime lastUpdated); + + @Query(""" + INSERT INTO "BOOK" ("AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED") + VALUES (:authorId, :genreId, :title, :totalPages, :publisherId, :lastUpdated) + RETURNING "TITLE" + """) + public abstract String customInsertReturningTitle(Long authorId, + @Nullable Long genreId, + String title, + int totalPages, + @Nullable Long publisherId, + LocalDateTime lastUpdated); + + @Query("UPDATE \"BOOK\" SET \"TITLE\"=:title,\"TOTAL_PAGES\"=:totalPages,\"LAST_UPDATED\"=:lastUpdated WHERE \"ID\" = :bookId RETURNING *") + public abstract Book customUpdateReturning(Long bookId, String title, int totalPages, LocalDateTime lastUpdated); + + @Query("DELETE FROM \"BOOK\" WHERE \"ID\" = :bookId RETURNING \"TITLE\"") + public abstract String customDeleteReturningTitle(Long bookId); + } diff --git a/data-model/src/main/java/io/micronaut/data/annotation/JsonView.java b/data-model/src/main/java/io/micronaut/data/annotation/JsonView.java index 12cea50775c..7bc3151e43e 100644 --- a/data-model/src/main/java/io/micronaut/data/annotation/JsonView.java +++ b/data-model/src/main/java/io/micronaut/data/annotation/JsonView.java @@ -32,7 +32,7 @@ * {@code * @JsonView(value = "CONTACT_VIEW", alias = "cv", entity = Contact.class) * public class ContactView { - * @Id + * \@Id * @GeneratedValue(GeneratedValue.Type.IDENTITY) * private Long id; * private String name; diff --git a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java index 2bad6196555..5549f6ef0be 100644 --- a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java +++ b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQuery.java @@ -105,6 +105,7 @@ * Meta member for storing the parameters. */ String META_MEMBER_PARAMETERS = "parameters"; + String META_MEMBER_OUT_PARAMETERS = "outParameters"; /** * The member name that holds the root entity type. @@ -139,6 +140,12 @@ */ DataMethodQueryParameter[] parameters() default {}; + /** + * @return The query OUT parameters (e.g. Oracle RETURNING ... INTO ...) + * @since 5.0 + */ + DataMethodQueryOutParameter[] outParameters() default {}; + /** * @return True if the method represents the procedure invocation. * @since 4.2.0 diff --git a/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQueryOutParameter.java b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQueryOutParameter.java new file mode 100644 index 00000000000..c941b38158c --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/intercept/annotation/DataMethodQueryOutParameter.java @@ -0,0 +1,61 @@ +/* + * 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.intercept.annotation; + +import io.micronaut.core.annotation.Internal; +import io.micronaut.data.model.DataType; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Internal annotation representing OUT parameter binding metadata for queries + * (e.g. Oracle RETURNING ... INTO ...). + * + * This mirrors a subset of {@link DataMethodQueryParameter} members that are + * relevant for OUT parameters. + * + * @since 5.0 + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +@Internal +@Inherited +public @interface DataMethodQueryOutParameter { + + /** + * The member name that holds an optional out parameter name (typically a column name). + */ + String META_MEMBER_NAME = "name"; + + /** + * The member name that holds the data type. + */ + String META_MEMBER_DATA_TYPE = "dataType"; + + /** + * @return The OUT parameter name (column/alias), when present. + */ + String name() default ""; + + /** + * @return The OUT parameter data type (if known). + */ + DataType dataType() default DataType.OBJECT; +} diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryOutParameterBinding.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryOutParameterBinding.java new file mode 100644 index 00000000000..8d3caa050bb --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryOutParameterBinding.java @@ -0,0 +1,46 @@ +/* + * 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.model.query.builder; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.data.model.DataType; + +/** + * Describes an OUT parameter binding for a SQL query (for example Oracle RETURNING ... INTO ...). + * + * This metadata is attached to {@link QueryResult} and can be propagated into the runtime + * to register CallableStatement OUT parameters with correct ordering and types. + * + * @since 5.0 + */ +@Experimental +@Internal +public interface QueryOutParameterBinding { + + /** + * @return The name of the column/parameter (when available). + */ + @NonNull + String getName(); + + /** + * @return The data type, when known. + */ + @NonNull + DataType getDataType(); +} diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryResult.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryResult.java index 9e7320cd1b2..1e38d19d139 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryResult.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/QueryResult.java @@ -15,6 +15,7 @@ */ package io.micronaut.data.model.query.builder; +import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; import io.micronaut.core.util.ArgumentUtils; import io.micronaut.data.model.DataType; @@ -38,7 +39,6 @@ public interface QueryResult { /** * @return A string representation of the original query. */ - String getQuery(); /** @@ -78,6 +78,16 @@ default Map getParameterTypes() { */ List getParameterBindings(); + /** + * Returns the out parameters binding for this query. + * + * @return the out parameters binding + */ + @NonNull + default List getOutParameterBindings() { + return List.of(); + } + /** * Returns additional required parameters. * @@ -138,7 +148,6 @@ static QueryResult of(String query, ArgumentUtils.requireNonNull("additionalRequiredParameters", additionalRequiredParameters); return new QueryResult() { - @Override public String getQuery() { return query; @@ -437,4 +446,54 @@ public Collection getJoinPaths() { }; } + /** + * Creates a new encoded query. + * + * @param query The query + * @param queryParts The queryParts + * @param parameterBindings The parameters binding + * @param outParameterBindings The out parameter binding + * @param additionalRequiredParameters Additional required parameters to execute the query + * @return The query + */ + @NonNull + static QueryResult of( + @NonNull String query, + @NonNull List queryParts, + @NonNull List parameterBindings, + @NonNull List outParameterBindings, + @NonNull Map additionalRequiredParameters) { + ArgumentUtils.requireNonNull("query", query); + ArgumentUtils.requireNonNull("parameterBindings", parameterBindings); + ArgumentUtils.requireNonNull("outParameterBindings", outParameterBindings); + ArgumentUtils.requireNonNull("additionalRequiredParameters", additionalRequiredParameters); + + return new QueryResult() { + @NonNull + @Override + public String getQuery() { + return query; + } + + @Override + public List getQueryParts() { + return queryParts; + } + + @Override + public List getParameterBindings() { + return parameterBindings; + } + + @Override + public Map getAdditionalRequiredParameters() { + return additionalRequiredParameters; + } + + @Override + public List getOutParameterBindings() { + return outParameterBindings; + } + }; + } } diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/AbstractSqlLikeQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/AbstractSqlLikeQueryBuilder.java index c5ac2bee734..374776ef4ff 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/AbstractSqlLikeQueryBuilder.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/AbstractSqlLikeQueryBuilder.java @@ -18,6 +18,7 @@ import io.micronaut.core.annotation.AnnotationMetadata; import io.micronaut.core.annotation.AnnotationValue; import io.micronaut.core.annotation.Internal; +import io.micronaut.data.model.query.builder.QueryOutParameterBinding; import org.jspecify.annotations.Nullable; import io.micronaut.core.util.ArgumentUtils; import io.micronaut.core.util.ArrayUtils; @@ -174,7 +175,6 @@ protected boolean traverseEmbedded() { * @param value The literal value * @return converter value */ - protected String asLiteral(@Nullable Object value) { if (value instanceof LiteralExpression literalExpression) { value = literalExpression.getValue(); @@ -344,7 +344,6 @@ public String getAliasName(JoinPath joinPath) { * @param joinPath The join path * @return The alias */ - protected String getPathOnlyAliasName(JoinPath joinPath) { return joinPath.getAlias().orElseGet(() -> { var p = new StringBuilder(); @@ -518,7 +517,6 @@ protected NamingStrategy getNamingStrategy(PersistentEntity entity) { * @param association the association * @return the mapped name for the association */ - protected String getMappedName(NamingStrategy namingStrategy, Association association) { return namingStrategy.mappedName(association); } @@ -531,7 +529,6 @@ protected String getMappedName(NamingStrategy namingStrategy, Association assoc * @param property the property * @return the mappen name for the list of associations and property using given naming strategy */ - protected String getMappedName(NamingStrategy namingStrategy, List associations, PersistentProperty property) { return namingStrategy.mappedName(associations, property); } @@ -543,7 +540,6 @@ protected String getMappedName(NamingStrategy namingStrategy, List * @param propertyPath the property path * @return the mappen name for the list of associations and property using given naming strategy */ - protected String getMappedName(NamingStrategy namingStrategy, PersistentPropertyPath propertyPath) { return namingStrategy.mappedName(propertyPath.getAssociations(), propertyPath.getProperty()); } @@ -959,8 +955,23 @@ protected void appendTransformed(StringBuilder sb, String transformed, Runnable */ protected abstract boolean computePropertyPaths(); + /** + * Creates a visitor for handling the RETURNING clause in an UPDATE/DELETE statement. + * + * This method is used to generate the necessary SQL for the RETURNING clause + * when executing an UPDATE or DELETE query with a RETURNING clause. + * + * @param annotationMetadata The annotation metadata associated with the query. + * @param queryState The current state of the query being built. + * @param distinct Whether the query is marked as DISTINCT. + * @return A visitor that can handle the RETURNING clause. + */ + protected ReturningSelectionVisitor createReturningSelectionVisitor(AnnotationMetadata annotationMetadata, QueryState queryState, boolean distinct) { + throw new UnsupportedOperationException("Not supported by this SQL builder."); + } + @Override - public QueryResult buildUpdate(AnnotationMetadata annotationMetadata, UpdateQueryDefinition definition) { + public QueryResult buildUpdate(AnnotationMetadata annotationMetadata, UpdateQueryDefinition definition) { Map propertiesToUpdate = definition.propertiesToUpdate(); if (propertiesToUpdate.isEmpty()) { throw new IllegalArgumentException("No properties specified to update"); @@ -983,10 +994,14 @@ public QueryResult buildUpdate(AnnotationMetadata annotationMetadata, UpdateQue throw new IllegalStateException("Dialect: " + getDialect() + " doesn't support UPDATE ... RETURNING clause"); } queryString.append(RETURNING); - buildSelect(annotationMetadata, - queryState, - returningSelection, - false); + if (getDialect() == Dialect.ORACLE) { + return buildOracleUpdateOrDeleteReturningQueryResult(annotationMetadata, queryState, returningSelection, definition, true); + } else { + buildSelect(annotationMetadata, + queryState, + returningSelection, + false); + } } return QueryResult.of(queryState.getFinalQuery(), queryState.getQueryParts(), @@ -1012,10 +1027,14 @@ public QueryResult buildDelete(AnnotationMetadata annotationMetadata, DeleteQue throw new IllegalStateException("Dialect: " + getDialect() + " doesn't support DELETE ... RETURNING clause"); } queryString.append(RETURNING); - buildSelect(annotationMetadata, - queryState, - returningSelection, - false); + if (getDialect() == Dialect.ORACLE) { + return buildOracleUpdateOrDeleteReturningQueryResult(annotationMetadata, queryState, returningSelection, definition, false); + } else { + buildSelect(annotationMetadata, + queryState, + returningSelection, + false); + } } return QueryResult.of(queryState.getFinalQuery(), queryState.getQueryParts(), @@ -1037,7 +1056,6 @@ public QueryResult buildDelete(AnnotationMetadata annotationMetadata, DeleteQue * @param queryString The query string * @return The delete clause */ - protected StringBuilder appendDeleteClause(StringBuilder queryString) { return queryString.append("DELETE ").append(FROM_CLAUSE); } @@ -1053,7 +1071,6 @@ protected StringBuilder appendDeleteClause(StringBuilder queryString) { * @param tableAlias The table alias * @return The encoded query */ - public String buildOrderBy(String query, PersistentEntity entity, AnnotationMetadata annotationMetadata, @@ -1485,6 +1502,48 @@ protected void appendLimitAndOffset(Dialect dialect, long limit, long offset, St } } + private QueryResult buildOracleUpdateOrDeleteReturningQueryResult(AnnotationMetadata annotationMetadata, + QueryState queryState, + Selection returningSelection, + BaseQueryDefinition definition, + boolean update) { + // Collect OUT parameter metadata (column names and data types) + ReturningSelectionVisitor visitor = createReturningSelectionVisitor(annotationMetadata, queryState, false); + if (returningSelection instanceof ISelection selectionVisitable) { + selectionVisitable.visitSelection(visitor); + } else { + throw new IllegalStateException("Unknown selection type: " + returningSelection.getClass().getName()); + } + int inCount = queryState.getParameterBindings().size(); + int outCount = visitor.getUnescapedColumns().size(); + if (outCount == 0) { + String operation = update ? "UPDATE" : "DELETE"; + throw new IllegalStateException(operation + " ... RETURNING requires at least one column to return for entity: " + definition.persistentEntity().getName()); + } + List placeholders = new ArrayList<>(outCount); + for (int i = 0; i < outCount; i++) { + placeholders.add(formatParameter(inCount + 1 + i).name()); + } + final String finalSql = "BEGIN " + queryState.getFinalQuery() + " INTO " + String.join(",", placeholders) + "; END;"; + final List outBindings = new ArrayList<>(outCount); + for (int i = 0; i < outCount; i++) { + final String col = visitor.getUnescapedColumns().get(i); + final DataType dt = visitor.getResultColumnTypes().get(i); + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return col; + } + + @Override + public DataType getDataType() { + return dt; + } + }); + } + return QueryResult.of(finalSql, List.of(), queryState.getParameterBindings(), outBindings, Map.of()); + } + protected record QueryBuilder(AtomicInteger position, List parameterBindings, StringBuilder query, @@ -3052,4 +3111,33 @@ private QueryPropertyPath findProperty(String propertyPath) { } } + + /** + * Visitor for handling the columns produced by a dialect-specific + * UPDATE/DELETE ... RETURNING clause. + *

+ * Implementations collect the unescaped column names as they are rendered + * into the SQL and the corresponding {@link DataType}s so that callers can + * construct the appropriate OUT parameter metadata (for example, when using + * Oracle's RETURNING INTO mechanism). + *

+ */ + protected interface ReturningSelectionVisitor extends SelectionVisitor { + + /** + * Returns the list of physical column names as they appear in the SQL, + * without dialect-specific quoting applied. + * + * @return unescaped column names in the order they are rendered + */ + List getUnescapedColumns(); + + /** + * Returns the data types for the columns produced by the RETURNING clause. + * The order must match {@link #getUnescapedColumns()}. + * + * @return result column data types + */ + List getResultColumnTypes(); + } } diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/Dialect.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/Dialect.java index 7cf77a8024a..782b6a58295 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/Dialect.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/Dialect.java @@ -68,7 +68,7 @@ public enum Dialect { /** * Oracle 12c or above. */ - ORACLE(true, true, ALL_TYPES, true, false, false, false), + ORACLE(true, true, ALL_TYPES, true, true, true, true), /** * Ansi compliant SQL. */ diff --git a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java index 46ab9a41bf5..4f4f86aa971 100644 --- a/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java +++ b/data-model/src/main/java/io/micronaut/data/model/query/builder/sql/SqlQueryBuilder.java @@ -57,6 +57,7 @@ import io.micronaut.data.model.query.JoinPath; import io.micronaut.data.model.query.builder.QueryParameterBinding; import io.micronaut.data.model.query.builder.QueryResult; +import io.micronaut.data.model.query.builder.QueryOutParameterBinding; import io.micronaut.data.model.schema.sql.SqlColumnMapping; import io.micronaut.data.model.schema.sql.SqlIndexMapping; import io.micronaut.data.model.schema.sql.SqlSequenceMapping; @@ -210,7 +211,6 @@ protected String asLiteral(@Nullable Object value) { * @return The table */ @Experimental - public String buildBatchCreateTableStatement(PersistentEntity... entities) { return Arrays.stream(entities).flatMap(entity -> Stream.of(buildCreateTableStatements(entity))) .collect(Collectors.joining(System.lineSeparator())); @@ -224,7 +224,6 @@ public String buildBatchCreateTableStatement(PersistentEntity... entities) { * @return The table */ @Experimental - public String buildBatchDropTableStatement(PersistentEntity... entities) { return Arrays.stream(entities).flatMap(entity -> Stream.of(buildDropTableStatements(entity))) .collect(Collectors.joining("\n")); @@ -355,7 +354,6 @@ public String[] buildCreateTableStatements(PersistentEntity entity) { * @return The tables for the given entities */ @Experimental - public String[] buildCreateTableStatements(PersistentEntity[] entities) { Map sqlTableMappingByTableName = CollectionUtils.newLinkedHashMap(entities.length); // Entity can generate indexes, sequences, join tables so need some longer map @@ -967,6 +965,11 @@ protected SqlSelectionVisitor createSelectionVisitor(AnnotationMetadata annotati return new SqlSelectionVisitor(queryState, annotationMetadata, distinct); } + @Override + protected ReturningSelectionVisitor createReturningSelectionVisitor(AnnotationMetadata annotationMetadata, QueryState queryState, boolean distinct) { + return new DefaultReturningSelectionVisitor(queryState, annotationMetadata, distinct); + } + @Override public String resolveJoinType(Join.Type jt) { if (!this.dialect.supportsJoinType(jt)) { @@ -992,6 +995,9 @@ public QueryResult buildInsert(AnnotationMetadata repositoryMetadata, InsertQuer final String unescapedSchema = SqlQueryBuilderUtils.getSchemaName(entity); String builder; + List resultColumns = new ArrayList<>(); + List unescapedColumns = new ArrayList<>(); + List resultColumnTypes = new ArrayList<>(); List parameterBindings = new ArrayList<>(); if (isJsonEntity(repositoryMetadata, entity)) { @@ -1007,6 +1013,9 @@ public QueryResult buildInsert(AnnotationMetadata repositoryMetadata, InsertQuer String identityName = identity.getAnnotationMetadata().stringValue(SERDE_CONFIG_ANNOTATION, "property") .orElse(identity.getAnnotationMetadata().stringValue(JSON_PROPERTY_ANNOTATION) .orElse(identity.getName())); + resultColumns.add(identityName); + resultColumnTypes.add(identity.getDataType()); + unescapedColumns.add(identityName); builder = "BEGIN " + builder + " RETURNING JSON_VALUE(" + columnName + ",'$." + identityName + "') INTO " + formatParameter(key + 1) + "; END;"; } parameterBindings.add(new QueryParameterBinding() { @@ -1039,7 +1048,6 @@ public JsonDataType getJsonDataType() { Collection persistentProperties = entity.getPersistentProperties(); List columns = new ArrayList<>(); - List resultColumns = new ArrayList<>(); List values = new ArrayList<>(); for (PersistentProperty prop : persistentProperties) { @@ -1047,10 +1055,12 @@ public JsonDataType getJsonDataType() { boolean generated = SqlQueryBuilderUtils.isGeneratedProperty(property, associations); if (generated) { String columnName = getMappedName(namingStrategy, associations, property); + unescapedColumns.add(columnName); if (escape) { columnName = quote(columnName); } resultColumns.add(columnName); + resultColumnTypes.add(property.getDataType()); return; } @@ -1086,11 +1096,13 @@ public String[] getPropertyPath() { }); String columnName = getMappedName(namingStrategy, associations, property); + unescapedColumns.add(columnName); if (escape) { columnName = quote(columnName); } columns.add(columnName); resultColumns.add(columnName); + resultColumnTypes.add(property.getDataType()); }); } if (entity.hasVersion()) { @@ -1123,18 +1135,21 @@ public String[] getPropertyPath() { }); String columnName = getMappedName(namingStrategy, Collections.emptyList(), version); + unescapedColumns.add(columnName); if (escape) { columnName = quote(columnName); } columns.add(columnName); resultColumns.add(columnName); + resultColumnTypes.add(version.getDataType()); } } for (PersistentProperty identity : entity.getIdentityProperties()) { // Property skipped PersistentEntityUtils.traversePersistentProperties(Collections.emptyList(), identity, (associations, property) -> { - String columnName = getMappedName(namingStrategy, associations, property); + String unescapedColumnName = getMappedName(namingStrategy, associations, property); + String columnName = unescapedColumnName; if (escape) { columnName = quote(columnName); } @@ -1142,7 +1157,9 @@ public String[] getPropertyPath() { boolean isSequence = false; if (SqlQueryBuilderUtils.isNotForeign(associations)) { + unescapedColumns.add(unescapedColumnName); resultColumns.add(columnName); + resultColumnTypes.add(property.getDataType()); Optional> generated = property.findAnnotation(GeneratedValue.class); if (generated.isPresent()) { @@ -1204,9 +1221,41 @@ public String[] getPropertyPath() { "VALUES (" + String.join(String.valueOf(COMMA), values) + CLOSE_BRACKET; if (definition.returning()) { - // TODO: proper selection of columns - builder += RETURNING + String.join(",", resultColumns); + if (dialect == Dialect.ORACLE) { + // For Oracle use RETURNING all result columns INTO placeholders with CallableStatement. + if (resultColumns.isEmpty()) { + throw new IllegalStateException("INSERT ... RETURNING requires at least one column to return for entity: " + entity.getName()); + } + List outPlaceholders = new ArrayList<>(resultColumns.size()); + for (int i = 0; i < resultColumns.size(); i++) { + outPlaceholders.add(formatParameter(values.size() + 1 + i).name()); + } + builder = "BEGIN " + builder + " RETURNING " + String.join(",", resultColumns) + " INTO " + String.join(",", outPlaceholders) + "; END;"; + } else { + // Postgres and others using a result set for RETURNING + builder += RETURNING + String.join(",", resultColumns); + } + } + } + if (definition.returning() && dialect == Dialect.ORACLE) { + // Attach OUT parameter bindings metadata (columns listed in RETURNING ...) + List outBindings = new ArrayList<>(); + for (int i = 0; i < unescapedColumns.size(); i++) { + final String col = unescapedColumns.get(i); + final DataType dt = i < resultColumnTypes.size() ? resultColumnTypes.get(i) : DataType.STRING; + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return col; + } + + @Override + public DataType getDataType() { + return dt; + } + }); } + return QueryResult.of(builder, List.of(), parameterBindings, outBindings, Map.of()); } return QueryResult.of(builder, Collections.emptyList(), @@ -1994,4 +2043,102 @@ private boolean canUseWildcardForSelect(AnnotationMetadata annotationMetadata, P } + /** + * Default implementation of {@link ReturningSelectionVisitor} used by {@link SqlQueryBuilder} + * to render the projection for SQL RETURNING clauses (INSERT/UPDATE/DELETE). + *

+ * In addition to emitting the selection into the SQL buffer, this visitor collects: + *

    + *
  • Unescaped column names in declaration order via {@link #getUnescapedColumns()}
  • + *
  • Result column data types via {@link #getResultColumnTypes()}
  • + *
+ * The collected metadata is used by dialects such as Oracle that require {@code RETURNING ... INTO} + * OUT parameters instead of a result set. + *

+ * This type is not thread-safe and is intended for per-query use only. + * It is an internal implementation detail and not part of the public API. + * + * @see SqlQueryBuilder#createReturningSelectionVisitor(AnnotationMetadata, QueryState, boolean) + * @see ReturningSelectionVisitor + */ + @Internal + protected final class DefaultReturningSelectionVisitor extends SqlSelectionVisitor implements ReturningSelectionVisitor { + private final List unescapedColumns = new ArrayList<>(); + private final List resultColumnTypes = new ArrayList<>(); + + DefaultReturningSelectionVisitor(QueryState queryState, AnnotationMetadata annotationMetadata, boolean distinct) { + super(queryState, annotationMetadata, distinct); + } + + @Override + public List getUnescapedColumns() { + return unescapedColumns; + } + + @Override + public List getResultColumnTypes() { + return resultColumnTypes; + } + + @Override + public void selectAllColumns(AnnotationMetadata annotationMetadata, PersistentEntity entity, @Nullable String alias) { + // Mirror base behavior, but also collect unescaped column names and types for OUT parameter metadata + boolean escape = shouldEscape(entity); + NamingStrategy namingStrategy = getNamingStrategy(entity); + int length = query.length(); + PersistentEntityUtils.traversePersistentProperties(entity, (associations, property) -> { + appendProperty(query, associations, property, namingStrategy, alias, escape); + unescapedColumns.add(getMappedName(namingStrategy, associations, property)); + resultColumnTypes.add(property.getDataType()); + }); + int newLength = query.length(); + if (newLength == length) { + // Fallback to wildcard if no properties were appended (shouldn't normally happen for non-JSON entities) + if (alias != null) { + query.append(alias).append(DOT); + } + query.append("*"); + } else { + query.setLength(newLength - 1); + } + } + + @Override + protected void appendPropertyProjection(QueryPropertyPath propertyPath) { + boolean jsonEntity = isJsonEntity(annotationMetadata, entity); + if (!computePropertyPaths() || jsonEntity) { + // Delegate to default rendering; collect best-effort name/type + super.appendPropertyProjection(propertyPath); + PersistentProperty prop = propertyPath.getPropertyPath().getProperty(); + unescapedColumns.add(prop.getPersistedName()); + resultColumnTypes.add(prop.getDataType()); + return; + } + String tableAlias = propertyPath.getTableAlias(); + boolean escape = propertyPath.shouldEscape(); + NamingStrategy namingStrategy = propertyPath.getNamingStrategy(); + boolean[] needsTrimming = {false}; + int[] propertiesCount = new int[1]; + + PersistentEntityUtils.traversePersistentProperties(propertyPath.getAssociations(), propertyPath.getProperty(), traverseEmbedded(), (associations, property) -> { + appendProperty(query, associations, property, namingStrategy, tableAlias, escape); + unescapedColumns.add(getMappedName(namingStrategy, associations, property)); + resultColumnTypes.add(property.getDataType()); + needsTrimming[0] = true; + propertiesCount[0]++; + }); + if (needsTrimming[0]) { + query.setLength(query.length() - 1); + } + if (StringUtils.isNotEmpty(columnAlias)) { + if (propertiesCount[0] > 1) { + throw new IllegalStateException("Cannot apply a column alias: " + columnAlias + " with expanded property: " + propertyPath); + } + if (propertiesCount[0] == 1) { + query.append(AS_CLAUSE).append(columnAlias); + } + } + } + } + } diff --git a/data-model/src/main/java/io/micronaut/data/model/runtime/QueryOutParameterBinding.java b/data-model/src/main/java/io/micronaut/data/model/runtime/QueryOutParameterBinding.java new file mode 100644 index 00000000000..051871317a7 --- /dev/null +++ b/data-model/src/main/java/io/micronaut/data/model/runtime/QueryOutParameterBinding.java @@ -0,0 +1,45 @@ +/* + * 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.model.runtime; + +import io.micronaut.core.annotation.Experimental; +import io.micronaut.core.annotation.Internal; +import io.micronaut.data.model.DataType; +import org.jspecify.annotations.NonNull; + +/** + * Runtime OUT parameter binding metadata for a stored query (e.g. Oracle RETURNING ... INTO ...). + * Mirrors the builder-time metadata and is used by repository operations to register + * CallableStatement OUT parameters in correct order and JDBC type. + * + * @since 5.0 + */ +@Experimental +@Internal +public interface QueryOutParameterBinding { + + /** + * @return The name of the OUT column/parameter if available. + */ + @NonNull + String name(); + + /** + * @return The data type for the OUT parameter when known. + */ + @NonNull + DataType dataType(); +} diff --git a/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java b/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java index 71274ff779c..41608a3a7d2 100644 --- a/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java +++ b/data-model/src/main/java/io/micronaut/data/model/runtime/StoredQuery.java @@ -44,7 +44,6 @@ public interface StoredQuery extends Named, StoredDataOperation { * * @return The root entity type */ - Class getRootEntity(); /** @@ -58,7 +57,6 @@ public interface StoredQuery extends Named, StoredDataOperation { * * @return The query to execute */ - String getQuery(); /** @@ -66,7 +64,6 @@ public interface StoredQuery extends Named, StoredDataOperation { * * @return The query to execute */ - String[] getExpandableQueryParts(); /** @@ -81,7 +78,6 @@ public interface StoredQuery extends Named, StoredDataOperation { * * @return The query result type */ - Class getResultType(); /** @@ -90,13 +86,11 @@ public interface StoredQuery extends Named, StoredDataOperation { * @return The query result type */ @Override - Argument getResultArgument(); /** * @return The result data type. */ - DataType getResultDataType(); /** @@ -145,7 +139,6 @@ default boolean isDtoProjection() { * * @return The parameter binding. */ - default Map getQueryHints() { return Collections.emptyMap(); } @@ -154,7 +147,6 @@ default Map getQueryHints() { * @return The all join paths * @since 4.8.1 */ - default Set getJoinPaths() { return Collections.emptySet(); } @@ -202,7 +194,6 @@ default Map> getParameterExpressions() { * @return The query limit * @since 4.13 */ - default Limit getQueryLimit() { return Limit.UNLIMITED; } @@ -211,11 +202,21 @@ default Limit getQueryLimit() { * @return The runtime sort * @since 4.13 */ - default Sort getSort() { return Sort.UNSORTED; } + /** + * OUT parameters metadata for this stored query (e.g. Oracle RETURNING ... INTO ...). + * Order corresponds to the order in which OUT parameters must be registered. + * + * @return list of OUT parameter bindings or empty if none + * @since 5.0 + */ + default List getOutParameterBindings() { + return List.of(); + } + /** * Describes the operation type. */ diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java index 7ff5b5329e9..e655ba4ecd3 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/RepositoryTypeElementVisitor.java @@ -63,6 +63,8 @@ import io.micronaut.data.model.query.builder.QueryBuilder; import io.micronaut.data.model.query.builder.QueryParameterBinding; import io.micronaut.data.model.query.builder.QueryResult; +import io.micronaut.data.model.query.builder.QueryOutParameterBinding; +import io.micronaut.data.intercept.annotation.DataMethodQueryOutParameter; import io.micronaut.data.model.query.builder.jpa.JpaQueryBuilder; import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder; import io.micronaut.data.processor.model.SourcePersistentEntity; @@ -688,6 +690,18 @@ private void addQueryDefinition(MethodMatchContext methodMatchContext, if (parameterBinding.stream().anyMatch(QueryParameterBinding::isExpandable)) { annotationBuilder.member(DataMethodQuery.META_MEMBER_EXPANDABLE_QUERY, queryResult.getQueryParts().toArray(new String[0])); } + // OUT parameter bindings (e.g. Oracle RETURNING ... INTO ...) + List outBindings = queryResult.getOutParameterBindings(); + if (CollectionUtils.isNotEmpty(outBindings)) { + List> outAnnotations = new ArrayList<>(outBindings.size()); + for (QueryOutParameterBinding b : outBindings) { + AnnotationValueBuilder outBuilder = AnnotationValue.builder(DataMethodQueryOutParameter.class); + outBuilder.member(DataMethodQueryOutParameter.META_MEMBER_NAME, b.getName()); + outBuilder.member(DataMethodQueryOutParameter.META_MEMBER_DATA_TYPE, b.getDataType()); + outAnnotations.add(outBuilder.build()); + } + annotationBuilder.member(DataMethodQuery.META_MEMBER_OUT_PARAMETERS, outAnnotations.toArray(new AnnotationValue[0])); + } int max = queryResult.getMax(); if (max > -1) { diff --git a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/RawQueryMethodMatcher.java b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/RawQueryMethodMatcher.java index 9f3831b4bcb..8bcfb112526 100644 --- a/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/RawQueryMethodMatcher.java +++ b/data-processor/src/main/java/io/micronaut/data/processor/visitors/finders/RawQueryMethodMatcher.java @@ -49,9 +49,18 @@ import java.util.Arrays; import java.util.List; import java.util.Locale; +import java.util.Map; +import java.util.Collections; import java.util.Optional; +import java.util.stream.Collectors; import java.util.regex.Matcher; import java.util.regex.Pattern; +import io.micronaut.data.annotation.Repository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.model.DataType; +import io.micronaut.data.model.Association; +import io.micronaut.data.model.query.builder.QueryOutParameterBinding; +import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder; /** * Finder with custom defied query used to return a single result. @@ -303,22 +312,242 @@ private QueryResult getQueryResult(MethodMatchContext matchContext, queryParts.add(newQueryString.substring(lastOffset).replace(COLON_TEMP_REPLACEMENT, COLON)); } String finalQueryString = newQueryString.replace(COLON_TEMP_REPLACEMENT, COLON); - return new QueryResult() { - @Override - public String getQuery() { - return finalQueryString; - } - @Override - public List getQueryParts() { - return queryParts; + String cleanLower = SQL_COMMENT_PATTERN.matcher(finalQueryString).replaceAll("").trim().toLowerCase(Locale.ENGLISH); + boolean hasReturning = RETURNING_PATTERN.matcher(cleanLower).find(); + if (hasReturning) { + Dialect dialect = matchContext.getRepositoryClass().enumValue(Repository.class, "dialect", Dialect.class) + .orElse(Dialect.ANSI); + if (dialect == Dialect.ORACLE) { + SourcePersistentEntity entity = persistentEntity != null ? persistentEntity : matchContext.getRootEntity(); + String finalQueryLower = finalQueryString.toLowerCase(Locale.ENGLISH); + int returningIdx = finalQueryLower.lastIndexOf("returning"); + String afterReturning = finalQueryString.substring(returningIdx + "returning".length()).trim(); + int intoPos = indexOfIntoIgnoreCase(afterReturning); + String selection = intoPos > -1 ? afterReturning.substring(0, intoPos).trim() : afterReturning; + + List outBindings = new ArrayList<>(); + List outColumns = new ArrayList<>(); + if (selection.equals("*")) { + if (entity == null) { + throw new MatchFailedException("RETURNING * requires a repository root entity to resolve columns"); + } + entity.getPersistentProperties().forEach(pp -> { + if (pp instanceof Association assoc) { + switch (assoc.getKind()) { + case ONE_TO_MANY, MANY_TO_MANY -> { + } + default -> { + String colName = pp.getPersistedName(); + DataType dt = DataType.STRING; + try { + var ae = assoc.getAssociatedEntity(); + if (ae != null && ae.hasIdentity()) { + dt = ae.getIdentity().getDataType(); + } + } catch (Exception ignored) { + } + outColumns.add(colName); + DataType finalDt = dt; + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return colName; + } + + @Override + public DataType getDataType() { + return finalDt; + } + }); + } + } + } else { + String colName = pp.getPersistedName(); + DataType dt = pp.getDataType(); + outColumns.add(colName); + DataType finalDt = dt; + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return colName; + } + + @Override + public DataType getDataType() { + return finalDt; + } + }); + } + }); + // Ensure id column is included last if not already present + if (entity.hasIdentity() && outColumns.stream().noneMatch(c -> c.equals(entity.getIdentity().getPersistedName()))) { + var id = entity.getIdentity(); + outColumns.add(id.getPersistedName()); + DataType dt = id.getDataType(); + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return id.getPersistedName(); + } + + @Override + public DataType getDataType() { + return dt; + } + }); + } + } else { + List parts = splitByComma(selection).stream().map(String::trim).filter(s -> !s.isEmpty()).collect(Collectors.toList()); + if (parts.size() > 1) { + throw new MatchFailedException("Non-custom returning methods support only entity (*) or a single property; multiple columns in RETURNING are not supported in custom @Query for Oracle"); + } + for (String part : parts) { + String col = part; + if ((col.startsWith("\"") && col.endsWith("\"")) || (col.startsWith("`") && col.endsWith("`"))) { + col = col.substring(1, col.length() - 1); + } + outColumns.add(col); + DataType dt = DataType.STRING; + if (entity != null) { + var prop = entity.getPropertyByNameIgnoreCase(col); + if (prop == null) { + for (var p : entity.getPersistentProperties()) { + if (p.getPersistedName().equalsIgnoreCase(col)) { + prop = p; + break; + } + } + } + if (prop != null) { + if (prop instanceof Association assocProp) { + try { + var ae = assocProp.getAssociatedEntity(); + if (ae != null && ae.hasIdentity()) { + dt = ae.getIdentity().getDataType(); + } + } catch (Exception ignored) { + dt = DataType.STRING; + } + } else if (prop.getDataType() != null) { + dt = prop.getDataType(); + } + } + } + DataType finalDt = dt; + String finalCol = col; + outBindings.add(new QueryOutParameterBinding() { + @Override + public String getName() { + return finalCol; + } + + @Override + public DataType getDataType() { + return finalDt; + } + }); + } + } + if (outBindings.isEmpty()) { + throw new MatchFailedException("RETURNING clause must contain at least one column for Oracle"); + } + int outCount = outBindings.size(); + String intoPlaceholders = String.join(",", Collections.nCopies(outCount, "?")); + + List quotedColumns = outColumns.stream().map(RawQueryMethodMatcher::quoteOracleIdentifier).toList(); + // Adjust query parts to produce final positional SQL via RepositoryTypeElementVisitor + if (!parameterBindings.isEmpty()) { + // Prefix BEGIN to the first literal part + if (!queryParts.isEmpty()) { + queryParts.set(0, "BEGIN " + queryParts.get(0)); + } else { + queryParts.add("BEGIN "); + } + // Replace the tail (after last param) to enforce RETURNING ... INTO ...; END; + int last = queryParts.size() - 1; + String tail = last >= 0 ? queryParts.get(last) : ""; + int idx = tail.toLowerCase(Locale.ENGLISH).lastIndexOf("returning"); + String newTail; + if (idx >= 0) { + newTail = tail.substring(0, idx) + "RETURNING " + String.join(",", quotedColumns) + " INTO " + intoPlaceholders + "; END;"; + } else { + newTail = tail + " RETURNING " + String.join(",", quotedColumns) + " INTO " + intoPlaceholders + "; END;"; + } + if (last >= 0) { + queryParts.set(last, newTail); + } else { + queryParts.add(newTail); + } + } else { + // No parameters: construct single-part final SQL with positional markers only for OUT + String withoutSemi = stripTrailingSemicolon(finalQueryString); + int retIdx = withoutSemi.toLowerCase(Locale.ENGLISH).lastIndexOf("returning"); + String beforeReturning = withoutSemi.substring(0, retIdx); + String finalSql = "BEGIN " + beforeReturning + " RETURNING " + String.join(",", quotedColumns) + " INTO " + intoPlaceholders + "; END;"; + queryParts = new ArrayList<>(); + queryParts.add(finalSql); + } + // Build the assembled SQL from queryParts: parts represent SQL fragments between + // positional IN-parameter placeholders (SqlQueryBuilder.DEFAULT_POSITIONAL_PARAMETER_MARKER). + String assembledSql; + if (queryParts.size() == 1) { + assembledSql = queryParts.get(0); + } else { + var sqlBuilder = new StringBuilder(queryParts.get(0)); + for (int i = 1; i < queryParts.size(); i++) { + sqlBuilder.append(SqlQueryBuilder.DEFAULT_POSITIONAL_PARAMETER_MARKER).append(queryParts.get(i)); + } + assembledSql = sqlBuilder.toString(); + } + return QueryResult.of(assembledSql, queryParts, parameterBindings, outBindings, Map.of()); } + } + + // Default: no transformation + return QueryResult.of(finalQueryString, queryParts, parameterBindings); + } - @Override - public List getParameterBindings() { - return parameterBindings; + private static int indexOfIntoIgnoreCase(String s) { + String lower = s.toLowerCase(Locale.ENGLISH); + return lower.indexOf(" into "); + } + + private static String stripTrailingSemicolon(String s) { + String t = s.trim(); + if (t.endsWith(";")) { + return t.substring(0, t.length() - 1); + } + return s; + } + + private static List splitByComma(String s) { + List parts = new ArrayList<>(); + int depth = 0; + StringBuilder current = new StringBuilder(); + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '(') { + depth++; + } + if (c == ')') { + depth--; } - }; + if (c == ',' && depth == 0) { + parts.add(current.toString()); + current.setLength(0); + } else { + current.append(c); + } + } + if (current.length() > 0) { + parts.add(current.toString()); + } + return parts; + } + + private static String quoteOracleIdentifier(String identifier) { + return '"' + identifier.toUpperCase(Locale.ENGLISH) + '"'; } public static QueryParameterBinding addBinding(MethodMatchContext matchContext, diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildDeleteSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildDeleteSpec.groovy index 16fcb62b4b9..7d52b68be2e 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildDeleteSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildDeleteSpec.groovy @@ -700,4 +700,36 @@ interface PersonRepository extends CrudRepository { deleteQuery.replace('\n', ' ') == "WITH ids AS (SELECT id FROM person) DELETE FROM person WHERE id = :id " method.classValue(DataMethod, "interceptor").get() == DeleteAllInterceptor } + + void "ORACLE test build delete returning "() { + given: + def repository = buildRepository('test.BookRepository', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.tck.entities.Book; +import io.micronaut.data.tck.entities.Author; + +@JdbcRepository(dialect = Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface BookRepository extends GenericRepository { + Book deleteReturning(Book book); + String deleteReturningTitle(Book book); +} +""") + when: + def deleteReturningMethod = repository.findPossibleMethods("deleteReturning").findFirst().get() + def deleteReturningTitleMethod = repository.findPossibleMethods("deleteReturningTitle").findFirst().get() + then: + getQuery(deleteReturningMethod) == 'BEGIN DELETE FROM "BOOK" WHERE ("ID" = ?) RETURNING "ID","AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED" INTO ?,?,?,?,?,?,?; END;' + getDataResultType(deleteReturningMethod) == "io.micronaut.data.tck.entities.Book" + getParameterPropertyPaths(deleteReturningMethod) == ["id"] as String[] + getDataInterceptor(deleteReturningMethod) == "io.micronaut.data.intercept.DeleteOneInterceptor" + getResultDataType(deleteReturningMethod) == DataType.ENTITY + getQuery(deleteReturningTitleMethod) == 'BEGIN DELETE FROM "BOOK" WHERE ("ID" = ?) RETURNING "TITLE" INTO ?; END;' + getDataResultType(deleteReturningTitleMethod) == "java.lang.String" + getParameterPropertyPaths(deleteReturningTitleMethod) == ["id"] as String[] + getDataInterceptor(deleteReturningTitleMethod) == "io.micronaut.data.intercept.DeleteReturningOneInterceptor" + getResultDataType(deleteReturningTitleMethod) == DataType.STRING + } } diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy index d31bb54e335..efb2238db49 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildInsertSpec.groovy @@ -33,6 +33,7 @@ import static io.micronaut.data.processor.visitors.TestUtils.getDataInterceptor import static io.micronaut.data.processor.visitors.TestUtils.getDataResultType import static io.micronaut.data.processor.visitors.TestUtils.getDataTypes import static io.micronaut.data.processor.visitors.TestUtils.getOperationType +import static io.micronaut.data.processor.visitors.TestUtils.getOutBindingParameters import static io.micronaut.data.processor.visitors.TestUtils.getParameterPropertyPaths import static io.micronaut.data.processor.visitors.TestUtils.getQuery import static io.micronaut.data.processor.visitors.TestUtils.getRawQuery @@ -614,4 +615,105 @@ interface PersonRepository extends CrudRepository { insertQuery.replace('\n', ' ') == "WITH ids AS (SELECT id FROM person) INSERT INTO person(name, age, enabled) VALUES (:name, :age, TRUE) " method.classValue(DataMethod, "interceptor").get() == SaveEntityInterceptor } + + void "ORACLE test build save returning"() { + given: + def repository = buildRepository('test.BookRepository', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.tck.entities.Book; + +@JdbcRepository(dialect= Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface BookRepository extends GenericRepository { + Book saveReturning(Book book); +} +""") + when: + def saveReturningMethod = repository.findPossibleMethods("saveReturning").findFirst().get() + def outBindingParameters = getOutBindingParameters(saveReturningMethod) + then: + getQuery(saveReturningMethod) == 'BEGIN INSERT INTO "BOOK" ("AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED") VALUES (?,?,?,?,?,?) RETURNING "AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED","ID" INTO ?,?,?,?,?,?,?; END;' + getDataResultType(saveReturningMethod) == "io.micronaut.data.tck.entities.Book" + getParameterPropertyPaths(saveReturningMethod) == ["author.id", "genre.id", "title", "totalPages", "publisher.id", "lastUpdated"] as String[] + getDataInterceptor(saveReturningMethod) == "io.micronaut.data.intercept.SaveEntityInterceptor" + getResultDataType(saveReturningMethod) == DataType.ENTITY + getOperationType(saveReturningMethod) == DataMethod.OperationType.INSERT_RETURNING + outBindingParameters.length == 7 + outBindingParameters[0].name == "author_id" + outBindingParameters[0].dataType == DataType.LONG + outBindingParameters[1].name == "genre_id" + outBindingParameters[1].dataType == DataType.LONG + outBindingParameters[2].name == "title" + outBindingParameters[2].dataType == DataType.STRING + outBindingParameters[3].name == "total_pages" + outBindingParameters[3].dataType == DataType.INTEGER + outBindingParameters[4].name == "publisher_id" + outBindingParameters[4].dataType == DataType.LONG + outBindingParameters[5].name == "last_updated" + outBindingParameters[5].dataType == DataType.TIMESTAMP + outBindingParameters[6].name == "id" + outBindingParameters[6].dataType == DataType.LONG + } + + void "ORACLE custom @Query insert returning multiple columns should fail"() { + when: + buildRepository('test.BookRepository', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.tck.entities.Book; +import io.micronaut.data.annotation.Query; + +@JdbcRepository(dialect= Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface BookRepository extends GenericRepository { + @Query("INSERT INTO book (author_id,genre_id,title,total_pages,publisher_id,last_updated) VALUES (:authorId,:genreId,:title,:totalPages,:publisherId,:lastUpdated) RETURNING id, title") + String brokenInsertReturning(Long authorId, Long genreId, String title, int totalPages, Long publisherId, java.time.LocalDateTime lastUpdated); +} +""") + then: + def ex = thrown(RuntimeException) + ex.message.contains("multiple columns in RETURNING are not supported") + } + + void "ORACLE custom @Query insert returning produces valid SQL"() { + given: + def repository = buildRepository('test.BookRepository', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.tck.entities.Book; +import io.micronaut.data.annotation.Query; +import java.time.LocalDateTime; + +@JdbcRepository(dialect= Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface BookRepository extends GenericRepository { + @Query("INSERT INTO \\"BOOK\\" (\\"AUTHOR_ID\\",\\"GENRE_ID\\",\\"TITLE\\",\\"TOTAL_PAGES\\",\\"PUBLISHER_ID\\",\\"LAST_UPDATED\\") VALUES (:authorId,:genreId,:title,:totalPages,:publisherId,:lastUpdated) RETURNING *") + Book customInsertReturning(Long authorId, Long genreId, String title, int totalPages, Long publisherId, LocalDateTime lastUpdated); + + @Query("INSERT INTO \\"BOOK\\" (\\"AUTHOR_ID\\",\\"GENRE_ID\\",\\"TITLE\\",\\"TOTAL_PAGES\\",\\"PUBLISHER_ID\\",\\"LAST_UPDATED\\") VALUES (:authorId,:genreId,:title,:totalPages,:publisherId,:lastUpdated) RETURNING \\"TITLE\\"") + String customInsertReturningTitle(Long authorId, Long genreId, String title, int totalPages, Long publisherId, LocalDateTime lastUpdated); +} +""") + when: + def customInsertReturningMethod = repository.findPossibleMethods("customInsertReturning").findFirst().get() + def outBindingEntityParameters = getOutBindingParameters(customInsertReturningMethod) + def customInsertReturningTitleMethod = repository.findPossibleMethods("customInsertReturningTitle").findFirst().get() + def outBindingTitleParameters = getOutBindingParameters(customInsertReturningTitleMethod) + then: + getRawQuery(customInsertReturningMethod) == 'BEGIN INSERT INTO "BOOK" ("AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED") VALUES (?,?,?,?,?,?) RETURNING "AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED","ID" INTO ?,?,?,?,?,?,?; END;' + getDataResultType(customInsertReturningMethod) == "io.micronaut.data.tck.entities.Book" + getDataInterceptor(customInsertReturningMethod) == "io.micronaut.data.intercept.InsertReturningOneInterceptor" + getOperationType(customInsertReturningMethod) == DataMethod.OperationType.INSERT_RETURNING + outBindingEntityParameters.length == 7 + + getRawQuery(customInsertReturningTitleMethod) == 'BEGIN INSERT INTO "BOOK" ("AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED") VALUES (?,?,?,?,?,?) RETURNING "TITLE" INTO ?; END;' + getDataResultType(customInsertReturningTitleMethod) == "java.lang.String" + getDataInterceptor(customInsertReturningTitleMethod) == "io.micronaut.data.intercept.InsertReturningOneInterceptor" + getOperationType(customInsertReturningTitleMethod) == DataMethod.OperationType.INSERT_RETURNING + outBindingTitleParameters.length == 1 + } } diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildUpdateSpec.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildUpdateSpec.groovy index 6b89f5bd3a0..9ddf815ae93 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildUpdateSpec.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/sql/BuildUpdateSpec.groovy @@ -814,30 +814,68 @@ interface AccountRepository extends CrudRepository { getResultDataType(update) == null } -// void "ORACLE test build update returning "() { -// given: -// def repository = buildRepository('test.BookRepository', """ -//import io.micronaut.data.jdbc.annotation.JdbcRepository; -//import io.micronaut.data.model.query.builder.sql.Dialect; -//import io.micronaut.data.repository.GenericRepository; -//import io.micronaut.data.tck.entities.Book; -//import io.micronaut.data.tck.entities.Author; -// -//@JdbcRepository(dialect= Dialect.ORACLE) -//@io.micronaut.context.annotation.Executable -//interface BookRepository extends GenericRepository { -// -// Book updateReturning(Book book); -// -//} -//""") -// when: -// def updateReturningCustomMethod = repository.findPossibleMethods("updateReturning").findFirst().get() -// then: -// getQuery(updateReturningCustomMethod) == 'UPDATE "BOOK" SET "AUTHOR_ID"=?,"GENRE_ID"=?,"TITLE"=?,"TOTAL_PAGES"=?,"PUBLISHER_ID"=?,"LAST_UPDATED"=? WHERE ("ID" = ?) RETURNING "ID","AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED" INTO "ID","AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED"' -// getDataResultType(updateReturningCustomMethod) == "io.micronaut.data.tck.entities.Book" -// getParameterPropertyPaths(updateReturningCustomMethod) == ["author.id", "genre.id", "title", "totalPages", "publisher.id", "lastUpdated", "id"] as String[] -// getDataInterceptor(updateReturningCustomMethod) == "io.micronaut.data.intercept.UpdateReturningInterceptor" -// } + void "ORACLE test build update returning "() { + given: + def repository = buildRepository('test.BookRepository', """ +import io.micronaut.data.jdbc.annotation.JdbcRepository; +import io.micronaut.data.model.query.builder.sql.Dialect; +import io.micronaut.data.repository.GenericRepository; +import io.micronaut.data.tck.entities.Book; +import io.micronaut.data.tck.entities.Author; +import java.time.LocalDateTime; + +@JdbcRepository(dialect= Dialect.ORACLE) +@io.micronaut.context.annotation.Executable +interface BookRepository extends GenericRepository { + Book updateReturning(Book book); + String updateReturningTitle(Book book); + @Query("UPDATE \\"BOOK\\" SET \\"TITLE\\"=:title,\\"TOTAL_PAGES\\"=:totalPages,\\"LAST_UPDATED\\"=:lastUpdated WHERE \\"ID\\"=:bookId RETURNING *") + Book customUpdateReturning(Long bookId, String title, int totalPages, LocalDateTime lastUpdated); + +} +""") + when: + def updateReturningMethod = repository.findPossibleMethods("updateReturning").findFirst().get() + def updateOutBindingParameters = getOutBindingParameters(updateReturningMethod) + def updateReturningTitleMethod = repository.findPossibleMethods("updateReturningTitle").findFirst().get() + def updateTitleOutBindingParameters = getOutBindingParameters(updateReturningTitleMethod) + def customUpdateReturningMethod = repository.findPossibleMethods("customUpdateReturning").findFirst().get() + def customUpdateOutBindingParameters = getOutBindingParameters(customUpdateReturningMethod) + then: + getQuery(updateReturningMethod) == 'BEGIN UPDATE "BOOK" SET "AUTHOR_ID"=?,"GENRE_ID"=?,"TITLE"=?,"TOTAL_PAGES"=?,"PUBLISHER_ID"=?,"LAST_UPDATED"=? WHERE ("ID" = ?) RETURNING "ID","AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED" INTO ?,?,?,?,?,?,?; END;' + getDataResultType(updateReturningMethod) == "io.micronaut.data.tck.entities.Book" + getParameterPropertyPaths(updateReturningMethod) == ["author.id", "genre.id", "title", "totalPages", "publisher.id", "lastUpdated", "id"] as String[] + getDataInterceptor(updateReturningMethod) == "io.micronaut.data.intercept.UpdateEntityInterceptor" + getQuery(updateReturningTitleMethod) == 'BEGIN UPDATE "BOOK" SET "AUTHOR_ID"=?,"GENRE_ID"=?,"TITLE"=?,"TOTAL_PAGES"=?,"PUBLISHER_ID"=?,"LAST_UPDATED"=? WHERE ("ID" = ?) RETURNING "TITLE" INTO ?; END;' + getDataResultType(updateReturningTitleMethod) == "java.lang.String" + getParameterPropertyPaths(updateReturningTitleMethod) == ["author.id", "genre.id", "title", "totalPages", "publisher.id", "lastUpdated", "id"] as String[] + getDataInterceptor(updateReturningTitleMethod) == "io.micronaut.data.intercept.UpdateReturningOneInterceptor" + + getRawQuery(customUpdateReturningMethod) == 'BEGIN UPDATE "BOOK" SET "TITLE"=?,"TOTAL_PAGES"=?,"LAST_UPDATED"=? WHERE "ID"=? RETURNING "AUTHOR_ID","GENRE_ID","TITLE","TOTAL_PAGES","PUBLISHER_ID","LAST_UPDATED","ID" INTO ?,?,?,?,?,?,?; END;' + getDataResultType(customUpdateReturningMethod) == "io.micronaut.data.tck.entities.Book" + getDataInterceptor(customUpdateReturningMethod) == "io.micronaut.data.intercept.UpdateReturningOneInterceptor" + + updateOutBindingParameters.length == 7 + updateOutBindingParameters[0].name == "id" + updateOutBindingParameters[0].dataType == DataType.LONG + updateOutBindingParameters[1].name == "author_id" + updateOutBindingParameters[1].dataType == DataType.LONG + updateOutBindingParameters[2].name == "genre_id" + updateOutBindingParameters[2].dataType == DataType.LONG + updateOutBindingParameters[3].name == "title" + updateOutBindingParameters[3].dataType == DataType.STRING + updateOutBindingParameters[4].name == "total_pages" + updateOutBindingParameters[4].dataType == DataType.INTEGER + updateOutBindingParameters[5].name == "publisher_id" + updateOutBindingParameters[5].dataType == DataType.LONG + updateOutBindingParameters[6].name == "last_updated" + updateOutBindingParameters[6].dataType == DataType.TIMESTAMP + + updateTitleOutBindingParameters.length == 1 + updateTitleOutBindingParameters[0].name == "title" + updateTitleOutBindingParameters[0].dataType == DataType.STRING + + customUpdateOutBindingParameters.length == 7 + } } diff --git a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/TestUtils.groovy b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/TestUtils.groovy index 81d8c1a67c1..d8d3705e156 100644 --- a/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/TestUtils.groovy +++ b/data-processor/src/test/groovy/io/micronaut/data/processor/visitors/TestUtils.groovy @@ -22,6 +22,8 @@ import io.micronaut.core.annotation.AnnotationValue import io.micronaut.data.annotation.Join import io.micronaut.data.annotation.Query import io.micronaut.data.intercept.annotation.DataMethod +import io.micronaut.data.intercept.annotation.DataMethodQuery +import io.micronaut.data.intercept.annotation.DataMethodQueryOutParameter import io.micronaut.data.intercept.annotation.DataMethodQueryParameter import io.micronaut.data.model.DataType; import io.micronaut.data.processor.model.SourcePersistentEntity; @@ -185,6 +187,18 @@ class TestUtils { .toArray(String[]::new) } + static OutBindingParameter[] getOutBindingParameters(AnnotationMetadata annotationMetadata) { + return getOutBindingParameters(annotationMetadata.getAnnotation(DataMethod)) + } + + static OutBindingParameter[] getOutBindingParameters(AnnotationValue annotationValue) { + return annotationValue.getAnnotations(DataMethodQuery.META_MEMBER_OUT_PARAMETERS, DataMethodQueryOutParameter) + .stream() + .map(p -> new OutBindingParameter(name: p.getRequiredValue("name", String.class), + dataType: p.getRequiredValue("dataType", DataType))) + .toArray(OutBindingParameter[]::new) + } + static Boolean[] getParameterExpressions(AnnotationValue annotationValue) { return annotationValue.getAnnotations(DataMethod.META_MEMBER_PARAMETERS, DataMethodQueryParameter) .stream() @@ -236,4 +250,9 @@ class TestUtils { .collect(Collectors.toMap((AnnotationValue av) -> av.stringValue().get(), (AnnotationValue av) -> av.enumValue("type", Join.Type).get())) } + + static class OutBindingParameter { + String name + DataType dataType + } } diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlStoredQuery.java b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlStoredQuery.java index 1cc2da89259..ffbf3cc5d80 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlStoredQuery.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/operations/internal/sql/DefaultSqlStoredQuery.java @@ -24,6 +24,7 @@ import io.micronaut.data.model.JsonDataType; import io.micronaut.data.model.query.builder.sql.Dialect; import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; import io.micronaut.data.model.runtime.QueryParameterBinding; import io.micronaut.data.model.runtime.QueryResultInfo; import io.micronaut.data.model.runtime.RuntimePersistentEntity; @@ -96,6 +97,11 @@ public QueryResultInfo getQueryResultInfo() { return queryResultInfo; } + @Override + public List getOutParameterBindings() { + return getStoredQueryDelegate().getOutParameterBindings(); + } + @Override public boolean isExpandableQuery() { return expandableQuery; diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DefaultStoredQuery.java b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DefaultStoredQuery.java index 72cef48e3f1..7b0b9ca726d 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DefaultStoredQuery.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DefaultStoredQuery.java @@ -29,6 +29,7 @@ import io.micronaut.data.intercept.annotation.DataMethod; import io.micronaut.data.intercept.annotation.DataMethodQuery; import io.micronaut.data.intercept.annotation.DataMethodQueryParameter; +import io.micronaut.data.intercept.annotation.DataMethodQueryOutParameter; import io.micronaut.data.model.AssociationUtils; import io.micronaut.data.model.DataType; import io.micronaut.data.model.JsonDataType; @@ -38,6 +39,7 @@ import io.micronaut.data.model.query.builder.sql.SqlQueryBuilder; import io.micronaut.data.model.runtime.DefaultStoredDataOperation; import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; import io.micronaut.data.model.runtime.StoredQuery; import io.micronaut.data.operations.HintsCapableRepository; import io.micronaut.inject.ExecutableMethod; @@ -89,6 +91,7 @@ public final class DefaultStoredQuery extends DefaultStoredDataOperation< @Nullable private Set joinPaths = null; private final List queryParameters; + private final List outParameterBindings; private final boolean rawQuery; private final boolean jsonEntity; private final OperationType operationType; @@ -242,6 +245,9 @@ public DefaultStoredQuery( dataMethodQuery.getAnnotations(DataMethodQuery.META_MEMBER_PARAMETERS, DataMethodQueryParameter.class), isNumericPlaceHolder ); + this.outParameterBindings = getOutParameters( + dataMethodQuery.getAnnotations(DataMethodQuery.META_MEMBER_OUT_PARAMETERS, DataMethodQueryOutParameter.class) + ); this.jsonEntity = DataAnnotationUtils.hasJsonEntityRepresentationAnnotation(annotationMetadata); this.parameterExpressions = annotationMetadata.getAnnotationValuesByType(ParameterExpression.class).stream() .collect(Collectors.toMap(av -> av.stringValue("name").orElseThrow(), av -> av)); @@ -317,6 +323,19 @@ private static List getQueryParameters(List getOutParameters(List> params) { + if (params == null || params.isEmpty()) { + return List.of(); + } + List outParams = new ArrayList<>(params.size()); + for (AnnotationValue av : params) { + DataType dataType = av.enumValue(DataMethodQueryOutParameter.META_MEMBER_DATA_TYPE, DataType.class).orElseThrow(); + String name = av.stringValue(DataMethodQueryOutParameter.META_MEMBER_NAME).orElseThrow(); + outParams.add(new StoredOutParameter(name, dataType)); + } + return outParams; + } + @Override public Limit getQueryLimit() { return limit; @@ -332,6 +351,11 @@ public List getQueryBindings() { return queryParameters; } + @Override + public List getOutParameterBindings() { + return outParameterBindings == null ? List.of() : outParameterBindings; + } + @Override public Set getJoinPaths() { if (joinPaths == null) { @@ -508,4 +532,7 @@ private static AnnotationValue getRequiredDataMethod(ExecutableMetho } return av; } + + private record StoredOutParameter(String name, DataType dataType) implements QueryOutParameterBinding { + } } diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DelegateStoredQuery.java b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DelegateStoredQuery.java index afdd8743010..53c647bd431 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DelegateStoredQuery.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/DelegateStoredQuery.java @@ -22,6 +22,7 @@ import io.micronaut.data.model.Limit; import io.micronaut.data.model.Sort; import io.micronaut.data.model.query.JoinPath; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; import io.micronaut.data.model.runtime.QueryParameterBinding; import io.micronaut.data.model.runtime.StoredQuery; @@ -74,6 +75,11 @@ default List getQueryBindings() { return getStoredQueryDelegate().getQueryBindings(); } + @Override + default List getOutParameterBindings() { + return getStoredQueryDelegate().getOutParameterBindings(); + } + @Override default Class getResultType() { return getStoredQueryDelegate().getResultType(); diff --git a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/QueryResultStoredQuery.java b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/QueryResultStoredQuery.java index c5f60fceb33..28c5e6f4b2a 100644 --- a/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/QueryResultStoredQuery.java +++ b/data-runtime/src/main/java/io/micronaut/data/runtime/query/internal/QueryResultStoredQuery.java @@ -23,6 +23,7 @@ import io.micronaut.data.model.query.JoinPath; import io.micronaut.data.model.query.builder.QueryResult; import io.micronaut.data.model.runtime.QueryParameterBinding; +import io.micronaut.data.model.runtime.QueryOutParameterBinding; import org.jspecify.annotations.Nullable; import java.util.ArrayList; @@ -45,6 +46,7 @@ public final class QueryResultStoredQuery extends BasicStoredQuery { private final QueryResult queryResult; private final Set joinPaths; + private final List outParameterBindings; public QueryResultStoredQuery(String name, AnnotationMetadata annotationMetadata, @@ -68,6 +70,7 @@ public QueryResultStoredQuery(String name, operationType); this.queryResult = queryResult; this.joinPaths = joinPaths == null ? Collections.emptySet() : Set.copyOf(joinPaths); + this.outParameterBindings = mapOut(queryResult.getOutParameterBindings()); } public QueryResultStoredQuery(String name, @@ -94,6 +97,7 @@ public QueryResultStoredQuery(String name, operationType); this.queryResult = queryResult; this.joinPaths = joinPaths == null ? Collections.emptySet() : Set.copyOf(joinPaths); + this.outParameterBindings = mapOut(queryResult.getOutParameterBindings()); } public static QueryResultStoredQuery single(OperationType operationType, @@ -175,11 +179,42 @@ public QueryResult getQueryResult() { return queryResult; } + @Override + public List getOutParameterBindings() { + return outParameterBindings; + } + @Override public Set getJoinPaths() { return joinPaths; } + private static List mapOut(List bindings) { + if (bindings == null || bindings.isEmpty()) { + return Collections.emptyList(); + } + List out = new ArrayList<>(bindings.size()); + for (io.micronaut.data.model.query.builder.QueryOutParameterBinding b : bindings) { + out.add(new QueryResultOutParameterBinding(b)); + } + return out; + } + + private record QueryResultOutParameterBinding( + io.micronaut.data.model.query.builder.QueryOutParameterBinding delegate) implements QueryOutParameterBinding { + + @Override + public String name() { + return delegate.getName(); + } + + @Override + public DataType dataType() { + return delegate.getDataType(); + } + + } + private static final class QueryResultParameterBinding implements QueryParameterBinding { private final io.micronaut.data.model.query.builder.QueryParameterBinding p; private final List all;