diff --git a/pom.xml b/pom.xml index 3097538048..6c68f0a4a8 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-relational-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT pom Spring Data Relational Parent diff --git a/spring-data-jdbc-distribution/pom.xml b/spring-data-jdbc-distribution/pom.xml index 271486f02a..d7e990d690 100644 --- a/spring-data-jdbc-distribution/pom.xml +++ b/spring-data-jdbc-distribution/pom.xml @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT ../pom.xml diff --git a/spring-data-jdbc/pom.xml b/spring-data-jdbc/pom.xml index 32f9269501..72c3b2ecbf 100644 --- a/spring-data-jdbc/pom.xml +++ b/spring-data-jdbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-jdbc - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT Spring Data JDBC Spring Data module for JDBC repositories. @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java index 8078e5df6a..9582edb87b 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java @@ -15,6 +15,8 @@ */ package org.springframework.data.jdbc.core.convert; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.ArrayList; import java.util.Iterator; import java.util.List; @@ -27,73 +29,87 @@ import org.springframework.data.relational.core.sqlgeneration.AliasFactory; import org.springframework.data.relational.core.sqlgeneration.SingleQuerySqlGenerator; import org.springframework.data.relational.core.sqlgeneration.SqlGenerator; +import org.springframework.data.relational.domain.RowDocument; import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations; import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** * Reads complete Aggregates from the database, by generating appropriate SQL using a {@link SingleQuerySqlGenerator} - * and a matching {@link AggregateResultSetExtractor} and invoking a - * {@link org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate} + * through {@link org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate}. Results are converterd into an + * intermediate {@link ResultSetRowDocumentExtractor RowDocument} and mapped via + * {@link org.springframework.data.relational.core.conversion.RelationalConverter#read(Class, RowDocument)}. * * @param the type of aggregate produced by this reader. * @author Jens Schauder + * @author Mark Paluch * @since 3.2 */ class AggregateReader { - private final RelationalPersistentEntity aggregate; + private final RelationalPersistentEntity entity; private final org.springframework.data.relational.core.sqlgeneration.SqlGenerator sqlGenerator; private final JdbcConverter converter; private final NamedParameterJdbcOperations jdbcTemplate; - private final AggregateResultSetExtractor extractor; + private final ResultSetRowDocumentExtractor extractor; AggregateReader(Dialect dialect, JdbcConverter converter, AliasFactory aliasFactory, - NamedParameterJdbcOperations jdbcTemplate, RelationalPersistentEntity aggregate) { + NamedParameterJdbcOperations jdbcTemplate, RelationalPersistentEntity entity) { this.converter = converter; - this.aggregate = aggregate; + this.entity = entity; this.jdbcTemplate = jdbcTemplate; this.sqlGenerator = new CachingSqlGenerator( - new SingleQuerySqlGenerator(converter.getMappingContext(), aliasFactory, dialect, aggregate)); + new SingleQuerySqlGenerator(converter.getMappingContext(), aliasFactory, dialect, entity)); - this.extractor = new AggregateResultSetExtractor<>(aggregate, converter, createPathToColumnMapping(aliasFactory)); + this.extractor = new ResultSetRowDocumentExtractor(converter.getMappingContext(), + createPathToColumnMapping(aliasFactory)); } public List findAll() { - - Iterable result = jdbcTemplate.query(sqlGenerator.findAll(), extractor); - - Assert.state(result != null, "result is null"); - - return (List) result; + return jdbcTemplate.query(sqlGenerator.findAll(), this::extractAll); } @Nullable public T findById(Object id) { - id = converter.writeValue(id, aggregate.getRequiredIdProperty().getTypeInformation()); + id = converter.writeValue(id, entity.getRequiredIdProperty().getTypeInformation()); - Iterator result = jdbcTemplate.query(sqlGenerator.findById(), Map.of("id", id), extractor).iterator(); + return jdbcTemplate.query(sqlGenerator.findById(), Map.of("id", id), rs -> { - T returnValue = result.hasNext() ? result.next() : null; + Iterator iterate = extractor.iterate(entity, rs); + if (iterate.hasNext()) { - if (result.hasNext()) { - throw new IncorrectResultSizeDataAccessException(1); - } - - return returnValue; + RowDocument object = iterate.next(); + if (iterate.hasNext()) { + throw new IncorrectResultSizeDataAccessException(1); + } + return converter.read(entity.getType(), object); + } + return null; + }); } public Iterable findAllById(Iterable ids) { List convertedIds = new ArrayList<>(); for (Object id : ids) { - convertedIds.add(converter.writeValue(id, aggregate.getRequiredIdProperty().getTypeInformation())); + convertedIds.add(converter.writeValue(id, entity.getRequiredIdProperty().getTypeInformation())); } - return jdbcTemplate.query(sqlGenerator.findAllById(), Map.of("ids", convertedIds), extractor); + return jdbcTemplate.query(sqlGenerator.findAllById(), Map.of("ids", convertedIds), this::extractAll); + } + + private List extractAll(ResultSet rs) throws SQLException { + + Iterator iterate = extractor.iterate(entity, rs); + List resultList = new ArrayList<>(); + while (iterate.hasNext()) { + resultList.add(converter.read(entity.getType(), iterate.next())); + } + + return resultList; } private PathToColumnMapping createPathToColumnMapping(AliasFactory aliasFactory) { @@ -117,8 +133,8 @@ public String keyColumn(AggregatePath path) { * A wrapper for the {@link org.springframework.data.relational.core.sqlgeneration.SqlGenerator} that caches the * generated statements. * - * @since 3.2 * @author Jens Schauder + * @since 3.2 */ static class CachingSqlGenerator implements org.springframework.data.relational.core.sqlgeneration.SqlGenerator { diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java deleted file mode 100644 index 0ecd12812a..0000000000 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java +++ /dev/null @@ -1,596 +0,0 @@ -/* - * Copyright 2023 the original author or 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 org.springframework.data.jdbc.core.convert; - -import java.sql.ResultSet; -import java.util.AbstractCollection; -import java.util.AbstractMap; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.function.Supplier; - -import org.springframework.dao.DataAccessException; -import org.springframework.data.mapping.Parameter; -import org.springframework.data.mapping.PersistentPropertyAccessor; -import org.springframework.data.mapping.PropertyHandler; -import org.springframework.data.mapping.model.ConvertingPropertyAccessor; -import org.springframework.data.mapping.model.EntityInstantiator; -import org.springframework.data.mapping.model.ParameterValueProvider; -import org.springframework.data.relational.core.mapping.AggregatePath; -import org.springframework.data.relational.core.mapping.RelationalMappingContext; -import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; -import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; -import org.springframework.data.util.TypeInformation; -import org.springframework.jdbc.core.ResultSetExtractor; -import org.springframework.lang.Nullable; -import org.springframework.util.Assert; - -/** - * Extracts complete aggregates from a {@link ResultSet}. The {@literal ResultSet} must have a very special structure - * which looks somewhat how one would represent an aggregate in a single excel table. The first row contains data of the - * aggregate root, any single valued reference and the first element of any collection. Following rows do NOT repeat the - * aggregate root data but contain data of second elements of any collections. For details see accompanying unit tests. - * - * @param the type of aggregates to extract - * @author Jens Schauder - * @since 3.2 - */ -class AggregateResultSetExtractor implements ResultSetExtractor> { - - private final RelationalMappingContext context; - private final RelationalPersistentEntity rootEntity; - private final JdbcConverter converter; - private final PathToColumnMapping propertyToColumn; - - /** - * @param rootEntity the aggregate root. Must not be {@literal null}. - * @param converter Used for converting objects from the database to whatever is required by the aggregate. Must not - * be {@literal null}. - * @param pathToColumn a mapping from {@link org.springframework.data.relational.core.mapping.AggregatePath} to the - * column of the {@link ResultSet} that holds the data for that - * {@link org.springframework.data.relational.core.mapping.AggregatePath}. - */ - AggregateResultSetExtractor(RelationalPersistentEntity rootEntity, JdbcConverter converter, - PathToColumnMapping pathToColumn) { - - Assert.notNull(rootEntity, "rootEntity must not be null"); - Assert.notNull(converter, "converter must not be null"); - Assert.notNull(pathToColumn, "propertyToColumn must not be null"); - - this.rootEntity = rootEntity; - this.converter = converter; - this.context = converter.getMappingContext(); - this.propertyToColumn = pathToColumn; - } - - @Override - public Iterable extractData(ResultSet resultSet) throws DataAccessException { - - CachingResultSet crs = new CachingResultSet(resultSet); - - CollectionReader reader = new CollectionReader(crs); - - while (crs.next()) { - reader.read(); - } - - return (Iterable) reader.getResultAndReset(); - } - - /** - * create an instance and populate all its properties - */ - @Nullable - private Object hydrateInstance(EntityInstantiator instantiator, ResultSetParameterValueProvider valueProvider, - RelationalPersistentEntity entity) { - - if (!valueProvider.basePath.isRoot() && // this is a nested ValueProvider - valueProvider.basePath.getRequiredLeafProperty().isEmbedded() && // it's an embedded - !valueProvider.basePath.getRequiredLeafProperty().shouldCreateEmptyEmbedded() && // it's embedded - !valueProvider.hasValue()) { // all values have been null - return null; - } - - Object instance = instantiator.createInstance(entity, valueProvider); - - PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>(entity.getPropertyAccessor(instance), - converter.getConversionService()); - - if (entity.requiresPropertyPopulation()) { - - entity.doWithProperties((PropertyHandler) p -> { - - if (!entity.isCreatorArgument(p)) { - accessor.setProperty(p, valueProvider.getValue(p)); - } - }); - } - return instance; - } - - - - /** - * A {@link Reader} is responsible for reading a single entity or collection of entities from a set of columns - * - * @since 3.2 - * @author Jens Schauder - */ - private interface Reader { - - /** - * read the data needed for creating the result of this {@literal Reader} - */ - void read(); - - /** - * Checks if this {@literal Reader} has all the data needed for a complete result, or if it needs to read further - * rows. - * - * @return the result of the check. - */ - boolean hasResult(); - - /** - * Constructs the result, returns it and resets the state of the reader to read the next instance. - * - * @return an instance of whatever this {@literal Reader} is supposed to read. - */ - @Nullable - Object getResultAndReset(); - } - - /** - * Adapts a {@link Map} to the interface of a {@literal Collection>}. - * - * @since 3.2 - * @author Jens Schauder - */ - private static class MapAdapter extends AbstractCollection> { - - private final Map map = new HashMap<>(); - - @Override - public Iterator> iterator() { - return map.entrySet().iterator(); - } - - @Override - public int size() { - return map.size(); - } - - @Override - public boolean add(Map.Entry entry) { - - map.put(entry.getKey(), entry.getValue()); - return true; - } - } - - /** - * Adapts a {@link List} to the interface of a {@literal Collection>}. - * - * @since 3.2 - * @author Jens Schauder - */ - private static class ListAdapter extends AbstractCollection> { - - private final List list = new ArrayList<>(); - - @Override - public Iterator> iterator() { - throw new UnsupportedOperationException("Do we need this?"); - } - - @Override - public int size() { - return list.size(); - } - - @Override - public boolean add(Map.Entry entry) { - - Integer index = (Integer) entry.getKey(); - while (index >= list.size()) { - list.add(null); - } - list.set(index, entry.getValue()); - return true; - } - } - - /** - * A {@link Reader} for reading entities. - * - * @since 3.2 - * @author Jens Schauder - */ - private class EntityReader implements Reader { - - /** - * Debugging the recursive structure of {@link Reader} instances can become a little mind bending. Giving each - * {@literal Reader} a descriptive name helps with that. - */ - private final String name; - - private final AggregatePath basePath; - private final CachingResultSet crs; - - private final EntityInstantiator instantiator; - @Nullable private final String idColumn; - - private ResultSetParameterValueProvider valueProvider; - private boolean result; - - Object oldId = null; - - private EntityReader(AggregatePath basePath, CachingResultSet crs) { - this(basePath, crs, null); - } - - private EntityReader(AggregatePath basePath, CachingResultSet crs, @Nullable String keyColumn) { - - this.basePath = basePath; - this.crs = crs; - - RelationalPersistentEntity entity = basePath.isRoot() ? rootEntity : basePath.getRequiredLeafEntity(); - instantiator = converter.getEntityInstantiators().getInstantiatorFor(entity); - - idColumn = entity.hasIdProperty() ? propertyToColumn.column(basePath.append(entity.getRequiredIdProperty())) - : keyColumn; - - reset(); - - name = "EntityReader for " + (basePath.isRoot() ? "" : basePath.toDotPath()); - } - - @Override - public void read() { - - if (idColumn != null && oldId == null) { - oldId = crs.getObject(idColumn); - } - - valueProvider.readValues(); - if (idColumn == null) { - result = true; - } else { - Object peekedId = crs.peek(idColumn); - if (peekedId == null || !peekedId.equals(oldId)) { - - result = true; - oldId = peekedId; - } - } - } - - @Override - public boolean hasResult() { - return result; - } - - @Override - @Nullable - public Object getResultAndReset() { - - try { - return hydrateInstance(instantiator, valueProvider, valueProvider.baseEntity); - } finally { - - reset(); - } - } - - private void reset() { - - valueProvider = new ResultSetParameterValueProvider(crs, basePath); - result = false; - } - - @Override - public String toString() { - return name; - } - } - - /** - * A {@link Reader} for reading collections of entities. - * - * @since 3.2 - * @author Jens Schauder - */ - class CollectionReader implements Reader { - - // debugging only - private final String name; - - private final Supplier collectionInitializer; - private final Reader entityReader; - - private Collection result; - - private static Supplier collectionInitializerFor(AggregatePath path) { - - RelationalPersistentProperty property = path.getRequiredLeafProperty(); - if (List.class.isAssignableFrom(property.getType())) { - return ListAdapter::new; - } else if (property.isMap()) { - return MapAdapter::new; - } else { - return HashSet::new; - } - } - - private CollectionReader(AggregatePath basePath, CachingResultSet crs) { - - this.collectionInitializer = collectionInitializerFor(basePath); - - String keyColumn = null; - final RelationalPersistentProperty property = basePath.getRequiredLeafProperty(); - if (property.isMap() || List.class.isAssignableFrom(basePath.getRequiredLeafProperty().getType())) { - keyColumn = propertyToColumn.keyColumn(basePath); - } - - if (property.isQualified()) { - this.entityReader = new EntryReader(basePath, crs, keyColumn, property.getQualifierColumnType()); - } else { - this.entityReader = new EntityReader(basePath, crs, keyColumn); - } - reset(); - name = "Reader for " + basePath.toDotPath(); - } - - private CollectionReader(CachingResultSet crs) { - - this.collectionInitializer = ArrayList::new; - this.entityReader = new EntityReader(context.getAggregatePath(rootEntity), crs); - reset(); - - name = "Collectionreader for "; - - } - - @Override - public void read() { - - entityReader.read(); - if (entityReader.hasResult()) { - result.add(entityReader.getResultAndReset()); - } - } - - @Override - public boolean hasResult() { - return false; - } - - @Override - public Object getResultAndReset() { - - try { - if (result instanceof MapAdapter) { - return ((MapAdapter) result).map; - } - if (result instanceof ListAdapter) { - return ((ListAdapter) result).list; - } - return result; - } finally { - reset(); - } - } - - private void reset() { - result = collectionInitializer.get(); - } - - @Override - public String toString() { - return name; - } - } - - /** - * A {@link Reader} for reading collection entries. Most of the work is done by an {@link EntityReader}, but a - * additional key column might get read. The result is - * - * @since 3.2 - * @author Jens Schauder - */ - private class EntryReader implements Reader { - - final EntityReader delegate; - final String keyColumn; - private final TypeInformation keyColumnType; - - Object key; - - EntryReader(AggregatePath basePath, CachingResultSet crs, String keyColumn, Class keyColumnType) { - - this.keyColumnType = TypeInformation.of(keyColumnType); - this.delegate = new EntityReader(basePath, crs, keyColumn); - this.keyColumn = keyColumn; - } - - @Override - public void read() { - - if (key == null) { - Object unconvertedKeyObject = delegate.crs.getObject(keyColumn); - key = converter.readValue(unconvertedKeyObject, keyColumnType); - } - delegate.read(); - } - - @Override - public boolean hasResult() { - return delegate.hasResult(); - } - - @Override - public Object getResultAndReset() { - - try { - return new AbstractMap.SimpleEntry<>(key, delegate.getResultAndReset()); - } finally { - key = null; - } - } - } - - /** - * A {@link ParameterValueProvider} that provided the values for an entity from a continues set of rows in a - * {@link ResultSet}. These might be referenced entities or collections of such entities. {@link ResultSet}. - * - * @since 3.2 - * @author Jens Schauder - */ - private class ResultSetParameterValueProvider implements ParameterValueProvider { - - private final CachingResultSet rs; - /** - * The path which is used to determine columnNames - */ - private final AggregatePath basePath; - private final RelationalPersistentEntity baseEntity; - - /** - * Holds all the values for the entity, either directly or in the form of an appropriate {@link Reader}. - */ - private final Map aggregatedValues = new HashMap<>(); - - ResultSetParameterValueProvider(CachingResultSet rs, AggregatePath basePath) { - - this.rs = rs; - this.basePath = basePath; - this.baseEntity = basePath.isRoot() ? rootEntity - : context.getRequiredPersistentEntity(basePath.getRequiredLeafProperty().getActualType()); - } - - @SuppressWarnings("unchecked") - @Override - @Nullable - public S getParameterValue(Parameter parameter) { - - return (S) getValue(baseEntity.getRequiredPersistentProperty(parameter.getName())); - } - - @Nullable - private Object getValue(RelationalPersistentProperty property) { - - Object value = aggregatedValues.get(property); - - if (value instanceof Reader) { - return ((Reader) value).getResultAndReset(); - } - - value = converter.readValue(value, property.getTypeInformation()); - - return value; - } - - /** - * read values for all collection like properties and aggregate them in a collection. - */ - void readValues() { - baseEntity.forEach(this::readValue); - } - - private void readValue(RelationalPersistentProperty p) { - - if (p.isEntity()) { - - Reader reader = null; - - if (p.isCollectionLike() || p.isMap()) { // even when there are no values we still want a (empty) collection. - - reader = (Reader) aggregatedValues.computeIfAbsent(p, pp -> new CollectionReader(basePath.append(pp), rs)); - } - if (getIndicatorOf(p) != null) { - - if (!(p.isCollectionLike() || p.isMap())) { // for single entities we want a null entity instead of on filled - // with null values. - - reader = (Reader) aggregatedValues.computeIfAbsent(p, pp -> new EntityReader(basePath.append(pp), rs)); - } - - Assert.state(reader != null, "reader must not be null"); - - reader.read(); - } - } else { - aggregatedValues.computeIfAbsent(p, this::getObject); - } - } - - @Nullable - private Object getIndicatorOf(RelationalPersistentProperty p) { - if (p.isMap() || List.class.isAssignableFrom(p.getType())) { - return rs.getObject(getKeyName(p)); - } - - if (p.isEmbedded()) { - return true; - } - - return rs.getObject(getColumnName(p)); - } - - /** - * Obtain a single columnValue from the resultset without throwing an exception. If the column does not exist a null - * value is returned. Does not instantiate complex objects. - * - * @param property - * @return - */ - @Nullable - private Object getObject(RelationalPersistentProperty property) { - return rs.getObject(getColumnName(property)); - } - - /** - * converts a property into a column name representing that property. - * - * @param property - * @return - */ - private String getColumnName(RelationalPersistentProperty property) { - - return propertyToColumn.column(basePath.append(property)); - } - - private String getKeyName(RelationalPersistentProperty property) { - - return propertyToColumn.keyColumn(basePath.append(property)); - } - - private boolean hasValue() { - - for (Object value : aggregatedValues.values()) { - if (value != null) { - return true; - } - } - return false; - } - } -} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java index 071c257d02..6409390088 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java @@ -25,7 +25,6 @@ import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; - import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.convert.ConverterNotFoundException; @@ -45,7 +44,7 @@ import org.springframework.data.mapping.model.SpELContext; import org.springframework.data.mapping.model.SpELExpressionEvaluator; import org.springframework.data.mapping.model.SpELExpressionParameterValueProvider; -import org.springframework.data.relational.core.conversion.BasicRelationalConverter; +import org.springframework.data.relational.core.conversion.MappingRelationalConverter; import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.mapping.AggregatePath; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -72,7 +71,7 @@ * @see CustomConversions * @since 1.1 */ -public class BasicJdbcConverter extends BasicRelationalConverter implements JdbcConverter, ApplicationContextAware { +public class BasicJdbcConverter extends MappingRelationalConverter implements JdbcConverter, ApplicationContextAware { private static final Log LOG = LogFactory.getLog(BasicJdbcConverter.class); private static final Converter, Map> ITERABLE_OF_ENTRY_TO_MAP_CONVERTER = new IterableOfEntryToMapConverter(); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java index c6706c9f7d..65d9d3c231 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/QueryMapper.java @@ -413,7 +413,7 @@ private Condition mapEmbeddedObjectCondition(CriteriaDefinition criteria, MapSql Condition condition = null; for (RelationalPersistentProperty nestedProperty : persistentEntity) { - SqlIdentifier sqlIdentifier = nestedProperty.getColumnName().transform(prefix::concat); + SqlIdentifier sqlIdentifier = nestedProperty.getColumnName(); Object mappedNestedValue = convertValue(embeddedAccessor.getProperty(nestedProperty), nestedProperty.getTypeInformation()); SQLType sqlType = converter.getTargetSqlType(nestedProperty); @@ -768,16 +768,6 @@ public SqlIdentifier getMappedColumnName() { throw new IllegalStateException("Cannot obtain a single column name for embedded property"); } - if (this.property != null && this.path != null && this.path.getParentPath() != null) { - - RelationalPersistentProperty owner = this.path.getParentPath().getLeafProperty(); - - if (owner != null && owner.isEmbedded()) { - return this.property.getColumnName() - .transform(it -> Objects.requireNonNull(owner.getEmbeddedPrefix()).concat(it)); - } - } - return this.path == null || this.path.getLeafProperty() == null ? super.getMappedColumnName() : this.path.getLeafProperty().getColumnName(); } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractor.java index 4735f4adbf..02fc1c6d3c 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractor.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractor.java @@ -15,6 +15,7 @@ */ package org.springframework.data.jdbc.core.convert; +import java.sql.Array; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.SQLException; @@ -58,7 +59,13 @@ enum ResultSetAdapter implements TabularResultAdapter { @Override public Object getObject(ResultSet row, int index) { try { - return JdbcUtils.getResultSetValue(row, index); + Object resultSetValue = JdbcUtils.getResultSetValue(row, index); + + if (resultSetValue instanceof Array a) { + return a.getArray(); + } + + return resultSetValue; } catch (SQLException e) { throw new DataRetrievalFailureException("Cannot retrieve column " + index + " from ResultSet", e); } @@ -140,8 +147,6 @@ private class RowDocumentIterator implements Iterator { private final RelationalPersistentEntity rootEntity; private final Integer identifierIndex; private final AggregateContext aggregateContext; - - private final boolean initiallyConsumed; private boolean hasNext; RowDocumentIterator(RelationalPersistentEntity entity, ResultSet resultSet) throws SQLException { @@ -150,9 +155,10 @@ private class RowDocumentIterator implements Iterator { if (resultSet.isBeforeFirst()) { hasNext = resultSet.next(); + } else { + hasNext = !resultSet.isAfterLast(); } - this.initiallyConsumed = resultSet.isAfterLast(); this.rootPath = context.getAggregatePath(entity); this.rootEntity = entity; @@ -166,11 +172,6 @@ private class RowDocumentIterator implements Iterator { @Override public boolean hasNext() { - - if (initiallyConsumed) { - return false; - } - return hasNext; } @@ -182,6 +183,7 @@ public RowDocument next() { Object key = ResultSetAdapter.INSTANCE.getObject(resultSet, identifierIndex); try { + do { Object nextKey = ResultSetAdapter.INSTANCE.getObject(resultSet, identifierIndex); diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java index 4890177b40..217dd9a0f9 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/RowDocumentExtractorSupport.java @@ -73,8 +73,8 @@ interface TabularResultAdapter { protected static class AggregateContext { private final TabularResultAdapter adapter; - final RelationalMappingContext context; - final PathToColumnMapping propertyToColumn; + private final RelationalMappingContext context; + private final PathToColumnMapping propertyToColumn; private final Map columnMap; protected AggregateContext(TabularResultAdapter adapter, RelationalMappingContext context, @@ -174,8 +174,11 @@ protected static class RowDocumentSink extends TabularSink { private final AggregateContext aggregateContext; private final RelationalPersistentEntity entity; private final AggregatePath basePath; - private RowDocument result; + + private String keyColumnName; + + private @Nullable Object key; private final Map> readerState = new LinkedHashMap<>(); public RowDocumentSink(AggregateContext aggregateContext, RelationalPersistentEntity entity, @@ -183,6 +186,15 @@ public RowDocumentSink(AggregateContext aggregateContext, RelationalPersiste this.aggregateContext = aggregateContext; this.entity = entity; this.basePath = basePath; + + String keyColumnName; + if (entity.hasIdProperty()) { + keyColumnName = aggregateContext.getColumnName(basePath.append(entity.getRequiredIdProperty())); + } else { + keyColumnName = aggregateContext.getColumnName(basePath); + } + + this.keyColumnName = keyColumnName; } @Override @@ -206,17 +218,29 @@ void accept(RS row) { */ private void readFirstRow(RS row, RowDocument document) { + // key marker + if (aggregateContext.containsColumn(keyColumnName)) { + key = aggregateContext.getObject(row, keyColumnName); + } + + readEntity(row, document, basePath, entity); + } + + private void readEntity(RS row, RowDocument document, AggregatePath basePath, + RelationalPersistentEntity entity) { + for (RelationalPersistentProperty property : entity) { AggregatePath path = basePath.append(property); - if (property.isQualified()) { + if (property.isEntity() && !property.isEmbedded() && (property.isCollectionLike() || property.isQualified())) { readerState.put(property, new ContainerSink<>(aggregateContext, property, path)); continue; } if (property.isEmbedded()) { - collectEmbeddedValues(row, document, property, path); + RelationalPersistentEntity embeddedEntity = aggregateContext.getRequiredPersistentEntity(property); + readEntity(row, document, path, embeddedEntity); continue; } @@ -262,7 +286,11 @@ boolean hasResult() { } } - return !result.isEmpty(); + if (result.isEmpty() && key == null) { + return false; + } + + return true; } @Override diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java deleted file mode 100644 index ce3f07530e..0000000000 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java +++ /dev/null @@ -1,789 +0,0 @@ -/* - * Copyright 2023 the original author or 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 org.springframework.data.jdbc.core.convert; - -import static java.util.Arrays.*; -import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.*; -import static org.springframework.data.jdbc.core.convert.AggregateResultSetExtractorUnitTests.ColumnType.*; - -import java.math.BigDecimal; -import java.sql.ResultSet; -import java.sql.SQLException; -import java.util.Iterator; -import java.util.List; -import java.util.Map; -import java.util.Set; - -import org.jetbrains.annotations.NotNull; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.springframework.data.annotation.Id; -import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; -import org.springframework.data.mapping.PersistentPropertyPath; -import org.springframework.data.relational.core.mapping.AggregatePath; -import org.springframework.data.relational.core.mapping.DefaultNamingStrategy; -import org.springframework.data.relational.core.mapping.Embedded; -import org.springframework.data.relational.core.mapping.RelationalMappingContext; -import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; -import org.springframework.data.relational.domain.RowDocument; - -/** - * Unit tests for the {@link AggregateResultSetExtractor}. - * - * @author Jens Schauder - * @author Mark Paluch - */ -public class AggregateResultSetExtractorUnitTests { - - RelationalMappingContext context = new JdbcMappingContext(new DefaultNamingStrategy()); - JdbcConverter converter = new BasicJdbcConverter(context, mock(RelationResolver.class)); - - private final PathToColumnMapping column = new PathToColumnMapping() { - @Override - public String column(AggregatePath path) { - return AggregateResultSetExtractorUnitTests.this.column(path); - } - - @Override - public String keyColumn(AggregatePath path) { - return column(path) + "_key"; - } - }; - - AggregateResultSetExtractor extractor = getExtractor(SimpleEntity.class); - ResultSetRowDocumentExtractor documentExtractor = new ResultSetRowDocumentExtractor(context, column); - - @Test // GH-1446 - void emptyResultSetYieldsEmptyResult() throws SQLException { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList("T0_C0_ID1", "T0_C1_NAME")); - assertThat(extractor.extractData(resultSet)).isEmpty(); - } - - @Test // GH-1446 - void singleSimpleEntityGetsExtractedFromSingleRow() throws SQLException { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name")), // - 1, "Alfred"); - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.name) - .containsExactly(tuple(1L, "Alfred")); - - resultSet.close(); - - RowDocument document = documentExtractor.extractNextDocument(SimpleEntity.class, resultSet); - - assertThat(document).containsEntry("id1", 1).containsEntry("name", "Alfred"); - } - - @Test // GH-1446 - void multipleSimpleEntitiesGetExtractedFromMultipleRows() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name")), // - 1, "Alfred", // - 2, "Bertram" // - ); - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.name).containsExactly( // - tuple(1L, "Alfred"), // - tuple(2L, "Bertram") // - ); - } - - @Nested - class Conversions { - - @Test // GH-1446 - void appliesConversionToProperty() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name")), // - new BigDecimal(1), "Alfred"); - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.name) - .containsExactly(tuple(1L, "Alfred")); - } - - @Test // GH-1446 - void appliesConversionToConstructorValue() { - - AggregateResultSetExtractor extractor = getExtractor(DummyRecord.class); - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name")), // - new BigDecimal(1), "Alfred"); - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.name) - .containsExactly(tuple(1L, "Alfred")); - } - - @Test // GH-1446 - void appliesConversionToKeyValue() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummyList", KEY), column("dummyList.dummyName")), // - 1, new BigDecimal(0), "Dummy Alfred", // - 1, new BigDecimal(1), "Dummy Berta", // - 1, new BigDecimal(2), "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // - .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - } - } - - @NotNull - private AggregateResultSetExtractor getExtractor(Class type) { - return (AggregateResultSetExtractor) new AggregateResultSetExtractor<>(context.getPersistentEntity(type), - converter, column); - } - - @Nested - class EmbeddedReference { - @Test // GH-1446 - void embeddedGetsExtractedFromSingleRow() throws SQLException { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("embeddedNullable.dummyName")), // - 1, "Imani"); - - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.embeddedNullable.dummyName) - .containsExactly(tuple(1L, "Imani")); - - resultSet.close(); - - RowDocument document = documentExtractor.extractNextDocument(SimpleEntity.class, resultSet); - assertThat(document).containsEntry("id1", 1).containsEntry("dummy_name", "Imani"); - } - - @Test // GH-1446 - void nullEmbeddedGetsExtractedFromSingleRow() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("embeddedNullable.dummyName")), // - 1, null); - - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.embeddedNullable) - .containsExactly(tuple(1L, null)); - } - - @Test // GH-1446 - void emptyEmbeddedGetsExtractedFromSingleRow() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("embeddedNonNull.dummyName")), // - 1, null); - - assertThat(extractor.extractData(resultSet)) // - .extracting(e -> e.id1, e -> e.embeddedNonNull.dummyName) // - .containsExactly(tuple(1L, null)); - } - } - - @Nested - class ToOneRelationships { - @Test // GH-1446 - void entityReferenceGetsExtractedFromSingleRow() throws SQLException { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummy"), column("dummy.dummyName")), // - 1, 1, "Dummy Alfred"); - - assertThat(extractor.extractData(resultSet)) // - .extracting(e -> e.id1, e -> e.dummy.dummyName) // - .containsExactly(tuple(1L, "Dummy Alfred")); - - resultSet.close(); - - RowDocument document = documentExtractor.extractNextDocument(SimpleEntity.class, resultSet); - - assertThat(document).containsKey("dummy").containsEntry("dummy", - new RowDocument().append("dummy_name", "Dummy Alfred")); - } - - @Test // GH-1446 - void nullEntityReferenceGetsExtractedFromSingleRow() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummy"), column("dummy.dummyName")), // - 1, null, "Dummy Alfred"); - - assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.dummy) - .containsExactly(tuple(1L, null)); - } - } - - @Nested - class Sets { - - @Test // GH-1446 - void extractEmptySetReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummies"), column("dummies.dummyName")), // - 1, null, null, // - 1, null, null, // - 1, null, null); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - assertThat(result.iterator().next().dummies).isEmpty(); - } - - @Test // GH-1446 - void extractSingleSetReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummies"), column("dummies.dummyName")), // - 1, 1, "Dummy Alfred", // - 1, 1, "Dummy Berta", // - 1, 1, "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - } - - @Test // GH-1446 - void extractSetReferenceAndSimpleProperty() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("name"), column("dummies"), column("dummies.dummyName")), // - 1, "Simplicissimus", 1, "Dummy Alfred", // - 1, null, 1, "Dummy Berta", // - 1, null, 1, "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); - assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - } - - @Test // GH-1446 - void extractMultipleSetReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // - column("dummies"), column("dummies.dummyName"), // - column("otherDummies"), column("otherDummies.dummyName")), // - 1, 1, "Dummy Alfred", 1, "Other Ephraim", // - 1, 1, "Dummy Berta", 1, "Other Zeno", // - 1, 1, "Dummy Carl", null, null); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); - } - - @Test // GH-1446 - void extractNestedSetsWithId() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // - column("intermediates"), column("intermediates.iId"), column("intermediates.intermediateName"), // - column("intermediates.dummies"), column("intermediates.dummies.dummyName")), // - 1, "Alfred", 1, 23, "Inami", 23, "Dustin", // - 1, null, 1, 23, null, 23, "Dora", // - 1, null, 1, 24, "Ina", 24, "Dotty", // - 1, null, 1, 25, "Ion", null, null, // - 2, "Bon Jovi", 2, 26, "Judith", 26, "Ephraim", // - 2, null, 2, 26, null, 26, "Erin", // - 2, null, 2, 27, "Joel", 27, "Erika", // - 2, null, 2, 28, "Justin", null, null // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediates.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediates.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - final Iterator iter = result.iterator(); - SimpleEntity alfred = iter.next(); - assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); - assertThat(alfred.intermediates).extracting(d -> d.intermediateName).containsExactlyInAnyOrder("Inami", "Ina", - "Ion"); - - assertThat(alfred.findInIntermediates("Inami").dummies).extracting(d -> d.dummyName) - .containsExactlyInAnyOrder("Dustin", "Dora"); - assertThat(alfred.findInIntermediates("Ina").dummies).extracting(d -> d.dummyName) - .containsExactlyInAnyOrder("Dotty"); - assertThat(alfred.findInIntermediates("Ion").dummies).isEmpty(); - - SimpleEntity bonJovy = iter.next(); - assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); - assertThat(bonJovy.intermediates).extracting(d -> d.intermediateName).containsExactlyInAnyOrder("Judith", "Joel", - "Justin"); - assertThat(bonJovy.findInIntermediates("Judith").dummies).extracting(d -> d.dummyName) - .containsExactlyInAnyOrder("Ephraim", "Erin"); - assertThat(bonJovy.findInIntermediates("Joel").dummies).extracting(d -> d.dummyName).containsExactly("Erika"); - assertThat(bonJovy.findInIntermediates("Justin").dummyList).isEmpty(); - - } - } - - @Nested - class Lists { - - @Test // GH-1446 - void extractSingleListReference() throws SQLException { - - AggregateResultSetExtractor extractor = getExtractor(WithList.class); - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id", WithList.class), column("people", KEY, WithList.class), - column("people.name", WithList.class)), // - 1, 0, "Dummy Alfred", // - 1, 1, "Dummy Berta", // - 1, 2, "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id).containsExactly(1L); - assertThat(result).flatExtracting(e -> e.people).extracting(e -> e.name) // - .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - - resultSet.close(); - RowDocument document = documentExtractor.extractNextDocument(WithList.class, resultSet); - - assertThat(document).containsKey("people"); - List dummy_list = document.getList("people"); - assertThat(dummy_list).hasSize(3).contains(new RowDocument().append("name", "Dummy Alfred")) - .contains(new RowDocument().append("name", "Dummy Berta")) - .contains(new RowDocument().append("name", "Dummy Carl")); - } - - @Test // GH-1446 - void extractSingleUnorderedListReference() throws SQLException { - - AggregateResultSetExtractor extractor = getExtractor(WithList.class); - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id", WithList.class), column("people", KEY, WithList.class), - column("people.name", WithList.class)), // - 1, 0, "Dummy Alfred", // - 1, 2, "Dummy Carl", // - 1, 1, "Dummy Berta" // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id).containsExactly(1L); - assertThat(result).flatExtracting(e -> e.people).extracting(e -> e.name) // - .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - - resultSet.close(); - - RowDocument document = documentExtractor.extractNextDocument(WithList.class, resultSet); - - assertThat(document).containsKey("people"); - List dummy_list = document.getList("people"); - assertThat(dummy_list).hasSize(3).contains(new RowDocument().append("name", "Dummy Alfred")) - .contains(new RowDocument().append("name", "Dummy Berta")) - .contains(new RowDocument().append("name", "Dummy Carl")); - } - - @Test // GH-1446 - void extractListReferenceAndSimpleProperty() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("name"), column("dummyList", KEY), column("dummyList.dummyName")), // - 1, "Simplicissimus", 0, "Dummy Alfred", // - 1, null, 1, "Dummy Berta", // - 1, null, 2, "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); - assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // - .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - } - - @Test // GH-1446 - void extractMultipleCollectionReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // - column("dummyList", KEY), column("dummyList.dummyName"), // - column("otherDummies"), column("otherDummies.dummyName")), // - 1, 0, "Dummy Alfred", 1, "Other Ephraim", // - 1, 1, "Dummy Berta", 1, "Other Zeno", // - 1, 2, "Dummy Carl", null, null); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // - .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); - assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); - } - - @Test // GH-1446 - void extractNestedListsWithId() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // - column("intermediateList", KEY), column("intermediateList.iId"), column("intermediateList.intermediateName"), // - column("intermediateList.dummyList", KEY), column("intermediateList.dummyList.dummyName")), // - 1, "Alfred", 0, 23, "Inami", 0, "Dustin", // - 1, null, 0, 23, null, 1, "Dora", // - 1, null, 1, 24, "Ina", 0, "Dotty", // - 1, null, 2, 25, "Ion", null, null, // - 2, "Bon Jovi", 0, 26, "Judith", 0, "Ephraim", // - 2, null, 0, 26, null, 1, "Erin", // - 2, null, 1, 27, "Joel", 0, "Erika", // - 2, null, 2, 28, "Justin", null, null // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateList.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - final Iterator iter = result.iterator(); - SimpleEntity alfred = iter.next(); - assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); - assertThat(alfred.intermediateList).extracting(d -> d.intermediateName).containsExactly("Inami", "Ina", "Ion"); - - assertThat(alfred.findInIntermediateList("Inami").dummyList).extracting(d -> d.dummyName) - .containsExactly("Dustin", "Dora"); - assertThat(alfred.findInIntermediateList("Ina").dummyList).extracting(d -> d.dummyName).containsExactly("Dotty"); - assertThat(alfred.findInIntermediateList("Ion").dummyList).isEmpty(); - - SimpleEntity bonJovy = iter.next(); - assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); - assertThat(bonJovy.intermediateList).extracting(d -> d.intermediateName).containsExactly("Judith", "Joel", - "Justin"); - assertThat(bonJovy.findInIntermediateList("Judith").dummyList).extracting(d -> d.dummyName) - .containsExactly("Ephraim", "Erin"); - assertThat(bonJovy.findInIntermediateList("Joel").dummyList).extracting(d -> d.dummyName) - .containsExactly("Erika"); - assertThat(bonJovy.findInIntermediateList("Justin").dummyList).isEmpty(); - - } - - @Test // GH-1446 - void extractNestedListsWithOutId() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // - column("intermediateListNoId", KEY), column("intermediateListNoId.intermediateName"), // - column("intermediateListNoId.dummyList", KEY), column("intermediateListNoId.dummyList.dummyName")), // - 1, "Alfred", 0, "Inami", 0, "Dustin", // - 1, null, 0, null, 1, "Dora", // - 1, null, 1, "Ina", 0, "Dotty", // - 1, null, 2, "Ion", null, null, // - 2, "Bon Jovi", 0, "Judith", 0, "Ephraim", // - 2, null, 0, null, 1, "Erin", // - 2, null, 1, "Joel", 0, "Erika", // - 2, null, 2, "Justin", null, null // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateListNoId.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - final Iterator iter = result.iterator(); - SimpleEntity alfred = iter.next(); - assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); - assertThat(alfred.intermediateListNoId).extracting(d -> d.intermediateName).containsExactly("Inami", "Ina", - "Ion"); - - assertThat(alfred.findInIntermediateListNoId("Inami").dummyList).extracting(d -> d.dummyName) - .containsExactly("Dustin", "Dora"); - assertThat(alfred.findInIntermediateListNoId("Ina").dummyList).extracting(d -> d.dummyName) - .containsExactly("Dotty"); - assertThat(alfred.findInIntermediateListNoId("Ion").dummyList).isEmpty(); - - SimpleEntity bonJovy = iter.next(); - assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); - assertThat(bonJovy.intermediateListNoId).extracting(d -> d.intermediateName).containsExactly("Judith", "Joel", - "Justin"); - - assertThat(bonJovy.findInIntermediateListNoId("Judith").dummyList).extracting(d -> d.dummyName) - .containsExactly("Ephraim", "Erin"); - assertThat(bonJovy.findInIntermediateListNoId("Joel").dummyList).extracting(d -> d.dummyName) - .containsExactly("Erika"); - assertThat(bonJovy.findInIntermediateListNoId("Justin").dummyList).isEmpty(); - - } - - } - - @Nested - class Maps { - - @Test // GH-1446 - void extractSingleMapReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("dummyMap", KEY), column("dummyMap.dummyName")), // - 1, "alpha", "Dummy Alfred", // - 1, "beta", "Dummy Berta", // - 1, "gamma", "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - Map dummyMap = result.iterator().next().dummyMap; - assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); - assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); - assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); - } - - @Test // GH-1446 - void extractMapReferenceAndSimpleProperty() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet( - asList(column("id1"), column("name"), column("dummyMap", KEY), column("dummyMap.dummyName")), // - 1, "Simplicissimus", "alpha", "Dummy Alfred", // - 1, null, "beta", "Dummy Berta", // - 1, null, "gamma", "Dummy Carl"); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); - Map dummyMap = result.iterator().next().dummyMap; - assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); - assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); - assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); - } - - @Test // GH-1446 - void extractMultipleCollectionReference() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // - column("dummyMap", KEY), column("dummyMap.dummyName"), // - column("otherDummies"), column("otherDummies.dummyName")), // - 1, "alpha", "Dummy Alfred", 1, "Other Ephraim", // - 1, "beta", "Dummy Berta", 1, "Other Zeno", // - 1, "gamma", "Dummy Carl", null, null); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1).containsExactly(1L); - Map dummyMap = result.iterator().next().dummyMap; - assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); - assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); - assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); - - assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // - .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); - } - - @Test // GH-1446 - void extractNestedMapsWithId() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // - column("intermediateMap", KEY), column("intermediateMap.iId"), column("intermediateMap.intermediateName"), // - column("intermediateMap.dummyMap", KEY), column("intermediateMap.dummyMap.dummyName")), // - 1, "Alfred", "alpha", 23, "Inami", "omega", "Dustin", // - 1, null, "alpha", 23, null, "zeta", "Dora", // - 1, null, "beta", 24, "Ina", "eta", "Dotty", // - 1, null, "gamma", 25, "Ion", null, null, // - 2, "Bon Jovi", "phi", 26, "Judith", "theta", "Ephraim", // - 2, null, "phi", 26, null, "jota", "Erin", // - 2, null, "chi", 27, "Joel", "sigma", "Erika", // - 2, null, "psi", 28, "Justin", null, null // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateMap.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - final Iterator iter = result.iterator(); - SimpleEntity alfred = iter.next(); - assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); - - assertThat(alfred.intermediateMap.get("alpha").dummyMap.get("omega").dummyName).isEqualTo("Dustin"); - assertThat(alfred.intermediateMap.get("alpha").dummyMap.get("zeta").dummyName).isEqualTo("Dora"); - assertThat(alfred.intermediateMap.get("beta").dummyMap.get("eta").dummyName).isEqualTo("Dotty"); - assertThat(alfred.intermediateMap.get("gamma").dummyMap).isEmpty(); - - SimpleEntity bonJovy = iter.next(); - - assertThat(bonJovy.intermediateMap.get("phi").dummyMap.get("theta").dummyName).isEqualTo("Ephraim"); - assertThat(bonJovy.intermediateMap.get("phi").dummyMap.get("jota").dummyName).isEqualTo("Erin"); - assertThat(bonJovy.intermediateMap.get("chi").dummyMap.get("sigma").dummyName).isEqualTo("Erika"); - assertThat(bonJovy.intermediateMap.get("psi").dummyMap).isEmpty(); - } - - @Test // GH-1446 - void extractNestedMapsWithOutId() { - - ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // - column("intermediateMapNoId", KEY), column("intermediateMapNoId.intermediateName"), // - column("intermediateMapNoId.dummyMap", KEY), column("intermediateMapNoId.dummyMap.dummyName")), // - 1, "Alfred", "alpha", "Inami", "omega", "Dustin", // - 1, null, "alpha", null, "zeta", "Dora", // - 1, null, "beta", "Ina", "eta", "Dotty", // - 1, null, "gamma", "Ion", null, null, // - 2, "Bon Jovi", "phi", "Judith", "theta", "Ephraim", // - 2, null, "phi", null, "jota", "Erin", // - 2, null, "chi", "Joel", "sigma", "Erika", // - 2, null, "psi", "Justin", null, null // - ); - - Iterable result = extractor.extractData(resultSet); - - assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateMapNoId.size()) - .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); - - final Iterator iter = result.iterator(); - SimpleEntity alfred = iter.next(); - assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); - - assertThat(alfred.intermediateMapNoId.get("alpha").dummyMap.get("omega").dummyName).isEqualTo("Dustin"); - assertThat(alfred.intermediateMapNoId.get("alpha").dummyMap.get("zeta").dummyName).isEqualTo("Dora"); - assertThat(alfred.intermediateMapNoId.get("beta").dummyMap.get("eta").dummyName).isEqualTo("Dotty"); - assertThat(alfred.intermediateMapNoId.get("gamma").dummyMap).isEmpty(); - - SimpleEntity bonJovy = iter.next(); - - assertThat(bonJovy.intermediateMapNoId.get("phi").dummyMap.get("theta").dummyName).isEqualTo("Ephraim"); - assertThat(bonJovy.intermediateMapNoId.get("phi").dummyMap.get("jota").dummyName).isEqualTo("Erin"); - assertThat(bonJovy.intermediateMapNoId.get("chi").dummyMap.get("sigma").dummyName).isEqualTo("Erika"); - assertThat(bonJovy.intermediateMapNoId.get("psi").dummyMap).isEmpty(); - } - - } - - private String column(String path) { - return column(path, NORMAL); - } - - private String column(String path, Class entityType) { - return column(path, NORMAL, entityType); - } - - private String column(String path, ColumnType columnType) { - return column(path, columnType, SimpleEntity.class); - } - - private String column(String path, ColumnType columnType, Class entityType) { - - PersistentPropertyPath propertyPath = context.getPersistentPropertyPath(path, - entityType); - - return column(context.getAggregatePath(propertyPath)) + (columnType == KEY ? "_key" : ""); - } - - private String column(AggregatePath path) { - return path.toDotPath(); - } - - enum ColumnType { - NORMAL, KEY - } - - private static class Person { - - String name; - } - - private static class PersonWithId { - - @Id Long id; - String name; - } - - private static class WithList { - - @Id long id; - - List people; - List peopleWithIds; - } - - private static class SimpleEntity { - - @Id long id1; - String name; - DummyEntity dummy; - @Embedded.Nullable DummyEntity embeddedNullable; - @Embedded.Empty DummyEntity embeddedNonNull; - - Set intermediates; - - Set dummies; - Set otherDummies; - - List dummyList; - List intermediateList; - List intermediateListNoId; - - Map dummyMap; - Map intermediateMap; - Map intermediateMapNoId; - - Intermediate findInIntermediates(String name) { - for (Intermediate intermediate : intermediates) { - if (intermediate.intermediateName.equals(name)) { - return intermediate; - } - } - fail("No intermediate with name " + name + " found in intermediates."); - return null; - } - - Intermediate findInIntermediateList(String name) { - for (Intermediate intermediate : intermediateList) { - if (intermediate.intermediateName.equals(name)) { - return intermediate; - } - } - fail("No intermediate with name " + name + " found in intermediateList."); - return null; - } - - IntermediateNoId findInIntermediateListNoId(String name) { - for (IntermediateNoId intermediate : intermediateListNoId) { - if (intermediate.intermediateName.equals(name)) { - return intermediate; - } - } - fail("No intermediates with name " + name + " found in intermediateListNoId."); - return null; - } - } - - private static class Intermediate { - - @Id long iId; - String intermediateName; - - Set dummies; - List dummyList; - Map dummyMap; - } - - private static class IntermediateNoId { - - String intermediateName; - - Set dummies; - List dummyList; - Map dummyMap; - } - - private static class DummyEntity { - String dummyName; - Long longValue; - } - - private record DummyRecord(Long id1, String name) { - } -} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractorUnitTests.java new file mode 100644 index 0000000000..122dfaadcf --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetRowDocumentExtractorUnitTests.java @@ -0,0 +1,573 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.jdbc.core.convert; + +import static org.assertj.core.api.Assertions.*; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import org.assertj.core.api.Assertions; +import org.assertj.core.api.ThrowingConsumer; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.jdbc.core.mapping.JdbcMappingContext; +import org.springframework.data.mapping.PersistentPropertyPath; +import org.springframework.data.relational.core.mapping.AggregatePath; +import org.springframework.data.relational.core.mapping.DefaultNamingStrategy; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; + +/** + * Unit tests for the {@link ResultSetRowDocumentExtractor}. + * + * @author Jens Schauder + * @author Mark Paluch + */ +public class ResultSetRowDocumentExtractorUnitTests { + + RelationalMappingContext context = new JdbcMappingContext(new DefaultNamingStrategy()); + + private final PathToColumnMapping column = new PathToColumnMapping() { + @Override + public String column(AggregatePath path) { + return ResultSetRowDocumentExtractorUnitTests.this.column(path); + } + + @Override + public String keyColumn(AggregatePath path) { + return column(path) + "_key"; + } + }; + + ResultSetRowDocumentExtractor documentExtractor = new ResultSetRowDocumentExtractor(context, column); + + @Test // GH-1446 + void emptyResultSetYieldsEmptyResult() { + + Assertions.setMaxElementsForPrinting(20); + + new ResultSetTester(WithEmbedded.class, context).resultSet(rsc -> { + rsc.withPaths("id1", "name"); + }).run(resultSet -> { + assertThatIllegalStateException() + .isThrownBy(() -> documentExtractor.extractNextDocument(WithEmbedded.class, resultSet)); + }); + } + + @Test // GH-1446 + void singleSimpleEntityGetsExtractedFromSingleRow() throws SQLException { + + testerFor(WithEmbedded.class).resultSet(rsc -> { + rsc.withPaths("id1", "name") // + .withRow(1, "Alfred"); + }).run(document -> { + + assertThat(document).containsEntry("id1", 1).containsEntry("name", "Alfred"); + }); + } + + @Test // GH-1446 + void multipleSimpleEntitiesGetExtractedFromMultipleRows() throws SQLException { + + new ResultSetTester(WithEmbedded.class, context).resultSet(rsc -> { + rsc.withPaths("id1", "name") // + .withRow(1, "Alfred") // + .withRow(2, "Bertram"); + }).run(resultSet -> { + + RowDocument document = documentExtractor.extractNextDocument(WithEmbedded.class, resultSet); + assertThat(document).containsEntry("id1", 1).containsEntry("name", "Alfred"); + + RowDocument nextDocument = documentExtractor.extractNextDocument(WithEmbedded.class, resultSet); + assertThat(nextDocument).containsEntry("id1", 2).containsEntry("name", "Bertram"); + }); + } + + @Nested + class EmbeddedReference { + @Test // GH-1446 + void embeddedGetsExtractedFromSingleRow() { + + testerFor(WithEmbedded.class).resultSet(rsc -> { + rsc.withPaths("id1", "embeddedNullable.dummyName") // + .withRow(1, "Imani"); + }).run(document -> { + + assertThat(document).containsEntry("id1", 1).containsEntry("dummy_name", "Imani"); + }); + } + + @Test // GH-1446 + void emptyEmbeddedGetsExtractedFromSingleRow() throws SQLException { + + testerFor(WithEmbedded.class).resultSet(rsc -> { + rsc.withPaths("id1", "embeddedNullable.dummyName") // + .withRow(1, null); + }).run(document -> { + + assertThat(document).hasSize(1).containsEntry("id1", 1); + }); + } + } + + @Nested + class ToOneRelationships { + @Test // GH-1446 + void entityReferenceGetsExtractedFromSingleRow() { + + testerFor(WithOneToOne.class).resultSet(rsc -> { + rsc.withPaths("id1", "related", "related.dummyName") // + .withRow(1, 1, "Dummy Alfred"); + }).run(document -> { + + assertThat(document).containsKey("related").containsEntry("related", + new RowDocument().append("dummy_name", "Dummy Alfred")); + }); + } + + @Test // GH-1446 + void nullEntityReferenceGetsExtractedFromSingleRow() { + + testerFor(WithOneToOne.class).resultSet(rsc -> { + rsc.withPaths("id1", "related", "related.dummyName") // + .withRow(1, null, "Dummy Alfred"); + }).run(document -> { + + assertThat(document).containsKey("related").containsEntry("related", + new RowDocument().append("dummy_name", "Dummy Alfred")); + }); + } + } + + @Nested + class Sets { + + @Test // GH-1446 + void extractEmptySetReference() { + + testerFor(WithSets.class).resultSet(rsc -> { + rsc.withPaths("id1", "first", "first.dummyName") // + .withRow(1, null, null)// + .withRow(1, null, null) // + .withRow(1, null, null); + }).run(document -> { + + assertThat(document).hasSize(1).containsEntry("id1", 1); + }); + } + + @Test // GH-1446 + void extractSingleSetReference() { + + testerFor(WithSets.class).resultSet(rsc -> { + rsc.withPath("id1").withKey("first").withPath("first.dummyName") // + .withRow(1, 1, "Dummy Alfred")// + .withRow(1, 2, "Dummy Berta") // + .withRow(1, 3, "Dummy Carl"); + }).run(document -> { + + assertThat(document).containsEntry("id1", 1).containsEntry("first", + Arrays.asList(RowDocument.of("dummy_name", "Dummy Alfred"), RowDocument.of("dummy_name", "Dummy Berta"), + RowDocument.of("dummy_name", "Dummy Carl"))); + }); + } + + @Test // GH-1446 + void extractSetReferenceAndSimpleProperty() { + + testerFor(WithSets.class).resultSet(rsc -> { + rsc.withPaths("id1", "name").withKey("first").withPath("first.dummyName") // + .withRow(1, "Simplicissimus", 1, "Dummy Alfred")// + .withRow(1, null, 2, "Dummy Berta") // + .withRow(1, null, 3, "Dummy Carl"); + }).run(document -> { + + assertThat(document).containsEntry("id1", 1).containsEntry("name", "Simplicissimus").containsEntry("first", + Arrays.asList(RowDocument.of("dummy_name", "Dummy Alfred"), RowDocument.of("dummy_name", "Dummy Berta"), + RowDocument.of("dummy_name", "Dummy Carl"))); + }); + } + + @Test // GH-1446 + void extractMultipleSetReference() { + + testerFor(WithSets.class).resultSet(rsc -> { + rsc.withPaths("id1").withKey("first").withPath("first.dummyName").withKey("second").withPath("second.dummyName") // + .withRow(1, 1, "Dummy Alfred", 1, "Other Ephraim")// + .withRow(1, 2, "Dummy Berta", 2, "Other Zeno") // + .withRow(1, 3, "Dummy Carl", null, null); + }).run(document -> { + + assertThat(document).hasSize(3) + .containsEntry("first", + Arrays.asList(RowDocument.of("dummy_name", "Dummy Alfred"), RowDocument.of("dummy_name", "Dummy Berta"), + RowDocument.of("dummy_name", "Dummy Carl"))) + .containsEntry("second", Arrays.asList(RowDocument.of("dummy_name", "Other Ephraim"), + RowDocument.of("dummy_name", "Other Zeno"))); + }); + } + + @Nested + class Lists { + + @Test // GH-1446 + void extractSingleListReference() { + + testerFor(WithList.class).resultSet(rsc -> { + rsc.withPaths("id").withKey("withoutIds").withPath("withoutIds.name") // + .withRow(1, 1, "Dummy Alfred")// + .withRow(1, 2, "Dummy Berta") // + .withRow(1, 3, "Dummy Carl"); + }).run(document -> { + + assertThat(document).hasSize(2).containsEntry("without_ids", + Arrays.asList(RowDocument.of("name", "Dummy Alfred"), RowDocument.of("name", "Dummy Berta"), + RowDocument.of("name", "Dummy Carl"))); + }); + } + + @Test // GH-1446 + void extractSingleUnorderedListReference() { + + testerFor(WithList.class).resultSet(rsc -> { + rsc.withPaths("id").withKey("withoutIds").withPath("withoutIds.name") // + .withRow(1, 0, "Dummy Alfred")// + .withRow(1, 2, "Dummy Carl") // + .withRow(1, 1, "Dummy Berta"); + }).run(document -> { + + assertThat(document).containsKey("without_ids"); + List dummy_list = document.getList("without_ids"); + assertThat(dummy_list).hasSize(3).contains(new RowDocument().append("name", "Dummy Alfred")) + .contains(new RowDocument().append("name", "Dummy Berta")) + .contains(new RowDocument().append("name", "Dummy Carl")); + }); + } + } + } + + @Nested + class Maps { + + @Test + // GH-1446 + void extractSingleMapReference() { + + testerFor(WithMaps.class).resultSet(rsc -> { + rsc.withPaths("id1").withKey("first").withPath("first.dummyName") // + .withRow(1, "alpha", "Dummy Alfred")// + .withRow(1, "beta", "Dummy Berta") // + .withRow(1, "gamma", "Dummy Carl"); + }).run(document -> { + + assertThat(document).containsEntry("first", Map.of("alpha", RowDocument.of("dummy_name", "Dummy Alfred"), + "beta", RowDocument.of("dummy_name", "Dummy Berta"), "gamma", RowDocument.of("dummy_name", "Dummy Carl"))); + }); + } + + @Test + // GH-1446 + void extractMultipleCollectionReference() { + + testerFor(WithMapsAndList.class).resultSet(rsc -> { + rsc.withPaths("id1").withKey("map").withPath("map.dummyName").withKey("list").withPath("list.name") // + .withRow(1, "alpha", "Dummy Alfred", 1, "Other Ephraim")// + .withRow(1, "beta", "Dummy Berta", 2, "Other Zeno") // + .withRow(1, "gamma", "Dummy Carl", null, null); + }).run(document -> { + + assertThat(document).containsEntry("map", Map.of("alpha", RowDocument.of("dummy_name", "Dummy Alfred"), // + "beta", RowDocument.of("dummy_name", "Dummy Berta"), // + "gamma", RowDocument.of("dummy_name", "Dummy Carl"))) // + .containsEntry("list", + Arrays.asList(RowDocument.of("name", "Other Ephraim"), RowDocument.of("name", "Other Zeno"))); + }); + } + + @Test + // GH-1446 + void extractNestedMapsWithId() { + + testerFor(WithMaps.class).resultSet(rsc -> { + rsc.withPaths("id1", "name").withKey("intermediate") + .withPaths("intermediate.iId", "intermediate.intermediateName").withKey("intermediate.dummyMap") + .withPaths("intermediate.dummyMap.dummyName") + // + .withRow(1, "Alfred", "alpha", 23, "Inami", "omega", "Dustin") // + .withRow(1, null, "alpha", 23, null, "zeta", "Dora") // + .withRow(1, null, "beta", 24, "Ina", "eta", "Dotty") // + .withRow(1, null, "gamma", 25, "Ion", null, null); + }).run(document -> { + + assertThat(document).containsEntry("id1", 1).containsEntry("name", "Alfred"); + + Map intermediate = document.getMap("intermediate"); + assertThat(intermediate).containsKeys("alpha", "beta", "gamma"); + + RowDocument alpha = (RowDocument) intermediate.get("alpha"); + assertThat(alpha).containsEntry("i_id", 23).containsEntry("intermediate_name", "Inami"); + Map dummyMap = alpha.getMap("dummy_map"); + assertThat(dummyMap).containsEntry("omega", RowDocument.of("dummy_name", "Dustin")).containsEntry("zeta", + RowDocument.of("dummy_name", "Dora")); + + RowDocument gamma = (RowDocument) intermediate.get("gamma"); + assertThat(gamma).hasSize(2).containsEntry("i_id", 25).containsEntry("intermediate_name", "Ion"); + }); + } + } + + private String column(AggregatePath path) { + return path.toDotPath(); + } + + private static class WithEmbedded { + + @Id long id1; + String name; + @Embedded.Nullable DummyEntity embeddedNullable; + @Embedded.Empty DummyEntity embeddedNonNull; + } + + private static class WithOneToOne { + + @Id long id1; + String name; + DummyEntity related; + } + + private static class Person { + + String name; + } + + private static class PersonWithId { + + @Id Long id; + String name; + } + + private static class WithList { + + @Id long id; + + List withoutIds; + List withIds; + } + + private static class WithSets { + + @Id long id1; + String name; + Set first; + Set second; + } + + private static class WithMaps { + + @Id long id1; + + String name; + + Map first; + Map intermediate; + Map noId; + } + + private static class WithMapsAndList { + + @Id long id1; + + Map map; + List list; + } + + private static class Intermediate { + + @Id long iId; + String intermediateName; + + Set dummies; + List dummyList; + Map dummyMap; + } + + private static class IntermediateNoId { + + String intermediateName; + + Set dummies; + List dummyList; + Map dummyMap; + } + + private static class DummyEntity { + String dummyName; + Long longValue; + } + + /** + * Configurer for a {@link ResultSet}. + */ + interface ResultSetConfigurer { + + ResultSetConfigurer withColumns(String... columns); + + /** + * Add mapped paths. + * + * @param path + * @return + */ + ResultSetConfigurer withPath(String path); + + /** + * Add mapped paths. + * + * @param paths + * @return + */ + default ResultSetConfigurer withPaths(String... paths) { + for (String path : paths) { + withPath(path); + } + + return this; + } + + /** + * Add mapped key paths. + * + * @param path + * @return + */ + ResultSetConfigurer withKey(String path); + + ResultSetConfigurer withRow(Object... values); + } + + DocumentTester testerFor(Class entityType) { + return new DocumentTester(entityType, context, documentExtractor); + } + + private static class AbstractTester { + + private final Class entityType; + private final RelationalMappingContext context; + ResultSet resultSet; + + AbstractTester(Class entityType, RelationalMappingContext context) { + this.entityType = entityType; + this.context = context; + } + + AbstractTester resultSet(Consumer configuration) { + + List values = new ArrayList<>(); + List columns = new ArrayList<>(); + ResultSetConfigurer configurer = new ResultSetConfigurer() { + @Override + public ResultSetConfigurer withColumns(String... columnNames) { + columns.addAll(Arrays.asList(columnNames)); + return this; + } + + public ResultSetConfigurer withPath(String path) { + + PersistentPropertyPath propertyPath = context.getPersistentPropertyPath(path, + entityType); + + columns.add(context.getAggregatePath(propertyPath).toDotPath()); + return this; + } + + public ResultSetConfigurer withKey(String path) { + + PersistentPropertyPath propertyPath = context.getPersistentPropertyPath(path, + entityType); + + columns.add(context.getAggregatePath(propertyPath).toDotPath() + "_key"); + return this; + } + + @Override + public ResultSetConfigurer withRow(Object... rowValues) { + values.addAll(Arrays.asList(rowValues)); + return this; + } + }; + + configuration.accept(configurer); + this.resultSet = ResultSetTestUtil.mockResultSet(columns, values.toArray()); + + return this; + } + } + + private static class DocumentTester extends AbstractTester { + + private final Class entityType; + private final ResultSetRowDocumentExtractor extractor; + + DocumentTester(Class entityType, RelationalMappingContext context, ResultSetRowDocumentExtractor extractor) { + super(entityType, context); + this.entityType = entityType; + this.extractor = extractor; + } + + @Override + DocumentTester resultSet(Consumer configuration) { + super.resultSet(configuration); + return this; + } + + public void run(ThrowingConsumer action) { + + try { + action.accept(extractor.extractNextDocument(entityType, resultSet)); + } catch (SQLException e) { + throw new RuntimeException(e); + } + } + } + + private static class ResultSetTester extends AbstractTester { + + ResultSetTester(Class entityType, RelationalMappingContext context) { + super(entityType, context); + } + + @Override + ResultSetTester resultSet(Consumer configuration) { + super.resultSet(configuration); + return this; + } + + public void run(ThrowingConsumer action) { + action.accept(resultSet); + } + } + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java index 8a9fda1ff0..736d300270 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java @@ -136,11 +136,15 @@ private boolean isAfterLast() { } private boolean isBeforeFirst() { - return index < 0 && !values.isEmpty(); + return index < 0; } private Object getObject(String column) throws SQLException { + if (index == -1) { + throw new SQLException("ResultSet.isBeforeFirst. Make sure to call next() before calling this method"); + } + Map rowMap = values.get(index); if (!rowMap.containsKey(column)) { diff --git a/spring-data-r2dbc/pom.xml b/spring-data-r2dbc/pom.xml index a60f8e183a..9352baebf4 100644 --- a/spring-data-r2dbc/pom.xml +++ b/spring-data-r2dbc/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-r2dbc - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT Spring Data R2DBC Spring Data module for R2DBC @@ -15,7 +15,7 @@ org.springframework.data spring-data-relational-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java index b1810a2309..b0c51559d0 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/MappingR2dbcConverter.java @@ -47,7 +47,7 @@ import org.springframework.data.mapping.model.SpELExpressionParameterValueProvider; import org.springframework.data.r2dbc.mapping.OutboundRow; import org.springframework.data.r2dbc.support.ArrayUtils; -import org.springframework.data.relational.core.conversion.BasicRelationalConverter; +import org.springframework.data.relational.core.conversion.MappingRelationalConverter; import org.springframework.data.relational.core.conversion.RelationalConverter; import org.springframework.data.relational.core.dialect.ArrayColumns; import org.springframework.data.relational.core.mapping.RelationalMappingContext; @@ -66,7 +66,7 @@ * @author Mark Paluch * @author Oliver Drotbohm */ -public class MappingR2dbcConverter extends BasicRelationalConverter implements R2dbcConverter { +public class MappingR2dbcConverter extends MappingRelationalConverter implements R2dbcConverter { /** * Creates a new {@link MappingR2dbcConverter} given {@link MappingContext}. diff --git a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/R2dbcConverter.java b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/R2dbcConverter.java index c0955a6ebd..7d062c3156 100644 --- a/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/R2dbcConverter.java +++ b/spring-data-r2dbc/src/main/java/org/springframework/data/r2dbc/convert/R2dbcConverter.java @@ -51,6 +51,7 @@ public interface R2dbcConverter * * @return never {@literal null}. */ + @Override ConversionService getConversionService(); /** diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index 74f350faa8..e90d631247 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -6,7 +6,7 @@ 4.0.0 spring-data-relational - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT Spring Data Relational Spring Data Relational support @@ -14,7 +14,7 @@ org.springframework.data spring-data-relational-parent - 3.2.0-SNAPSHOT + 3.2.0-GH-1586-SNAPSHOT diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BasicRelationalConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BasicRelationalConverter.java index b5bf5534f9..b9fb1f254a 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BasicRelationalConverter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/BasicRelationalConverter.java @@ -42,6 +42,7 @@ import org.springframework.data.relational.core.mapping.RelationalMappingContext; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; import org.springframework.util.Assert; @@ -125,6 +126,11 @@ public PersistentPropertyPathAccessor getPropertyAccessor(PersistentEntit return new ConvertingPropertyAccessor<>(accessor, conversionService); } + @Override + public R read(Class type, RowDocument source) { + throw new UnsupportedOperationException(); + } + @Override public T createInstance(PersistentEntity entity, Function, Object> parameterValueProvider) { @@ -271,9 +277,8 @@ private Object getPotentiallyConvertedSimpleWrite(Object value) { * @param type {@link TypeInformation} into which the value is to be converted. Must not be {@code null}. * @return the converted value if a conversion applies or the original value. Might return {@code null}. */ - @Nullable @SuppressWarnings({ "rawtypes", "unchecked" }) - private Object getPotentiallyConvertedSimpleRead(Object value, TypeInformation type) { + protected Object getPotentiallyConvertedSimpleRead(Object value, TypeInformation type) { Class target = type.getType(); if (ClassUtils.isAssignableValue(target, value)) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DocumentPropertyAccessor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DocumentPropertyAccessor.java new file mode 100644 index 0000000000..f7d4afd204 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/DocumentPropertyAccessor.java @@ -0,0 +1,60 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.conversion; + +import java.util.Map; + +import org.springframework.context.expression.MapAccessor; +import org.springframework.data.relational.domain.RowDocument; +import org.springframework.expression.EvaluationContext; +import org.springframework.expression.TypedValue; +import org.springframework.lang.Nullable; + +/** + * {@link org.springframework.expression.PropertyAccessor} to allow entity based field access to + * {@link org.springframework.data.relational.domain.RowDocument}s. + * + * @author Oliver Gierke + * @author Christoph Strobl + */ +class DocumentPropertyAccessor extends MapAccessor { + + static final MapAccessor INSTANCE = new DocumentPropertyAccessor(); + + @Override + public Class[] getSpecificTargetClasses() { + return new Class[] { RowDocument.class }; + } + + @Override + public boolean canRead(EvaluationContext context, @Nullable Object target, String name) { + return true; + } + + @Override + @SuppressWarnings("unchecked") + public TypedValue read(EvaluationContext context, @Nullable Object target, String name) { + + if (target == null) { + return TypedValue.NULL; + } + + Map source = (Map) target; + + Object value = source.get(name); + return value == null ? TypedValue.NULL : new TypedValue(value); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java new file mode 100644 index 0000000000..d7fea6a8ff --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/MappingRelationalConverter.java @@ -0,0 +1,632 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.conversion; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +import org.springframework.core.CollectionFactory; +import org.springframework.core.convert.ConversionService; +import org.springframework.data.convert.CustomConversions; +import org.springframework.data.mapping.InstanceCreatorMetadata; +import org.springframework.data.mapping.MappingException; +import org.springframework.data.mapping.Parameter; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentPropertyAccessor; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.ConvertingPropertyAccessor; +import org.springframework.data.mapping.model.DefaultSpELExpressionEvaluator; +import org.springframework.data.mapping.model.EntityInstantiator; +import org.springframework.data.mapping.model.ParameterValueProvider; +import org.springframework.data.mapping.model.PersistentEntityParameterValueProvider; +import org.springframework.data.mapping.model.PropertyValueProvider; +import org.springframework.data.mapping.model.SpELContext; +import org.springframework.data.mapping.model.SpELExpressionEvaluator; +import org.springframework.data.mapping.model.SpELExpressionParameterValueProvider; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.Embedded.OnEmpty; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * {@link RelationalConverter} that uses a {@link MappingContext} to apply sophisticated mapping of domain objects from + * {@link RowDocument}. + * + * @author Mark Paluch + * @since 3.2 + */ +public class MappingRelationalConverter extends BasicRelationalConverter { + + private SpELContext spELContext; + + /** + * Creates a new {@link MappingRelationalConverter} given the new {@link RelationalMappingContext}. + * + * @param context must not be {@literal null}. + */ + public MappingRelationalConverter(RelationalMappingContext context) { + super(context); + this.spELContext = new SpELContext(DocumentPropertyAccessor.INSTANCE); + } + + /** + * Creates a new {@link MappingRelationalConverter} given the new {@link RelationalMappingContext} and + * {@link CustomConversions}. + * + * @param context must not be {@literal null}. + * @param conversions must not be {@literal null}. + */ + public MappingRelationalConverter(RelationalMappingContext context, CustomConversions conversions) { + super(context, conversions); + this.spELContext = new SpELContext(DocumentPropertyAccessor.INSTANCE); + } + + /** + * Creates a new {@link ConversionContext}. + * + * @return the {@link ConversionContext}. + */ + protected ConversionContext getConversionContext(ObjectPath path) { + + Assert.notNull(path, "ObjectPath must not be null"); + + return new DefaultConversionContext(this, getConversions(), path, this::readAggregate, this::readCollectionOrArray, + this::readMap, this::getPotentiallyConvertedSimpleRead); + } + + /** + * Read a {@link RowDocument} into the requested {@link Class aggregate type}. + * + * @param type target aggregate type. + * @param source source {@link RowDocument}. + * @return the converted object. + * @param aggregate type. + */ + @Override + public R read(Class type, RowDocument source) { + return read(TypeInformation.of(type), source); + } + + protected S read(TypeInformation type, RowDocument source) { + return readAggregate(getConversionContext(ObjectPath.ROOT), source, type); + } + + /** + * Conversion method to materialize an object from a {@link RowDocument document}. Can be overridden by subclasses. + * + * @param context must not be {@literal null} + * @param document must not be {@literal null} + * @param typeHint the {@link TypeInformation} to be used to unmarshall this {@link RowDocument}. + * @return the converted object, will never be {@literal null}. + */ + @SuppressWarnings("unchecked") + protected S readAggregate(ConversionContext context, RowDocument document, + TypeInformation typeHint) { + + Class rawType = typeHint.getType(); + + if (getConversions().hasCustomReadTarget(document.getClass(), rawType)) { + return doConvert(document, rawType, typeHint.getType()); + } + + if (RowDocument.class.isAssignableFrom(rawType)) { + return (S) document; + } + + if (typeHint.isMap()) { + return context.convert(document, typeHint); + } + + RelationalPersistentEntity entity = getMappingContext().getPersistentEntity(rawType); + + if (entity == null) { + throw new MappingException( + String.format("Expected to read Document %s into type %s but didn't find a PersistentEntity for the latter", + document, rawType)); + } + + return read(context, (RelationalPersistentEntity) entity, document); + } + + /** + * Reads the given {@link RowDocument} into a {@link Map}. will recursively resolve nested {@link Map}s as well. Can + * be overridden by subclasses. + * + * @param context must not be {@literal null} + * @param source must not be {@literal null} + * @param targetType the {@link Map} {@link TypeInformation} to be used to unmarshall this {@link RowDocument}. + * @return the converted {@link Map}, will never be {@literal null}. + */ + protected Map readMap(ConversionContext context, Map source, TypeInformation targetType) { + + Assert.notNull(source, "Document must not be null"); + Assert.notNull(targetType, "TypeInformation must not be null"); + + Class mapType = targetType.getType(); + + TypeInformation keyType = targetType.getComponentType(); + TypeInformation valueType = targetType.getMapValueType() == null ? TypeInformation.OBJECT + : targetType.getRequiredMapValueType(); + + Class rawKeyType = keyType != null ? keyType.getType() : Object.class; + + Map map = CollectionFactory.createMap(mapType, rawKeyType, + ((Map) source).keySet().size()); + + source.forEach((k, v) -> { + + Object key = k; + + if (!rawKeyType.isAssignableFrom(key.getClass())) { + key = doConvert(key, rawKeyType); + } + + map.put(key, v == null ? v : context.convert(v, valueType)); + }); + + return map; + } + + /** + * Reads the given {@link Collection} into a collection of the given {@link TypeInformation}. Can be overridden by + * subclasses. + * + * @param context must not be {@literal null} + * @param source must not be {@literal null} + * @param targetType the {@link Map} {@link TypeInformation} to be used to unmarshall this {@link RowDocument}. + * @return the converted {@link Collection} or array, will never be {@literal null}. + */ + @SuppressWarnings("unchecked") + protected Object readCollectionOrArray(ConversionContext context, Collection source, + TypeInformation targetType) { + + Assert.notNull(targetType, "Target type must not be null"); + + Class collectionType = targetType.isSubTypeOf(Collection.class) // + ? targetType.getType() // + : List.class; + + TypeInformation componentType = targetType.getComponentType() != null // + ? targetType.getComponentType() // + : TypeInformation.OBJECT; + Class rawComponentType = componentType.getType(); + + Collection items = targetType.getType().isArray() // + ? new ArrayList<>(source.size()) // + : CollectionFactory.createCollection(collectionType, rawComponentType, source.size()); + + if (source.isEmpty()) { + return getPotentiallyConvertedSimpleRead(items, targetType); + } + + for (Object element : source) { + items.add(element != null ? context.convert(element, componentType) : element); + } + + return getPotentiallyConvertedSimpleRead(items, targetType); + } + + private T doConvert(Object value, Class target) { + return doConvert(value, target, null); + } + + @SuppressWarnings("ConstantConditions") + private T doConvert(Object value, Class target, + @Nullable Class fallback) { + + if (getConversionService().canConvert(value.getClass(), target) || fallback == null) { + return getConversionService().convert(value, target); + } + return getConversionService().convert(value, fallback); + } + + private S read(ConversionContext context, RelationalPersistentEntity entity, RowDocument document) { + + SpELExpressionEvaluator evaluator = new DefaultSpELExpressionEvaluator(document, spELContext); + RowDocumentAccessor documentAccessor = new RowDocumentAccessor(document); + + InstanceCreatorMetadata instanceCreatorMetadata = entity.getInstanceCreatorMetadata(); + + ParameterValueProvider provider = instanceCreatorMetadata != null + && instanceCreatorMetadata.hasParameters() ? getParameterProvider(context, entity, documentAccessor, evaluator) + : NoOpParameterValueProvider.INSTANCE; + + EntityInstantiator instantiator = getEntityInstantiators().getInstantiatorFor(entity); + S instance = instantiator.createInstance(entity, provider); + + if (entity.requiresPropertyPopulation()) { + return populateProperties(context, entity, documentAccessor, evaluator, instance); + } + + return instance; + } + + private ParameterValueProvider getParameterProvider(ConversionContext context, + RelationalPersistentEntity entity, RowDocumentAccessor source, SpELExpressionEvaluator evaluator) { + + RelationalPropertyValueProvider provider = new RelationalPropertyValueProvider(context, source, evaluator, + spELContext); + + // TODO: Add support for enclosing object (non-static inner classes) + PersistentEntityParameterValueProvider parameterProvider = new PersistentEntityParameterValueProvider<>( + entity, provider, context.getPath().getCurrentObject()); + + return new ConverterAwareSpELExpressionParameterValueProvider(context, evaluator, getConversionService(), + parameterProvider); + } + + private S populateProperties(ConversionContext context, RelationalPersistentEntity entity, + RowDocumentAccessor documentAccessor, SpELExpressionEvaluator evaluator, S instance) { + + PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>(entity.getPropertyAccessor(instance), + getConversionService()); + + // Make sure id property is set before all other properties + ObjectPath currentPath = context.getPath().push(accessor.getBean(), entity); + ConversionContext contextToUse = context.withPath(currentPath); + + RelationalPropertyValueProvider valueProvider = new RelationalPropertyValueProvider(contextToUse, documentAccessor, + evaluator, spELContext); + + Predicate propertyFilter = isConstructorArgument(entity).negate(); + readProperties(contextToUse, entity, accessor, documentAccessor, valueProvider, evaluator, propertyFilter); + + return accessor.getBean(); + } + + private void readProperties(ConversionContext context, RelationalPersistentEntity entity, + PersistentPropertyAccessor accessor, RowDocumentAccessor documentAccessor, + RelationalPropertyValueProvider valueProvider, SpELExpressionEvaluator evaluator, + Predicate propertyFilter) { + + for (RelationalPersistentProperty prop : entity) { + + if (!propertyFilter.test(prop)) { + continue; + } + + ConversionContext propertyContext = context.forProperty(prop); + RelationalPropertyValueProvider valueProviderToUse = valueProvider.withContext(propertyContext); + + if (prop.isAssociation()) { + + // TODO: Read AggregateReference + continue; + } + + if (prop.isEmbedded()) { + accessor.setProperty(prop, readEmbedded(propertyContext, documentAccessor, prop, + getMappingContext().getRequiredPersistentEntity(prop))); + continue; + } + + if (!documentAccessor.hasValue(prop)) { + continue; + } + + accessor.setProperty(prop, valueProviderToUse.getPropertyValue(prop)); + } + } + + @Nullable + private Object readEmbedded(ConversionContext context, RowDocumentAccessor documentAccessor, + RelationalPersistentProperty prop, RelationalPersistentEntity unwrappedEntity) { + + if (prop.findAnnotation(Embedded.class).onEmpty().equals(OnEmpty.USE_EMPTY)) { + return read(context, unwrappedEntity, documentAccessor.getDocument()); + } + + for (RelationalPersistentProperty persistentProperty : unwrappedEntity) { + if (documentAccessor.hasValue(persistentProperty)) { + return read(context, unwrappedEntity, documentAccessor.getDocument()); + } + } + + return null; + } + + static Predicate isConstructorArgument(PersistentEntity entity) { + return entity::isCreatorArgument; + } + + /** + * Conversion context holding references to simple {@link ValueConverter} and {@link ContainerValueConverter}. + * Entrypoint for recursive conversion of {@link RowDocument} and other types. + * + * @since 3.2 + */ + protected static class DefaultConversionContext implements ConversionContext { + + final RelationalConverter sourceConverter; + final org.springframework.data.convert.CustomConversions conversions; + final ObjectPath objectPath; + final ContainerValueConverter documentConverter; + final ContainerValueConverter> collectionConverter; + final ContainerValueConverter> mapConverter; + final ValueConverter elementConverter; + + DefaultConversionContext(RelationalConverter sourceConverter, + org.springframework.data.convert.CustomConversions customConversions, ObjectPath objectPath, + ContainerValueConverter documentConverter, + ContainerValueConverter> collectionConverter, ContainerValueConverter> mapConverter, + ValueConverter elementConverter) { + + this.sourceConverter = sourceConverter; + this.conversions = customConversions; + this.objectPath = objectPath; + this.documentConverter = documentConverter; + this.collectionConverter = collectionConverter; + this.mapConverter = mapConverter; + this.elementConverter = elementConverter; + } + + @SuppressWarnings("unchecked") + @Override + public S convert(Object source, TypeInformation typeHint, + ConversionContext context) { + + Assert.notNull(source, "Source must not be null"); + Assert.notNull(typeHint, "TypeInformation must not be null"); + + if (conversions.hasCustomReadTarget(source.getClass(), typeHint.getType())) { + return (S) elementConverter.convert(source, typeHint); + } + + if (source instanceof Collection collection) { + + if (typeHint.isCollectionLike() || typeHint.getType().isAssignableFrom(Collection.class)) { + return (S) collectionConverter.convert(context, collection, typeHint); + } + } + + if (typeHint.isMap()) { + + if (ClassUtils.isAssignable(RowDocument.class, typeHint.getType())) { + return (S) documentConverter.convert(context, (RowDocument) source, typeHint); + } + + if (source instanceof Map map) { + return (S) mapConverter.convert(context, map, typeHint); + } + + throw new IllegalArgumentException( + String.format("Expected map like structure but found %s", source.getClass())); + } + + if (source instanceof RowDocument document) { + return (S) documentConverter.convert(context, document, typeHint); + } + + return (S) elementConverter.convert(source, typeHint); + } + + @Override + public ConversionContext withPath(ObjectPath currentPath) { + + Assert.notNull(currentPath, "ObjectPath must not be null"); + + return new DefaultConversionContext(sourceConverter, conversions, currentPath, documentConverter, + collectionConverter, mapConverter, elementConverter); + } + + @Override + public ObjectPath getPath() { + return objectPath; + } + + @Override + public CustomConversions getCustomConversions() { + return conversions; + } + + @Override + public RelationalConverter getSourceConverter() { + return sourceConverter; + } + + /** + * Converts a simple {@code source} value into {@link TypeInformation the target type}. + * + * @param + */ + interface ValueConverter { + + Object convert(T source, TypeInformation typeHint); + + } + + /** + * Converts a container {@code source} value into {@link TypeInformation the target type}. Containers may + * recursively apply conversions for entities, collections, maps, etc. + * + * @param + */ + interface ContainerValueConverter { + + Object convert(ConversionContext context, T source, TypeInformation typeHint); + + } + + } + + /** + * Conversion context defining an interface for graph-traversal-based conversion of row documents. Entrypoint for + * recursive conversion of {@link RowDocument} and other types. + * + * @since 3.2 + */ + protected interface ConversionContext { + + /** + * Converts a source object into {@link TypeInformation target}. + * + * @param source must not be {@literal null}. + * @param typeHint must not be {@literal null}. + * @return the converted object. + */ + default S convert(Object source, TypeInformation typeHint) { + return convert(source, typeHint, this); + } + + /** + * Converts a source object into {@link TypeInformation target}. + * + * @param source must not be {@literal null}. + * @param typeHint must not be {@literal null}. + * @param context must not be {@literal null}. + * @return the converted object. + */ + S convert(Object source, TypeInformation typeHint, ConversionContext context); + + /** + * Obtain a {@link ConversionContext} for the given property {@code name}. + * + * @param name must not be {@literal null}. + * @return the {@link ConversionContext} to be used for conversion of the given property. + */ + default ConversionContext forProperty(String name) { + return this; + } + + /** + * Obtain a {@link ConversionContext} for the given {@link RelationalPersistentProperty}. + * + * @param property must not be {@literal null}. + * @return the {@link ConversionContext} to be used for conversion of the given property. + */ + default ConversionContext forProperty(RelationalPersistentProperty property) { + return forProperty(property.getName()); + } + + /** + * Create a new {@link ConversionContext} with {@link ObjectPath currentPath} applied. + * + * @param currentPath must not be {@literal null}. + * @return a new {@link ConversionContext} with {@link ObjectPath currentPath} applied. + */ + ConversionContext withPath(ObjectPath currentPath); + + ObjectPath getPath(); + + CustomConversions getCustomConversions(); + + RelationalConverter getSourceConverter(); + + } + + enum NoOpParameterValueProvider implements ParameterValueProvider { + + INSTANCE; + + @Override + public T getParameterValue(Parameter parameter) { + return null; + } + } + + /** + * {@link PropertyValueProvider} to evaluate a SpEL expression if present on the property or simply accesses the field + * of the configured source {@link RowDocument}. + * + * @author Oliver Gierke + * @author Mark Paluch + * @author Christoph Strobl + */ + record RelationalPropertyValueProvider(ConversionContext context, RowDocumentAccessor accessor, + SpELExpressionEvaluator evaluator, + SpELContext spELContext) implements PropertyValueProvider { + + /** + * Creates a new {@link RelationalPropertyValueProvider} for the given source and {@link SpELExpressionEvaluator}. + * + * @param context must not be {@literal null}. + * @param accessor must not be {@literal null}. + * @param evaluator must not be {@literal null}. + */ + RelationalPropertyValueProvider { + + Assert.notNull(context, "ConversionContext must no be null"); + Assert.notNull(accessor, "DocumentAccessor must no be null"); + Assert.notNull(evaluator, "SpELExpressionEvaluator must not be null"); + } + + @Nullable + @SuppressWarnings("unchecked") + public T getPropertyValue(RelationalPersistentProperty property) { + + String expression = property.getSpelExpression(); + Object value = expression != null ? evaluator.evaluate(expression) : accessor.get(property); + + if (value == null) { + return null; + } + + ConversionContext contextToUse = context.forProperty(property); + + return (T) contextToUse.convert(value, property.getTypeInformation()); + } + + public RelationalPropertyValueProvider withContext(ConversionContext context) { + + return context == this.context ? this + : new RelationalPropertyValueProvider(context, accessor, evaluator, spELContext); + } + } + + /** + * Extension of {@link SpELExpressionParameterValueProvider} to recursively trigger value conversion on the raw + * resolved SpEL value. + */ + private static class ConverterAwareSpELExpressionParameterValueProvider + extends SpELExpressionParameterValueProvider { + + private final ConversionContext context; + + /** + * Creates a new {@link ConverterAwareSpELExpressionParameterValueProvider}. + * + * @param context must not be {@literal null}. + * @param evaluator must not be {@literal null}. + * @param conversionService must not be {@literal null}. + * @param delegate must not be {@literal null}. + */ + public ConverterAwareSpELExpressionParameterValueProvider(ConversionContext context, + SpELExpressionEvaluator evaluator, ConversionService conversionService, + ParameterValueProvider delegate) { + + super(evaluator, conversionService, delegate); + + Assert.notNull(context, "ConversionContext must no be null"); + + this.context = context; + } + + @Override + protected T potentiallyConvertSpelValue(Object object, Parameter parameter) { + return context.convert(object, parameter.getType()); + } + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/ObjectPath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/ObjectPath.java new file mode 100644 index 0000000000..8fc2248e6f --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/ObjectPath.java @@ -0,0 +1,109 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.conversion; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ObjectUtils; +import org.springframework.util.StringUtils; + +/** + * A path of objects nested into each other. The type allows access to all parent objects currently in creation even + * when resolving more nested objects. This allows to avoid re-resolving object instances that are logically equivalent + * to already resolved ones. + *

+ * An immutable ordered set of target objects for {@link org.springframework.data.relational.domain.RowDocument} to + * {@link Object} conversions. Object paths can be extended via + * {@link #push(Object, org.springframework.data.relational.core.mapping.RelationalPersistentEntity)}. + * + * @author Mark Paluch + * @since 3.2 + */ +class ObjectPath { + + static final ObjectPath ROOT = new ObjectPath(); + + private final @Nullable ObjectPath parent; + private final @Nullable Object object; + + private ObjectPath() { + + this.parent = null; + this.object = null; + } + + /** + * Creates a new {@link ObjectPath} from the given parent {@link ObjectPath} and adding the provided path values. + * + * @param parent must not be {@literal null}. + * @param object must not be {@literal null}. + */ + private ObjectPath(ObjectPath parent, Object object) { + + this.parent = parent; + this.object = object; + } + + /** + * Returns a copy of the {@link ObjectPath} with the given {@link Object} as current object. + * + * @param object must not be {@literal null}. + * @param entity must not be {@literal null}. + * @return new instance of {@link ObjectPath}. + */ + ObjectPath push(Object object, RelationalPersistentEntity entity) { + + Assert.notNull(object, "Object must not be null"); + Assert.notNull(entity, "RelationalPersistentEntity must not be null"); + + return new ObjectPath(this, object); + } + + /** + * Returns the current object of the {@link ObjectPath} or {@literal null} if the path is empty. + * + * @return + */ + @Nullable + Object getCurrentObject() { + return getObject(); + } + + @Nullable + private Object getObject() { + return object; + } + + @Override + public String toString() { + + if (parent == null) { + return "[empty]"; + } + + List strings = new ArrayList<>(); + + for (ObjectPath current = this; current != null; current = current.parent) { + strings.add(ObjectUtils.nullSafeToString(current.getObject())); + } + + return StringUtils.collectionToDelimitedString(strings, " -> "); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalConverter.java index 55c9cf4b09..9cff6dc7ac 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalConverter.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RelationalConverter.java @@ -27,6 +27,7 @@ import org.springframework.data.mapping.model.ParameterValueProvider; import org.springframework.data.relational.core.mapping.RelationalPersistentEntity; import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; import org.springframework.data.util.TypeInformation; import org.springframework.lang.Nullable; @@ -53,6 +54,17 @@ public interface RelationalConverter { */ ConversionService getConversionService(); + /** + * Read a {@link RowDocument} into the requested {@link Class aggregate type}. + * + * @param type target aggregate type. + * @param source source {@link RowDocument}. + * @return the converted object. + * @param aggregate type. + * @since 3.2 + */ + R read(Class type, RowDocument source); + /** * Create a new instance of {@link PersistentEntity} given {@link ParameterValueProvider} to obtain constructor * properties. diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RowDocumentAccessor.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RowDocumentAccessor.java new file mode 100644 index 0000000000..ae6fcb59d2 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/conversion/RowDocumentAccessor.java @@ -0,0 +1,109 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.conversion; + +import org.springframework.data.relational.core.mapping.RelationalPersistentProperty; +import org.springframework.data.relational.domain.RowDocument; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Wrapper value object for a {@link RowDocument} to be able to access raw values by + * {@link RelationalPersistentProperty} references. The accessors will transparently resolve nested document values that + * a {@link RelationalPersistentProperty} might refer to through a path expression in field names. + * + * @author Mark Paluch + * @since 3.2 + */ +record RowDocumentAccessor(RowDocument document) { + + /** + * Creates a new {@link RowDocumentAccessor} for the given {@link RowDocument}. + * + * @param document must be a {@link RowDocument} effectively, must not be {@literal null}. + */ + RowDocumentAccessor { + + Assert.notNull(document, "Document must not be null"); + } + + /** + * @return the underlying {@link RowDocument document}. + */ + public RowDocument getDocument() { + return this.document; + } + + /** + * Copies all mappings from the given {@link RowDocument} to the underlying target {@link RowDocument}. These mappings + * will replace any mappings that the target document had for any of the keys currently in the specified map. + * + * @param source + */ + public void putAll(RowDocument source) { + + document.putAll(source); + } + + /** + * Puts the given value into the backing {@link RowDocument} based on the coordinates defined through the given + * {@link RelationalPersistentProperty}. By default, this will be the plain field name. But field names might also + * consist of path traversals so we might need to create intermediate {@link RowDocument}s. + * + * @param prop must not be {@literal null}. + * @param value can be {@literal null}. + */ + public void put(RelationalPersistentProperty prop, @Nullable Object value) { + + Assert.notNull(prop, "RelationalPersistentProperty must not be null"); + String fieldName = getColumnName(prop); + + document.put(fieldName, value); + } + + /** + * Returns the value the given {@link RelationalPersistentProperty} refers to. By default, this will be a direct field + * but the method will also transparently resolve nested values the {@link RelationalPersistentProperty} might refer + * to through a path expression in the field name metadata. + * + * @param property must not be {@literal null}. + * @return can be {@literal null}. + */ + @Nullable + public Object get(RelationalPersistentProperty property) { + return document.get(getColumnName(property)); + } + + /** + * Returns whether the underlying {@link RowDocument} has a value ({@literal null} or non-{@literal null}) for the + * given {@link RelationalPersistentProperty}. + * + * @param property must not be {@literal null}. + * @return {@literal true} if no non {@literal null} value present. + */ + @SuppressWarnings("unchecked") + public boolean hasValue(RelationalPersistentProperty property) { + + Assert.notNull(property, "Property must not be null"); + + return document.containsKey(getColumnName(property)); + } + + String getColumnName(RelationalPersistentProperty prop) { + return prop.getColumnName().getReference(); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java index 0ad660f907..3c5f67794b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePath.java @@ -334,9 +334,8 @@ static ColumnInfo of(AggregatePath path) { // TODO: Multi-valued paths cannot be represented with a single column // Assert.isTrue(!path.isMultiValued(), () -> "Cannot obtain ColumnInfo for multi-valued path"); - SqlIdentifier name = AggregatePathTableUtils.assembleColumnName(path, - path.getRequiredLeafProperty().getColumnName()); - return new ColumnInfo(name, AggregatePathTableUtils.prefixWithTableAlias(path, name)); + SqlIdentifier columnName = path.getRequiredLeafProperty().getColumnName(); + return new ColumnInfo(columnName, AggregatePathTableUtils.prefixWithTableAlias(path, columnName)); } } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTableUtils.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTableUtils.java index fa02220f8e..a8f9eeaeb6 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTableUtils.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTableUtils.java @@ -33,18 +33,6 @@ */ class AggregatePathTableUtils { - public static SqlIdentifier assembleColumnName(AggregatePath path, SqlIdentifier suffix) { - return suffix.transform(constructEmbeddedPrefix(path)::concat); - } - - private static String constructEmbeddedPrefix(AggregatePath path) { - - return path.stream() // - .filter(p -> p != path) // - .takeWhile(AggregatePath::isEmbedded).map(p -> p.getRequiredLeafProperty().getEmbeddedPrefix()) // - .collect(new ReverseJoinCollector()); - } - public static SqlIdentifier prefixWithTableAlias(AggregatePath path, SqlIdentifier columnName) { AggregatePath tableOwner = AggregatePathTraversal.getTableOwningPath(path); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java index b462a299e1..3a696a0410 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java @@ -37,9 +37,9 @@ public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) { public static AggregatePath getTableOwningPath(AggregatePath aggregatePath) { - Predicate idDefiningPathFilter = ap -> ap.isEntity() && !ap.isEmbedded(); + Predicate tableOwningPathFilter = ap -> ap.isEntity() && !ap.isEmbedded(); - AggregatePath result = aggregatePath.filter(idDefiningPathFilter); + AggregatePath result = aggregatePath.filter(tableOwningPathFilter); if (result == null) { throw new NoSuchElementException(); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedContext.java new file mode 100644 index 0000000000..d5f2307335 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedContext.java @@ -0,0 +1,45 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.mapping; + +/** + * Holder for an embedded {@link RelationalPersistentProperty} + * + * @author Mark Paluch + * @since 3.2 + */ +record EmbeddedContext(RelationalPersistentProperty ownerProperty) { + + EmbeddedContext { + } + + public String getEmbeddedPrefix() { + return ownerProperty.getEmbeddedPrefix(); + } + + public String withEmbeddedPrefix(String name) { + + if (!ownerProperty.isEmbedded()) { + return name; + } + String embeddedPrefix = ownerProperty.getEmbeddedPrefix(); + if (embeddedPrefix != null) { + return embeddedPrefix + name; + } + + return name; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java new file mode 100644 index 0000000000..3bca90a2e6 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentEntity.java @@ -0,0 +1,257 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.mapping; + +import java.lang.annotation.Annotation; +import java.util.Iterator; + +import org.jetbrains.annotations.NotNull; +import org.springframework.data.mapping.*; +import org.springframework.data.mapping.model.PersistentPropertyAccessorFactory; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.spel.EvaluationContextProvider; +import org.springframework.data.util.Streamable; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +/** + * Embedded entity extension for a {@link Embedded entity}. + * + * @author Mark Paluch + * @since 3.2 + */ +class EmbeddedRelationalPersistentEntity implements RelationalPersistentEntity { + + private final RelationalPersistentEntity delegate; + + private final EmbeddedContext context; + + public EmbeddedRelationalPersistentEntity(RelationalPersistentEntity delegate, EmbeddedContext context) { + this.delegate = delegate; + this.context = context; + } + + @Override + public SqlIdentifier getTableName() { + throw new MappingException("Cannot map embedded entity to table"); + } + + @Override + public SqlIdentifier getIdColumn() { + throw new MappingException("Embedded entity does not have an id column"); + } + + @Override + public void addPersistentProperty(RelationalPersistentProperty property) { + throw new UnsupportedOperationException(); + } + + @Override + public void addAssociation(Association association) { + throw new UnsupportedOperationException(); + } + + @Override + public void verify() throws MappingException {} + + @NotNull + @Override + public Iterator iterator() { + + Iterator iterator = delegate.iterator(); + + return new Iterator<>() { + @Override + public boolean hasNext() { + return iterator.hasNext(); + } + + @Override + public RelationalPersistentProperty next() { + return wrap(iterator.next()); + } + }; + } + + @Override + public void setPersistentPropertyAccessorFactory(PersistentPropertyAccessorFactory factory) { + delegate.setPersistentPropertyAccessorFactory(factory); + } + + @Override + public void setEvaluationContextProvider(EvaluationContextProvider provider) { + delegate.setEvaluationContextProvider(provider); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + @Deprecated + @Nullable + public PreferredConstructor getPersistenceConstructor() { + return delegate.getPersistenceConstructor(); + } + + @Override + @Nullable + public InstanceCreatorMetadata getInstanceCreatorMetadata() { + return delegate.getInstanceCreatorMetadata(); + } + + @Override + public boolean isCreatorArgument(PersistentProperty property) { + return delegate.isCreatorArgument(property); + } + + @Override + public boolean isIdProperty(PersistentProperty property) { + return delegate.isIdProperty(property); + } + + @Override + public boolean isVersionProperty(PersistentProperty property) { + return delegate.isVersionProperty(property); + } + + @Override + @Nullable + public RelationalPersistentProperty getIdProperty() { + return wrap(delegate.getIdProperty()); + } + + @Override + @Nullable + public RelationalPersistentProperty getVersionProperty() { + return wrap(delegate.getVersionProperty()); + } + + @Override + @Nullable + public RelationalPersistentProperty getPersistentProperty(String name) { + return wrap(delegate.getPersistentProperty(name)); + } + + @Override + public Iterable getPersistentProperties(Class annotationType) { + return Streamable.of(delegate.getPersistentProperties(annotationType)).map(this::wrap); + } + + @Override + public boolean hasIdProperty() { + return delegate.hasIdProperty(); + } + + @Override + public boolean hasVersionProperty() { + return delegate.hasVersionProperty(); + } + + @Override + public Class getType() { + return delegate.getType(); + } + + @Override + public Alias getTypeAlias() { + return delegate.getTypeAlias(); + } + + @Override + public TypeInformation getTypeInformation() { + return delegate.getTypeInformation(); + } + + @Override + public void doWithProperties(PropertyHandler handler) { + delegate.doWithProperties((PropertyHandler) persistentProperty -> { + handler.doWithPersistentProperty(wrap(persistentProperty)); + }); + } + + @Override + public void doWithProperties(SimplePropertyHandler handler) { + delegate.doWithProperties((SimplePropertyHandler) property -> handler + .doWithPersistentProperty(wrap((RelationalPersistentProperty) property))); + } + + @Override + public void doWithAssociations(AssociationHandler handler) { + delegate.doWithAssociations(handler); + } + + @Override + public void doWithAssociations(SimpleAssociationHandler handler) { + delegate.doWithAssociations(handler); + } + + @Override + @Nullable + public A findAnnotation(Class annotationType) { + return delegate.findAnnotation(annotationType); + } + + @Override + public boolean isAnnotationPresent(Class annotationType) { + return delegate.isAnnotationPresent(annotationType); + } + + @Override + public PersistentPropertyAccessor getPropertyAccessor(B bean) { + return delegate.getPropertyAccessor(bean); + } + + @Override + public PersistentPropertyPathAccessor getPropertyPathAccessor(B bean) { + return delegate.getPropertyPathAccessor(bean); + } + + @Override + public IdentifierAccessor getIdentifierAccessor(Object bean) { + return delegate.getIdentifierAccessor(bean); + } + + @Override + public boolean isNew(Object bean) { + return delegate.isNew(bean); + } + + @Override + public boolean isImmutable() { + return delegate.isImmutable(); + } + + @Override + public boolean requiresPropertyPopulation() { + return delegate.requiresPropertyPopulation(); + } + + @Nullable + private RelationalPersistentProperty wrap(@Nullable RelationalPersistentProperty source) { + + if (source == null) { + return null; + } + return new EmbeddedRelationalPersistentProperty(source, context); + } + + @Override + public String toString() { + return String.format("EmbeddedRelationalPersistentEntity<%s>", getType()); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentProperty.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentProperty.java new file mode 100644 index 0000000000..a63f4335ad --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/EmbeddedRelationalPersistentProperty.java @@ -0,0 +1,312 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.mapping; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import org.springframework.data.mapping.Association; +import org.springframework.data.relational.core.sql.SqlIdentifier; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.ObjectUtils; + +/** + * Embedded property extension to a {@link RelationalPersistentProperty} + * + * @author Mark Paluch + * @since 3.2 + */ +class EmbeddedRelationalPersistentProperty implements RelationalPersistentProperty { + + private final RelationalPersistentProperty delegate; + + private final EmbeddedContext context; + + public EmbeddedRelationalPersistentProperty(RelationalPersistentProperty delegate, EmbeddedContext context) { + + this.delegate = delegate; + this.context = context; + } + + @Override + public boolean isEmbedded() { + return delegate.isEmbedded(); + } + + @Nullable + @Override + public String getEmbeddedPrefix() { + return context.withEmbeddedPrefix(delegate.getEmbeddedPrefix()); + } + + @Override + public SqlIdentifier getColumnName() { + return delegate.getColumnName().transform(context::withEmbeddedPrefix); + } + + @Override + public RelationalPersistentEntity getOwner() { + return delegate.getOwner(); + } + + @Override + @Deprecated(since = "3.2", forRemoval = true) + public SqlIdentifier getReverseColumnName(PersistentPropertyPathExtension path) { + return delegate.getReverseColumnName(path); + } + + @Override + public SqlIdentifier getReverseColumnName(RelationalPersistentEntity owner) { + return delegate.getReverseColumnName(owner); + } + + @Override + @Nullable + public SqlIdentifier getKeyColumn() { + return delegate.getKeyColumn(); + } + + @Override + public boolean isQualified() { + return delegate.isQualified(); + } + + @Override + public Class getQualifierColumnType() { + return delegate.getQualifierColumnType(); + } + + @Override + public boolean isOrdered() { + return delegate.isOrdered(); + } + + @Override + public boolean shouldCreateEmptyEmbedded() { + return delegate.shouldCreateEmptyEmbedded(); + } + + @Override + public boolean isInsertOnly() { + return delegate.isInsertOnly(); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public Class getType() { + return delegate.getType(); + } + + @Override + public TypeInformation getTypeInformation() { + return delegate.getTypeInformation(); + } + + @Override + public Iterable> getPersistentEntityTypeInformation() { + return delegate.getPersistentEntityTypeInformation(); + } + + @Override + @Nullable + public Method getGetter() { + return delegate.getGetter(); + } + + @Override + @Nullable + public Method getSetter() { + return delegate.getSetter(); + } + + @Override + @Nullable + public Method getWither() { + return delegate.getWither(); + } + + @Override + @Nullable + public Field getField() { + return delegate.getField(); + } + + @Override + @Nullable + public String getSpelExpression() { + return delegate.getSpelExpression(); + } + + @Override + @Nullable + public Association getAssociation() { + return delegate.getAssociation(); + } + + @Override + public Association getRequiredAssociation() { + return delegate.getRequiredAssociation(); + } + + @Override + public boolean isEntity() { + return delegate.isEntity(); + } + + @Override + public boolean isIdProperty() { + return delegate.isIdProperty(); + } + + @Override + public boolean isVersionProperty() { + return delegate.isVersionProperty(); + } + + @Override + public boolean isCollectionLike() { + return delegate.isCollectionLike(); + } + + @Override + public boolean isMap() { + return delegate.isMap(); + } + + @Override + public boolean isArray() { + return delegate.isArray(); + } + + @Override + public boolean isTransient() { + return delegate.isTransient(); + } + + @Override + public boolean isWritable() { + return delegate.isWritable(); + } + + @Override + public boolean isReadable() { + return delegate.isReadable(); + } + + @Override + public boolean isImmutable() { + return delegate.isImmutable(); + } + + @Override + public boolean isAssociation() { + return delegate.isAssociation(); + } + + @Override + @Nullable + public Class getComponentType() { + return delegate.getComponentType(); + } + + @Override + public Class getRawType() { + return delegate.getRawType(); + } + + @Override + @Nullable + public Class getMapValueType() { + return delegate.getMapValueType(); + } + + @Override + public Class getActualType() { + return delegate.getActualType(); + } + + @Override + @Nullable + public A findAnnotation(Class annotationType) { + return delegate.findAnnotation(annotationType); + } + + @Override + @Nullable + public A findPropertyOrOwnerAnnotation(Class annotationType) { + return delegate.findPropertyOrOwnerAnnotation(annotationType); + } + + @Override + public boolean isAnnotationPresent(Class annotationType) { + return delegate.isAnnotationPresent(annotationType); + } + + @Override + public boolean usePropertyAccess() { + return delegate.usePropertyAccess(); + } + + @Override + public boolean hasActualTypeAnnotation(Class annotationType) { + return delegate.hasActualTypeAnnotation(annotationType); + } + + @Override + @Nullable + public Class getAssociationTargetType() { + return delegate.getAssociationTargetType(); + } + + @Override + @Nullable + public TypeInformation getAssociationTargetTypeInformation() { + return delegate.getAssociationTargetTypeInformation(); + } + + @Override + public boolean equals(Object o) { + if (this == o) + return true; + if (o == null || getClass() != o.getClass()) + return false; + + EmbeddedRelationalPersistentProperty that = (EmbeddedRelationalPersistentProperty) o; + + if (!ObjectUtils.nullSafeEquals(delegate, that.delegate)) { + return false; + } + return ObjectUtils.nullSafeEquals(context, that.context); + } + + @Override + public int hashCode() { + int result = ObjectUtils.nullSafeHashCode(delegate); + result = 31 * result + ObjectUtils.nullSafeHashCode(context); + return result; + } + + @Override + public String toString() { + return delegate.toString(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java index eb7be56d0d..28dd8c124d 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/PersistentPropertyPathExtension.java @@ -214,7 +214,7 @@ public SqlIdentifier getColumnName() { Assert.state(path != null, "Path is null"); - return assembleColumnName(path.getLeafProperty().getColumnName()); + return path.getLeafProperty().getColumnName(); } /** @@ -451,26 +451,6 @@ private SqlIdentifier assembleTableAlias() { } - private SqlIdentifier assembleColumnName(SqlIdentifier suffix) { - - Assert.state(path != null, "Path is null"); - - if (path.getLength() <= 1) { - return suffix; - } - - PersistentPropertyPath parentPath = path.getParentPath(); - RelationalPersistentProperty parentLeaf = parentPath.getLeafProperty(); - - if (!parentLeaf.isEmbedded()) { - return suffix; - } - - String embeddedPrefix = parentLeaf.getEmbeddedPrefix(); - - return getParentPath().assembleColumnName(suffix.transform(embeddedPrefix::concat)); - } - private SqlIdentifier prefixWithTableAlias(SqlIdentifier columnName) { SqlIdentifier tableAlias = getTableAlias(); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java index e237e56eeb..ac453b4a55 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java @@ -28,6 +28,7 @@ import org.springframework.data.spel.EvaluationContextProvider; import org.springframework.data.spel.ExtensionAwareEvaluationContextProvider; import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** @@ -110,6 +111,24 @@ public void setApplicationContext(ApplicationContext applicationContext) throws this.expressionEvaluator.setProvider(new ExtensionAwareEvaluationContextProvider(applicationContext)); } + @Nullable + @Override + public RelationalPersistentEntity getPersistentEntity(RelationalPersistentProperty persistentProperty) { + + boolean embeddedDelegation = false; + if (persistentProperty instanceof EmbeddedRelationalPersistentProperty) { + embeddedDelegation = true; + } + + RelationalPersistentEntity entity = super.getPersistentEntity(persistentProperty); + + if (entity != null && (persistentProperty.isEmbedded() || embeddedDelegation)) { + return new EmbeddedRelationalPersistentEntity<>(entity, new EmbeddedContext(persistentProperty)); + } + + return entity; + } + @Override protected RelationalPersistentEntity createPersistentEntity(TypeInformation typeInformation) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/domain/RowDocument.java b/spring-data-relational/src/main/java/org/springframework/data/relational/domain/RowDocument.java index 2f6c2fe2f1..f2217766d7 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/domain/RowDocument.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/domain/RowDocument.java @@ -41,11 +41,15 @@ public RowDocument() { this.delegate = new LinkedCaseInsensitiveMap<>(); } - public RowDocument(Map map) { + public RowDocument(Map map) { this.delegate = new LinkedCaseInsensitiveMap<>(); this.delegate.putAll(delegate); } + public static Object of(String field, Object value) { + return new RowDocument().append(field, value); + } + /** * Retrieve the value at {@code key} as {@link List}. * diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/MappingRelationalConverterUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/MappingRelationalConverterUnitTests.java new file mode 100644 index 0000000000..640b717307 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/conversion/MappingRelationalConverterUnitTests.java @@ -0,0 +1,233 @@ +/* + * Copyright 2023 the original author or 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 org.springframework.data.relational.core.conversion; + +import static org.assertj.core.api.Assertions.*; + +import java.util.EnumSet; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.annotation.Id; +import org.springframework.data.convert.ConverterBuilder; +import org.springframework.data.convert.ConverterBuilder.ConverterAware; +import org.springframework.data.convert.CustomConversions; +import org.springframework.data.convert.CustomConversions.StoreConversions; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.relational.core.mapping.Embedded; +import org.springframework.data.relational.core.mapping.RelationalMappingContext; +import org.springframework.data.relational.domain.RowDocument; + +/** + * Unit tests for {@link MappingRelationalConverter}. + * + * @author Mark Paluch + */ +class MappingRelationalConverterUnitTests { + + MappingRelationalConverter converter = new MappingRelationalConverter(new RelationalMappingContext()); + + @Test // GH-1586 + void shouldReadSimpleType() { + + RowDocument document = new RowDocument().append("id", "1").append("name", "John").append("age", 30); + + SimpleType result = converter.read(SimpleType.class, document); + + assertThat(result.id).isEqualTo("1"); + assertThat(result.name).isEqualTo("John"); + assertThat(result.age).isEqualTo(30); + } + + @Test // GH-1586 + void shouldReadSimpleRecord() { + + RowDocument document = new RowDocument().append("id", "1").append("name", "John").append("age", 30); + + SimpleRecord result = converter.read(SimpleRecord.class, document); + + assertThat(result.id).isEqualTo("1"); + assertThat(result.name).isEqualTo("John"); + assertThat(result.age).isEqualTo(30); + } + + @Test // GH-1586 + void shouldEvaluateExpression() { + + RowDocument document = new RowDocument().append("myprop", "bar"); + + WithExpression result = converter.read(WithExpression.class, document); + + assertThat(result.name).isEqualTo("bar"); + } + + @Test // GH-1586 + void shouldReadNonstaticInner() { + + RowDocument document = new RowDocument().append("name", "John").append("child", + new RowDocument().append("name", "Johnny")); + + Parent result = converter.read(Parent.class, document); + + assertThat(result.name).isEqualTo("John"); + assertThat(result.child.name).isEqualTo("Johnny"); + } + + @Test // GH-1586 + void shouldReadEmbedded() { + + RowDocument document = new RowDocument().append("simple_id", "1").append("simple_name", "John").append("simple_age", + 30); + + WithEmbedded result = converter.read(WithEmbedded.class, document); + + assertThat(result.simple).isNotNull(); + assertThat(result.simple.id).isEqualTo("1"); + assertThat(result.simple.name).isEqualTo("John"); + assertThat(result.simple.age).isEqualTo(30); + } + + @Test // GH-1586 + void shouldReadWithLists() { + + RowDocument nested = new RowDocument().append("id", "1").append("name", "John").append("age", 30); + + RowDocument document = new RowDocument().append("strings", List.of("one", "two")) + .append("states", List.of("NEW", "USED")).append("entities", List.of(nested)); + + WithCollection result = converter.read(WithCollection.class, document); + + assertThat(result.strings).containsExactly("one", "two"); + assertThat(result.states).containsExactly(State.NEW, State.USED); + assertThat(result.entities).hasSize(1); + + assertThat(result.entities.get(0).id).isEqualTo("1"); + assertThat(result.entities.get(0).name).isEqualTo("John"); + } + + @Test // GH-1586 + void shouldReadWithMaps() { + + RowDocument nested = new RowDocument().append("id", "1").append("name", "John").append("age", 30); + + RowDocument document = new RowDocument().append("strings", Map.of(1, "one", 2, "two")).append("entities", + Map.of(1, nested)); + + WithMap result = converter.read(WithMap.class, document); + + assertThat(result.strings).hasSize(2).containsEntry(1, "one").containsEntry(2, "two"); + + assertThat(result.entities).hasSize(1); + assertThat(result.entities.get(1).id).isEqualTo("1"); + assertThat(result.entities.get(1).name).isEqualTo("John"); + } + + @Test // GH-1586 + void shouldApplyConverters() { + + ConverterAware converterAware = ConverterBuilder.reading(String.class, Money.class, s -> { + String[] s1 = s.split(" "); + return new Money(Integer.parseInt(s1[0]), s1[1]); + }).andWriting(money -> money.amount + " " + money.currency); + + CustomConversions conversions = new CustomConversions(StoreConversions.of(SimpleTypeHolder.DEFAULT), + List.of(converterAware)); + RelationalMappingContext mappingContext = new RelationalMappingContext(); + mappingContext.setSimpleTypeHolder(conversions.getSimpleTypeHolder()); + mappingContext.afterPropertiesSet(); + + MappingRelationalConverter converter = new MappingRelationalConverter(mappingContext, conversions); + + RowDocument document = new RowDocument().append("money", "1 USD"); + + WithMoney result = converter.read(WithMoney.class, document); + + assertThat(result.money.amount).isEqualTo(1); + assertThat(result.money.currency).isEqualTo("USD"); + } + + static class SimpleType { + + @Id String id; + String name; + int age; + + } + + record SimpleRecord(@Id String id, String name, int age) { + } + + static class Parent { + String name; + + Child child; + + class Child { + + String name; + } + } + + static class WithCollection { + + List strings; + EnumSet states; + List entities; + } + + static class WithMap { + + Map strings; + Map entities; + } + + enum State { + NEW, USED, UNKNOWN + } + + static class WithMoney { + + Money money; + + } + + static class Money { + final int amount; + final String currency; + + public Money(int amount, String currency) { + this.amount = amount; + this.currency = currency; + } + } + + static class WithExpression { + + private final String name; + + public WithExpression(@Value("#root.myprop") String foo) { + this.name = foo; + } + } + + static class WithEmbedded { + + @Embedded.Nullable(prefix = "simple_") SimpleType simple; + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java index 5c5b9df991..54178be9af 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/DefaultAggregatePathUnitTests.java @@ -200,6 +200,7 @@ void isEmbedded() { softly.assertThat(path().isEmbedded()).isFalse(); softly.assertThat(path("withId").isEmbedded()).isFalse(); softly.assertThat(path("second2.third").isEmbedded()).isFalse(); + softly.assertThat(path("second2.third2").isEmbedded()).isTrue(); softly.assertThat(path("second2").isEmbedded()).isTrue(); }); @@ -390,8 +391,9 @@ void getRequiredLeafProperty() { assertSoftly(softly -> { - softly.assertThat(path("second.third2.value").getRequiredLeafProperty()) - .isEqualTo(context.getRequiredPersistentEntity(Third.class).getPersistentProperty("value")); + RelationalPersistentProperty prop = path("second.third2.value").getRequiredLeafProperty(); + softly.assertThat(prop.getName()).isEqualTo("value"); + softly.assertThat(prop.getOwner().getType()).isEqualTo(Third.class); softly.assertThat(path("second.third").getRequiredLeafProperty()) .isEqualTo(context.getRequiredPersistentEntity(Second.class).getPersistentProperty("third")); softly.assertThat(path("secondList").getRequiredLeafProperty()) diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java index c76b3a80b4..c0377fdca7 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/mapping/RelationalMappingContextUnitTests.java @@ -26,6 +26,7 @@ import org.springframework.data.annotation.Id; import org.springframework.data.mapping.PersistentPropertyPath; import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.relational.core.sql.SqlIdentifier; /** * Unit tests for {@link RelationalMappingContext}. @@ -87,8 +88,37 @@ public void rootAggregatePathsGetCached() { assertThat(one).isSameAs(two); } + @Test // GH-1586 + void correctlyCascadesPrefix() { + + RelationalPersistentEntity entity = context.getRequiredPersistentEntity(WithEmbedded.class); + + RelationalPersistentProperty parent = entity.getRequiredPersistentProperty("parent"); + RelationalPersistentEntity parentEntity = context.getRequiredPersistentEntity(parent); + RelationalPersistentProperty child = parentEntity.getRequiredPersistentProperty("child"); + RelationalPersistentEntity childEntity = context.getRequiredPersistentEntity(child); + RelationalPersistentProperty name = childEntity.getRequiredPersistentProperty("name"); + + assertThat(parent.getEmbeddedPrefix()).isEqualTo("prnt_"); + assertThat(child.getEmbeddedPrefix()).isEqualTo("prnt_chld_"); + assertThat(name.getColumnName()).isEqualTo(SqlIdentifier.quoted("PRNT_CHLD_NAME")); + } + static class EntityWithUuid { @Id UUID uuid; } + static class WithEmbedded { + @Embedded.Empty(prefix = "prnt_") Parent parent; + } + + static class Parent { + + @Embedded.Empty(prefix = "chld_") Child child; + } + + static class Child { + String name; + } + }