diff --git a/ci/accept-third-party-license.sh b/ci/accept-third-party-license.sh index c40adfb5e6..bd6b40a2c1 100755 --- a/ci/accept-third-party-license.sh +++ b/ci/accept-third-party-license.sh @@ -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" diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java new file mode 100644 index 0000000000..d34a93dc62 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateReader.java @@ -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 the type of aggregate produced by this reader. + * @since 3.2 + * @author Jens Schauder + */ +class AggregateReader { + + private final RelationalMappingContext mappingContext; + private final RelationalPersistentEntity 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 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 findAll() { + + String sql = sqlGenerator.findAll(); + + PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory); + AggregateResultSetExtractor extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter, + pathToColumn); + + Iterable result = jdbcTemplate.query(sql, extractor); + + Assert.state(result != null, "result is null"); + + return (List) result; + } + + public T findById(Object id) { + + PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory); + AggregateResultSetExtractor extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter, + pathToColumn); + + String sql = sqlGenerator.findById(); + + id = converter.writeValue(id, aggregate.getRequiredIdProperty().getTypeInformation()); + + Iterator 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 findAllById(Iterable ids) { + + PathToColumnMapping pathToColumn = createPathToColumnMapping(aliasFactory); + AggregateResultSetExtractor extractor = new AggregateResultSetExtractor<>(mappingContext, aggregate, converter, + pathToColumn); + + String sql = sqlGenerator.findAllById(); + + List 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 + " AggregateReader createAggregateReaderFor(RelationalPersistentEntity entity) { + return new AggregateReader<>(mappingContext, dialect, converter, jdbcTemplate, entity); + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java new file mode 100644 index 0000000000..4d81feae90 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractor.java @@ -0,0 +1,595 @@ +/* + * 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.lang.Nullable; +import org.springframework.util.Assert; + +/** + * Extracts complete aggregates from a {@link ResultSet}. The {@literal ResultSet} must have a very special structure + * which looks somewhat how one would represent an aggregate in a single excel table. The first row contains data of the + * aggregate root, any single valued reference and the first element of any collection. Following rows do NOT repeat the + * aggregate root data but contain data of second elements of any collections. For details see accompanying unit tests. + * + * @param the type of aggregates to extract + * @since 3.2 + * @author Jens Schauder + */ +class AggregateResultSetExtractor implements org.springframework.jdbc.core.ResultSetExtractor> { + + private final RelationalMappingContext context; + private final RelationalPersistentEntity 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 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 extractData(ResultSet resultSet) throws DataAccessException { + + CachingResultSet crs = new CachingResultSet(resultSet); + + CollectionReader reader = new CollectionReader(crs); + + while (crs.next()) { + reader.read(); + } + + return (Iterable) reader.getResultAndReset(); + } + + /** + * create an instance and populate all its properties + */ + @Nullable + private Object hydrateInstance(EntityInstantiator instantiator, ResultSetParameterValueProvider valueProvider, + RelationalPersistentEntity entity) { + + if (!valueProvider.basePath.isRoot() && // this is a nested ValueProvider + valueProvider.basePath.getRequiredLeafProperty().isEmbedded() && // it's an embedded + !valueProvider.basePath.getRequiredLeafProperty().shouldCreateEmptyEmbedded() && // it's embedded + !valueProvider.hasValue()) { // all values have been null + return null; + } + + Object instance = instantiator.createInstance(entity, valueProvider); + + PersistentPropertyAccessor accessor = new ConvertingPropertyAccessor<>(entity.getPropertyAccessor(instance), + converter.getConversionService()); + + if (entity.requiresPropertyPopulation()) { + + entity.doWithProperties((PropertyHandler) p -> { + + if (!entity.isCreatorArgument(p)) { + accessor.setProperty(p, valueProvider.getValue(p)); + } + }); + } + return instance; + } + + /** + * A {@link Reader} is responsible for reading a single entity or collection of entities from a set of columns + * + * @since 3.2 + * @author Jens Schauder + */ + private interface Reader { + + /** + * read the data needed for creating the result of this {@literal Reader} + */ + void read(); + + /** + * Checks if this {@literal Reader} has all the data needed for a complete result, or if it needs to read further + * rows. + * + * @return the result of the check. + */ + boolean hasResult(); + + /** + * Constructs the result, returns it and resets the state of the reader to read the next instance. + * + * @return an instance of whatever this {@literal Reader} is supposed to read. + */ + @Nullable + Object getResultAndReset(); + } + + /** + * Adapts a {@link Map} to the interface of a {@literal Collection>}. + * + * @since 3.2 + * @author Jens Schauder + */ + private static class MapAdapter extends AbstractCollection> { + + private final Map map = new HashMap<>(); + + @Override + public Iterator> iterator() { + return map.entrySet().iterator(); + } + + @Override + public int size() { + return map.size(); + } + + @Override + public boolean add(Map.Entry entry) { + + map.put(entry.getKey(), entry.getValue()); + return true; + } + } + + /** + * Adapts a {@link List} to the interface of a {@literal Collection>}. + * + * @since 3.2 + * @author Jens Schauder + */ + private static class ListAdapter extends AbstractCollection> { + + private final List list = new ArrayList<>(); + + @Override + public Iterator> iterator() { + throw new UnsupportedOperationException("Do we need this?"); + } + + @Override + public int size() { + return list.size(); + } + + @Override + public boolean add(Map.Entry entry) { + + Integer index = (Integer) entry.getKey(); + while (index >= list.size()) { + list.add(null); + } + list.set(index, entry.getValue()); + return true; + } + } + + /** + * A {@link Reader} for reading entities. + * + * @since 3.2 + * @author Jens Schauder + */ + private class EntityReader implements Reader { + + /** + * Debugging the recursive structure of {@link Reader} instances can become a little mind bending. Giving each + * {@literal Reader} a descriptive name helps with that. + */ + private final String name; + + private final AggregatePath basePath; + private final CachingResultSet crs; + + private final EntityInstantiator instantiator; + @Nullable private final String idColumn; + + private ResultSetParameterValueProvider valueProvider; + private boolean result; + + Object oldId = null; + + private EntityReader(AggregatePath basePath, CachingResultSet crs) { + this(basePath, crs, null); + } + + private EntityReader(AggregatePath basePath, CachingResultSet crs, @Nullable String keyColumn) { + + this.basePath = basePath; + this.crs = crs; + + RelationalPersistentEntity entity = basePath.isRoot() ? rootEntity : basePath.getRequiredLeafEntity(); + instantiator = converter.getEntityInstantiators().getInstantiatorFor(entity); + + idColumn = entity.hasIdProperty() ? propertyToColumn.column(basePath.append(entity.getRequiredIdProperty())) + : keyColumn; + + reset(); + + name = "EntityReader for " + (basePath.isRoot() ? "" : basePath.toDotPath()); + } + + @Override + public void read() { + + if (idColumn != null && oldId == null) { + oldId = crs.getObject(idColumn); + } + + valueProvider.readValues(); + if (idColumn == null) { + result = true; + } else { + Object peekedId = crs.peek(idColumn); + if (peekedId == null || !peekedId.equals(oldId)) { + + result = true; + oldId = peekedId; + } + } + } + + @Override + public boolean hasResult() { + return result; + } + + @Override + @Nullable + public Object getResultAndReset() { + + try { + return hydrateInstance(instantiator, valueProvider, valueProvider.baseEntity); + } finally { + + reset(); + } + } + + private void reset() { + + valueProvider = new ResultSetParameterValueProvider(crs, basePath); + result = false; + } + + @Override + public String toString() { + return name; + } + } + + /** + * A {@link Reader} for reading collections of entities. + * + * @since 3.2 + * @author Jens Schauder + */ + class CollectionReader implements Reader { + + // debugging only + private final String name; + + private final Supplier collectionInitializer; + private final Reader entityReader; + + private Collection result; + + private static Supplier collectionInitializerFor(AggregatePath path) { + + RelationalPersistentProperty property = path.getRequiredLeafProperty(); + if (List.class.isAssignableFrom(property.getType())) { + return ListAdapter::new; + } else if (property.isMap()) { + return MapAdapter::new; + } else { + return HashSet::new; + } + } + + private CollectionReader(AggregatePath basePath, CachingResultSet crs) { + + this.collectionInitializer = collectionInitializerFor(basePath); + + String keyColumn = null; + final RelationalPersistentProperty property = basePath.getRequiredLeafProperty(); + if (property.isMap() || List.class.isAssignableFrom(basePath.getRequiredLeafProperty().getType())) { + keyColumn = propertyToColumn.keyColumn(basePath); + } + + if (property.isQualified()) { + this.entityReader = new EntryReader(basePath, crs, keyColumn, property.getQualifierColumnType()); + } else { + this.entityReader = new EntityReader(basePath, crs, keyColumn); + } + reset(); + name = "Reader for " + basePath.toDotPath(); + } + + private CollectionReader(CachingResultSet crs) { + + this.collectionInitializer = ArrayList::new; + this.entityReader = new EntityReader(context.getAggregatePath(rootEntity), crs); + reset(); + + name = "Collectionreader for "; + + } + + @Override + public void read() { + + entityReader.read(); + if (entityReader.hasResult()) { + result.add(entityReader.getResultAndReset()); + } + } + + @Override + public boolean hasResult() { + return false; + } + + @Override + public Object getResultAndReset() { + + try { + if (result instanceof MapAdapter) { + return ((MapAdapter) result).map; + } + if (result instanceof ListAdapter) { + return ((ListAdapter) result).list; + } + return result; + } finally { + reset(); + } + } + + private void reset() { + result = collectionInitializer.get(); + } + + @Override + public String toString() { + return name; + } + } + + /** + * A {@link Reader} for reading collection entries. Most of the work is done by an {@link EntityReader}, but a + * additional key column might get read. The result is + * + * @since 3.2 + * @author Jens Schauder + */ + private class EntryReader implements Reader { + + final EntityReader delegate; + final String keyColumn; + private final TypeInformation keyColumnType; + + Object key; + + EntryReader(AggregatePath basePath, CachingResultSet crs, String keyColumn, Class keyColumnType) { + + this.keyColumnType = TypeInformation.of(keyColumnType); + this.delegate = new EntityReader(basePath, crs, keyColumn); + this.keyColumn = keyColumn; + } + + @Override + public void read() { + + if (key == null) { + Object unconvertedKeyObject = delegate.crs.getObject(keyColumn); + key = converter.readValue(unconvertedKeyObject, keyColumnType); + } + delegate.read(); + } + + @Override + public boolean hasResult() { + return delegate.hasResult(); + } + + @Override + public Object getResultAndReset() { + + try { + return new AbstractMap.SimpleEntry<>(key, delegate.getResultAndReset()); + } finally { + key = null; + } + } + } + + /** + * A {@link ParameterValueProvider} that provided the values for an entity from a continues set of rows in a {@link ResultSet}. These might be referenced entities or collections of such entities. {@link ResultSet}. + * + * @since 3.2 + * @author Jens Schauder + */ + private class ResultSetParameterValueProvider implements ParameterValueProvider { + + private final CachingResultSet rs; + /** + * The path which is used to determine columnNames + */ + private final AggregatePath basePath; + private final RelationalPersistentEntity baseEntity; + + /** + * Holds all the values for the entity, either directly or in the form of an appropriate {@link Reader}. + */ + private final Map aggregatedValues = new HashMap<>(); + + ResultSetParameterValueProvider(CachingResultSet rs, AggregatePath basePath) { + + this.rs = rs; + this.basePath = basePath; + this.baseEntity = basePath.isRoot() ? rootEntity + : context.getRequiredPersistentEntity(basePath.getRequiredLeafProperty().getActualType()); + } + + @SuppressWarnings("unchecked") + @Override + @Nullable + public S getParameterValue(Parameter parameter) { + + return (S) getValue(baseEntity.getRequiredPersistentProperty(parameter.getName())); + } + + @Nullable + private Object getValue(RelationalPersistentProperty property) { + + Object value = aggregatedValues.get(property); + + if (value instanceof Reader) { + return ((Reader) value).getResultAndReset(); + } + + value = converter.readValue(value, property.getTypeInformation()); + + return value; + } + + /** + * read values for all collection like properties and aggregate them in a collection. + */ + void readValues() { + baseEntity.forEach(this::readValue); + } + + private void readValue(RelationalPersistentProperty p) { + + if (p.isEntity()) { + + Reader reader = null; + + if (p.isCollectionLike() || p.isMap()) { // even when there are no values we still want a (empty) collection. + + reader = (Reader) aggregatedValues.computeIfAbsent(p, pp -> new CollectionReader(basePath.append(pp), rs)); + } + if (getIndicatorOf(p) != null) { + + if (!(p.isCollectionLike() || p.isMap())) { // for single entities we want a null entity instead of on filled + // with null values. + + reader = (Reader) aggregatedValues.computeIfAbsent(p, pp -> new EntityReader(basePath.append(pp), rs)); + } + + Assert.state(reader != null, "reader must not be null"); + + reader.read(); + } + } else { + aggregatedValues.computeIfAbsent(p, this::getObject); + } + } + + @Nullable + private Object getIndicatorOf(RelationalPersistentProperty p) { + if (p.isMap() || List.class.isAssignableFrom(p.getType())) { + return rs.getObject(getKeyName(p)); + } + + if (p.isEmbedded()) { + return true; + } + + return rs.getObject(getColumnName(p)); + } + + /** + * Obtain a single columnValue from the resultset without throwing an exception. If the column does not exist a null + * value is returned. Does not instantiate complex objects. + * + * @param property + * @return + */ + @Nullable + private Object getObject(RelationalPersistentProperty property) { + return rs.getObject(getColumnName(property)); + } + + /** + * converts a property into a column name representing that property. + * + * @param property + * @return + */ + private String getColumnName(RelationalPersistentProperty property) { + + return propertyToColumn.column(basePath.append(property)); + } + + private String getKeyName(RelationalPersistentProperty property) { + + return propertyToColumn.keyColumn(basePath.append(property)); + } + + private boolean hasValue() { + + for (Object value : aggregatedValues.values()) { + if (value != null) { + return true; + } + } + return false; + } + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java index 31ab36f2f2..dc37808ffc 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/BasicJdbcConverter.java @@ -394,6 +394,7 @@ private ReadingContext(RelationalPersistentEntity entity, AggregatePath rootP } private ReadingContext extendBy(RelationalPersistentProperty property) { + return new ReadingContext<>( (RelationalPersistentEntity) getMappingContext().getRequiredPersistentEntity(property.getActualType()), rootPath.append(property), path.append(property), identifier, key, diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CachingResultSet.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CachingResultSet.java new file mode 100644 index 0000000000..5b92e21c85 --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/CachingResultSet.java @@ -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 values = new HashMap<>(); + + void add(String columnName, Object value) { + values.put(columnName, value); + } + } +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java index 47f7898279..2bb074d862 100644 --- a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/DefaultDataAccessStrategy.java @@ -69,6 +69,7 @@ public class DefaultDataAccessStrategy implements DataAccessStrategy { private final NamedParameterJdbcOperations operations; private final SqlParametersFactory sqlParametersFactory; private final InsertStrategyFactory insertStrategyFactory; + private final FindingDataAccessStrategy singleSelectDelegate; /** * Creates a {@link DefaultDataAccessStrategy} @@ -96,6 +97,7 @@ public DefaultDataAccessStrategy(SqlGeneratorSource sqlGeneratorSource, Relation this.operations = operations; this.sqlParametersFactory = sqlParametersFactory; this.insertStrategyFactory = insertStrategyFactory; + this.singleSelectDelegate = new SingleQueryDataAccessStrategy(context, sqlGeneratorSource.getDialect(), converter, operations); } @Override @@ -260,6 +262,10 @@ public long count(Class domainType) { @Override public T findById(Object id, Class 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 +278,11 @@ public T findById(Object id, Class domainType) { @Override public Iterable findAll(Class domainType) { + + if (isSingleSelectQuerySupported(domainType)){ + return singleSelectDelegate.findAll(domainType); + } + return operations.query(sql(domainType).getFindAll(), getEntityRowMapper(domainType)); } @@ -282,10 +293,12 @@ public Iterable findAllById(Iterable ids, Class domainType) { return Collections.emptyList(); } - SqlParameterSource parameterSource = sqlParametersFactory.forQueryByIds(ids, domainType); + 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 +443,40 @@ private Class getBaseType(PersistentPropertyPath entityType) { + + return context.isSingleQueryLoadingEnabled() && sqlGeneratorSource.getDialect().supportsSingleQueryLoading()// + && entityQualifiesForSingleSelectQuery(entityType); + } + + private boolean entityQualifiesForSingleSelectQuery(Class entityType) { + + boolean referenceFound = false; + for (PersistentPropertyPath 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; + + } + } diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/FindingDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/FindingDataAccessStrategy.java new file mode 100644 index 0000000000..1c1bcc53fe --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/FindingDataAccessStrategy.java @@ -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 FindingDataAccessStrategy { + /** + * 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 the type of the entity. + * @return Might return {@code null}. + */ + @Nullable + T findById(Object id, Class domainType); + + /** + * Loads all entities of the given type. + * + * @param domainType the type of entities to load. Must not be {@code null}. + * @param the type of entities to load. + * @return Guaranteed to be not {@code null}. + */ + Iterable findAll(Class 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 type of entities to load. + * @return the loaded entities. Guaranteed to be not {@code null}. + */ + Iterable findAllById(Iterable ids, Class domainType); + + /** + * Loads all entities of the given type, sorted. + * + * @param domainType the type of entities to load. Must not be {@code null}. + * @param 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 + */ + Iterable findAll(Class 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 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 + */ + Iterable findAll(Class 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 + */ + Optional findOne(Query query, Class 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 + */ + Iterable findAll(Query query, Class 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 + */ + Iterable findAll(Query query, Class domainType, Pageable pageable); +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/PathToColumnMapping.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/PathToColumnMapping.java new file mode 100644 index 0000000000..b2b2f4355f --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/PathToColumnMapping.java @@ -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); +} diff --git a/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SingleQueryDataAccessStrategy.java b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SingleQueryDataAccessStrategy.java new file mode 100644 index 0000000000..37a8b1da3d --- /dev/null +++ b/spring-data-jdbc/src/main/java/org/springframework/data/jdbc/core/convert/SingleQueryDataAccessStrategy.java @@ -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 FindingDataAccessStrategy} that uses an {@link AggregateReader} to load entities with a single query. + * + * @since 3.2 + * @author Jens Schauder + */ +public class SingleQueryDataAccessStrategy implements FindingDataAccessStrategy { + 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 findById(Object id, Class domainType) { + return getReader(domainType).findById(id); + } + + @Override + public Iterable findAll(Class domainType) { + return getReader(domainType).findAll(); + } + + @Override + public Iterable findAllById(Iterable ids, Class domainType) { + return getReader(domainType).findAllById(ids); + } + + @Override + public Iterable findAll(Class domainType, Sort sort) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable findAll(Class domainType, Pageable pageable) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findOne(Query query, Class domainType) { + return Optional.empty(); + } + + @Override + public Iterable findAll(Query query, Class domainType) { + throw new UnsupportedOperationException(); + } + + @Override + public Iterable findAll(Query query, Class domainType, Pageable pageable) { + throw new UnsupportedOperationException(); + } + + private AggregateReader getReader(Class domainType) { + + RelationalPersistentEntity persistentEntity = (RelationalPersistentEntity) mappingContext + .getRequiredPersistentEntity(domainType); + return readerFactory.createAggregateReaderFor(persistentEntity); + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java similarity index 96% rename from spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java rename to spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java index 0ad8dd997b..39e9fabeca 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/JdbcAggregateTemplateIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/AbstractJdbcAggregateTemplateIntegrationTests.java @@ -36,6 +36,7 @@ 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 @@ @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: *
    @@ -193,6 +202,42 @@ private static LegoSet createLegoSet(String name) { 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 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 @@ JdbcAggregateOperations operations(ApplicationEventPublisher publisher, Relation 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; + } + } } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java new file mode 100644 index 0000000000..f20ac36b4a --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/AggregateResultSetExtractorUnitTests.java @@ -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 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 extractor = getExtractor(DummyRecord.class); + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name")), // + new BigDecimal(1), "Alfred"); + assertThat(extractor.extractData(resultSet)).extracting(e -> e.id1, e -> e.name) + .containsExactly(tuple(1L, "Alfred")); + } + + @Test // GH-1446 + void appliesConversionToKeyValue() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet( + asList(column("id1"), column("dummyList", KEY), column("dummyList.dummyName")), // + 1, new BigDecimal(0), "Dummy Alfred", // + 1, new BigDecimal(1), "Dummy Berta", // + 1, new BigDecimal(2), "Dummy Carl"); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // + .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + } + } + + @NotNull + private AggregateResultSetExtractor getExtractor(Class type) { + return (AggregateResultSetExtractor) new AggregateResultSetExtractor<>(context, + (RelationalPersistentEntity) 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 result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + assertThat(result.iterator().next().dummies).isEmpty(); + } + + @Test // GH-1446 + void extractSingleSetReference() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet( + asList(column("id1"), column("dummies"), column("dummies.dummyName")), // + 1, 1, "Dummy Alfred", // + 1, 1, "Dummy Berta", // + 1, 1, "Dummy Carl"); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + } + + @Test // GH-1446 + void extractSetReferenceAndSimpleProperty() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet( + asList(column("id1"), column("name"), column("dummies"), column("dummies.dummyName")), // + 1, "Simplicissimus", 1, "Dummy Alfred", // + 1, null, 1, "Dummy Berta", // + 1, null, 1, "Dummy Carl"); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); + assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + } + + @Test // GH-1446 + void extractMultipleSetReference() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // + column("dummies"), column("dummies.dummyName"), // + column("otherDummies"), column("otherDummies.dummyName")), // + 1, 1, "Dummy Alfred", 1, "Other Ephraim", // + 1, 1, "Dummy Berta", 1, "Other Zeno", // + 1, 1, "Dummy Carl", null, null); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + assertThat(result.iterator().next().dummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); + } + + @Test // GH-1446 + void extractNestedSetsWithId() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // + column("intermediates"), column("intermediates.iId"), column("intermediates.intermediateName"), // + column("intermediates.dummies"), column("intermediates.dummies.dummyName")), // + 1, "Alfred", 1, 23, "Inami", 23, "Dustin", // + 1, null, 1, 23, null, 23, "Dora", // + 1, null, 1, 24, "Ina", 24, "Dotty", // + 1, null, 1, 25, "Ion", null, null, // + 2, "Bon Jovi", 2, 26, "Judith", 26, "Ephraim", // + 2, null, 2, 26, null, 26, "Erin", // + 2, null, 2, 27, "Joel", 27, "Erika", // + 2, null, 2, 28, "Justin", null, null // + ); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediates.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediates.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + final Iterator iter = result.iterator(); + SimpleEntity alfred = iter.next(); + assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); + assertThat(alfred.intermediates).extracting(d -> d.intermediateName).containsExactlyInAnyOrder("Inami", "Ina", + "Ion"); + + assertThat(alfred.findInIntermediates("Inami").dummies).extracting(d -> d.dummyName) + .containsExactlyInAnyOrder("Dustin", "Dora"); + assertThat(alfred.findInIntermediates("Ina").dummies).extracting(d -> d.dummyName) + .containsExactlyInAnyOrder("Dotty"); + assertThat(alfred.findInIntermediates("Ion").dummies).isEmpty(); + + SimpleEntity bonJovy = iter.next(); + assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); + assertThat(bonJovy.intermediates).extracting(d -> d.intermediateName).containsExactlyInAnyOrder("Judith", "Joel", + "Justin"); + assertThat(bonJovy.findInIntermediates("Judith").dummies).extracting(d -> d.dummyName) + .containsExactlyInAnyOrder("Ephraim", "Erin"); + assertThat(bonJovy.findInIntermediates("Joel").dummies).extracting(d -> d.dummyName).containsExactly("Erika"); + assertThat(bonJovy.findInIntermediates("Justin").dummyList).isEmpty(); + + } + } + + @Nested + class Lists { + + @Test // GH-1446 + void extractSingleListReference() { + + 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 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 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 result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); + assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // + .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + } + + @Test // GH-1446 + void extractMultipleCollectionReference() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // + column("dummyList", KEY), column("dummyList.dummyName"), // + column("otherDummies"), column("otherDummies.dummyName")), // + 1, 0, "Dummy Alfred", 1, "Other Ephraim", // + 1, 1, "Dummy Berta", 1, "Other Zeno", // + 1, 2, "Dummy Carl", null, null); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + assertThat(result.iterator().next().dummyList).extracting(d -> d.dummyName) // + .containsExactly("Dummy Alfred", "Dummy Berta", "Dummy Carl"); + assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); + } + + @Test // GH-1446 + void extractNestedListsWithId() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // + column("intermediateList", KEY), column("intermediateList.iId"), column("intermediateList.intermediateName"), // + column("intermediateList.dummyList", KEY), column("intermediateList.dummyList.dummyName")), // + 1, "Alfred", 0, 23, "Inami", 0, "Dustin", // + 1, null, 0, 23, null, 1, "Dora", // + 1, null, 1, 24, "Ina", 0, "Dotty", // + 1, null, 2, 25, "Ion", null, null, // + 2, "Bon Jovi", 0, 26, "Judith", 0, "Ephraim", // + 2, null, 0, 26, null, 1, "Erin", // + 2, null, 1, 27, "Joel", 0, "Erika", // + 2, null, 2, 28, "Justin", null, null // + ); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateList.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + final Iterator iter = result.iterator(); + SimpleEntity alfred = iter.next(); + assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); + assertThat(alfred.intermediateList).extracting(d -> d.intermediateName).containsExactly("Inami", "Ina", "Ion"); + + assertThat(alfred.findInIntermediateList("Inami").dummyList).extracting(d -> d.dummyName) + .containsExactly("Dustin", "Dora"); + assertThat(alfred.findInIntermediateList("Ina").dummyList).extracting(d -> d.dummyName).containsExactly("Dotty"); + assertThat(alfred.findInIntermediateList("Ion").dummyList).isEmpty(); + + SimpleEntity bonJovy = iter.next(); + assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); + assertThat(bonJovy.intermediateList).extracting(d -> d.intermediateName).containsExactly("Judith", "Joel", + "Justin"); + assertThat(bonJovy.findInIntermediateList("Judith").dummyList).extracting(d -> d.dummyName) + .containsExactly("Ephraim", "Erin"); + assertThat(bonJovy.findInIntermediateList("Joel").dummyList).extracting(d -> d.dummyName) + .containsExactly("Erika"); + assertThat(bonJovy.findInIntermediateList("Justin").dummyList).isEmpty(); + + } + + @Test // GH-1446 + void extractNestedListsWithOutId() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // + column("intermediateListNoId", KEY), column("intermediateListNoId.intermediateName"), // + column("intermediateListNoId.dummyList", KEY), column("intermediateListNoId.dummyList.dummyName")), // + 1, "Alfred", 0, "Inami", 0, "Dustin", // + 1, null, 0, null, 1, "Dora", // + 1, null, 1, "Ina", 0, "Dotty", // + 1, null, 2, "Ion", null, null, // + 2, "Bon Jovi", 0, "Judith", 0, "Ephraim", // + 2, null, 0, null, 1, "Erin", // + 2, null, 1, "Joel", 0, "Erika", // + 2, null, 2, "Justin", null, null // + ); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateListNoId.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + final Iterator iter = result.iterator(); + SimpleEntity alfred = iter.next(); + assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); + assertThat(alfred.intermediateListNoId).extracting(d -> d.intermediateName).containsExactly("Inami", "Ina", + "Ion"); + + assertThat(alfred.findInIntermediateListNoId("Inami").dummyList).extracting(d -> d.dummyName) + .containsExactly("Dustin", "Dora"); + assertThat(alfred.findInIntermediateListNoId("Ina").dummyList).extracting(d -> d.dummyName) + .containsExactly("Dotty"); + assertThat(alfred.findInIntermediateListNoId("Ion").dummyList).isEmpty(); + + SimpleEntity bonJovy = iter.next(); + assertThat(bonJovy).extracting("id1", "name").containsExactly(2L, "Bon Jovi"); + assertThat(bonJovy.intermediateListNoId).extracting(d -> d.intermediateName).containsExactly("Judith", "Joel", + "Justin"); + + assertThat(bonJovy.findInIntermediateListNoId("Judith").dummyList).extracting(d -> d.dummyName) + .containsExactly("Ephraim", "Erin"); + assertThat(bonJovy.findInIntermediateListNoId("Joel").dummyList).extracting(d -> d.dummyName) + .containsExactly("Erika"); + assertThat(bonJovy.findInIntermediateListNoId("Justin").dummyList).isEmpty(); + + } + + } + + @Nested + class Maps { + + @Test // GH-1446 + void extractSingleMapReference() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet( + asList(column("id1"), column("dummyMap", KEY), column("dummyMap.dummyName")), // + 1, "alpha", "Dummy Alfred", // + 1, "beta", "Dummy Berta", // + 1, "gamma", "Dummy Carl"); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + Map dummyMap = result.iterator().next().dummyMap; + assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); + assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); + assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); + } + + @Test // GH-1446 + void extractMapReferenceAndSimpleProperty() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet( + asList(column("id1"), column("name"), column("dummyMap", KEY), column("dummyMap.dummyName")), // + 1, "Simplicissimus", "alpha", "Dummy Alfred", // + 1, null, "beta", "Dummy Berta", // + 1, null, "gamma", "Dummy Carl"); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name).containsExactly(tuple(1L, "Simplicissimus")); + Map dummyMap = result.iterator().next().dummyMap; + assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); + assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); + assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); + } + + @Test // GH-1446 + void extractMultipleCollectionReference() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), // + column("dummyMap", KEY), column("dummyMap.dummyName"), // + column("otherDummies"), column("otherDummies.dummyName")), // + 1, "alpha", "Dummy Alfred", 1, "Other Ephraim", // + 1, "beta", "Dummy Berta", 1, "Other Zeno", // + 1, "gamma", "Dummy Carl", null, null); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1).containsExactly(1L); + Map dummyMap = result.iterator().next().dummyMap; + assertThat(dummyMap).extracting("alpha").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Alfred"); + assertThat(dummyMap).extracting("beta").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Berta"); + assertThat(dummyMap).extracting("gamma").extracting(d -> ((DummyEntity) d).dummyName).isEqualTo("Dummy Carl"); + + assertThat(result.iterator().next().otherDummies).extracting(d -> d.dummyName) // + .containsExactlyInAnyOrder("Other Ephraim", "Other Zeno"); + } + + @Test // GH-1446 + void extractNestedMapsWithId() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // + column("intermediateMap", KEY), column("intermediateMap.iId"), column("intermediateMap.intermediateName"), // + column("intermediateMap.dummyMap", KEY), column("intermediateMap.dummyMap.dummyName")), // + 1, "Alfred", "alpha", 23, "Inami", "omega", "Dustin", // + 1, null, "alpha", 23, null, "zeta", "Dora", // + 1, null, "beta", 24, "Ina", "eta", "Dotty", // + 1, null, "gamma", 25, "Ion", null, null, // + 2, "Bon Jovi", "phi", 26, "Judith", "theta", "Ephraim", // + 2, null, "phi", 26, null, "jota", "Erin", // + 2, null, "chi", 27, "Joel", "sigma", "Erika", // + 2, null, "psi", 28, "Justin", null, null // + ); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateMap.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + final Iterator iter = result.iterator(); + SimpleEntity alfred = iter.next(); + assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); + + assertThat(alfred.intermediateMap.get("alpha").dummyMap.get("omega").dummyName).isEqualTo("Dustin"); + assertThat(alfred.intermediateMap.get("alpha").dummyMap.get("zeta").dummyName).isEqualTo("Dora"); + assertThat(alfred.intermediateMap.get("beta").dummyMap.get("eta").dummyName).isEqualTo("Dotty"); + assertThat(alfred.intermediateMap.get("gamma").dummyMap).isEmpty(); + + SimpleEntity bonJovy = iter.next(); + + assertThat(bonJovy.intermediateMap.get("phi").dummyMap.get("theta").dummyName).isEqualTo("Ephraim"); + assertThat(bonJovy.intermediateMap.get("phi").dummyMap.get("jota").dummyName).isEqualTo("Erin"); + assertThat(bonJovy.intermediateMap.get("chi").dummyMap.get("sigma").dummyName).isEqualTo("Erika"); + assertThat(bonJovy.intermediateMap.get("psi").dummyMap).isEmpty(); + } + + @Test // GH-1446 + void extractNestedMapsWithOutId() { + + ResultSet resultSet = ResultSetTestUtil.mockResultSet(asList(column("id1"), column("name"), // + column("intermediateMapNoId", KEY), column("intermediateMapNoId.intermediateName"), // + column("intermediateMapNoId.dummyMap", KEY), column("intermediateMapNoId.dummyMap.dummyName")), // + 1, "Alfred", "alpha", "Inami", "omega", "Dustin", // + 1, null, "alpha", null, "zeta", "Dora", // + 1, null, "beta", "Ina", "eta", "Dotty", // + 1, null, "gamma", "Ion", null, null, // + 2, "Bon Jovi", "phi", "Judith", "theta", "Ephraim", // + 2, null, "phi", null, "jota", "Erin", // + 2, null, "chi", "Joel", "sigma", "Erika", // + 2, null, "psi", "Justin", null, null // + ); + + Iterable result = extractor.extractData(resultSet); + + assertThat(result).extracting(e -> e.id1, e -> e.name, e -> e.intermediateMapNoId.size()) + .containsExactlyInAnyOrder(tuple(1L, "Alfred", 3), tuple(2L, "Bon Jovi", 3)); + + final Iterator iter = result.iterator(); + SimpleEntity alfred = iter.next(); + assertThat(alfred).extracting("id1", "name").containsExactly(1L, "Alfred"); + + assertThat(alfred.intermediateMapNoId.get("alpha").dummyMap.get("omega").dummyName).isEqualTo("Dustin"); + assertThat(alfred.intermediateMapNoId.get("alpha").dummyMap.get("zeta").dummyName).isEqualTo("Dora"); + assertThat(alfred.intermediateMapNoId.get("beta").dummyMap.get("eta").dummyName).isEqualTo("Dotty"); + assertThat(alfred.intermediateMapNoId.get("gamma").dummyMap).isEmpty(); + + SimpleEntity bonJovy = iter.next(); + + assertThat(bonJovy.intermediateMapNoId.get("phi").dummyMap.get("theta").dummyName).isEqualTo("Ephraim"); + assertThat(bonJovy.intermediateMapNoId.get("phi").dummyMap.get("jota").dummyName).isEqualTo("Erin"); + assertThat(bonJovy.intermediateMapNoId.get("chi").dummyMap.get("sigma").dummyName).isEqualTo("Erika"); + assertThat(bonJovy.intermediateMapNoId.get("psi").dummyMap).isEmpty(); + } + + } + + private String column(String path) { + return column(path, NORMAL); + } + + private String column(String path, ColumnType columnType) { + + PersistentPropertyPath 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 intermediates; + + Set dummies; + Set otherDummies; + + List dummyList; + List intermediateList; + List intermediateListNoId; + + Map dummyMap; + Map intermediateMap; + Map intermediateMapNoId; + + Intermediate findInIntermediates(String name) { + for (Intermediate intermediate : intermediates) { + if (intermediate.intermediateName.equals(name)) { + return intermediate; + } + } + fail("No intermediate with name " + name + " found in intermediates."); + return null; + } + + Intermediate findInIntermediateList(String name) { + for (Intermediate intermediate : intermediateList) { + if (intermediate.intermediateName.equals(name)) { + return intermediate; + } + } + fail("No intermediate with name " + name + " found in intermediateList."); + return null; + } + + IntermediateNoId findInIntermediateListNoId(String name) { + for (IntermediateNoId intermediate : intermediateListNoId) { + if (intermediate.intermediateName.equals(name)) { + return intermediate; + } + } + fail("No intermediates with name " + name + " found in intermediateListNoId."); + return null; + } + } + + private static class Intermediate { + + @Id long iId; + String intermediateName; + + Set dummies; + List dummyList; + Map dummyMap; + } + + private static class IntermediateNoId { + + String intermediateName; + + Set dummies; + List dummyList; + Map dummyMap; + } + + private static class DummyEntity { + String dummyName; + Long longValue; + } + + private record DummyRecord(Long id1, String name) { + } +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java new file mode 100644 index 0000000000..206a6f5949 --- /dev/null +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/core/convert/ResultSetTestUtil.java @@ -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 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> result = convertValues(columns, values); + + return mock(ResultSet.class, new ResultSetAnswer(columns, result)); + } + + + private static List> convertValues(List columns, Object[] values) { + + List> result = new ArrayList<>(); + + int index = 0; + while (index < values.length) { + + Map row = new LinkedCaseInsensitiveMap<>(); + result.add(row); + for (String column : columns) { + + row.put(column, values[index]); + index++; + } + } + return result; + } + + private static class ResultSetAnswer implements Answer { + + private final List names; + private final List> values; + private int index = -1; + + ResultSetAnswer(List names, List> 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> 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 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 unwrap(Class aClass) { + return null; + } + + @Override + public boolean isWrapperFor(Class aClass) { + return false; + } + } + } + +} diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryCustomConversionIntegrationTests.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryCustomConversionIntegrationTests.java index d541444567..72803e21e1 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryCustomConversionIntegrationTests.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/repository/JdbcRepositoryCustomConversionIntegrationTests.java @@ -160,8 +160,7 @@ void queryByEnumTypeIn() { 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 @@ void queryByEnumTypeEqual() { 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); } diff --git a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/MsSqlDataSourceConfiguration.java b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/MsSqlDataSourceConfiguration.java index 2694d01bbe..b70ce07e5b 100644 --- a/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/MsSqlDataSourceConfiguration.java +++ b/spring-data-jdbc/src/test/java/org/springframework/data/jdbc/testing/MsSqlDataSourceConfiguration.java @@ -19,13 +19,11 @@ 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. *

    @@ -36,14 +34,14 @@ * @see */ @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 @@ protected DataSource createDataSource() { 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) { diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesBrokenTransactionManagerRefIntegrationTests-oracle.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesBrokenTransactionManagerRefIntegrationTests-oracle.sql index 24f9f77597..cc28f6be46 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesBrokenTransactionManagerRefIntegrationTests-oracle.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesBrokenTransactionManagerRefIntegrationTests-oracle.sql @@ -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); \ No newline at end of file diff --git a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesIntegrationTests-oracle.sql b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesIntegrationTests-oracle.sql index 24f9f77597..cc28f6be46 100644 --- a/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesIntegrationTests-oracle.sql +++ b/spring-data-jdbc/src/test/resources/org.springframework.data.jdbc.repository.config/EnableJdbcRepositoriesIntegrationTests-oracle.sql @@ -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); \ No newline at end of file diff --git a/spring-data-relational/pom.xml b/spring-data-relational/pom.xml index bc68c9b7fd..83b4434799 100644 --- a/spring-data-relational/pom.xml +++ b/spring-data-relational/pom.xml @@ -97,6 +97,12 @@ test + + com.github.jsqlparser + jsqlparser + 4.6 + + diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java index e059de59bb..0b853c181c 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/Dialect.java @@ -116,9 +116,8 @@ default Set> simpleTypes() { } /** - * @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 @@ default OrderByNullPrecedence orderByNullHandling() { } /** - * 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; + }; } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java index 32c006188f..eb100d5246 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/H2Dialect.java @@ -108,4 +108,9 @@ public Set> simpleTypes() { return Collections.emptySet(); } + + @Override + public boolean supportsSingleQueryLoading() { + return false; + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java index ed2e699061..cb0fb9249f 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/HsqlDbDialect.java @@ -37,6 +37,11 @@ public LockClause lock() { return AnsiDialect.LOCK_CLAUSE; } + @Override + public boolean supportsSingleQueryLoading() { + return false; + } + private static final LimitClause LIMIT_CLAUSE = new LimitClause() { @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java index 897357bcb3..101fa1b816 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MariaDbDialect.java @@ -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 MariaDbDialect(IdentifierProcessing identifierProcessing) { @Override public Collection getConverters() { - return Collections.singletonList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE); + return Arrays.asList( + TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, + NumberToBooleanConverter.INSTANCE); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java index fef8cd1318..1a1745428b 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/MySqlDialect.java @@ -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 IdentifierProcessing getIdentifierProcessing() { @Override public Collection getConverters() { - return Collections.singletonList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE); + return Arrays.asList( + TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, + NumberToBooleanConverter.INSTANCE + ); } @Override diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/NumberToBooleanConverter.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/NumberToBooleanConverter.java new file mode 100644 index 0000000000..3662948cf3 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/NumberToBooleanConverter.java @@ -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 { + INSTANCE; + + @Override + public Boolean convert(Number number) { + return number.intValue() != 0; + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java index cb47d04f6e..86af12137a 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/dialect/OracleDialect.java @@ -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 Collection getConverters() { return asList(TimestampAtUtcToOffsetDateTimeConverter.INSTANCE, NumberToBooleanConverter.INSTANCE, BooleanToIntegerConverter.INSTANCE); } - @ReadingConverter - enum NumberToBooleanConverter implements Converter { - INSTANCE; - - @Override - public Boolean convert(Number number) { - return number.intValue() != 0; - } - } @WritingConverter enum BooleanToIntegerConverter implements Converter { INSTANCE; diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java index 450761d73f..b462a299e1 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/AggregatePathTraversal.java @@ -21,7 +21,7 @@ /** * @author Mark Paluch */ -class AggregatePathTraversal { +public class AggregatePathTraversal { public static AggregatePath getIdDefiningPath(AggregatePath aggregatePath) { diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java index 6d102c7acf..e237e56eeb 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/mapping/RelationalMappingContext.java @@ -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 @@ protected RelationalPersistentProperty createPersistentProperty(Property propert 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()); diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AliasedExpression.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AliasedExpression.java index 3971e0f4bb..0f596abd7e 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AliasedExpression.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/AliasedExpression.java @@ -21,7 +21,7 @@ * @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 @@ public SqlIdentifier getAlias() { @Override public String toString() { - return expression + " AS " + alias; + return expression.toString(); } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java index cc6dc2f47d..e06da61327 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/DefaultSelectBuilder.java @@ -136,9 +136,9 @@ public DefaultSelectBuilder orderBy(Collection orderByFi } @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 @@ public SelectOrdered orderBy(Collection orderByFields) { } @Override - public SelectOrdered orderBy(Column... columns) { + public SelectOrdered orderBy(Expression... columns) { selectBuilder.join(finishJoin()); return selectBuilder.orderBy(columns); } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Functions.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Functions.java index 665c1e3983..158e5415f7 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Functions.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/Functions.java @@ -19,6 +19,7 @@ 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 static SimpleFunction count(Expression... columns) { } 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 list) { + return SimpleFunction.create("GREATEST", list); + } + /** * Creates a new {@code COUNT} function. * @@ -96,4 +113,8 @@ public static SimpleFunction lower(Expression expression) { // Utility constructor. private Functions() {} + + public static SimpleFunction coalesce(Expression... expressions) { + return SimpleFunction.create("COALESCE", Arrays.asList(expressions)); + } } diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java index 1c6d256054..140eb7ad14 100644 --- a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sql/SelectBuilder.java @@ -238,7 +238,7 @@ interface SelectFromAndOrderBy extends SelectFrom, SelectOrdered, SelectLimitOff SelectFromAndOrderBy from(Collection tables); @Override - SelectFromAndOrderBy orderBy(Column... columns); + SelectFromAndOrderBy orderBy(Expression... columns); @Override SelectFromAndOrderBy orderBy(OrderByField... orderByFields); @@ -393,7 +393,7 @@ interface SelectOrdered extends SelectLock, BuildSelect { * @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}. diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/AliasFactory.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/AliasFactory.java new file mode 100644 index 0000000000..e428cbd19b --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/AliasFactory.java @@ -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 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(); + } + +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/CachingSqlGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/CachingSqlGenerator.java new file mode 100644 index 0000000000..f8e4604325 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/CachingSqlGenerator.java @@ -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 findAll; + private final Lazy findById; + private final Lazy 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(); + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java new file mode 100644 index 0000000000..80b208df4a --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGenerator.java @@ -0,0 +1,447 @@ +/* + * 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; +import org.springframework.util.Assert; + +/** + * 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) { + + List columns = new ArrayList<>(); + + AggregatePath rootPath = context.getAggregatePath(aggregate); + QueryMeta queryMeta = createInlineQuery(rootPath, condition); + InlineQuery rootQuery = queryMeta.inlineQuery; + columns.addAll(queryMeta.selectableExpressions); + + List rownumbers = new ArrayList<>(); + rownumbers.add(queryMeta.rowNumber); + PersistentPropertyPaths entityPaths = context + .findPersistentPropertyPaths(aggregate.getType(), PersistentProperty::isEntity); + List 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 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 columns, AggregatePath rootPath, InlineQuery rootQuery, List 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(); + + InlineQuery inlineQuery = InlineQuery.create(mainSelect, "main"); + return inlineQuery; + } + + /** + * 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 createInlineQueries(PersistentPropertyPaths paths) { + + List 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 paths = new ArrayList<>(); + + entity.doWithProperties((RelationalPersistentProperty p) -> { + if (!p.isEntity()) { + paths.add(basePath.append(p)); + } + }); + + List columns = new ArrayList<>(); + List 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 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. + *
      + *
    1. 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
    2. + *
    3. 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
    4. + *
    + * + * @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 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; + } + + /** + * creates an {@link Expression} referencing the rownumber column for the given {@link AggregatePath}. + * + * @param path the path for which to return the rownumber column. + * @return the rownumber column. + */ + private Expression createRowNumberAliasExpression(AggregatePath path) { + + return just(aliases.getRowNumberAlias(path)); + } + + @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 expressions) { + + List 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: + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    + * root valuechild value
    root1child1
    root1child2
    root1child3
    root1child4
    + * This expression transforms this into + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
    + * root valuechild value
    root1child1
    nullchild2
    nullchild3
    nullchild4
    + * + * @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 simpleColumns, + Collection selectableExpressions, Expression id, Expression backReference, Expression key, + Expression rowNumber, Expression rowCount) { + + static QueryMeta of(AggregatePath basePath, InlineQuery inlineQuery, Collection simpleColumns, + Expression id, Expression backReference, Expression key, Expression rowNumber, Expression rowCount) { + + List selectableExpressions = new ArrayList<>(); + + selectableExpressions.addAll(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); + } + + } +} diff --git a/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SqlGenerator.java b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SqlGenerator.java new file mode 100644 index 0000000000..ce247e1364 --- /dev/null +++ b/spring-data-relational/src/main/java/org/springframework/data/relational/core/sqlgeneration/SqlGenerator.java @@ -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(); +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java index a613fdcbd4..d3277b84e2 100644 --- a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sql/render/SelectRendererUnitTests.java @@ -620,6 +620,21 @@ void rendersFullyQualifiedNamesInOrderBy() { .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. */ diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java new file mode 100644 index 0000000000..5f0f988769 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasFactoryUnitTests.java @@ -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; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasedPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasedPattern.java new file mode 100644 index 0000000000..d21344ad13 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AliasedPattern.java @@ -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; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AnalyticFunctionPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AnalyticFunctionPattern.java new file mode 100644 index 0000000000..6800f3d03d --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/AnalyticFunctionPattern.java @@ -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 { + + 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 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 + ')'; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ColumnPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ColumnPattern.java new file mode 100644 index 0000000000..66cf704597 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ColumnPattern.java @@ -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 { + 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); + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ExpressionPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ExpressionPattern.java new file mode 100644 index 0000000000..d93fa05598 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/ExpressionPattern.java @@ -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); +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/FunctionPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/FunctionPattern.java new file mode 100644 index 0000000000..e09d68cf57 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/FunctionPattern.java @@ -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 { + private final String name; + private final List params; + + /** + * @param name name of the function. + * @param params patterns to match the function arguments. + */ + public FunctionPattern(String name, List 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 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 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); + } + +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/JoinAssert.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/JoinAssert.java new file mode 100644 index 0000000000..dde5640a14 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/JoinAssert.java @@ -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 { + public JoinAssert(Join join) { + super(join, JoinAssert.class); + } + + JoinAssert on(String left, String right) { + + Collection 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; + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/LiteralPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/LiteralPattern.java new file mode 100644 index 0000000000..3ea2c07593 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/LiteralPattern.java @@ -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)); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SelectItemPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SelectItemPattern.java new file mode 100644 index 0000000000..103f496499 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SelectItemPattern.java @@ -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); +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorUnitTests.java new file mode 100644 index 0000000000..5901fadf21 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SingleQuerySqlGeneratorUnitTests.java @@ -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 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 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)); + } + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssert.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssert.java new file mode 100644 index 0000000000..96de5c2871 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssert.java @@ -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 { + 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 actualSelectItems = actual.getSelectItems(); + List unmatchedPatterns = new ArrayList<>(Arrays.asList(columns)); + List 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 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 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 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 getSubSelects(PlainSelect select) { + + FromItem fromItem = select.getFromItem(); + + Stream fromStream = subSelects(fromItem); + + return Stream.of(select).flatMap(s -> { + List joins = s.getJoins(); + if (joins == null) { + return fromStream; + } + + Stream joinStream = joins.stream() // + .map(j -> j.getRightItem()) // + .flatMap(ss -> subSelects(ss)); + return Stream.concat(fromStream, joinStream); + }); + } + + private static Stream subSelects(FromItem fromItem) { + Stream 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 orderByElements = actual.getOrderByElements(); + return new StringAssert(orderByElements == null ? "" : Strings.join(orderByElements).with(", ")); + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssertUnitTests.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssertUnitTests.java new file mode 100644 index 0000000000..3dcfcfaa06 --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/SqlAssertUnitTests.java @@ -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"); + } + + } +} diff --git a/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/TypedExpressionPattern.java b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/TypedExpressionPattern.java new file mode 100644 index 0000000000..f9fcdc403c --- /dev/null +++ b/spring-data-relational/src/test/java/org/springframework/data/relational/core/sqlgeneration/TypedExpressionPattern.java @@ -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 the type of the expression that is matched by this pattern. + */ +abstract class TypedExpressionPattern implements SelectItemPattern, ExpressionPattern { + + private final Class type; + + TypedExpressionPattern(Class 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); +}