spring-projects / spring-projects/spring-data-relational
Incorrect mapping of List<T> to PostgreSQL composite array column
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 827
- Forks
- 394
- PR merge metrics
- No merged PRs in 30d
Description
Spring Boot 4.0.5, Java 25, JDBC.
Hi,
I ran into an issue when trying to map a PostgreSQL composite array column to a collection property in Spring Data JDBC. Given the following schema:
CREATE TYPE my_element AS (
value1 INT,
value2 TEXT
);
CREATE TABLE my_table (
id BIGSERIAL PRIMARY KEY,
elements my_element[]
);
import java.util.List;
import org.springframework.data.annotation.Id;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.Table;
@Table("my_table")
public record MyTable(@Id Long id, @Column("elements") List<MyElement> elements) {
public record MyElement(int value1, String value2) {}
}
import org.springframework.data.repository.CrudRepository;
public interface MyTableRepository extends CrudRepository<MyTable, Long> {
}
The integration test:
@Test
void postgresCompositeArraysAreMappedCorrectly() {
var myTable = new MyTable(null, List.of(new MyElement(111, "value111"), new MyElement(222, "foo222")));
var saved = myTableRepository.save(myTable); //Throws exception
//Assert later
}
The call to save throws exception:
org.springframework.jdbc.BadSqlGrammarException: PreparedStatementCallback; bad SQL grammar [INSERT INTO "my_element" ("elements", "my_table_key", "value1", "value2") VALUES (?, ?, ?, ?)]
at org.springframework.jdbc.support.SQLStateSQLExceptionTranslator.doTranslate(SQLStateSQLExceptionTranslator.java:134)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:102)
at org.springframework.jdbc.support.AbstractFallbackSQLExceptionTranslator.translate(AbstractFallbackSQLExceptionTranslator.java:111)
at org.springframework.jdbc.core.JdbcTemplate.translateException(JdbcTemplate.java:1549)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:689)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:711)
at org.springframework.jdbc.core.JdbcTemplate.batchUpdate(JdbcTemplate.java:1043)
at org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate.batchUpdate(NamedParameterJdbcTemplate.java:379)
at org.springframework.data.jdbc.core.convert.InsertStrategyFactory$DefaultBatchInsertStrategy.execute(InsertStrategyFactory.java:89)
at org.springframework.data.jdbc.core.convert.DefaultDataAccessStrategy.insert(DefaultDataAccessStrategy.java:151)
at org.springframework.data.jdbc.core.JdbcAggregateChangeExecutionContext.executeBatchInsert(JdbcAggregateChangeExecutionContext.java:116)
at org.springframework.data.jdbc.core.AggregateChangeExecutor.execute(AggregateChangeExecutor.java:89)
at org.springframework.data.jdbc.core.AggregateChangeExecutor.lambda$executeSave$0(AggregateChangeExecutor.java:60)
at org.springframework.data.relational.core.conversion.BatchedActions$InsertCombiner.lambda$forEach$1(BatchedActions.java:170)
at java.base/java.util.HashMap.forEach(HashMap.java:1430)
at org.springframework.data.relational.core.conversion.BatchedActions$InsertCombiner.lambda$forEach$0(BatchedActions.java:170)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.accept(ForEachOps.java:186)
at java.base/java.util.stream.SortedOps$SizedRefSortingSink.end(SortedOps.java:357)
at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:571)
at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:560)
at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:153)
at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:176)
at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:265)
at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:632)
at org.springframework.data.relational.core.conversion.BatchedActions$InsertCombiner.forEach(BatchedActions.java:169)
at org.springframework.data.relational.core.conversion.BatchedActions.forEach(BatchedActions.java:79)
at org.springframework.data.relational.core.conversion.SaveBatchingAggregateChange.forEachAction(SaveBatchingAggregateChange.java:71)
at org.springframework.data.jdbc.core.AggregateChangeExecutor.executeSave(AggregateChangeExecutor.java:60)
at org.springframework.data.jdbc.core.JdbcAggregateTemplate.performSave(JdbcAggregateTemplate.java:587)
at org.springframework.data.jdbc.core.JdbcAggregateTemplate.save(JdbcAggregateTemplate.java:219)
at org.springframework.data.jdbc.repository.support.SimpleJdbcRepository.save(SimpleJdbcRepository.java:74)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:278)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:169)
at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158)
at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:545)
at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:290)
at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:690)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:171)
at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:146)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.transaction.interceptor.TransactionInterceptor$1.proceedWithInvocation(TransactionInterceptor.java:133)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:371)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135)
at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179)
at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222)
at jdk.proxy1/jdk.proxy1.$Proxy158.save(Unknown Source)
at se.myname.myapp.persistence.repository.CourtCaseRepositoryIT.postgresArraysAreMappedCorrectly(CourtCaseRepositoryIT.java:32)
Caused by: java.sql.BatchUpdateException: Batch entry 0 INSERT INTO "my_element" ("elements", "my_table_key", "value1", "value2") VALUES (('1'::int8), ('0'::int4), ('111'::int4), ('value111')) was aborted: ERROR: cannot open relation "my_element"
Detail: This operation is not supported for composite types.
Position: 13 Call getNextException to see other errors in the batch.
at org.postgresql.jdbc.BatchResultHandler.handleError(BatchResultHandler.java:165)
at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2561)
at org.postgresql.core.v3.QueryExecutorImpl.execute(QueryExecutorImpl.java:672)
at org.postgresql.jdbc.PgStatement.internalExecuteBatch(PgStatement.java:892)
at org.postgresql.jdbc.PgStatement.executeBatch(PgStatement.java:916)
at org.postgresql.jdbc.PgPreparedStatement.executeBatch(PgPreparedStatement.java:1778)
at com.zaxxer.hikari.pool.ProxyStatement.executeBatch(ProxyStatement.java:128)
at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeBatch(HikariProxyPreparedStatement.java)
at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104)
at java.base/java.lang.reflect.Method.invoke(Method.java:565)
at net.ttddyy.dsproxy.proxy.StatementProxyLogic.performProxyLogic(StatementProxyLogic.java:287)
at net.ttddyy.dsproxy.proxy.ProxyLogicSupport.proceedMethodExecution(ProxyLogicSupport.java:103)
at net.ttddyy.dsproxy.proxy.StatementProxyLogic.invoke(StatementProxyLogic.java:119)
at net.ttddyy.dsproxy.proxy.jdk.PreparedStatementInvocationHandler.invoke(PreparedStatementInvocationHandler.java:37)
at jdk.proxy1/jdk.proxy1.$Proxy133.executeBatch(Unknown Source)
at org.springframework.jdbc.core.JdbcTemplate.lambda$getPreparedStatementCallback$0(JdbcTemplate.java:1609)
at org.springframework.jdbc.core.JdbcTemplate.execute(JdbcTemplate.java:670)
... 48 more
Caused by: org.postgresql.util.PSQLException: ERROR: cannot open relation "my_element"
Detail: This operation is not supported for composite types.
Position: 13
at org.postgresql.core.v3.QueryExecutorImpl.receiveErrorResponse(QueryExecutorImpl.java:2875)
at org.postgresql.core.v3.QueryExecutorImpl.processResults(QueryExecutorImpl.java:2560)
... 63 more
This indicates that the framework interprets List as a child collection and attempts to persist it into a separate table named after the element type. However, in this case my_element is a PostgreSQL composite type, not a table. PostgreSQL then fails with:
ERROR: cannot open relation "my_element"
Detail: This operation is not supported for composite types.
This demonstrates that Spring Data JDBC currently has no way to map a collection property to a single column containing a PostgreSQL composite array (my_element[]). Instead, it always treats List as a relational association. From a PostgreSQL perspective, mapping a collection of structured values into a single column is a valid and common pattern, but it seems unsupported in the current mapping model.
My questions are:
- Is mapping a collection property to a single column (e.g. PostgreSQL composite arrays) considered out of scope for Spring Data JDBC?
- If not, what is the recommended extension point to support this (custom JdbcConverter, overriding MappingJdbcConverter, or something else)?
- Would the team consider adding an official extension mechanism for handling vendor-specific structured column types like this?
- Does the new JdbcClient support this?
I’d be happy to contribute a PR, but would like guidance on the intended direction before proceeding.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the reported postgresCompositeArraysAreMappedCorrectly integration test and inspect the MappingJdbcConverter and custom JdbcConverter entry points mentioned in the issue. Compare the current child-collection behavior with the JdbcClient question, then establish whether an official extension mechanism is intended and define support for a List mapped to a PostgreSQL composite-array column as the completion criteria.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, postgresql, spring
- Domain
- backend, database
- Issue type
- Feature
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Needs clarification
- Newbie friendliness
- 35/100