Enable Single Query Loading for simple aggregates.

Single Query Loading loads as the name suggests complete aggregates using a single select.
While the ultimate goal is to support this for all aggregates, this commit enables it only for simple aggregate and also only for some operations.
A simple aggregate is an aggregate that only reference up to one other entity and does not have embedded entities.
The supported operations are those available via `CrudRepository`: `findAll`, `findById`, and `findAllByIds`.

Single Query Loading does NOT work with the supported in memory databases H2 and HSQLDB, since these do not properly support windowing functions, which are essential for Single Query Loading.

To turn on Single Query Loading call `RelationalMappingContext.setSingleQueryLoadingEnabled(true)`.

Closes #1446
See #1450
See #1445
Original pull request: #1572
This commit is contained in:
Jens Schauder
2023-06-12 10:11:57 +02:00
committed by Mark Paluch
parent dffde372f9
commit 93821f5f42
50 changed files with 4493 additions and 49 deletions

View File

@@ -1,7 +1,7 @@
#!/bin/sh
{
echo "mcr.microsoft.com/mssql/server:2019-CU16-ubuntu-20.04"
echo "mcr.microsoft.com/mssql/server:2022-CU5-ubuntu-20.04"
echo "ibmcom/db2:11.5.7.0a"
echo "harbor-repo.vmware.com/mcr-proxy-cache/mssql/server:2019-CU16-ubuntu-20.04"
echo "harbor-repo.vmware.com/dockerhub-proxy-cache/ibmcom/db2:11.5.7.0a"

View File

@@ -0,0 +1,134 @@
/*
* 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.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import org.springframework.dao.IncorrectResultSizeDataAccessException;
import org.springframework.data.relational.core.dialect.Dialect;
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.sqlgeneration.AliasFactory;
import org.springframework.data.relational.core.sqlgeneration.CachingSqlGenerator;
import org.springframework.data.relational.core.sqlgeneration.SingleQuerySqlGenerator;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
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}
*
* @param <T> the type of aggregate produced by this reader.
* @since 3.2
* @author Jens Schauder
*/
class AggregateReader<T> {
private final RelationalMappingContext mappingContext;
private final RelationalPersistentEntity<T> aggregate;
private final AliasFactory aliasFactory;
private final org.springframework.data.relational.core.sqlgeneration.SqlGenerator sqlGenerator;
private final JdbcConverter converter;
private final NamedParameterJdbcOperations jdbcTemplate;
AggregateReader(RelationalMappingContext mappingContext, Dialect dialect, JdbcConverter converter,
NamedParameterJdbcOperations jdbcTemplate, RelationalPersistentEntity<T> aggregate) {
this.mappingContext = mappingContext;
this.aggregate = aggregate;
this.converter = converter;
this.jdbcTemplate = jdbcTemplate;
this.sqlGenerator = new CachingSqlGenerator(new SingleQuerySqlGenerator(mappingContext, dialect, aggregate));
this.aliasFactory = sqlGenerator.getAliasFactory();
}
public List<T> findAll() {
String sql = sqlGenerator.findAll();
PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory);
AggregateResultSetExtractor<T> extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter,
pathToColumn);
Iterable<T> result = jdbcTemplate.query(sql, extractor);
Assert.state(result != null, "result is null");
return (List<T>) result;
}
public T findById(Object id) {
PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory);
AggregateResultSetExtractor<T> extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter,
pathToColumn);
String sql = sqlGenerator.findById();
id = converter.writeValue(id, aggregate.getRequiredIdProperty().getTypeInformation());
Iterator<T> result = jdbcTemplate.query(sql, Map.of("id", id), extractor).iterator();
T returnValue = result.hasNext() ? result.next() : null;
if (result.hasNext()) {
throw new IncorrectResultSizeDataAccessException(1);
}
return returnValue;
}
public Iterable<T> findAllById(Iterable<?> ids) {
PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory);
AggregateResultSetExtractor<T> extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter,
pathToColumn);
String sql = sqlGenerator.findAllById();
List<Object> convertedIds = new ArrayList<>();
for (Object id : ids) {
convertedIds.add(converter.writeValue(id, aggregate.getRequiredIdProperty().getTypeInformation()));
}
return jdbcTemplate.query(sql, Map.of("ids", convertedIds), extractor);
}
private PathToColumnMapping createPathToColumnMapping(AliasFactory aliasFactory) {
return new PathToColumnMapping() {
@Override
public String column(AggregatePath path) {
String alias = aliasFactory.getColumnAlias(path);
Assert.notNull(alias, () -> "alias for >" + path + "<must not be null");
return alias;
}
@Override
public String keyColumn(AggregatePath path) {
return aliasFactory.getKeyAlias(path);
}
};
}
}

View File

@@ -0,0 +1,49 @@
/*
* 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 org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
/**
* Creates {@link AggregateReader} instances.
*
* @since 3.2
* @author Jens Schauder
*/
class AggregateReaderFactory {
private final RelationalMappingContext mappingContext;
private final Dialect dialect;
private final JdbcConverter converter;
private final NamedParameterJdbcOperations jdbcTemplate;
public AggregateReaderFactory(RelationalMappingContext mappingContext, Dialect dialect, JdbcConverter converter,
NamedParameterJdbcOperations jdbcTemplate) {
this.mappingContext = mappingContext;
this.dialect = dialect;
this.converter = converter;
this.jdbcTemplate = jdbcTemplate;
}
<T> AggregateReader<T> createAggregateReaderFor(RelationalPersistentEntity<T> entity) {
return new AggregateReader<>(mappingContext, dialect, converter, jdbcTemplate, entity);
}
}

View File

@@ -0,0 +1,596 @@
/*
* 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 <T> the type of aggregates to extract
* @since 3.2
* @author Jens Schauder
*/
class AggregateResultSetExtractor<T> implements ResultSetExtractor<Iterable<T>> {
private final RelationalMappingContext context;
private final RelationalPersistentEntity<T> rootEntity;
private final JdbcConverter converter;
private final PathToColumnMapping propertyToColumn;
/**
* @param context the {@link org.springframework.data.mapping.context.MappingContext} providing the metadata for the
* aggregate and its entity. Must not be {@literal null}.
* @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(RelationalMappingContext context, RelationalPersistentEntity<T> rootEntity,
JdbcConverter converter, PathToColumnMapping pathToColumn) {
Assert.notNull(context, "context must not be null");
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.context = context;
this.rootEntity = rootEntity;
this.converter = converter;
this.propertyToColumn = pathToColumn;
}
@Override
public Iterable<T> extractData(ResultSet resultSet) throws DataAccessException {
CachingResultSet crs = new CachingResultSet(resultSet);
CollectionReader reader = new CollectionReader(crs);
while (crs.next()) {
reader.read();
}
return (Iterable<T>) 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<RelationalPersistentProperty>) 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<Map.Entry<Object, Object>>}.
*
* @since 3.2
* @author Jens Schauder
*/
private static class MapAdapter extends AbstractCollection<Map.Entry<Object, Object>> {
private final Map<Object, Object> map = new HashMap<>();
@Override
public Iterator<Map.Entry<Object, Object>> iterator() {
return map.entrySet().iterator();
}
@Override
public int size() {
return map.size();
}
@Override
public boolean add(Map.Entry<Object, Object> entry) {
map.put(entry.getKey(), entry.getValue());
return true;
}
}
/**
* Adapts a {@link List} to the interface of a {@literal Collection<Map.Entry<Object, Object>>}.
*
* @since 3.2
* @author Jens Schauder
*/
private static class ListAdapter extends AbstractCollection<Map.Entry<Object, Object>> {
private final List<Object> list = new ArrayList<>();
@Override
public Iterator<Map.Entry<Object, Object>> iterator() {
throw new UnsupportedOperationException("Do we need this?");
}
@Override
public int size() {
return list.size();
}
@Override
public boolean add(Map.Entry<Object, Object> 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() ? "<root>" : 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<Collection> collectionInitializer;
private final Reader entityReader;
private Collection result;
private static Supplier<Collection> 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 <root>";
}
@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<RelationalPersistentProperty> {
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<RelationalPersistentProperty, Object> 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> S getParameterValue(Parameter<S, RelationalPersistentProperty> 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;
}
}
}

View File

@@ -394,6 +394,7 @@ public class BasicJdbcConverter extends BasicRelationalConverter implements Jdbc
}
private <S> ReadingContext<S> extendBy(RelationalPersistentProperty property) {
return new ReadingContext<>(
(RelationalPersistentEntity<S>) getMappingContext().getRequiredPersistentEntity(property.getActualType()),
rootPath.append(property), path.append(property), identifier, key,

View File

@@ -0,0 +1,124 @@
/*
* 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.sql.SQLException;
import java.util.HashMap;
import java.util.Map;
import org.springframework.lang.Nullable;
/**
* Despite its name not really a {@link ResultSet}, but it offers the part of the {@literal ResultSet} API that is used
* by {@link AggregateReader}. It allows peeking in the next row of a ResultSet by caching one row of the ResultSet.
*
* @since 3.2
* @author Jens Schauder
*/
class CachingResultSet {
private final ResultSetAccessor accessor;
private final ResultSet resultSet;
private Cache cache;
CachingResultSet(ResultSet resultSet) {
this.accessor = new ResultSetAccessor(resultSet);
this.resultSet = resultSet;
}
public boolean next() {
if (isPeeking()) {
final boolean next = cache.next;
cache = null;
return next;
}
try {
return resultSet.next();
} catch (SQLException e) {
throw new RuntimeException("Failed to advance CachingResultSet", e);
}
}
@Nullable
public Object getObject(String columnLabel) {
Object returnValue;
if (isPeeking()) {
returnValue = cache.values.get(columnLabel);
} else {
returnValue = safeGetFromDelegate(columnLabel);
}
return returnValue;
}
@Nullable
Object peek(String columnLabel) {
if (!isPeeking()) {
createCache();
}
if (!cache.next) {
return null;
}
return safeGetFromDelegate(columnLabel);
}
@Nullable
private Object safeGetFromDelegate(String columnLabel) {
return accessor.getObject(columnLabel);
}
private void createCache() {
cache = new Cache();
try {
int columnCount = resultSet.getMetaData().getColumnCount();
for (int i = 1; i <= columnCount; i++) {
// at least some databases return lower case labels although rs.getObject(UPPERCASE_LABEL) returns the expected
// value. The aliases we use happen to be uppercase. So we transform everything to upper case.
cache.add(resultSet.getMetaData().getColumnLabel(i).toLowerCase(),
accessor.getObject(resultSet.getMetaData().getColumnLabel(i)));
}
cache.next = resultSet.next();
} catch (SQLException se) {
throw new RuntimeException("Can't cache result set data", se);
}
}
private boolean isPeeking() {
return cache != null;
}
private static class Cache {
boolean next;
Map<String, Object> values = new HashMap<>();
void add(String columnName, Object value) {
values.put(columnName, value);
}
}
}

View File

@@ -29,7 +29,6 @@ import org.springframework.data.domain.Sort;
import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.relational.core.conversion.IdValueSource;
import org.springframework.data.relational.core.mapping.AggregatePath;
import org.springframework.data.relational.core.mapping.PersistentPropertyPathExtension;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
@@ -69,6 +68,7 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
private final NamedParameterJdbcOperations operations;
private final SqlParametersFactory sqlParametersFactory;
private final InsertStrategyFactory insertStrategyFactory;
private final ReadingDataAccessStrategy singleSelectDelegate;
/**
* Creates a {@link DefaultDataAccessStrategy}
@@ -96,6 +96,7 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
this.operations = operations;
this.sqlParametersFactory = sqlParametersFactory;
this.insertStrategyFactory = insertStrategyFactory;
this.singleSelectDelegate = new SingleQueryDataAccessStrategy(context, sqlGeneratorSource.getDialect(), converter, operations);
}
@Override
@@ -260,6 +261,10 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
@Override
public <T> T findById(Object id, Class<T> domainType) {
if (isSingleSelectQuerySupported(domainType)) {
return singleSelectDelegate.findById(id, domainType);
}
String findOneSql = sql(domainType).getFindOne();
SqlIdentifierParameterSource parameter = sqlParametersFactory.forQueryById(id, domainType, ID_SQL_PARAMETER);
@@ -272,6 +277,11 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
@Override
public <T> Iterable<T> findAll(Class<T> domainType) {
if (isSingleSelectQuerySupported(domainType)){
return singleSelectDelegate.findAll(domainType);
}
return operations.query(sql(domainType).getFindAll(), getEntityRowMapper(domainType));
}
@@ -282,10 +292,12 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
return Collections.emptyList();
}
if (isSingleSelectQuerySupported(domainType)){
return singleSelectDelegate.findAllById(ids, domainType);
}
SqlParameterSource parameterSource = sqlParametersFactory.forQueryByIds(ids, domainType);
String findAllInListSql = sql(domainType).getFindAllInList();
return operations.query(findAllInListSql, parameterSource, getEntityRowMapper(domainType));
}
@@ -430,4 +442,40 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy {
return baseProperty.getOwner().getType();
}
private boolean isSingleSelectQuerySupported(Class<?> entityType) {
return context.isSingleQueryLoadingEnabled() && sqlGeneratorSource.getDialect().supportsSingleQueryLoading()//
&& entityQualifiesForSingleSelectQuery(entityType);
}
private boolean entityQualifiesForSingleSelectQuery(Class<?> entityType) {
boolean referenceFound = false;
for (PersistentPropertyPath<RelationalPersistentProperty> path : context.findPersistentPropertyPaths(entityType, __ -> true)) {
RelationalPersistentProperty property = path.getLeafProperty();
if (property.isEntity()) {
// embedded entities are currently not supported
if (property.isEmbedded()) {
return false;
}
// only a single reference is currently supported
if (referenceFound) {
return false;
}
referenceFound = true;
}
// AggregateReferences aren't supported yet
if (property.isAssociation()) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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 org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.relational.core.mapping.AggregatePath;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
/**
* A mapping between {@link PersistentPropertyPath} and column names of a query. Column names are intentionally
* represented by {@link String} values, since this is what a {@link java.sql.ResultSet} uses, and since all the query
* columns should be aliases there is no need for quoting or similar as provided by
* {@link org.springframework.data.relational.core.sql.SqlIdentifier}.
*
* @since 3.2
* @author Jens Schauder
*/
public interface PathToColumnMapping {
String column(AggregatePath path);
String keyColumn(AggregatePath path);
}

View File

@@ -0,0 +1,120 @@
/*
* 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.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.relational.core.query.Query;
import org.springframework.lang.Nullable;
/**
* The finding methods of a {@link DataAccessStrategy}.
*
* @since 3.2
* @author Jens Schauder
*/
interface ReadingDataAccessStrategy {
/**
* Loads a single entity identified by type and id.
*
* @param id the id of the entity to load. Must not be {@code null}.
* @param domainType the domain type of the entity. Must not be {@code null}.
* @param <T> the type of the entity.
* @return Might return {@code null}.
*/
@Nullable
<T> T findById(Object id, Class<T> domainType);
/**
* Loads all entities of the given type.
*
* @param domainType the type of entities to load. Must not be {@code null}.
* @param <T> the type of entities to load.
* @return Guaranteed to be not {@code null}.
*/
<T> Iterable<T> findAll(Class<T> domainType);
/**
* Loads all entities that match one of the ids passed as an argument. It is not guaranteed that the number of ids
* passed in matches the number of entities returned.
*
* @param ids the Ids of the entities to load. Must not be {@code null}.
* @param domainType the type of entities to load. Must not be {@code null}.
* @param <T> type of entities to load.
* @return the loaded entities. Guaranteed to be not {@code null}.
*/
<T> Iterable<T> findAllById(Iterable<?> ids, Class<T> domainType);
/**
* Loads all entities of the given type, sorted.
*
* @param domainType the type of entities to load. Must not be {@code null}.
* @param <T> the type of entities to load.
* @param sort the sorting information. Must not be {@code null}.
* @return Guaranteed to be not {@code null}.
* @since 2.0
*/
<T> Iterable<T> findAll(Class<T> domainType, Sort sort);
/**
* Loads all entities of the given type, paged and sorted.
*
* @param domainType the type of entities to load. Must not be {@code null}.
* @param <T> the type of entities to load.
* @param pageable the pagination information. Must not be {@code null}.
* @return Guaranteed to be not {@code null}.
* @since 2.0
*/
<T> Iterable<T> findAll(Class<T> domainType, Pageable pageable);
/**
* Execute a {@code SELECT} query and convert the resulting item to an entity ensuring exactly one result.
*
* @param query must not be {@literal null}.
* @param domainType the type of entities. Must not be {@code null}.
* @return exactly one result or {@link Optional#empty()} if no match found.
* @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one match found.
* @since 3.0
*/
<T> Optional<T> findOne(Query query, Class<T> domainType);
/**
* Execute a {@code SELECT} query and convert the resulting items to a {@link Iterable}.
*
* @param query must not be {@literal null}.
* @param domainType the type of entities. Must not be {@code null}.
* @return a non-null list with all the matching results.
* @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one match found.
* @since 3.0
*/
<T> Iterable<T> findAll(Query query, Class<T> domainType);
/**
* Execute a {@code SELECT} query and convert the resulting items to a {@link Iterable}. Applies the {@link Pageable}
* to the result.
*
* @param query must not be {@literal null}.
* @param domainType the type of entities. Must not be {@literal null}.
* @param pageable the pagination that should be applied. Must not be {@literal null}.
* @return a non-null list with all the matching results.
* @throws org.springframework.dao.IncorrectResultSizeDataAccessException if more than one match found.
* @since 3.0
*/
<T> Iterable<T> findAll(Query query, Class<T> domainType, Pageable pageable);
}

View File

@@ -0,0 +1,93 @@
/*
* 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.util.Optional;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentEntity;
import org.springframework.data.relational.core.query.Query;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcOperations;
/**
* A {@link ReadingDataAccessStrategy} that uses an {@link AggregateReader} to load entities with a single query.
*
* @since 3.2
* @author Jens Schauder
*/
public class SingleQueryDataAccessStrategy implements ReadingDataAccessStrategy {
private final AggregateReaderFactory readerFactory;
private final RelationalMappingContext mappingContext;
public SingleQueryDataAccessStrategy(RelationalMappingContext mappingContext, Dialect dialect,
JdbcConverter converter, NamedParameterJdbcOperations jdbcTemplate) {
this.mappingContext = mappingContext;
this.readerFactory = new AggregateReaderFactory(mappingContext, dialect, converter, jdbcTemplate);
;
}
@Override
public <T> T findById(Object id, Class<T> domainType) {
return getReader(domainType).findById(id);
}
@Override
public <T> Iterable<T> findAll(Class<T> domainType) {
return getReader(domainType).findAll();
}
@Override
public <T> Iterable<T> findAllById(Iterable<?> ids, Class<T> domainType) {
return getReader(domainType).findAllById(ids);
}
@Override
public <T> Iterable<T> findAll(Class<T> domainType, Sort sort) {
throw new UnsupportedOperationException();
}
@Override
public <T> Iterable<T> findAll(Class<T> domainType, Pageable pageable) {
throw new UnsupportedOperationException();
}
@Override
public <T> Optional<T> findOne(Query query, Class<T> domainType) {
return Optional.empty();
}
@Override
public <T> Iterable<T> findAll(Query query, Class<T> domainType) {
throw new UnsupportedOperationException();
}
@Override
public <T> Iterable<T> findAll(Query query, Class<T> domainType, Pageable pageable) {
throw new UnsupportedOperationException();
}
private <T> AggregateReader<T> getReader(Class<T> domainType) {
RelationalPersistentEntity<T> persistentEntity = (RelationalPersistentEntity<T>) mappingContext
.getRequiredPersistentEntity(domainType);
return readerFactory.createAggregateReaderFor(persistentEntity);
}
}

View File

@@ -36,6 +36,7 @@ import java.util.function.Function;
import java.util.stream.IntStream;
import org.assertj.core.api.SoftAssertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.springframework.beans.factory.annotation.Autowired;
@@ -90,13 +91,21 @@ import org.springframework.transaction.annotation.Transactional;
@Transactional
@TestExecutionListeners(value = AssumeFeatureTestExecutionListener.class, mergeMode = MERGE_WITH_DEFAULTS)
@ExtendWith(SpringExtension.class)
class JdbcAggregateTemplateIntegrationTests {
abstract class AbstractJdbcAggregateTemplateIntegrationTests {
@Autowired JdbcAggregateOperations template;
@Autowired NamedParameterJdbcOperations jdbcTemplate;
@Autowired RelationalMappingContext mappingContext;
LegoSet legoSet = createLegoSet("Star Destroyer");
@BeforeEach
void beforeEach(){
mappingContext.setSingleQueryLoadingEnabled(useSingleQuery());
}
abstract boolean useSingleQuery();
/**
* creates an instance of {@link NoIdListChain4} with the following properties:
* <ul>
@@ -193,6 +202,42 @@ class JdbcAggregateTemplateIntegrationTests {
return entity;
}
@Test // GH-1446
void findById() {
WithInsertOnly entity = new WithInsertOnly();
entity.insertOnly = "entity";
entity = template.save(entity);
WithInsertOnly other = new WithInsertOnly();
other.insertOnly = "other";
other = template.save(other);
assertThat(template.findById(entity.id, WithInsertOnly.class).insertOnly).isEqualTo("entity");
assertThat(template.findById(other.id, WithInsertOnly.class).insertOnly).isEqualTo("other");
}
@Test // GH-1446
void findAllById() {
WithInsertOnly entity = new WithInsertOnly();
entity.insertOnly = "entity";
entity = template.save(entity);
WithInsertOnly other = new WithInsertOnly();
other.insertOnly = "other";
other = template.save(other);
WithInsertOnly yetAnother = new WithInsertOnly();
yetAnother.insertOnly = "yetAnother";
yetAnother = template.save(yetAnother);
Iterable<WithInsertOnly> reloadedById = template.findAllById(asList(entity.id, yetAnother.id),
WithInsertOnly.class);
assertThat(reloadedById).extracting(e -> e.id, e -> e.insertOnly)
.containsExactlyInAnyOrder(tuple(entity.id, "entity"), tuple(yetAnother.id, "yetAnother"));
}
@Test // DATAJDBC-112
@EnabledOnFeature(SUPPORTS_QUOTED_IDS)
void saveAndLoadAnEntityWithReferencedEntityById() {
@@ -1833,4 +1878,17 @@ class JdbcAggregateTemplateIntegrationTests {
return new JdbcAggregateTemplate(publisher, context, converter, dataAccessStrategy);
}
}
static class JdbcAggregateTemplateIntegrationTests extends AbstractJdbcAggregateTemplateIntegrationTests {
@Override
boolean useSingleQuery() {
return false;
}
}
static class JdbcAggregateTemplateSqlIntegrationTests extends AbstractJdbcAggregateTemplateIntegrationTests {
@Override
boolean useSingleQuery() {
return true;
}
}
}

View File

@@ -0,0 +1,718 @@
/*
* 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.RelationalPersistentEntity;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
/**
* Unit tests for the {@link AggregateResultSetExtractor}.
*
* @author Jens Schauder
*/
public class AggregateResultSetExtractorUnitTests {
RelationalMappingContext context = new JdbcMappingContext(new DefaultNamingStrategy());
private final 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<SimpleEntity> extractor = getExtractor(SimpleEntity.class);
@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() {
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"));
}
@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<DummyRecord> 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<SimpleEntity> 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 <T> AggregateResultSetExtractor<T> getExtractor(Class<T> type) {
return (AggregateResultSetExtractor<T>) new AggregateResultSetExtractor<>(context,
(RelationalPersistentEntity<DummyRecord>) context.getPersistentEntity(type), converter, column);
}
@Nested
class EmbeddedReference {
@Test // GH-1446
void embeddedGetsExtractedFromSingleRow() {
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"));
}
@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() {
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"));
}
@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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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() {
ResultSet resultSet = ResultSetTestUtil.mockResultSet(
asList(column("id1"), column("dummyList", KEY), column("dummyList.dummyName")), //
1, 0, "Dummy Alfred", //
1, 1, "Dummy Berta", //
1, 2, "Dummy Carl");
Iterable<SimpleEntity> 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");
}
@Test // GH-1446
void extractSingleUnorderedListReference() {
ResultSet resultSet = ResultSetTestUtil.mockResultSet(
asList(column("id1"), column("dummyList", KEY), column("dummyList.dummyName")), //
1, 0, "Dummy Alfred", //
1, 2, "Dummy Carl", 1, 1, "Dummy Berta" //
);
Iterable<SimpleEntity> 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");
}
@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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> result = extractor.extractData(resultSet);
assertThat(result).extracting(e -> e.id1).containsExactly(1L);
Map<String, DummyEntity> 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<SimpleEntity> result = extractor.extractData(resultSet);
assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus"));
Map<String, DummyEntity> 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<SimpleEntity> result = extractor.extractData(resultSet);
assertThat(result).extracting(e -> e.id1).containsExactly(1L);
Map<String, DummyEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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<SimpleEntity> 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, ColumnType columnType) {
PersistentPropertyPath<RelationalPersistentProperty> propertyPath = context.getPersistentPropertyPath(path,
SimpleEntity.class);
return column(context.getAggregatePath(propertyPath)) + (columnType == KEY ? "_key" : "");
}
private String column(AggregatePath path) {
return path.toDotPath();
}
enum ColumnType {
NORMAL, KEY
}
private static class SimpleEntity {
@Id long id1;
String name;
DummyEntity dummy;
@Embedded.Nullable DummyEntity embeddedNullable;
@Embedded.Empty DummyEntity embeddedNonNull;
Set<Intermediate> intermediates;
Set<DummyEntity> dummies;
Set<DummyEntity> otherDummies;
List<DummyEntity> dummyList;
List<Intermediate> intermediateList;
List<IntermediateNoId> intermediateListNoId;
Map<String, DummyEntity> dummyMap;
Map<String, Intermediate> intermediateMap;
Map<String, IntermediateNoId> 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<DummyEntity> dummies;
List<DummyEntity> dummyList;
Map<String, DummyEntity> dummyMap;
}
private static class IntermediateNoId {
String intermediateName;
Set<DummyEntity> dummies;
List<DummyEntity> dummyList;
Map<String, DummyEntity> dummyMap;
}
private static class DummyEntity {
String dummyName;
Long longValue;
}
private record DummyRecord(Long id1, String name) {
}
}

View File

@@ -0,0 +1,272 @@
/*
* 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 org.mockito.invocation.InvocationOnMock;
import org.mockito.stubbing.Answer;
import org.springframework.util.Assert;
import org.springframework.util.LinkedCaseInsensitiveMap;
import javax.naming.OperationNotSupportedException;
import java.sql.ResultSet;
import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import static org.mockito.Mockito.*;
/**
* Utility for mocking ResultSets for tests.
*
* @author Jens Schauder
*/
class ResultSetTestUtil {
static ResultSet mockResultSet(List<String> columns, Object... values) {
Assert.isTrue( //
values.length % columns.size() == 0, //
String //
.format( //
"Number of values [%d] must be a multiple of the number of columns [%d]", //
values.length, //
columns.size() //
) //
);
List<Map<String, Object>> result = convertValues(columns, values);
return mock(ResultSet.class, new ResultSetAnswer(columns, result));
}
private static List<Map<String, Object>> convertValues(List<String> columns, Object[] values) {
List<Map<String, Object>> result = new ArrayList<>();
int index = 0;
while (index < values.length) {
Map<String, Object> row = new LinkedCaseInsensitiveMap<>();
result.add(row);
for (String column : columns) {
row.put(column, values[index]);
index++;
}
}
return result;
}
private static class ResultSetAnswer implements Answer<Object> {
private final List<String> names;
private final List<Map<String, Object>> values;
private int index = -1;
ResultSetAnswer(List<String> names, List<Map<String, Object>> values) {
this.names = names;
this.values = values;
}
@Override
public Object answer(InvocationOnMock invocation) throws Throwable {
switch (invocation.getMethod().getName()) {
case "next" -> {
return next();
}
case "getObject" -> {
Object argument = invocation.getArgument(0);
String name = argument instanceof Integer ? names.get(((Integer) argument) - 1) : (String) argument;
return getObject(name);
}
case "isAfterLast" -> {
return isAfterLast();
}
case "isBeforeFirst" -> {
return isBeforeFirst();
}
case "getRow" -> {
return isAfterLast() || isBeforeFirst() ? 0 : index + 1;
}
case "toString" -> {
return this.toString();
}
case "findColumn" -> {
return isThereAColumnNamed(invocation.getArgument(0));
}
case "getMetaData" -> {
return new MockedMetaData();
}
default -> throw new OperationNotSupportedException(invocation.getMethod().getName());
}
}
private int isThereAColumnNamed(String name) {
throw new UnsupportedOperationException("duh");
// Optional<Map<String, Object>> first = values.stream().filter(s -> s.equals(name)).findFirst();
// return (first.isPresent()) ? 1 : 0;
}
private boolean isAfterLast() {
return index >= values.size() && !values.isEmpty();
}
private boolean isBeforeFirst() {
return index < 0 && !values.isEmpty();
}
private Object getObject(String column) throws SQLException {
Map<String, Object> rowMap = values.get(index);
if (!rowMap.containsKey(column)) {
throw new SQLException(String.format("Trying to access a column (%s) that does not exist", column));
}
return rowMap.get(column);
}
private boolean next() {
index++;
return index < values.size();
}
private class MockedMetaData implements ResultSetMetaData {
@Override
public int getColumnCount() {
return names.size();
}
@Override
public boolean isAutoIncrement(int i) {
return false;
}
@Override
public boolean isCaseSensitive(int i) {
return false;
}
@Override
public boolean isSearchable(int i) {
return false;
}
@Override
public boolean isCurrency(int i) {
return false;
}
@Override
public int isNullable(int i) {
return 0;
}
@Override
public boolean isSigned(int i) {
return false;
}
@Override
public int getColumnDisplaySize(int i) {
return 0;
}
@Override
public String getColumnLabel(int i) {
return names.get(i - 1);
}
@Override
public String getColumnName(int i) {
return null;
}
@Override
public String getSchemaName(int i) {
return null;
}
@Override
public int getPrecision(int i) {
return 0;
}
@Override
public int getScale(int i) {
return 0;
}
@Override
public String getTableName(int i) {
return null;
}
@Override
public String getCatalogName(int i) {
return null;
}
@Override
public int getColumnType(int i) {
return 0;
}
@Override
public String getColumnTypeName(int i) {
return null;
}
@Override
public boolean isReadOnly(int i) {
return false;
}
@Override
public boolean isWritable(int i) {
return false;
}
@Override
public boolean isDefinitelyWritable(int i) {
return false;
}
@Override
public String getColumnClassName(int i) {
return null;
}
@Override
public <T> T unwrap(Class<T> aClass) {
return null;
}
@Override
public boolean isWrapperFor(Class<?> aClass) {
return false;
}
}
}
}

View File

@@ -160,8 +160,7 @@ public class JdbcRepositoryCustomConversionIntegrationTests {
repository.saveAll(asList(entityA, entityB, entityC));
assertThat(repository.findByEnumTypeIn(Set.of(Direction.LEFT, Direction.RIGHT)))
.extracting(entity -> entity.direction)
.containsExactlyInAnyOrder(Direction.LEFT, Direction.RIGHT);
.extracting(entity -> entity.direction).containsExactlyInAnyOrder(Direction.LEFT, Direction.RIGHT);
}
@Test // GH-1212
@@ -175,8 +174,7 @@ public class JdbcRepositoryCustomConversionIntegrationTests {
entityC.direction = Direction.RIGHT;
repository.saveAll(asList(entityA, entityB, entityC));
assertThat(repository.findByEnumTypeIn(Set.of(Direction.CENTER)))
.extracting(entity -> entity.direction)
assertThat(repository.findByEnumTypeIn(Set.of(Direction.CENTER))).extracting(entity -> entity.direction)
.containsExactly(Direction.CENTER);
}

View File

@@ -19,13 +19,11 @@ import javax.sql.DataSource;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator;
import org.testcontainers.containers.MSSQLServerContainer;
import com.microsoft.sqlserver.jdbc.SQLServerDataSource;
/**
* {@link DataSource} setup for PostgreSQL.
* <p>
@@ -36,14 +34,14 @@ import com.microsoft.sqlserver.jdbc.SQLServerDataSource;
* @see <a href="https://github.com/testcontainers/testcontainers-java/tree/master/modules/mssqlserver"></a>
*/
@Configuration
@Profile({"mssql"})
@Profile({ "mssql" })
public class MsSqlDataSourceConfiguration extends DataSourceConfiguration {
public static final String MS_SQL_SERVER_VERSION = "mcr.microsoft.com/mssql/server:2019-CU16-ubuntu-20.04";
public static final String MS_SQL_SERVER_VERSION = "mcr.microsoft.com/mssql/server:2022-CU5-ubuntu-20.04";
private static MSSQLServerContainer<?> MSSQL_CONTAINER;
@Override
protected DataSource createDataSource() {
@Override
protected DataSource createDataSource() {
if (MSSQL_CONTAINER == null) {
@@ -54,14 +52,13 @@ public class MsSqlDataSourceConfiguration extends DataSourceConfiguration {
MSSQL_CONTAINER = container;
}
SQLServerDataSource sqlServerDataSource = new SQLServerDataSource();
SQLServerDataSource sqlServerDataSource = new SQLServerDataSource();
sqlServerDataSource.setURL(MSSQL_CONTAINER.getJdbcUrl());
sqlServerDataSource.setUser(MSSQL_CONTAINER.getUsername());
sqlServerDataSource.setPassword(MSSQL_CONTAINER.getPassword());
return sqlServerDataSource;
}
return sqlServerDataSource;
}
@Override
protected void customizePopulator(ResourceDatabasePopulator populator) {

View File

@@ -1,3 +1,3 @@
DROP TABLE DUMMY_ENTITY;
DROP TABLE DUMMY_ENTITY CASCADE CONSTRAINTS;
CREATE TABLE DUMMY_ENTITY ( id NUMBER GENERATED by default on null as IDENTITY PRIMARY KEY);

View File

@@ -1,3 +1,3 @@
DROP TABLE DUMMY_ENTITY;
DROP TABLE DUMMY_ENTITY CASCADE CONSTRAINTS;
CREATE TABLE DUMMY_ENTITY ( id NUMBER GENERATED by default on null as IDENTITY PRIMARY KEY);

View File

@@ -97,6 +97,12 @@
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.github.jsqlparser</groupId>
<artifactId>jsqlparser</artifactId>
<version>4.6</version>
</dependency>
</dependencies>
</project>

View File

@@ -116,9 +116,8 @@ public interface Dialect {
}
/**
* @return an appropriate {@link InsertRenderContext} for that specific dialect.
* for most of the Dialects the default implementation will be valid, but, for
* example, in case of {@link SqlServerDialect} it is not.
* @return an appropriate {@link InsertRenderContext} for that specific dialect. for most of the Dialects the default
* implementation will be valid, but, for example, in case of {@link SqlServerDialect} it is not.
* @since 2.4
*/
default InsertRenderContext getInsertRenderContext() {
@@ -136,12 +135,16 @@ public interface Dialect {
}
/**
* Provide a SQL function that is suitable for implementing an exists-query.
* The default is `COUNT(1)`, but for some database a `LEAST(COUNT(1), 1)` might be required, which doesn't get accepted by other databases.
* Provide a SQL function that is suitable for implementing an exists-query. The default is `COUNT(1)`, but for some
* database a `LEAST(COUNT(1), 1)` might be required, which doesn't get accepted by other databases.
*
* @since 3.0
*/
default SimpleFunction getExistsFunction(){
default SimpleFunction getExistsFunction() {
return Functions.count(SQL.literalOf(1));
}
default boolean supportsSingleQueryLoading() {
return true;
};
}

View File

@@ -108,4 +108,9 @@ public class H2Dialect extends AbstractDialect {
return Collections.emptySet();
}
@Override
public boolean supportsSingleQueryLoading() {
return false;
}
}

View File

@@ -37,6 +37,11 @@ public class HsqlDbDialect extends AbstractDialect {
return AnsiDialect.LOCK_CLAUSE;
}
@Override
public boolean supportsSingleQueryLoading() {
return false;
}
private static final LimitClause LIMIT_CLAUSE = new LimitClause() {
@Override

View File

@@ -15,6 +15,7 @@
*/
package org.springframework.data.relational.core.dialect;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -34,6 +35,8 @@ public class MariaDbDialect extends MySqlDialect {
@Override
public Collection<Object> getConverters() {
return Collections.singletonList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE);
return Arrays.asList(
TimestampAtUtcToOffsetDateTimeConverter.INSTANCE,
NumberToBooleanConverter.INSTANCE);
}
}

View File

@@ -15,6 +15,8 @@
*/
package org.springframework.data.relational.core.dialect;
import java.lang.reflect.Array;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
@@ -131,7 +133,10 @@ public class MySqlDialect extends AbstractDialect {
@Override
public Collection<Object> getConverters() {
return Collections.singletonList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE);
return Arrays.asList(
TimestampAtUtcToOffsetDateTimeConverter.INSTANCE,
NumberToBooleanConverter.INSTANCE
);
}
@Override

View File

@@ -0,0 +1,34 @@
/*
* 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.dialect;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
/**
* A {@link ReadingConverter} to convert from {@link Number} to {@link Boolean}. 0 is considered {@literal false}
* everything else is considered {@literal true}.
*/
@ReadingConverter
enum NumberToBooleanConverter implements Converter<Number, Boolean> {
INSTANCE;
@Override
public Boolean convert(Number number) {
return number.intValue() != 0;
}
}

View File

@@ -16,11 +16,9 @@
package org.springframework.data.relational.core.dialect;
import org.springframework.core.convert.converter.Converter;
import org.springframework.data.convert.ReadingConverter;
import org.springframework.data.convert.WritingConverter;
import java.util.Collection;
import java.util.Collections;
import static java.util.Arrays.*;
@@ -56,15 +54,6 @@ public class OracleDialect extends AnsiDialect {
return asList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, NumberToBooleanConverter.INSTANCE, BooleanToIntegerConverter.INSTANCE);
}
@ReadingConverter
enum NumberToBooleanConverter implements Converter<Number, Boolean> {
INSTANCE;
@Override
public Boolean convert(Number number) {
return number.intValue() != 0;
}
}
@WritingConverter
enum BooleanToIntegerConverter implements Converter<Boolean, Integer> {
INSTANCE;

View File

@@ -21,7 +21,7 @@ import java.util.function.Predicate;
/**
* @author Mark Paluch
*/
class AggregatePathTraversal {
public class AggregatePathTraversal {
public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) {

View File

@@ -48,6 +48,7 @@ public class RelationalMappingContext
private boolean forceQuote = true;
private final ExpressionEvaluator expressionEvaluator = new ExpressionEvaluator(EvaluationContextProvider.DEFAULT);
private boolean singleQueryLoadingEnabled = false;
/**
* Creates a new {@link RelationalMappingContext}.
@@ -130,6 +131,27 @@ public class RelationalMappingContext
return persistentProperty;
}
/**
* @since 3.2
* @return iff single query loading is enabled.
* @see #setSingleQueryLoadingEnabled(boolean)
*/
public boolean isSingleQueryLoadingEnabled() {
return singleQueryLoadingEnabled;
}
/**
* Set the {@literal singleQueryLoadingEnabled} flag. If it is set to true and the
* {@link org.springframework.data.relational.core.dialect.Dialect} supports it, Spring Data JDBC will try to use
* Single Query Loading if possible.
*
* @since 3.2
* @param singleQueryLoadingEnabled
*/
public void setSingleQueryLoadingEnabled(boolean singleQueryLoadingEnabled) {
this.singleQueryLoadingEnabled = singleQueryLoadingEnabled;
}
protected void applyDefaults(BasicRelationalPersistentProperty persistentProperty) {
persistentProperty.setForceQuote(isForceQuote());

View File

@@ -21,7 +21,7 @@ package org.springframework.data.relational.core.sql;
* @author Jens Schauder
* @since 1.1
*/
class AliasedExpression extends AbstractSegment implements Aliased, Expression {
public class AliasedExpression extends AbstractSegment implements Aliased, Expression {
private final Expression expression;
private final SqlIdentifier alias;
@@ -49,6 +49,6 @@ class AliasedExpression extends AbstractSegment implements Aliased, Expression {
@Override
public String toString() {
return expression + " AS " + alias;
return expression.toString();
}
}

View File

@@ -136,9 +136,9 @@ class DefaultSelectBuilder implements SelectBuilder, SelectAndFrom, SelectFromAn
}
@Override
public DefaultSelectBuilder orderBy(Column... columns) {
public DefaultSelectBuilder orderBy(Expression... columns) {
for (Column column : columns) {
for (Expression column : columns) {
this.orderBy.add(OrderByField.from(column));
}
@@ -299,7 +299,7 @@ class DefaultSelectBuilder implements SelectBuilder, SelectAndFrom, SelectFromAn
}
@Override
public SelectOrdered orderBy(Column... columns) {
public SelectOrdered orderBy(Expression... columns) {
selectBuilder.join(finishJoin());
return selectBuilder.orderBy(columns);
}

View File

@@ -19,6 +19,7 @@ import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.util.Assert;
@@ -49,10 +50,26 @@ public class Functions {
}
public static SimpleFunction least(Expression... expressions) {
return SimpleFunction.create("LEAST", Arrays.asList(expressions));
}
/**
* Creates a {@literal GREATEST} function with the given arguments.
* @since 3.2
*/
public static SimpleFunction greatest(Expression... expressions) {
return greatest(Arrays.asList(expressions));
}
/**
* Creates a {@literal GREATEST} function with the given arguments.
* @since 3.2
*/
public static SimpleFunction greatest(List<Expression> list) {
return SimpleFunction.create("GREATEST", list);
}
/**
* Creates a new {@code COUNT} function.
*
@@ -96,4 +113,8 @@ public class Functions {
// Utility constructor.
private Functions() {}
public static SimpleFunction coalesce(Expression... expressions) {
return SimpleFunction.create("COALESCE", Arrays.asList(expressions));
}
}

View File

@@ -238,7 +238,7 @@ public interface SelectBuilder {
SelectFromAndOrderBy from(Collection<? extends TableLike> tables);
@Override
SelectFromAndOrderBy orderBy(Column... columns);
SelectFromAndOrderBy orderBy(Expression... columns);
@Override
SelectFromAndOrderBy orderBy(OrderByField... orderByFields);
@@ -393,7 +393,7 @@ public interface SelectBuilder {
* @param columns the columns to order by.
* @return {@code this} builder.
*/
SelectOrdered orderBy(Column... columns);
SelectOrdered orderBy(Expression... columns);
/**
* Add one or more {@link OrderByField order by fields}.

View File

@@ -0,0 +1,93 @@
/*
* 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.sqlgeneration;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.data.relational.core.mapping.AggregatePath;
import org.springframework.data.relational.core.mapping.AggregatePathTraversal;
/**
* Creates aliases to be used in SQL generation
*
* @since 3.2
* @author Jens Schauder
*/
public class AliasFactory {
private final SingleAliasFactory columnAliases = new SingleAliasFactory("c");
private final SingleAliasFactory tableAliases = new SingleAliasFactory("t");
private final SingleAliasFactory rowNumberAliases = new SingleAliasFactory("rn");
private final SingleAliasFactory rowCountAliases = new SingleAliasFactory("rc");
private final SingleAliasFactory backReferenceAliases = new SingleAliasFactory("br");
private final SingleAliasFactory keyAliases = new SingleAliasFactory("key");
private int counter = 0;
private static String sanitize(String name) {
return name.replaceAll("\\W", "");
}
public String getColumnAlias(AggregatePath path) {
return columnAliases.getOrCreateFor(path);
}
public String getTableAlias(AggregatePath path) {
return tableAliases.getOrCreateFor(path);
}
public String getRowNumberAlias(AggregatePath path) {
return rowNumberAliases.getOrCreateFor(AggregatePathTraversal.getTableOwningPath(path));
}
public String getRowCountAlias(AggregatePath path) {
return rowCountAliases.getOrCreateFor(path);
}
public String getBackReferenceAlias(AggregatePath path) {
return backReferenceAliases.getOrCreateFor(path);
}
public String getKeyAlias(AggregatePath path) {
return keyAliases.getOrCreateFor(path);
}
private class SingleAliasFactory {
private final String prefix;
private final Map<AggregatePath, String> cache = new ConcurrentHashMap<>();
SingleAliasFactory(String prefix) {
this.prefix = prefix + "_";
}
String getOrCreateFor(AggregatePath path) {
return cache.computeIfAbsent(path, this::createName);
}
private String createName(AggregatePath path) {
return prefix + getName(path) + "_" + ++counter;
}
}
private static String getName(AggregatePath path) {
return sanitize( //
path.isEntity() //
? path.getTableInfo().qualifiedTableName().getReference() //
: path.getColumnInfo().name().getReference()) //
.toLowerCase();
}
}

View File

@@ -0,0 +1,62 @@
/*
* 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.sqlgeneration;
import org.springframework.data.util.Lazy;
/**
* A wrapper for the {@link SqlGenerator} that caches the generated statements.
* @since 3.2
* @author Jens Schauder
*/
public class CachingSqlGenerator implements SqlGenerator{
private final SqlGenerator delegate;
private final Lazy<String> findAll;
private final Lazy<String> findById;
private final Lazy<String> findAllById;
public CachingSqlGenerator(SqlGenerator delegate) {
this.delegate = delegate;
findAll = Lazy.of(delegate.findAll());
findById = Lazy.of(delegate.findById());
findAllById = Lazy.of(delegate.findAllById());
}
@Override
public String findAll() {
return findAll.get();
}
@Override
public String findById() {
return findById.get();
}
@Override
public String findAllById() {
return findAllById.get();
}
@Override
public AliasFactory getAliasFactory() {
return delegate.getAliasFactory();
}
}

View File

@@ -0,0 +1,437 @@
/*
* 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.sqlgeneration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import org.jetbrains.annotations.NotNull;
import org.springframework.data.mapping.PersistentProperty;
import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.mapping.PersistentPropertyPaths;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.dialect.RenderContextFactory;
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.relational.core.sql.*;
import org.springframework.data.relational.core.sql.render.SqlRenderer;
/**
* A {@link SqlGenerator} that creates SQL statements for loading complete aggregates with a single statement.
*
* @since 3.2
* @author Jens Schauder
*/
public class SingleQuerySqlGenerator implements SqlGenerator {
private final RelationalMappingContext context;
private final Dialect dialect;
private final AliasFactory aliases = new AliasFactory();
private final RelationalPersistentEntity<?> aggregate;
private final Table table;
public SingleQuerySqlGenerator(RelationalMappingContext context, Dialect dialect,
RelationalPersistentEntity<?> aggregate) {
this.context = context;
this.dialect = dialect;
this.aggregate = aggregate;
this.table = Table.create(aggregate.getQualifiedTableName());
}
@Override
public String findAll() {
return createSelect(null);
}
@Override
public String findById() {
AggregatePath path = getRootIdPath();
Condition condition = Conditions.isEqual(table.column(path.getColumnInfo().name()), Expressions.just(":id"));
return createSelect(condition);
}
@Override
public String findAllById() {
AggregatePath path = getRootIdPath();
Condition condition = Conditions.in(table.column(path.getColumnInfo().name()), Expressions.just(":ids"));
return createSelect(condition);
}
/**
* @return The {@link AggregatePath} to the id property of the aggregate root.
*/
private AggregatePath getRootIdPath() {
return context.getAggregatePath(aggregate).append(aggregate.getRequiredIdProperty());
}
/**
* Creates a SQL suitable of loading all the data required for constructing complete aggregates.
*
* @param condition a constraint for limiting the aggregates to be loaded.
* @return a {@literal String} containing the generated SQL statement
*/
private String createSelect(Condition condition) {
AggregatePath rootPath = context.getAggregatePath(aggregate);
QueryMeta queryMeta = createInlineQuery(rootPath, condition);
InlineQuery rootQuery = queryMeta.inlineQuery;
List<Expression> columns = new ArrayList<>(queryMeta.selectableExpressions);
List<Expression> rownumbers = new ArrayList<>();
rownumbers.add(queryMeta.rowNumber);
PersistentPropertyPaths<?, RelationalPersistentProperty> entityPaths = context
.findPersistentPropertyPaths(aggregate.getType(), PersistentProperty::isEntity);
List<QueryMeta> inlineQueries = createInlineQueries(entityPaths);
inlineQueries.forEach(qm -> {
columns.addAll(qm.selectableExpressions);
rownumbers.add(qm.rowNumber);
});
Expression totalRownumber = rownumbers.size() > 1 ? greatest(rownumbers).as("rn")
: new AliasedExpression(rownumbers.get(0), "rn");
columns.add(totalRownumber);
InlineQuery inlineQuery = createMainSelect(columns, rootPath, rootQuery, inlineQueries);
Expression rootIdExpression = just(aliases.getColumnAlias(rootPath.append(aggregate.getRequiredIdProperty())));
List<Expression> finalColumns = new ArrayList<>();
queryMeta.simpleColumns
.forEach(e -> finalColumns.add(filteredColumnExpression(queryMeta.rowNumber.toString(), e.toString())));
for (QueryMeta meta : inlineQueries) {
meta.simpleColumns
.forEach(e -> finalColumns.add(filteredColumnExpression(meta.rowNumber.toString(), e.toString())));
if (meta.id != null) {
finalColumns.add(meta.id);
}
if (meta.key != null) {
finalColumns.add(meta.key);
}
}
finalColumns.add(rootIdExpression);
Select fullQuery = StatementBuilder.select(finalColumns).from(inlineQuery).orderBy(rootIdExpression, just("rn"))
.build();
return SqlRenderer.create(new RenderContextFactory(dialect).createRenderContext()).render(fullQuery);
}
@NotNull
private InlineQuery createMainSelect(List<Expression> columns, AggregatePath rootPath, InlineQuery rootQuery,
List<QueryMeta> inlineQueries) {
SelectBuilder.SelectJoin select = StatementBuilder.select(columns).from(rootQuery);
select = applyJoins(rootPath, inlineQueries, select);
SelectBuilder.BuildSelect buildSelect = applyWhereCondition(rootPath, inlineQueries, select);
Select mainSelect = buildSelect.build();
return InlineQuery.create(mainSelect, "main");
}
/**
* Creates inline queries for all entities referenced by the paths passed as an argument.
*
* @param paths the paths to consider.
* @return a {@link Map} that contains all the inline queries indexed by the path to the entity that gets loaded by
* the subquery.
*/
private List<QueryMeta> createInlineQueries(PersistentPropertyPaths<?, RelationalPersistentProperty> paths) {
List<QueryMeta> inlineQueries = new ArrayList<>();
for (PersistentPropertyPath ppp : paths) {
QueryMeta queryMeta = createInlineQuery(context.getAggregatePath(ppp), null);
inlineQueries.add(queryMeta);
}
return inlineQueries;
}
/**
* Creates a single inline query for the given basePath. The query selects all the columns for the entity plus a
* rownumber and a rowcount expression. The first numbers all rows of the subselect sequentially starting from 1. The
* rowcount contains the total number of child rows. All selected expressions are globally uniquely aliased and are
* referenced by that alias in the rest of the query. This ensures that we don't run into problems with column names
* that are not unique across tables and also the generated SQL doesn't contain quotes and funny column names, making
* them easier to understand and also potentially shorter.
*
* @param basePath the path for which to create the inline query.
* @param condition a condition that is to be applied to the query. May be {@literal null}.
* @return an inline query for the given path.
*/
private QueryMeta createInlineQuery(AggregatePath basePath, Condition condition) {
RelationalPersistentEntity<?> entity = basePath.getRequiredLeafEntity();
Table table = Table.create(entity.getQualifiedTableName());
List<AggregatePath> paths = new ArrayList<>();
entity.doWithProperties((RelationalPersistentProperty p) -> {
if (!p.isEntity()) {
paths.add(basePath.append(p));
}
});
List<Expression> columns = new ArrayList<>();
List<Expression> columnAliases = new ArrayList<>();
String rowNumberAlias = aliases.getRowNumberAlias(basePath);
Expression rownumber = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowNumberAlias)
: createRowNumberExpression(basePath, table, rowNumberAlias);
columns.add(rownumber);
String rowCountAlias = aliases.getRowCountAlias(basePath);
Expression count = basePath.isRoot() ? new AliasedExpression(SQL.literalOf(1), rowCountAlias)
: AnalyticFunction.create("count", Expressions.just("*"))
.partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())).as(rowCountAlias);
columns.add(count);
String backReferenceAlias = null;
String keyAlias = null;
if (!basePath.isRoot()) {
backReferenceAlias = aliases.getBackReferenceAlias(basePath);
columns.add(table.column(basePath.getTableInfo().reverseColumnInfo().name()).as(backReferenceAlias));
if (basePath.isQualified()) {
keyAlias = aliases.getKeyAlias(basePath);
columns.add(table.column(basePath.getTableInfo().qualifierColumnInfo().name()).as(keyAlias));
} else {
String alias = aliases.getColumnAlias(basePath);
columns.add(new AliasedExpression(just("1"), alias));
columnAliases.add(just(alias));
}
}
String id = null;
for (AggregatePath path : paths) {
String alias = aliases.getColumnAlias(path);
if (path.getRequiredLeafProperty().isIdProperty()) {
id = alias;
} else {
columnAliases.add(just(alias));
}
columns.add(table.column(path.getColumnInfo().name()).as(alias));
}
SelectBuilder.SelectWhere select = StatementBuilder.select(columns).from(table);
SelectBuilder.BuildSelect buildSelect = condition != null ? select.where(condition) : select;
InlineQuery inlineQuery = InlineQuery.create(buildSelect.build(),
aliases.getTableAlias(context.getAggregatePath(entity)));
return QueryMeta.of(basePath, inlineQuery, columnAliases, just(id), just(backReferenceAlias), just(keyAlias),
just(rowNumberAlias), just(rowCountAlias));
}
@NotNull
private static AnalyticFunction createRowNumberExpression(AggregatePath basePath, Table table,
String rowNumberAlias) {
return AnalyticFunction.create("row_number") //
.partitionBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
.orderBy(table.column(basePath.getTableInfo().reverseColumnInfo().name())) //
.as(rowNumberAlias);
}
/**
* Adds joins to a select.
*
* @param rootPath the AggregatePath that gets selected by the select in question.
* @param inlineQueries all the inline queries to added as joins as returned by
* {@link #createInlineQueries(PersistentPropertyPaths)}
* @param select the select to modify.
* @return the original select but with added joins
*/
private SelectBuilder.SelectJoin applyJoins(AggregatePath rootPath, List<QueryMeta> inlineQueries,
SelectBuilder.SelectJoin select) {
RelationalPersistentProperty rootIdProperty = rootPath.getRequiredIdProperty();
AggregatePath rootIdPath = rootPath.append(rootIdProperty);
for (QueryMeta queryMeta : inlineQueries) {
AggregatePath path = queryMeta.basePath();
String backReferenceAlias = aliases.getBackReferenceAlias(path);
Comparison joinCondition = Conditions.isEqual(Expressions.just(aliases.getColumnAlias(rootIdPath)),
Expressions.just(backReferenceAlias));
select = select.leftOuterJoin(queryMeta.inlineQuery).on(joinCondition);
}
return select;
}
/**
* Applies a where condition to the select. The Where condition is constructed such that one root and multiple child
* selects are combined such that.
* <ol>
* <li>all child elements with a given rn become part of a single row. I.e. all child rows with for example rownumber
* 3 are contained in a single row</li>
* <li>if for a given rownumber no matching element is present for a given child the columns for that child are either
* null (when there is no child elements at all) or the values for rownumber 1 are used for that child</li>
* </ol>
*
* @param rootPath path to the root entity that gets selected.
* @param inlineQueries all in the inline queries for all the children, as returned by
* {@link #createInlineQueries(PersistentPropertyPaths)}
* @param select the select to which the where clause gets added.
* @return the modified select.
*/
private SelectBuilder.SelectOrdered applyWhereCondition(AggregatePath rootPath, List<QueryMeta> inlineQueries,
SelectBuilder.SelectJoin select) {
SelectBuilder.SelectWhereAndOr selectWhere = null;
for (QueryMeta queryMeta : inlineQueries) {
AggregatePath path = queryMeta.basePath;
Expression childRowNumber = just(aliases.getRowNumberAlias(path));
Condition pseudoJoinCondition = Conditions.isNull(childRowNumber)
.or(Conditions.isEqual(childRowNumber, Expressions.just(aliases.getRowNumberAlias(rootPath))))
.or(Conditions.isGreater(childRowNumber, Expressions.just(aliases.getRowCountAlias(rootPath))));
selectWhere = ((SelectBuilder.SelectWhere) select).where(pseudoJoinCondition);
}
return selectWhere == null ? (SelectBuilder.SelectOrdered) select : selectWhere;
}
@Override
public AliasFactory getAliasFactory() {
return aliases;
}
/**
* Constructs a SQL function of the following form
* {@code GREATEST(Coalesce(x1, 1), Coalesce(x2, 1), ..., Coalesce(xN, 1)}. this is used for cobining rownumbers from
* different child tables. The {@code coalesce} is used because the values {@code x1 ... xN} might be {@code null} and
* we want {@code null} to be equivalent with the first entry.
*
* @param expressions the different values to combined.
*/
private static SimpleFunction greatest(List<Expression> expressions) {
List<Expression> guarded = new ArrayList<>();
for (Expression expression : expressions) {
guarded.add(Functions.coalesce(expression, SQL.literalOf(1)));
}
return Functions.greatest(guarded);
}
/**
* Constructs SQL of the form {@code CASE WHEN x = rn THEN alias ELSE NULL END AS ALIAS}. This expression is used to
* replace values that would appear multiple times in the result with {@code null} values in all but the first
* occurrence. With out this the result for an aggregate root with a single collection item would look like this:
* <table>
* <th>
* <td>root value</td>
* <td>child value</td></th>
* <tr>
* <td>root1</td>
* <td>child1</td>
* </tr>
* <tr>
* <td>root1</td>
* <td>child2</td>
* </tr>
* <tr>
* <td>root1</td>
* <td>child3</td>
* </tr>
* <tr>
* <td>root1</td>
* <td>child4</td>
* </tr>
* </table>
* This expression transforms this into
* <table>
* <th>
* <td>root value</td>
* <td>child value</td></th>
* <tr>
* <td>root1</td>
* <td>child1</td>
* </tr>
* <tr>
* <td>null</td>
* <td>child2</td>
* </tr>
* <tr>
* <td>null</td>
* <td>child3</td>
* </tr>
* <tr>
* <td>null</td>
* <td>child4</td>
* </tr>
* </table>
*
* @param rowNumberAlias the alias of the rownumber column of the subselect under consideration. This determines if
* the other value is replaced by null or not.
* @param alias the column potentially to be replace by null
* @return a SQL expression.
*/
private static Expression filteredColumnExpression(String rowNumberAlias, String alias) {
return just("case when " + rowNumberAlias + " = rn THEN " + alias + " else null end as " + alias);
}
private static Expression just(String alias) {
if (alias == null) {
return null;
}
return Expressions.just(alias);
}
record QueryMeta(AggregatePath basePath, InlineQuery inlineQuery, Collection<Expression> simpleColumns,
Collection<Expression> selectableExpressions, Expression id, Expression backReference, Expression key,
Expression rowNumber, Expression rowCount) {
static QueryMeta of(AggregatePath basePath, InlineQuery inlineQuery, Collection<Expression> simpleColumns,
Expression id, Expression backReference, Expression key, Expression rowNumber, Expression rowCount) {
List<Expression> selectableExpressions = new ArrayList<>(simpleColumns);
selectableExpressions.add(rowNumber);
if (id != null) {
selectableExpressions.add(id);
}
if (backReference != null) {
selectableExpressions.add(backReference);
}
if (key != null) {
selectableExpressions.add(key);
}
return new QueryMeta(basePath, inlineQuery, simpleColumns, selectableExpressions, id, backReference, key,
rowNumber, rowCount);
}
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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.sqlgeneration;
/**
* Generates SQL statements for loading aggregates.
* @since 3.2
* @author Jens Schauder
*/
public interface SqlGenerator {
String findAll();
String findById();
String findAllById();
AliasFactory getAliasFactory();
}

View File

@@ -620,6 +620,21 @@ class SelectRendererUnitTests {
.isEqualTo("SELECT * FROM tableA JOIN tableB ON tableA.id = tableB.id ORDER BY tableA.name, tableB.name");
}
@Test // GH-1446
void rendersAliasedExpression() {
Table table = SQL.table("table");
Column tableName = table.column("name");
Select select = StatementBuilder.select(new AliasedExpression(tableName, "alias")) //
.from(table) //
.build();
String rendered = SqlRenderer.toString(select);
assertThat(rendered)
.isEqualTo("SELECT table.name AS alias FROM table");
}
/**
* Tests the rendering of analytic functions.
*/

View File

@@ -0,0 +1,148 @@
/*
* 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.sqlgeneration;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.relational.core.mapping.Column;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
/**
* Unit tests for the {@link AliasFactory}.
* @author Jens Schauder
*/
class AliasFactoryUnitTests {
RelationalMappingContext context = new RelationalMappingContext();
AliasFactory aliasFactory = new AliasFactory();
@Nested
class SimpleAlias {
@Test
void aliasForRoot() {
String alias = aliasFactory
.getColumnAlias(context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
assertThat(alias).isEqualTo("c_dummy_entity_1");
}
@Test
void aliasSimpleProperty() {
String alias = aliasFactory
.getColumnAlias(context.getAggregatePath(context.getPersistentPropertyPath("name", DummyEntity.class)));
assertThat(alias).isEqualTo("c_name_1");
}
@Test
void nameGetsSanatized() {
String alias = aliasFactory.getColumnAlias(
context.getAggregatePath( context.getPersistentPropertyPath("evil", DummyEntity.class)));
assertThat(alias).isEqualTo("c_ameannamecontains3illegal_characters_1");
}
@Test
void aliasIsStable() {
String alias1 = aliasFactory.getColumnAlias(
context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
String alias2 = aliasFactory.getColumnAlias(
context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
assertThat(alias1).isEqualTo(alias2);
}
}
@Nested
class RnAlias {
@Test
void aliasIsStable() {
String alias1 = aliasFactory.getRowNumberAlias(
context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
String alias2 = aliasFactory.getRowNumberAlias(
context.getAggregatePath( context.getRequiredPersistentEntity(DummyEntity.class)));
assertThat(alias1).isEqualTo(alias2);
}
@Test
void aliasProjectsOnTableReferencingPath() {
String alias1 = aliasFactory.getRowNumberAlias(
context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
String alias2 = aliasFactory.getRowNumberAlias(
context.getAggregatePath(context.getPersistentPropertyPath("evil", DummyEntity.class)));
assertThat(alias1).isEqualTo(alias2);
}
@Test
void rnAliasIsIndependentOfTableAlias() {
String alias1 = aliasFactory.getRowNumberAlias(
context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
String alias2 = aliasFactory.getColumnAlias(
context.getAggregatePath(context.getRequiredPersistentEntity(DummyEntity.class)));
assertThat(alias1).isNotEqualTo(alias2);
}
}
@Nested
class BackReferenceAlias {
@Test
void testBackReferenceAlias() {
String alias = aliasFactory.getBackReferenceAlias(
context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
assertThat(alias).isEqualTo("br_dummy_entity_1");
}
}
@Nested
class KeyAlias {
@Test
void testKeyAlias() {
String alias = aliasFactory.getKeyAlias(
context.getAggregatePath(context.getPersistentPropertyPath("dummy", Reference.class)));
assertThat(alias).isEqualTo("key_dummy_entity_1");
}
}
static class DummyEntity {
String name;
@Column("a mean name <-- contains > 3 illegal_characters.") String evil;
}
static class Reference {
DummyEntity dummy;
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.statement.select.SelectExpressionItem;
import net.sf.jsqlparser.statement.select.SelectItem;
/**
* Matches an expression with an alias.
*
* @param pattern for the expression to match
* @param alias to match
*
* @author Jens Schauder
*/
record AliasedPattern (SelectItemPattern pattern, String alias) implements SelectItemPattern {
@Override
public boolean matches(SelectItem selectItem) {
if (selectItem instanceof SelectExpressionItem sei) {
return pattern.matches(sei) && sei.getAlias() != null && sei.getAlias().getName().equals(alias);
}
return false;
}
@Override
public String toString() {
return pattern + " as " + alias;
}
}

View File

@@ -0,0 +1,72 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.AnalyticExpression;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.statement.select.SelectExpressionItem;
import net.sf.jsqlparser.statement.select.SelectItem;
import java.util.List;
/**
* Pattern matching analytic functions
*
* @author Jens Schauder
*/
public class AnalyticFunctionPattern extends TypedExpressionPattern<AnalyticExpression> {
private final ExpressionPattern partitionBy;
private String functionName;
public AnalyticFunctionPattern(String rowNumber, ExpressionPattern partitionBy) {
super(AnalyticExpression.class);
this.functionName = rowNumber;
this.partitionBy = partitionBy;
}
@Override
public boolean matches(SelectItem selectItem) {
if (selectItem instanceof SelectExpressionItem sei) {
Expression expression = sei.getExpression();
if (expression instanceof AnalyticExpression analyticExpression) {
return matches(analyticExpression);
}
}
return false;
}
@Override
boolean matches(AnalyticExpression analyticExpression) {
return analyticExpression.getName().toLowerCase().equals(functionName)
&& partitionByMatches(analyticExpression);
}
private boolean partitionByMatches(AnalyticExpression analyticExpression) {
List<Expression> expressions = analyticExpression.getPartitionExpressionList().getExpressions();
return expressions != null && expressions.size() == 1 && partitionBy.matches(expressions.get(0));
}
@Override
public String toString() {
return "row_number() OVER (PARTITION BY " + partitionBy + ')';
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.schema.Column;
import java.util.Objects;
/**
* Pattern matching just a simple column
*
* @author Jens Schauder
*/
class ColumnPattern extends TypedExpressionPattern<Column> {
private final String columnName;
/**
* @param columnName name of the expected column.
*/
ColumnPattern(String columnName) {
super(Column.class);
this.columnName = columnName;
}
@Override
public boolean matches(Column actualColumn) {
return actualColumn.getColumnName().equals(columnName);
}
@Override
public String toString() {
return columnName;
}
public String columnName() {
return columnName;
}
@Override
public boolean equals(Object obj) {
if (obj == this)
return true;
if (obj == null || obj.getClass() != this.getClass())
return false;
var that = (ColumnPattern) obj;
return Objects.equals(this.columnName, that.columnName);
}
@Override
public int hashCode() {
return Objects.hash(columnName);
}
}

View File

@@ -0,0 +1,28 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.Expression;
/**
* A pattern that matches various SQL expressions.
*
* @author Jens Schauder
*/
public interface ExpressionPattern {
boolean matches(Expression expression);
}

View File

@@ -0,0 +1,105 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.expression.Function;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
/**
* A pattern matching a function call.
*
* @author Jens Schauder
*/
public final class FunctionPattern extends TypedExpressionPattern<Function> {
private final String name;
private final List<ExpressionPattern> params;
/**
* @param name name of the function.
* @param params patterns to match the function arguments.
*/
public FunctionPattern(String name, List<ExpressionPattern> params) {
super(Function.class);
this.name = name;
this.params = params;
}
FunctionPattern(String name, ExpressionPattern... params) {
this(name, Arrays.asList(params));
}
@Override
public boolean matches(Function function) {
if (function.getName().equalsIgnoreCase(name)) {
List<Expression> expressions = new ArrayList<>(function.getParameters().getExpressions());
for (ExpressionPattern param : params) {
boolean found = false;
for (Expression exp : expressions) {
if (param.matches(exp)) {
expressions.remove(exp);
found = true;
break;
}
}
if (!found) {
return false;
}
}
return expressions.isEmpty();
}
return false;
}
@Override
public String toString() {
return name + "(" + params.stream().map(Object::toString).collect(Collectors.joining(", ")) + ")";
}
public String name() {
return name;
}
public List<ExpressionPattern> params() {
return params;
}
@Override
public boolean equals(Object obj) {
if (obj == this) return true;
if (obj == null || obj.getClass() != this.getClass()) return false;
var that = (FunctionPattern) obj;
return Objects.equals(this.name, that.name) &&
Objects.equals(this.params, that.params);
}
@Override
public int hashCode() {
return Objects.hash(name, params);
}
}

View File

@@ -0,0 +1,46 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.statement.select.Join;
import java.util.Collection;
import org.assertj.core.api.AbstractAssert;
/**
* AspectJ {@link org.assertj.core.api.Assert} for writing assertions about joins in SQL statements.
*
* @author Jens Schauder
*/
public class JoinAssert extends AbstractAssert<JoinAssert, Join> {
public JoinAssert(Join join) {
super(join, JoinAssert.class);
}
JoinAssert on(String left, String right) {
Collection<Expression> onExpressions = actual.getOnExpressions();
if (!(onExpressions.iterator().next().toString().equals(left + " = " + right))) {
throw failureWithActualExpected(actual, left + " = " + right,
"actual join condition %s does not match expected %s = %s", actual, left, right);
}
return this;
}
}

View File

@@ -0,0 +1,40 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.statement.select.SelectExpressionItem;
import net.sf.jsqlparser.statement.select.SelectItem;
/**
* Pattern matching a literal expression in a SQL statement.
*
* @param value the value of the expression
* @author Jens Schauder
*/
record LiteralPattern(Object value) implements SelectItemPattern, ExpressionPattern {
@Override
public boolean matches(SelectItem selectItem) {
return selectItem instanceof SelectExpressionItem sei && matches(sei.getExpression());
}
@Override
public boolean matches(Expression expression) {
return expression.toString().equals(String.valueOf(value));
}
}

View File

@@ -0,0 +1,31 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.statement.select.SelectItem;
/**
* A pattern matching a simple column.
* @author Jens Schauder
*/
interface SelectItemPattern {
default AliasedPattern as(String alias) {
return new AliasedPattern(this, alias);
}
boolean matches(SelectItem selectItem);
}

View File

@@ -0,0 +1,255 @@
/*
* 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.sqlgeneration;
import static org.springframework.data.relational.core.sqlgeneration.SqlAssert.*;
import java.util.List;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.springframework.data.annotation.Id;
import org.springframework.data.mapping.PersistentPropertyPath;
import org.springframework.data.relational.core.dialect.Dialect;
import org.springframework.data.relational.core.dialect.PostgresDialect;
import org.springframework.data.relational.core.mapping.AggregatePath;
import org.springframework.data.relational.core.mapping.RelationalMappingContext;
import org.springframework.data.relational.core.mapping.RelationalPersistentProperty;
/**
* Tests for {@link SingleQuerySqlGenerator}.
*
* @author Jens Schauder
*/
class SingleQuerySqlGeneratorUnitTests {
RelationalMappingContext context = new RelationalMappingContext();
Dialect dialect = createDialect();
@Nested
class TrivialAggregateWithoutReferences extends AbstractTestFixture {
TrivialAggregateWithoutReferences() {
super(TrivialAggregate.class);
}
@Test // GH-1446
void createSelectForFindAll() {
String sql = sqlGenerator.findAll();
SqlAssert fullSelect = assertThatParsed(sql);
fullSelect.extractOrderBy().isEqualTo(alias("id") + ", rn");
SqlAssert baseSelect = fullSelect.hasInlineView();
baseSelect //
.hasExactlyColumns( //
col(rnAlias()).as("rn"), //
col(rnAlias()), //
col(alias("id")), //
col(alias("name")) //
) //
.hasInlineViewSelectingFrom("\"trivial_aggregate\"") //
.hasExactlyColumns( //
lit(1).as(rnAlias()), //
lit(1).as(rcAlias()), //
col("\"id\"").as(alias("id")), //
col("\"name\"").as(alias("name")) //
);
}
@Test // GH-1446
void createSelectForFindById() {
String sql = sqlGenerator.findById();
SqlAssert baseSelect = assertThatParsed(sql).hasInlineView();
baseSelect //
.hasExactlyColumns( //
col(rnAlias()).as("rn"), //
col(rnAlias()), //
col(alias("id")), //
col(alias("name")) //
) //
.hasInlineViewSelectingFrom("\"trivial_aggregate\"") //
.hasExactlyColumns( //
lit(1).as(rnAlias()), //
lit(1).as(rcAlias()), //
col("\"id\"").as(alias("id")), //
col("\"name\"").as(alias("name")) //
) //
.extractWhereClause().isEqualTo("\"trivial_aggregate\".\"id\" = :id");
}
@Test // GH-1446
void createSelectForFindAllById() {
String sql = sqlGenerator.findAllById();
SqlAssert baseSelect = assertThatParsed(sql).hasInlineView();
baseSelect //
.hasExactlyColumns( //
col(rnAlias()).as("rn"), //
col(rnAlias()), //
col(alias("id")), //
col(alias("name")) //
) //
.hasInlineViewSelectingFrom("\"trivial_aggregate\"") //
.hasExactlyColumns( //
lit(1).as(rnAlias()), //
lit(1).as(rcAlias()), //
col("\"id\"").as(alias("id")), //
col("\"name\"").as(alias("name")) //
) //
.extractWhereClause().isEqualTo("\"trivial_aggregate\".\"id\" IN (:ids)");
}
}
@Nested
class AggregateWithSingleReference extends AbstractTestFixture {
private AggregateWithSingleReference() {
super(SingleReferenceAggregate.class);
}
@Test // GH-1446
void createSelectForFindById() {
String sql = sqlGenerator.findById();
String rootRowNumber = rnAlias();
String rootCount = rcAlias();
String trivialsRowNumber = rnAlias("trivials");
String backref = backRefAlias("trivials");
String keyAlias = keyAlias("trivials");
SqlAssert baseSelect = assertThatParsed(sql).hasInlineView();
baseSelect //
.hasExactlyColumns( //
col(rootRowNumber), //
col(alias("id")), //
col(alias("name")), //
col(trivialsRowNumber), //
col(alias("trivials.id")), //
col(alias("trivials.name")), //
func("greatest", func("coalesce",col(rootRowNumber), lit(1)), func("coalesce",col(trivialsRowNumber), lit(1))), //
col(backref), //
col(keyAlias) //
).extractWhereClause() //
.doesNotContainIgnoringCase("and") //
.containsIgnoringCase(trivialsRowNumber + " is null") //
.containsIgnoringCase(trivialsRowNumber + " = " + rootRowNumber) //
.containsIgnoringCase(trivialsRowNumber + " > " + rootCount);
baseSelect.hasInlineViewSelectingFrom("\"single_reference_aggregate\"") //
.hasExactlyColumns( //
lit(1).as(rnAlias()), lit(1).as(rootCount), //
col("\"id\"").as(alias("id")), //
col("\"name\"").as(alias("name")) //
) //
.extractWhereClause().isEqualTo("\"single_reference_aggregate\".\"id\" = :id");
baseSelect.hasInlineViewSelectingFrom("\"trivial_aggregate\"") //
.hasExactlyColumns( //
rn(col("\"single_reference_aggregate\"")).as(trivialsRowNumber), //
count(col("\"single_reference_aggregate\"")).as(rcAlias("trivials")), //
col("\"id\"").as(alias("trivials.id")), //
col("\"name\"").as(alias("trivials.name")), //
col("\"single_reference_aggregate\"").as(backref), //
col("\"single_reference_aggregate_key\"").as(keyAlias) //
).extractWhereClause().isEmpty();
baseSelect.hasJoin().on(alias("id"), backref);
}
}
private AggregatePath path(Class<?> type) {
return context.getAggregatePath(context.getRequiredPersistentEntity(type));
}
private AggregatePath path(Class<?> type, String pathAsString) {
PersistentPropertyPath<RelationalPersistentProperty> persistentPropertyPath = context
.getPersistentPropertyPath(pathAsString, type);
return context.getAggregatePath(persistentPropertyPath);
}
private static Dialect createDialect() {
return PostgresDialect.INSTANCE;
}
record TrivialAggregate(@Id Long id, String name) {
}
record SingleReferenceAggregate(@Id Long id, String name, List<TrivialAggregate> trivials) {
}
private class AbstractTestFixture {
final Class<?> aggregateRootType;
final SingleQuerySqlGenerator sqlGenerator;
final AliasFactory aliases;
private AbstractTestFixture(Class<?> aggregateRootType) {
this.aggregateRootType = aggregateRootType;
this.sqlGenerator = new SingleQuerySqlGenerator(context, dialect,
context.getRequiredPersistentEntity(aggregateRootType));
this.aliases = sqlGenerator.getAliasFactory();
}
AggregatePath path() {
return SingleQuerySqlGeneratorUnitTests.this.path(aggregateRootType);
}
AggregatePath path(String pathAsString) {
return SingleQuerySqlGeneratorUnitTests.this.path(aggregateRootType, pathAsString);
}
protected String rnAlias() {
return aliases.getRowNumberAlias(path());
}
protected String rnAlias(String path) {
return aliases.getRowNumberAlias(path(path));
}
protected String rcAlias() {
return aliases.getRowCountAlias(path());
}
protected String rcAlias(String path) {
return aliases.getRowCountAlias(path(path));
}
protected String alias(String path) {
return aliases.getColumnAlias(path(path));
}
protected String backRefAlias(String path) {
return aliases.getBackReferenceAlias(path(path));
}
protected String keyAlias(String path) {
return aliases.getKeyAlias(path(path));
}
}
}

View File

@@ -0,0 +1,246 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.FromItem;
import net.sf.jsqlparser.statement.select.Join;
import net.sf.jsqlparser.statement.select.OrderByElement;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.statement.select.SelectItem;
import net.sf.jsqlparser.statement.select.SpecialSubSelect;
import net.sf.jsqlparser.statement.select.SubSelect;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.assertj.core.api.AbstractAssert;
import org.assertj.core.api.StringAssert;
import org.assertj.core.util.Strings;
import org.junit.jupiter.api.Assertions;
/**
* AssertJ {@link org.assertj.core.api.Assert} for writing assertions about SQL statements.
*
* @author Jens Schauder
*/
class SqlAssert extends AbstractAssert<SqlAssert, PlainSelect> {
private final PlainSelect actual;
public SqlAssert(PlainSelect actual) {
super(actual, SqlAssert.class);
this.actual = actual;
}
static SqlAssert assertThatParsed(String actualSql) {
try {
Statement parsed = CCJSqlParserUtil.parse(actualSql);
return new SqlAssert((PlainSelect) ((Select) parsed).getSelectBody());
} catch (JSQLParserException e) {
Assertions.fail("Couldn't parse '%s'".formatted(actualSql), e);
}
throw new IllegalStateException("This should be unreachable");
}
static LiteralPattern lit(Object value) {
return new LiteralPattern(value);
}
static ColumnPattern col(String columnName) {
return new ColumnPattern(columnName);
}
static AnalyticFunctionPattern rn(ExpressionPattern partitionBy) {
return new AnalyticFunctionPattern("row_number", partitionBy);
}
static AnalyticFunctionPattern count(ExpressionPattern partitionBy) {
return new AnalyticFunctionPattern("count", partitionBy);
}
static FunctionPattern func(String name, ExpressionPattern ... params) {
return new FunctionPattern(name, params);
}
static FunctionPattern func(String name, String ... params) {
return new FunctionPattern(name, Arrays.stream(params).map(p -> col(p)).collect(Collectors.toList()));
}
SqlAssert hasExactlyColumns(String... columns) {
SelectItemPattern[] patterns = new SelectItemPattern[columns.length];
for (int i = 0; i < columns.length; i++) {
patterns[i] = col(columns[i]);
}
return hasExactlyColumns(patterns);
}
SqlAssert hasExactlyColumns(SelectItemPattern... columns) {
List<SelectItem> actualSelectItems = actual.getSelectItems();
List<SelectItemPattern> unmatchedPatterns = new ArrayList<>(Arrays.asList(columns));
List<SelectItem> unmatchedSelectItems = new ArrayList<>();
for (SelectItem selectItem : actualSelectItems) {
SelectItemPattern matchedPattern = null;
for (SelectItemPattern column : unmatchedPatterns) {
if (column.matches(selectItem)) {
matchedPattern = column;
break;
}
}
if (matchedPattern != null) {
unmatchedPatterns.remove(matchedPattern);
} else {
unmatchedSelectItems.add(selectItem);
}
}
if (unmatchedPatterns.isEmpty() && unmatchedSelectItems.isEmpty()) {
return this;
}
String preparedExpectedColumns = prepare(columns);
if (unmatchedPatterns.isEmpty()) {
throw failureWithActualExpected(actual, preparedExpectedColumns, """
Expected
%s
to select the columns
%s
but
%s
were not expected
""", actual, preparedExpectedColumns, unmatchedSelectItems);
}
if (unmatchedSelectItems.isEmpty()) {
throw failureWithActualExpected(actual, preparedExpectedColumns, """
Expected
%s
to select the columns
%s
but
%s
were not present
""", actual, preparedExpectedColumns, unmatchedPatterns);
}
throw failureWithActualExpected(actual, preparedExpectedColumns, """
Expected
%s
to select the columns
%s
but
%s
were not present and
%s
were not expected""", actual, preparedExpectedColumns, unmatchedPatterns, unmatchedSelectItems);
}
public StringAssert extractWhereClause() {
Expression where = actual.getWhere();
return new StringAssert(where == null ? "" : where.toString());
}
public JoinAssert hasJoin() {
List<Join> joins = actual.getJoins();
if (joins == null || joins.size() < 1) {
throw failureWithActualExpected(actual, "select with a join", "Expected %s to contain a join but it doesn't.", actual);
}
return new JoinAssert(joins.get(0));
}
private String prepare(SelectItemPattern[] columns) {
return Arrays.toString(columns);
}
SqlAssert hasInlineViewSelectingFrom(String tableName) {
Optional<PlainSelect> matchingSelect = getSubSelects(actual)
.filter(ps -> (ps.getFromItem()instanceof Table t) && t.getName().equals(tableName)).findFirst();
if (matchingSelect.isEmpty()) {
throw failureWithActualExpected(actual, "Subselect from " + tableName,
"%s is expected to contain a subselect selecting from %s but doesn't", actual, tableName);
}
return new SqlAssert(matchingSelect.get());
}
public SqlAssert hasInlineView() {
Optional<PlainSelect> matchingSelect = getSubSelects(actual).findFirst();
if (matchingSelect.isEmpty()) {
throw failureWithActualExpected(actual, "Subselect",
"%s is expected to contain a subselect", actual);
}
return new SqlAssert(matchingSelect.get());
}
private static Stream<PlainSelect> getSubSelects(PlainSelect select) {
FromItem fromItem = select.getFromItem();
Stream<PlainSelect> fromStream = subSelects(fromItem);
return Stream.of(select).flatMap(s -> {
List<Join> joins = s.getJoins();
if (joins == null) {
return fromStream;
}
Stream<PlainSelect> joinStream = joins.stream() //
.map(j -> j.getRightItem()) //
.flatMap(ss -> subSelects(ss));
return Stream.concat(fromStream, joinStream);
});
}
private static Stream<PlainSelect> subSelects(FromItem fromItem) {
Stream<PlainSelect> fromStream;
if (fromItem instanceof SubSelect ss) {
fromStream = Stream.of((PlainSelect) ss.getSelectBody());
} else if (fromItem instanceof SpecialSubSelect ss) {
fromStream = Stream.of((PlainSelect) ss.getSubSelect().getSelectBody());
} else {
fromStream = Stream.empty();
}
return fromStream;
}
public StringAssert extractOrderBy() {
List<OrderByElement> orderByElements = actual.getOrderByElements();
return new StringAssert(orderByElements == null ? "" : Strings.join(orderByElements).with(", "));
}
}

View File

@@ -0,0 +1,297 @@
/*
* 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.sqlgeneration;
import static org.assertj.core.api.Assertions.*;
import static org.springframework.data.relational.core.sqlgeneration.SqlAssert.*;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
/**
* Tests for SqlAssert.
* @author Jens Schauder
*/
class SqlAssertUnitTests {
@Test // GH-1446
void givesProperNullPointerExceptionWhenSqlIsNull() {
assertThatThrownBy(() -> SqlAssert.assertThatParsed(null)).isInstanceOf(NullPointerException.class);
}
@Nested
class AssertWhereClause {
@Test // GH-1446
void assertWhereClause() {
SqlAssert.assertThatParsed("select x from t where z > y").extractWhereClause().isEqualTo("z > y");
}
@Test // GH-1446
void assertNoWhereClause() {
SqlAssert.assertThatParsed("select x from t").extractWhereClause().isEmpty();
}
}
@Nested
class AssertOrderByClause {
@Test // GH-1446
void assertOrderByClause() {
SqlAssert.assertThatParsed("select x from t order by x, y").extractOrderBy().isEqualTo("x, y");
}
@Test // GH-1446
void assertNoOrderByClause() {
SqlAssert.assertThatParsed("select x from t").extractOrderBy().isEmpty();
}
}
@Nested
class AssertColumns {
@Test // GH-1446
void matchingSimpleColumns() {
SqlAssert.assertThatParsed("select x, y, z from t").hasExactlyColumns("x", "y", "z");
}
@Test // GH-1446
void extraSimpleColumn() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x, y, z, a from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns("x", "y", "z")) //
.hasMessageContaining("x, y, z") //
.hasMessageContaining("x, y, z, a") //
.hasMessageContaining("a");
}
@Test // GH-1446
void missingSimpleColumn() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x, y, z from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns("x", "y", "z", "a")) //
.hasMessageContaining("x, y, z") //
.hasMessageContaining("x, y, z, a") //
.hasMessageContaining("a");
}
@Test // GH-1446
void wrongSimpleColumn() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x, y, z from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns("x", "a", "z")) //
.hasMessageContaining("x, y, z") //
.hasMessageContaining("x, a, z") //
.hasMessageContaining("a") //
.hasMessageContaining("y");
}
@Test // GH-1446
void matchesFullyQualifiedColumn() {
SqlAssert.assertThatParsed("select t.x from t") //
.hasExactlyColumns("x");
}
@Test // GH-1446
void matchesFunction() { //
SqlAssert.assertThatParsed("select someFunc(x) from t")
.hasExactlyColumns(func("someFunc", col("x")));
}
@Test // GH-1446
void matchesFunctionCaseInsensitive() {
SqlAssert.assertThatParsed("select COUNT(x) from t") //
.hasExactlyColumns(func("count", col("x")));
}
@Test // GH-1446
void matchFunctionFailsOnDifferentName() {
SqlAssert sqlAssert = assertThatParsed("select countx(x) from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(func("count", col("x")))) //
.hasMessageContaining("countx(x)") //
.hasMessageContaining("count(x)");
}
@Test // GH-1446
void matchFunctionFailsOnDifferentParameter() {
SqlAssert sqlAssert = assertThatParsed("select count(y) from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(func("count", col("x")))) //
.hasMessageContaining("count(y)") //
.hasMessageContaining("count(x)");
}
@Test // GH-1446
void matchFunctionFailsOnWrongParameterCount() {
SqlAssert sqlAssert = assertThatParsed("select count(x, y) from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(func("count", col("x")))) //
.hasMessageContaining("count(x, y)") //
.hasMessageContaining("count(x)");
}
}
@Nested
class AssertRowNumber {
@Test // GH-1446
void testMatchingRowNumber() {
SqlAssert sqlAssert = assertThatParsed("select row_number() over (partition by x) from t");
sqlAssert.hasExactlyColumns(rn(col("x")));
}
@Test // GH-1446
void testMatchingRowNumberUpperCase() {
SqlAssert sqlAssert = assertThatParsed("select ROW_NUMBER() over (partition by x) from t");
sqlAssert.hasExactlyColumns(rn(col("x")));
}
@Test // GH-1446
void testFailureNoRowNumber() {
SqlAssert sqlAssert = assertThatParsed("select row_number as x from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(rn(col("x")))) //
.hasMessageContaining("row_number AS x") //
.hasMessageContaining("row_number() OVER (PARTITION BY x)");
;
}
@Test // GH-1446
void testFailureWrongPartitionBy() {
SqlAssert sqlAssert = assertThatParsed("select row_number() over (partition by y) from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(rn(col("x")))) //
.hasMessageContaining("row_number() OVER (PARTITION BY y )") //
.hasMessageContaining("row_number() OVER (PARTITION BY x)");
}
}
@Nested
class AssertAliases {
@Test // GH-1446
void simpleColumnMatchesWithAlias() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x as a from t");
sqlAssert.hasExactlyColumns("x");
}
@Test // GH-1446
void matchWithAlias() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x as a from t");
sqlAssert.hasExactlyColumns(col("x").as("a"));
}
@Test // GH-1446
void matchWithWrongAlias() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select x as b from t");
assertThatThrownBy(() -> sqlAssert.hasExactlyColumns(col("x").as("a"))) //
.hasMessageContaining("x as a") //
.hasMessageContaining("x AS b");
}
@Test // GH-1446
void matchesIdenticalColumnsWithDifferentAliases() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select 1 as x, 1 as y from t");
sqlAssert.hasExactlyColumns(lit(1).as("x"), lit(1).as("y"));
}
}
@Nested
class AssertSubSelects {
@Test // GH-1446
void subselectGetsFound() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select a from (select x as a from t) s");
sqlAssert //
.hasInlineViewSelectingFrom("t") //
.hasExactlyColumns(col("x").as("a"));
}
@Test // GH-1446
void subselectWithWrongTableDoesNotGetFound() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select a from (select x as a from u) s");
assertThatThrownBy(() -> sqlAssert //
.hasInlineViewSelectingFrom("t"))
.hasMessageContaining("is expected to contain a subselect selecting from t but doesn't");
}
}
@Nested
class AssertJoins {
@Test // GH-1446
void hasJoin() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select c from t join s on x = y");
sqlAssert.hasJoin();
}
@Test // GH-1446
void hasJoinFailure() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select c from t where x = y");
assertThatThrownBy(() -> sqlAssert //
.hasJoin()).hasMessageContaining("to contain a join but it doesn't");
}
@Test // GH-1446
void on() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select c from t join s on x = y");
sqlAssert.hasJoin().on("x", "y");
}
@Test // GH-1446
void onFailureFirst() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select c from t join s on z = y");
assertThatThrownBy(() -> sqlAssert.hasJoin().on("x", "y"))
.hasMessageContaining("z = y does not match expected x = y");
}
@Test // GH-1446
void onFailureSecond() {
SqlAssert sqlAssert = SqlAssert.assertThatParsed("select c from t join s on x = z");
assertThatThrownBy(() -> sqlAssert.hasJoin().on("x", "y"))
.hasMessageContaining("x = z does not match expected x = y");
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.sqlgeneration;
import net.sf.jsqlparser.expression.Expression;
import net.sf.jsqlparser.statement.select.SelectExpressionItem;
import net.sf.jsqlparser.statement.select.SelectItem;
/**
* A {@link SelectItemPattern} that matches a specific type of expression
*
* @param <T> the type of the expression that is matched by this pattern.
*/
abstract class TypedExpressionPattern<T> implements SelectItemPattern, ExpressionPattern {
private final Class<T> type;
TypedExpressionPattern(Class<T> type) {
this.type = type;
}
@Override
public boolean matches(SelectItem selectItem) {
if (selectItem instanceof SelectExpressionItem sei) {
Expression expression = sei.getExpression();
return matches(expression);
}
return false;
}
@Override
public boolean matches(Expression expression) {
if (type.isInstance(expression)) {
return matches((T) expression);
}
return false;
}
abstract boolean matches(T expression);
}

View File

@@ -471,6 +471,33 @@ This process also applies to inserting new aggregates, where a `null` or `0` ver
During deletes the version check also applies but no version is increased.
[[jdbc.loading-aggregates]]
== Loading Aggregates
Spring Data JDBC offers two ways how it can load aggregates.
The traditional and before version 3.2 the only way is really simple:
Each query loads the aggregate roots, independently if the query is based on a `CrudRepository` method, a derived query or a annotated query.
If the aggregate root references other entities those are loaded with separate statements.
Spring Data JDBC now allows the use of _Single Query Loading_.
With this an arbitrary number of aggregates can be fully loaded with a single SQL query.
This should be significant more efficient, especially for complex aggregates, consisting of many entities.
Currently this feature is very restricted.
1. It only works for aggregates that only reference one entity collection. The plan is to remove this constraint in the future.
2. The aggregate must also not use `AggregateReference` or embedded entities. The plan is to remove this constraint in the future.
3. The database dialect must support it. Of the dialects provided by Spring Data JDBC all but H2 and HSQL support this. H2 and HSQL don't support analytic functions (aka windowing functions).
4. It only works for the find methods in `CrudRepository`, not for derived queries and not for annotated queries. The plan is to remove this constraint in the future.
5. Single Query Loading needs to be enabled in the `JdbcMappingContext`, by calling `setSingleQueryLoadingEnabled(true)`
Note: Single Query Loading is to be considered experimental. We appreciate feedback on how it works for you.
Note:Single Query Loading can be abbreviated as SQL, but we highly discourage that since confusion with Structured Query Language is almost guaranteed.
[[jdbc.query-methods]]
== Query Methods