diff --git a/pom.xml b/pom.xml index cc9055d8b01..8c0c07e9530 100644 --- a/pom.xml +++ b/pom.xml @@ -104,6 +104,10 @@ Tomáš Neuberg neuberg@m-atelier.cz + + Willie Scholtz + williescholtz@gmail.com + diff --git a/src/main/java/org/apache/ibatis/builder/xml/XMLConfigBuilder.java b/src/main/java/org/apache/ibatis/builder/xml/XMLConfigBuilder.java index 255b94b5f2d..a07e14ee990 100644 --- a/src/main/java/org/apache/ibatis/builder/xml/XMLConfigBuilder.java +++ b/src/main/java/org/apache/ibatis/builder/xml/XMLConfigBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. @@ -293,6 +293,8 @@ private void settingsElement(Properties props) { booleanValueOf(props.getProperty("argNameBasedConstructorAutoMapping"), false)); configuration.setDefaultSqlProviderType(resolveClass(props.getProperty("defaultSqlProviderType"))); configuration.setNullableOnForEach(booleanValueOf(props.getProperty("nullableOnForEach"), false)); + configuration.setExperimentalConstructorCollectionMapping( + booleanValueOf(props.getProperty("experimentalConstructorCollectionMapping"), false)); } private void environmentsElement(XNode context) throws Exception { diff --git a/src/main/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandler.java b/src/main/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandler.java index 8cdc29d6377..9454c9f72bb 100644 --- a/src/main/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandler.java +++ b/src/main/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandler.java @@ -24,6 +24,7 @@ import java.text.MessageFormat; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; @@ -73,6 +74,7 @@ * @author Eduardo Macarron * @author Iwao AVE! * @author Kazuki Shimizu + * @author Willie Scholtz */ public class DefaultResultSetHandler implements ResultSetHandler { @@ -363,7 +365,7 @@ private void handleRowValuesForSimpleResultMap(ResultSetWrapper rsw, ResultMap r skipRows(resultSet, rowBounds); while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) { ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null); - Object rowValue = getRowValue(rsw, discriminatedResultMap, null); + Object rowValue = getRowValue(rsw, discriminatedResultMap, null, null); storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet); } } @@ -406,9 +408,10 @@ private void skipRows(ResultSet rs, RowBounds rowBounds) throws SQLException { // GET VALUE FROM ROW FOR SIMPLE RESULT MAP // - private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix) throws SQLException { + private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix, CacheKey parentRowKey) + throws SQLException { final ResultLoaderMap lazyLoader = new ResultLoaderMap(); - Object rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix); + Object rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix, parentRowKey); if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) { final MetaObject metaObject = configuration.newMetaObject(rowValue); boolean foundValues = this.useConstructorMappings; @@ -437,7 +440,7 @@ private Object getRowValue(ResultSetWrapper rsw, ResultMap resultMap, CacheKey c ancestorObjects.remove(resultMapId); } else { final ResultLoaderMap lazyLoader = new ResultLoaderMap(); - rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix); + rowValue = createResultObject(rsw, resultMap, lazyLoader, columnPrefix, combinedKey); if (rowValue != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) { final MetaObject metaObject = configuration.newMetaObject(rowValue); boolean foundValues = this.useConstructorMappings; @@ -652,11 +655,13 @@ private CacheKey createKeyForMultipleResults(ResultSet rs, ResultMapping resultM // private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, ResultLoaderMap lazyLoader, - String columnPrefix) throws SQLException { + String columnPrefix, CacheKey parentRowKey) throws SQLException { this.useConstructorMappings = false; // reset previous mapping result final List> constructorArgTypes = new ArrayList<>(); final List constructorArgs = new ArrayList<>(); - Object resultObject = createResultObject(rsw, resultMap, constructorArgTypes, constructorArgs, columnPrefix); + + Object resultObject = createResultObject(rsw, resultMap, constructorArgTypes, constructorArgs, columnPrefix, + parentRowKey); if (resultObject != null && !hasTypeHandlerForResultObject(rsw, resultMap.getType())) { final List propertyMappings = resultMap.getPropertyResultMappings(); for (ResultMapping propertyMapping : propertyMappings) { @@ -667,13 +672,21 @@ private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, Res break; } } + + // (issue #101) + if (resultMap.hasResultMapsUsingConstructorCollection() && resultObject instanceof PendingConstructorCreation) { + linkNestedPendingCreations(rsw, resultMap, columnPrefix, parentRowKey, + (PendingConstructorCreation) resultObject, constructorArgs); + } } + this.useConstructorMappings = resultObject != null && !constructorArgTypes.isEmpty(); // set current mapping result return resultObject; } private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, List> constructorArgTypes, - List constructorArgs, String columnPrefix) throws SQLException { + List constructorArgs, String columnPrefix, CacheKey parentRowKey) throws SQLException { + final Class resultType = resultMap.getType(); final MetaClass metaType = MetaClass.forClass(resultType, reflectorFactory); final List constructorMappings = resultMap.getConstructorResultMappings(); @@ -682,7 +695,7 @@ private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, Lis } if (!constructorMappings.isEmpty()) { return createParameterizedResultObject(rsw, resultType, constructorMappings, constructorArgTypes, constructorArgs, - columnPrefix); + columnPrefix, resultMap.hasResultMapsUsingConstructorCollection(), parentRowKey); } else if (resultType.isInterface() || metaType.hasDefaultConstructor()) { return objectFactory.create(resultType); } else if (shouldApplyAutomaticMappings(resultMap, false)) { @@ -694,8 +707,9 @@ private Object createResultObject(ResultSetWrapper rsw, ResultMap resultMap, Lis Object createParameterizedResultObject(ResultSetWrapper rsw, Class resultType, List constructorMappings, List> constructorArgTypes, List constructorArgs, - String columnPrefix) { + String columnPrefix, boolean useCollectionConstructorInjection, CacheKey parentRowKey) { boolean foundValues = false; + for (ResultMapping constructorMapping : constructorMappings) { final Class parameterType = constructorMapping.getJavaType(); final String column = constructorMapping.getColumn(); @@ -704,10 +718,11 @@ Object createParameterizedResultObject(ResultSetWrapper rsw, Class resultType if (constructorMapping.getNestedQueryId() != null) { value = getNestedQueryConstructorValue(rsw.getResultSet(), constructorMapping, columnPrefix); } else if (constructorMapping.getNestedResultMapId() != null) { - String constructorColumnPrefix = getColumnPrefix(columnPrefix, constructorMapping); + final String constructorColumnPrefix = getColumnPrefix(columnPrefix, constructorMapping); final ResultMap resultMap = resolveDiscriminatedResultMap(rsw.getResultSet(), configuration.getResultMap(constructorMapping.getNestedResultMapId()), constructorColumnPrefix); - value = getRowValue(rsw, resultMap, constructorColumnPrefix); + value = getRowValue(rsw, resultMap, constructorColumnPrefix, + useCollectionConstructorInjection ? parentRowKey : null); } else { final TypeHandler typeHandler = constructorMapping.getTypeHandler(); value = typeHandler.getResult(rsw.getResultSet(), prependPrefix(column, columnPrefix)); @@ -715,11 +730,23 @@ Object createParameterizedResultObject(ResultSetWrapper rsw, Class resultType } catch (ResultMapException | SQLException e) { throw new ExecutorException("Could not process result for mapping: " + constructorMapping, e); } + constructorArgTypes.add(parameterType); constructorArgs.add(value); + foundValues = value != null || foundValues; } - return foundValues ? objectFactory.create(resultType, constructorArgTypes, constructorArgs) : null; + + if (!foundValues) { + return null; + } + + if (useCollectionConstructorInjection) { + // at least one of the nestedResultMaps contained a collection, we have to defer until later + return new PendingConstructorCreation(resultType, constructorArgTypes, constructorArgs); + } + + return objectFactory.create(resultType, constructorArgTypes, constructorArgs); } private Object createByConstructorSignature(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix, @@ -1008,29 +1035,59 @@ private String prependPrefix(String columnName, String prefix) { private void handleRowValuesForNestedResultMap(ResultSetWrapper rsw, ResultMap resultMap, ResultHandler resultHandler, RowBounds rowBounds, ResultMapping parentMapping) throws SQLException { + final boolean useCollectionConstructorInjection = resultMap.hasResultMapsUsingConstructorCollection(); + boolean verifyPendingCreationResult = true; + PendingConstructorCreation lastHandledCreation = null; + if (useCollectionConstructorInjection) { + verifyPendingCreationPreconditions(parentMapping); + } + final DefaultResultContext resultContext = new DefaultResultContext<>(); ResultSet resultSet = rsw.getResultSet(); skipRows(resultSet, rowBounds); Object rowValue = previousRowValue; + while (shouldProcessMoreRows(resultContext, rowBounds) && !resultSet.isClosed() && resultSet.next()) { final ResultMap discriminatedResultMap = resolveDiscriminatedResultMap(resultSet, resultMap, null); final CacheKey rowKey = createRowKey(discriminatedResultMap, rsw, null); - Object partialObject = nestedResultObjects.get(rowKey); - // issue #577 && #542 - if (mappedStatement.isResultOrdered()) { - if (partialObject == null && rowValue != null) { + + final Object partialObject = nestedResultObjects.get(rowKey); + final boolean foundNewUniqueRow = partialObject == null; + + // issue #577, #542 && #101 + if (useCollectionConstructorInjection) { + if (foundNewUniqueRow && lastHandledCreation != null) { + createAndStorePendingCreation(resultHandler, resultSet, resultContext, lastHandledCreation, + verifyPendingCreationResult); + lastHandledCreation = null; + // we only need to verify the first the result for a given result set + // as we can assume the next result will look exactly the same w.r.t its mapping + verifyPendingCreationResult = false; + } + + rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject); + if (rowValue instanceof PendingConstructorCreation) { + lastHandledCreation = (PendingConstructorCreation) rowValue; + } + } else if (mappedStatement.isResultOrdered()) { + if (foundNewUniqueRow && rowValue != null) { nestedResultObjects.clear(); storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet); } rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject); } else { rowValue = getRowValue(rsw, discriminatedResultMap, rowKey, null, partialObject); - if (partialObject == null) { + if (foundNewUniqueRow) { storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet); } } } - if (rowValue != null && mappedStatement.isResultOrdered() && shouldProcessMoreRows(resultContext, rowBounds)) { + + if (useCollectionConstructorInjection && lastHandledCreation != null) { + createAndStorePendingCreation(resultHandler, resultSet, resultContext, lastHandledCreation, + verifyPendingCreationResult); + } else if (rowValue != null && mappedStatement.isResultOrdered() + && shouldProcessMoreRows(resultContext, rowBounds)) { storeObject(resultHandler, resultContext, rowValue, parentMapping, resultSet); previousRowValue = null; } else if (rowValue != null) { @@ -1038,6 +1095,163 @@ private void handleRowValuesForNestedResultMap(ResultSetWrapper rsw, ResultMap r } } + // + // NESTED RESULT MAP (PENDING CONSTRUCTOR CREATIONS) + // + private void linkNestedPendingCreations(ResultSetWrapper rsw, ResultMap resultMap, String columnPrefix, + CacheKey parentRowKey, PendingConstructorCreation pendingCreation, List constructorArgs) + throws SQLException { + final CacheKey rowKey = createRowKey(resultMap, rsw, columnPrefix); + final CacheKey combinedKey = combineKeys(rowKey, parentRowKey); + + if (combinedKey != CacheKey.NULL_CACHE_KEY) { + nestedResultObjects.put(combinedKey, pendingCreation); + } + + final List constructorMappings = resultMap.getConstructorResultMappings(); + for (int index = 0; index < constructorMappings.size(); index++) { + final ResultMapping constructorMapping = constructorMappings.get(index); + final String nestedResultMapId = constructorMapping.getNestedResultMapId(); + + if (nestedResultMapId == null) { + continue; + } + + final Class javaType = constructorMapping.getJavaType(); + if (javaType == null || !objectFactory.isCollection(javaType)) { + continue; + } + + final String constructorColumnPrefix = getColumnPrefix(columnPrefix, constructorMapping); + final ResultMap nestedResultMap = resolveDiscriminatedResultMap(rsw.getResultSet(), + configuration.getResultMap(constructorMapping.getNestedResultMapId()), constructorColumnPrefix); + + final Object actualValue = constructorArgs.get(index); + final boolean hasValue = actualValue != null; + final boolean isInnerCreation = actualValue instanceof PendingConstructorCreation; + final boolean alreadyCreatedCollection = hasValue && objectFactory.isCollection(actualValue.getClass()); + + if (!isInnerCreation) { + final Collection value = pendingCreation.initializeCollectionForResultMapping(objectFactory, + nestedResultMap, constructorMapping, index); + if (!alreadyCreatedCollection) { + // override values with empty collection + constructorArgs.set(index, value); + } + + // since we are linking a new value, we need to let nested objects know we did that + final CacheKey nestedRowKey = createRowKey(nestedResultMap, rsw, constructorColumnPrefix); + final CacheKey nestedCombinedKey = combineKeys(nestedRowKey, combinedKey); + + if (nestedCombinedKey != CacheKey.NULL_CACHE_KEY) { + nestedResultObjects.put(nestedCombinedKey, pendingCreation); + } + + if (hasValue) { + pendingCreation.linkCollectionValue(constructorMapping, actualValue); + } + } else { + final PendingConstructorCreation innerCreation = (PendingConstructorCreation) actualValue; + final Collection value = pendingCreation.initializeCollectionForResultMapping(objectFactory, + nestedResultMap, constructorMapping, index); + // we will fill this collection when building the final object + constructorArgs.set(index, value); + // link the creation for building later + pendingCreation.linkCreation(nestedResultMap, innerCreation); + } + } + } + + private boolean applyNestedPendingConstructorCreations(ResultSetWrapper rsw, ResultMap resultMap, + MetaObject metaObject, String parentPrefix, CacheKey parentRowKey, boolean newObject, boolean foundValues) { + if (newObject) { + // new objects are linked by createResultObject + return false; + } + + for (ResultMapping constructorMapping : resultMap.getConstructorResultMappings()) { + final String nestedResultMapId = constructorMapping.getNestedResultMapId(); + final Class parameterType = constructorMapping.getJavaType(); + if (nestedResultMapId == null || constructorMapping.getResultSet() != null || parameterType == null + || !objectFactory.isCollection(parameterType)) { + continue; + } + + try { + final String columnPrefix = getColumnPrefix(parentPrefix, constructorMapping); + final ResultMap nestedResultMap = getNestedResultMap(rsw.getResultSet(), nestedResultMapId, columnPrefix); + + final CacheKey rowKey = createRowKey(nestedResultMap, rsw, columnPrefix); + final CacheKey combinedKey = combineKeys(rowKey, parentRowKey); + + // should have inserted already as a nested result object + Object rowValue = nestedResultObjects.get(combinedKey); + + PendingConstructorCreation pendingConstructorCreation = null; + if (rowValue instanceof PendingConstructorCreation) { + pendingConstructorCreation = (PendingConstructorCreation) rowValue; + } + + final boolean newValueForNestedResultMap = pendingConstructorCreation == null; + if (newValueForNestedResultMap) { + final Object parentObject = metaObject.getOriginalObject(); + if (!(parentObject instanceof PendingConstructorCreation)) { + throw new ExecutorException( + "parentObject is not a pending creation, cannot continue linking! MyBatis internal error!"); + } + + pendingConstructorCreation = (PendingConstructorCreation) parentObject; + } + + rowValue = getRowValue(rsw, nestedResultMap, combinedKey, columnPrefix, + newValueForNestedResultMap ? null : pendingConstructorCreation); + + if (rowValue == null) { + continue; + } + + if (rowValue instanceof PendingConstructorCreation) { + if (newValueForNestedResultMap) { + // we created a brand new pcc. this is a new collection value + pendingConstructorCreation.linkCreation(nestedResultMap, (PendingConstructorCreation) rowValue); + foundValues = true; + } + } else { + pendingConstructorCreation.linkCollectionValue(constructorMapping, rowValue); + foundValues = true; + + if (combinedKey != CacheKey.NULL_CACHE_KEY) { + nestedResultObjects.put(combinedKey, pendingConstructorCreation); + } + } + } catch (SQLException e) { + throw new ExecutorException("Error getting experimental nested result map values for '" + + constructorMapping.getProperty() + "'. Cause: " + e, e); + } + } + return foundValues; + } + + private void verifyPendingCreationPreconditions(ResultMapping parentMapping) { + if (parentMapping != null) { + throw new ExecutorException( + "Cannot construct objects with collections in constructors using multiple result sets yet!"); + } + + if (!mappedStatement.isResultOrdered()) { + throw new ExecutorException("Cannot reliably construct result if we are not sure the results are ordered " + + "so that no new previous rows would occur, set resultOrdered on your mapped statement if you have verified this"); + } + } + + private void createAndStorePendingCreation(ResultHandler resultHandler, ResultSet resultSet, + DefaultResultContext resultContext, PendingConstructorCreation pendingCreation, boolean shouldVerify) + throws SQLException { + final Object result = pendingCreation.create(objectFactory, shouldVerify); + storeObject(resultHandler, resultContext, result, null, resultSet); + nestedResultObjects.clear(); + } + // // NESTED RESULT MAP (JOIN MAPPING) // @@ -1080,6 +1294,13 @@ private boolean applyNestedResultMappings(ResultSetWrapper rsw, ResultMap result } } } + + // (issue #101) + if (resultMap.hasResultMapsUsingConstructorCollection()) { + foundValues = applyNestedPendingConstructorCreations(rsw, resultMap, metaObject, parentPrefix, parentRowKey, + newObject, foundValues); + } + return foundValues; } diff --git a/src/main/java/org/apache/ibatis/executor/resultset/PendingConstructorCreation.java b/src/main/java/org/apache/ibatis/executor/resultset/PendingConstructorCreation.java new file mode 100644 index 00000000000..1859d7494ef --- /dev/null +++ b/src/main/java/org/apache/ibatis/executor/resultset/PendingConstructorCreation.java @@ -0,0 +1,232 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.executor.resultset; + +import java.lang.reflect.Constructor; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +import org.apache.ibatis.executor.ExecutorException; +import org.apache.ibatis.mapping.ResultMap; +import org.apache.ibatis.mapping.ResultMapping; +import org.apache.ibatis.reflection.ReflectionException; +import org.apache.ibatis.reflection.factory.ObjectFactory; + +/** + * Represents an object that is still to be created once all nested results with collection values have been gathered + * + * @author Willie Scholtz + */ +final class PendingConstructorCreation { + + private final Class resultType; + private final List> constructorArgTypes; + private final List constructorArgs; + private final Map linkedCollectionMetaInfo; + private final Map> linkedCollectionsByResultMapId; + private final Map> linkedCreationsByResultMapId; + + PendingConstructorCreation(Class resultType, List> types, List args) { + // since all our keys are based on result map id, we know we will never go over args size + final int maxSize = types.size(); + this.linkedCollectionMetaInfo = new HashMap<>(maxSize); + this.linkedCollectionsByResultMapId = new HashMap<>(maxSize); + this.linkedCreationsByResultMapId = new HashMap<>(maxSize); + this.resultType = resultType; + this.constructorArgTypes = types; + this.constructorArgs = args; + } + + @SuppressWarnings("unchecked") + Collection initializeCollectionForResultMapping(ObjectFactory objectFactory, ResultMap resultMap, + ResultMapping constructorMapping, Integer index) { + final Class parameterType = constructorMapping.getJavaType(); + if (!objectFactory.isCollection(parameterType)) { + throw new ExecutorException( + "Cannot add a collection result to non-collection based resultMapping: " + constructorMapping); + } + + final String resultMapId = constructorMapping.getNestedResultMapId(); + return linkedCollectionsByResultMapId.computeIfAbsent(resultMapId, (k) -> { + // this will allow us to verify the types of the collection before creating the final object + linkedCollectionMetaInfo.put(index, new PendingCreationMetaInfo(resultMap.getType(), resultMapId)); + + // will be checked before we finally create the object) as we cannot reliably do that here + return (Collection) objectFactory.create(parameterType); + }); + } + + void linkCreation(ResultMap nestedResultMap, PendingConstructorCreation pcc) { + final String resultMapId = nestedResultMap.getId(); + final List pendingConstructorCreations = linkedCreationsByResultMapId + .computeIfAbsent(resultMapId, (k) -> new ArrayList<>()); + + if (pendingConstructorCreations.contains(pcc)) { + throw new ExecutorException("Cannot link inner pcc with same value, MyBatis programming error!"); + } + + pendingConstructorCreations.add(pcc); + } + + void linkCollectionValue(ResultMapping constructorMapping, Object value) { + // not necessary to add null results to the collection (is this a config flag?) + if (value == null) { + return; + } + + final String resultMapId = constructorMapping.getNestedResultMapId(); + if (!linkedCollectionsByResultMapId.containsKey(resultMapId)) { + throw new ExecutorException("Cannot link collection value for resultMapping: " + constructorMapping + + ", resultMap has not been seen/initialized yet! Internal error"); + } + + linkedCollectionsByResultMapId.get(resultMapId).add(value); + } + + /** + * Verifies preconditions before we can actually create the result object, this is more of a sanity check to ensure + * all the mappings are as we expect them to be. + * + * @param objectFactory + * the object factory + */ + private void verifyCanCreate(ObjectFactory objectFactory) { + // before we create, we need to get the constructor to be used and verify our types match + // since we added to the collection completely unchecked + final Constructor resolvedConstructor = resolveConstructor(resultType, constructorArgTypes); + final Type[] genericParameterTypes = resolvedConstructor.getGenericParameterTypes(); + for (int i = 0; i < genericParameterTypes.length; i++) { + if (!linkedCollectionMetaInfo.containsKey(i)) { + continue; + } + + final PendingCreationMetaInfo creationMetaInfo = linkedCollectionMetaInfo.get(i); + final Class resolvedItemType = checkResolvedItemType(creationMetaInfo, genericParameterTypes[i]); + + // ensure we have an empty collection if there are linked creations for this arg + final String resultMapId = creationMetaInfo.getResultMapId(); + if (linkedCreationsByResultMapId.containsKey(resultMapId)) { + final Object emptyCollection = constructorArgs.get(i); + if (emptyCollection == null || !objectFactory.isCollection(emptyCollection.getClass())) { + throw new ExecutorException( + "Expected empty collection for '" + resolvedItemType + "', this is a MyBatis internal error!"); + } + } else { + final Object linkedCollection = constructorArgs.get(i); + if (!linkedCollectionsByResultMapId.containsKey(resultMapId)) { + throw new ExecutorException("Expected linked collection for resultMap '" + resultMapId + + "', not found! this is a MyBatis internal error!"); + } + + // comparing memory locations here (we rely on that fact) + if (linkedCollection != linkedCollectionsByResultMapId.get(resultMapId)) { + throw new ExecutorException("Expected linked collection in creation to be the same as arg for resultMap '" + + resultMapId + "', not equal! this is a MyBatis internal error!"); + } + } + } + } + + private static Constructor resolveConstructor(Class type, List> constructorArgTypes) { + try { + if (constructorArgTypes == null) { + return type.getDeclaredConstructor(); + } + + return type.getDeclaredConstructor(constructorArgTypes.toArray(new Class[0])); + } catch (Exception e) { + String argTypes = Optional.ofNullable(constructorArgTypes).orElseGet(Collections::emptyList).stream() + .map(Class::getSimpleName).collect(Collectors.joining(",")); + throw new ReflectionException( + "Error resolving constructor for " + type + " with invalid types (" + argTypes + ") . Cause: " + e, e); + } + } + + private static Class checkResolvedItemType(PendingCreationMetaInfo creationMetaInfo, Type genericParameterTypes) { + final ParameterizedType genericParameterType = (ParameterizedType) genericParameterTypes; + final Class expectedType = (Class) genericParameterType.getActualTypeArguments()[0]; + final Class resolvedItemType = creationMetaInfo.getArgumentType(); + + if (!expectedType.isAssignableFrom(resolvedItemType)) { + throw new ReflectionException( + "Expected type '" + resolvedItemType + "', while the actual type of the collection was '" + expectedType + + "', ensure your resultMap matches the type of the collection you are trying to inject"); + } + + return resolvedItemType; + } + + @Override + public String toString() { + return "PendingConstructorCreation(" + this.hashCode() + "){" + "resultType=" + resultType + '}'; + } + + /** + * Recursively creates the final result of this creation. + * + * @param objectFactory + * the object factory + * @param verifyCreate + * should we verify this object can be created, should only be needed once + * + * @return the new immutable result + */ + Object create(ObjectFactory objectFactory, boolean verifyCreate) { + if (verifyCreate) { + verifyCanCreate(objectFactory); + } + + final List newArguments = new ArrayList<>(constructorArgs.size()); + for (int i = 0; i < constructorArgs.size(); i++) { + final PendingCreationMetaInfo creationMetaInfo = linkedCollectionMetaInfo.get(i); + final Object existingArg = constructorArgs.get(i); + + if (creationMetaInfo == null) { + // we are not aware of this argument wrt pending creations + newArguments.add(existingArg); + continue; + } + + // time to finally build this collection + final String resultMapId = creationMetaInfo.getResultMapId(); + if (linkedCreationsByResultMapId.containsKey(resultMapId)) { + @SuppressWarnings("unchecked") + final Collection emptyCollection = (Collection) existingArg; + final List linkedCreations = linkedCreationsByResultMapId.get(resultMapId); + + for (PendingConstructorCreation linkedCreation : linkedCreations) { + emptyCollection.add(linkedCreation.create(objectFactory, verifyCreate)); + } + + newArguments.add(emptyCollection); + continue; + } + + // handle the base collection (it was built inline already) + newArguments.add(existingArg); + } + + return objectFactory.create(resultType, constructorArgTypes, newArguments); + } +} diff --git a/src/main/java/org/apache/ibatis/executor/resultset/PendingCreationMetaInfo.java b/src/main/java/org/apache/ibatis/executor/resultset/PendingCreationMetaInfo.java new file mode 100644 index 00000000000..b2abc43ca8e --- /dev/null +++ b/src/main/java/org/apache/ibatis/executor/resultset/PendingCreationMetaInfo.java @@ -0,0 +1,42 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.executor.resultset; + +/** + * @author Willie Scholtz + */ +final class PendingCreationMetaInfo { + private final Class argumentType; + private final String resultMapId; + + PendingCreationMetaInfo(Class argumentType, String resultMapId) { + this.argumentType = argumentType; + this.resultMapId = resultMapId; + } + + Class getArgumentType() { + return argumentType; + } + + String getResultMapId() { + return resultMapId; + } + + @Override + public String toString() { + return "PendingCreationMetaInfo{" + "argumentType=" + argumentType + ", resultMapId='" + resultMapId + '\'' + '}'; + } +} diff --git a/src/main/java/org/apache/ibatis/mapping/ResultMap.java b/src/main/java/org/apache/ibatis/mapping/ResultMap.java index a4f7d4ae57a..ac11e18df29 100644 --- a/src/main/java/org/apache/ibatis/mapping/ResultMap.java +++ b/src/main/java/org/apache/ibatis/mapping/ResultMap.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. @@ -46,6 +46,7 @@ public class ResultMap { private Set mappedColumns; private Set mappedProperties; private Discriminator discriminator; + private boolean hasResultMapsUsingConstructorCollection; private boolean hasNestedResultMaps; private boolean hasNestedQueries; private Boolean autoMapping; @@ -111,6 +112,15 @@ public ResultMap build() { } if (resultMapping.getFlags().contains(ResultFlag.CONSTRUCTOR)) { resultMap.constructorResultMappings.add(resultMapping); + + // #101 + if (resultMap.configuration.isExperimentalConstructorCollectionMappingEnabled()) { + Class javaType = resultMapping.getJavaType(); + resultMap.hasResultMapsUsingConstructorCollection = resultMap.hasResultMapsUsingConstructorCollection + || (resultMapping.getNestedQueryId() == null && javaType != null + && resultMap.configuration.getObjectFactory().isCollection(javaType)); + } + if (resultMapping.getProperty() != null) { constructorArgNames.add(resultMapping.getProperty()); } @@ -210,6 +220,10 @@ public String getId() { return id; } + public boolean hasResultMapsUsingConstructorCollection() { + return hasResultMapsUsingConstructorCollection; + } + public boolean hasNestedResultMaps() { return hasNestedResultMaps; } diff --git a/src/main/java/org/apache/ibatis/session/Configuration.java b/src/main/java/org/apache/ibatis/session/Configuration.java index 72b50d3808f..503894399a2 100644 --- a/src/main/java/org/apache/ibatis/session/Configuration.java +++ b/src/main/java/org/apache/ibatis/session/Configuration.java @@ -118,6 +118,7 @@ public class Configuration { protected boolean shrinkWhitespacesInSql; protected boolean nullableOnForEach; protected boolean argNameBasedConstructorAutoMapping; + protected boolean experimentalConstructorCollectionMapping; protected String logPrefix; protected Class logImpl; @@ -370,6 +371,14 @@ public boolean isSafeRowBoundsEnabled() { return safeRowBoundsEnabled; } + public void setExperimentalConstructorCollectionMapping(boolean experimentalConstructorCollectionMapping) { + this.experimentalConstructorCollectionMapping = experimentalConstructorCollectionMapping; + } + + public boolean isExperimentalConstructorCollectionMappingEnabled() { + return experimentalConstructorCollectionMapping; + } + public void setSafeRowBoundsEnabled(boolean safeRowBoundsEnabled) { this.safeRowBoundsEnabled = safeRowBoundsEnabled; } diff --git a/src/site/markdown/configuration.md b/src/site/markdown/configuration.md index fad0b1efef0..267ce33562e 100644 --- a/src/site/markdown/configuration.md +++ b/src/site/markdown/configuration.md @@ -101,40 +101,41 @@ This feature is disabled by default. If you specify a default value into placeho These are extremely important tweaks that modify the way that MyBatis behaves at runtime. The following table describes the settings, their meanings and their default values. -| Setting | Description | Valid Values | Default | -|------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------| -| cacheEnabled | Globally enables or disables any caches configured in any mapper under this configuration. | true | false | true | -| lazyLoadingEnabled | Globally enables or disables lazy loading. When enabled, all relations will be lazily loaded. This value can be superseded for a specific relation by using the `fetchType` attribute on it. | true | false | false | -| aggressiveLazyLoading | When enabled, any method call will load all the lazy properties of the object. Otherwise, each property is loaded on demand (see also `lazyLoadTriggerMethods`). | true | false | false (true in ≤3.4.1) | -| multipleResultSetsEnabled | Allows or disallows multiple ResultSets to be returned from a single statement (compatible driver required). | true | false | true | -| useColumnLabel | Uses the column label instead of the column name. Different drivers behave differently in this respect. Refer to the driver documentation, or test out both modes to determine how your driver behaves. | true | false | true | -| useGeneratedKeys | Allows JDBC support for generated keys. A compatible driver is required. This setting forces generated keys to be used if set to true, as some drivers deny compatibility but still work (e.g. Derby). | true | false | False | -| autoMappingBehavior | Specifies if and how MyBatis should automatically map columns to fields/properties. NONE disables auto-mapping. PARTIAL will only auto-map results with no nested result mappings defined inside. FULL will auto-map result mappings of any complexity (containing nested or otherwise). | NONE, PARTIAL, FULL | PARTIAL | -| autoMappingUnknownColumnBehavior | Specify the behavior when detects an unknown column (or unknown property type) of automatic mapping target.
  • `NONE`: Do nothing
  • `WARNING`: Output warning log (The log level of `'org.apache.ibatis.session.AutoMappingUnknownColumnBehavior'` must be set to `WARN`)
  • `FAILING`: Fail mapping (Throw `SqlSessionException`)
Note that there could be false-positives when `autoMappingBehavior` is set to `FULL`. | NONE, WARNING, FAILING | NONE | -| defaultExecutorType | Configures the default executor. SIMPLE executor does nothing special. REUSE executor reuses prepared statements. BATCH executor reuses statements and batches updates. | SIMPLE REUSE BATCH | SIMPLE | -| defaultStatementTimeout | Sets the number of seconds the driver will wait for a response from the database. | Any positive integer | Not Set (null) | -| defaultFetchSize | Sets the driver a hint as to control fetching size for return results. This parameter value can be override by a query setting. | Any positive integer | Not Set (null) | -| defaultResultSetType | Specifies a scroll strategy when omit it per statement settings. (Since: 3.5.2) | FORWARD_ONLY | SCROLL_SENSITIVE | SCROLL_INSENSITIVE | DEFAULT(same behavior with 'Not Set') | Not Set (null) | -| safeRowBoundsEnabled | Allows using RowBounds on nested statements. If allow, set the false. | true | false | False | -| safeResultHandlerEnabled | Allows using ResultHandler on nested statements. If allow, set the false. | true | false | True | -| mapUnderscoreToCamelCase | Enables automatic mapping from classic database column names A_COLUMN to camel case classic Java property names aColumn. | true | false | False | -| localCacheScope | MyBatis uses local cache to prevent circular references and speed up repeated nested queries. By default (SESSION) all queries executed during a session are cached. If localCacheScope=STATEMENT local session will be used just for statement execution, no data will be shared between two different calls to the same SqlSession. | SESSION | STATEMENT | SESSION | -| jdbcTypeForNull | Specifies the JDBC type for null values when no specific JDBC type was provided for the parameter. Some drivers require specifying the column JDBC type but others work with generic values like NULL, VARCHAR or OTHER. | JdbcType enumeration. Most common are: NULL, VARCHAR and OTHER | OTHER | -| lazyLoadTriggerMethods | Specifies which Object's methods trigger a lazy load | A method name list separated by commas | equals,clone,hashCode,toString | -| defaultScriptingLanguage | Specifies the language used by default for dynamic SQL generation. | A type alias or fully qualified class name. | org.apache.ibatis.scripting.xmltags.XMLLanguageDriver | -| defaultEnumTypeHandler | Specifies the `TypeHandler` used by default for Enum. (Since: 3.4.5) | A type alias or fully qualified class name. | org.apache.ibatis.type.EnumTypeHandler | -| callSettersOnNulls | Specifies if setters or map's put method will be called when a retrieved value is null. It is useful when you rely on Map.keySet() or null value initialization. Note primitives such as (int,boolean,etc.) will not be set to null. | true | false | false | -| returnInstanceForEmptyRow | MyBatis, by default, returns `null` when all the columns of a returned row are NULL. When this setting is enabled, MyBatis returns an empty instance instead. Note that it is also applied to nested results (i.e. collectioin and association). Since: 3.4.2 | true | false | false | -| logPrefix | Specifies the prefix string that MyBatis will add to the logger names. | Any String | Not set | -| logImpl | Specifies which logging implementation MyBatis should use. If this setting is not present logging implementation will be autodiscovered. | SLF4J | LOG4J(deprecated since 3.5.9) | LOG4J2 | JDK_LOGGING | COMMONS_LOGGING | STDOUT_LOGGING | NO_LOGGING | Not set | -| proxyFactory | Specifies the proxy tool that MyBatis will use for creating lazy loading capable objects. | CGLIB (deprecated since 3.5.10) | JAVASSIST | JAVASSIST (MyBatis 3.3 or above) | -| vfsImpl | Specifies VFS implementations | Fully qualified class names of custom VFS implementation separated by commas. | Not set | -| useActualParamName | Allow referencing statement parameters by their actual names declared in the method signature. To use this feature, your project must be compiled in Java 8 with `-parameters` option. (Since: 3.4.1) | true | false | true | -| configurationFactory | Specifies the class that provides an instance of `Configuration`. The returned Configuration instance is used to load lazy properties of deserialized objects. This class must have a method with a signature `static Configuration getConfiguration()`. (Since: 3.2.3) | A type alias or fully qualified class name. | Not set | -| shrinkWhitespacesInSql | Removes extra whitespace characters from the SQL. Note that this also affects literal strings in SQL. (Since 3.5.5) | true | false | false | -| defaultSqlProviderType | Specifies an sql provider class that holds provider method (Since 3.5.6). This class apply to the `type`(or `value`) attribute on sql provider annotation(e.g. `@SelectProvider`), when these attribute was omitted. | A type alias or fully qualified class name | Not set | -| nullableOnForEach | Specifies the default value of 'nullable' attribute on 'foreach' tag. (Since 3.5.9) | true | false | false | -| argNameBasedConstructorAutoMapping | When applying constructor auto-mapping, argument name is used to search the column to map instead of relying on the column order. (Since 3.5.10) | true | false | false | +| Setting | Description | Valid Values | Default | +|------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------| +| cacheEnabled | Globally enables or disables any caches configured in any mapper under this configuration. | true | false | true | +| lazyLoadingEnabled | Globally enables or disables lazy loading. When enabled, all relations will be lazily loaded. This value can be superseded for a specific relation by using the `fetchType` attribute on it. | true | false | false | +| aggressiveLazyLoading | When enabled, any method call will load all the lazy properties of the object. Otherwise, each property is loaded on demand (see also `lazyLoadTriggerMethods`). | true | false | false (true in ≤3.4.1) | +| multipleResultSetsEnabled | Allows or disallows multiple ResultSets to be returned from a single statement (compatible driver required). | true | false | true | +| useColumnLabel | Uses the column label instead of the column name. Different drivers behave differently in this respect. Refer to the driver documentation, or test out both modes to determine how your driver behaves. | true | false | true | +| useGeneratedKeys | Allows JDBC support for generated keys. A compatible driver is required. This setting forces generated keys to be used if set to true, as some drivers deny compatibility but still work (e.g. Derby). | true | false | False | +| autoMappingBehavior | Specifies if and how MyBatis should automatically map columns to fields/properties. NONE disables auto-mapping. PARTIAL will only auto-map results with no nested result mappings defined inside. FULL will auto-map result mappings of any complexity (containing nested or otherwise). | NONE, PARTIAL, FULL | PARTIAL | +| autoMappingUnknownColumnBehavior | Specify the behavior when detects an unknown column (or unknown property type) of automatic mapping target.
  • `NONE`: Do nothing
  • `WARNING`: Output warning log (The log level of `'org.apache.ibatis.session.AutoMappingUnknownColumnBehavior'` must be set to `WARN`)
  • `FAILING`: Fail mapping (Throw `SqlSessionException`)
Note that there could be false-positives when `autoMappingBehavior` is set to `FULL`. | NONE, WARNING, FAILING | NONE | +| defaultExecutorType | Configures the default executor. SIMPLE executor does nothing special. REUSE executor reuses prepared statements. BATCH executor reuses statements and batches updates. | SIMPLE REUSE BATCH | SIMPLE | +| defaultStatementTimeout | Sets the number of seconds the driver will wait for a response from the database. | Any positive integer | Not Set (null) | +| defaultFetchSize | Sets the driver a hint as to control fetching size for return results. This parameter value can be override by a query setting. | Any positive integer | Not Set (null) | +| defaultResultSetType | Specifies a scroll strategy when omit it per statement settings. (Since: 3.5.2) | FORWARD_ONLY | SCROLL_SENSITIVE | SCROLL_INSENSITIVE | DEFAULT(same behavior with 'Not Set') | Not Set (null) | +| safeRowBoundsEnabled | Allows using RowBounds on nested statements. If allow, set the false. | true | false | False | +| safeResultHandlerEnabled | Allows using ResultHandler on nested statements. If allow, set the false. | true | false | True | +| mapUnderscoreToCamelCase | Enables automatic mapping from classic database column names A_COLUMN to camel case classic Java property names aColumn. | true | false | False | +| localCacheScope | MyBatis uses local cache to prevent circular references and speed up repeated nested queries. By default (SESSION) all queries executed during a session are cached. If localCacheScope=STATEMENT local session will be used just for statement execution, no data will be shared between two different calls to the same SqlSession. | SESSION | STATEMENT | SESSION | +| jdbcTypeForNull | Specifies the JDBC type for null values when no specific JDBC type was provided for the parameter. Some drivers require specifying the column JDBC type but others work with generic values like NULL, VARCHAR or OTHER. | JdbcType enumeration. Most common are: NULL, VARCHAR and OTHER | OTHER | +| lazyLoadTriggerMethods | Specifies which Object's methods trigger a lazy load | A method name list separated by commas | equals,clone,hashCode,toString | +| defaultScriptingLanguage | Specifies the language used by default for dynamic SQL generation. | A type alias or fully qualified class name. | org.apache.ibatis.scripting.xmltags.XMLLanguageDriver | +| defaultEnumTypeHandler | Specifies the `TypeHandler` used by default for Enum. (Since: 3.4.5) | A type alias or fully qualified class name. | org.apache.ibatis.type.EnumTypeHandler | +| callSettersOnNulls | Specifies if setters or map's put method will be called when a retrieved value is null. It is useful when you rely on Map.keySet() or null value initialization. Note primitives such as (int,boolean,etc.) will not be set to null. | true | false | false | +| returnInstanceForEmptyRow | MyBatis, by default, returns `null` when all the columns of a returned row are NULL. When this setting is enabled, MyBatis returns an empty instance instead. Note that it is also applied to nested results (i.e. collectioin and association). Since: 3.4.2 | true | false | false | +| logPrefix | Specifies the prefix string that MyBatis will add to the logger names. | Any String | Not set | +| logImpl | Specifies which logging implementation MyBatis should use. If this setting is not present logging implementation will be autodiscovered. | SLF4J | LOG4J(deprecated since 3.5.9) | LOG4J2 | JDK_LOGGING | COMMONS_LOGGING | STDOUT_LOGGING | NO_LOGGING | Not set | +| proxyFactory | Specifies the proxy tool that MyBatis will use for creating lazy loading capable objects. | CGLIB (deprecated since 3.5.10) | JAVASSIST | JAVASSIST (MyBatis 3.3 or above) | +| vfsImpl | Specifies VFS implementations | Fully qualified class names of custom VFS implementation separated by commas. | Not set | +| useActualParamName | Allow referencing statement parameters by their actual names declared in the method signature. To use this feature, your project must be compiled in Java 8 with `-parameters` option. (Since: 3.4.1) | true | false | true | +| configurationFactory | Specifies the class that provides an instance of `Configuration`. The returned Configuration instance is used to load lazy properties of deserialized objects. This class must have a method with a signature `static Configuration getConfiguration()`. (Since: 3.2.3) | A type alias or fully qualified class name. | Not set | +| shrinkWhitespacesInSql | Removes extra whitespace characters from the SQL. Note that this also affects literal strings in SQL. (Since 3.5.5) | true | false | false | +| defaultSqlProviderType | Specifies an sql provider class that holds provider method (Since 3.5.6). This class apply to the `type`(or `value`) attribute on sql provider annotation(e.g. `@SelectProvider`), when these attribute was omitted. | A type alias or fully qualified class name | Not set | +| nullableOnForEach | Specifies the default value of 'nullable' attribute on 'foreach' tag. (Since 3.5.9) | true | false | false | +| argNameBasedConstructorAutoMapping | When applying constructor auto-mapping, argument name is used to search the column to map instead of relying on the column order. (Since 3.5.10) | true | false | false | +| experimentalConstructorCollectionMapping | When applying constructor mapping and any nested result mappings containing collections are found, they will now be filled (Since 3.6.0) | true | false | false | An example of the settings element fully configured is as follows: diff --git a/src/site/markdown/sqlmap-xml.md b/src/site/markdown/sqlmap-xml.md index 8560fb7e28f..398514ddb57 100644 --- a/src/site/markdown/sqlmap-xml.md +++ b/src/site/markdown/sqlmap-xml.md @@ -65,23 +65,23 @@ The select element has more attributes that allow you to configure the details o resultSetType="FORWARD_ONLY"> ``` -| Attribute | Description | -|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `id` | A unique identifier in this namespace that can be used to reference this statement. | -| `parameterType` | The fully qualified class name or alias for the parameter that will be passed into this statement. This attribute is optional because MyBatis can calculate the TypeHandler to use out of the actual parameter passed to the statement. Default is `unset`. | -| `parameterMap` | This is a deprecated approach to referencing an external `parameterMap`. Use inline parameter mappings and the `parameterType` attribute. | -| `resultType` | The fully qualified class name or alias for the expected type that will be returned from this statement. Note that in the case of collections, this should be the type that the collection contains, not the type of the collection itself. Use `resultType` OR `resultMap`, not both. | -| `resultMap` | A named reference to an external `resultMap`. Result maps are the most powerful feature of MyBatis, and with a good understanding of them, many difficult mapping cases can be solved. Use `resultMap` OR `resultType`, not both. | -| `flushCache` | Setting this to true will cause the local and 2nd level caches to be flushed whenever this statement is called. Default: `false` for select statements. | -| `useCache` | Setting this to true will cause the results of this statement to be cached in 2nd level cache. Default: `true` for select statements. | -| `timeout` | This sets the number of seconds the driver will wait for the database to return from a request, before throwing an exception. Default is `unset` (driver dependent). | -| `fetchSize` | This is a driver hint that will attempt to cause the driver to return results in batches of rows numbering in size equal to this setting. Default is `unset` (driver dependent). | -| `statementType` | Any one of `STATEMENT`, `PREPARED` or `CALLABLE`. This causes MyBatis to use `Statement`, `PreparedStatement` or `CallableStatement` respectively. Default: `PREPARED`. | -| `resultSetType` | Any one of `FORWARD_ONLY`|`SCROLL_SENSITIVE`|`SCROLL_INSENSITIVE`|`DEFAULT`(same as unset). Default is `unset` (driver dependent). | -| `databaseId` | In case there is a configured databaseIdProvider, MyBatis will load all statements with no `databaseId` attribute or with a `databaseId` that matches the current one. If case the same statement if found with and without the `databaseId` the latter will be discarded. | -| `resultOrdered` | This is only applicable for nested result select statements: If this is true, it is assumed that nested results are contained or grouped together such that when a new main result row is returned, no references to a previous result row will occur anymore. This allows nested results to be filled much more memory friendly. Default: `false`. | -| `resultSets` | This is only applicable for multiple result sets. It lists the result sets that will be returned by the statement and gives a name to each one. Names are separated by commas. | -| `affectData` | Set this to true when writing a INSERT, UPDATE or DELETE statement that returns data so that the transaction is controlled properly. Also see [Transaction Control Method](./java-api.html#transaction-control-methods). Default: `false` (since 3.5.12) | +| Attribute | Description | +|---------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `id` | A unique identifier in this namespace that can be used to reference this statement. | +| `parameterType` | The fully qualified class name or alias for the parameter that will be passed into this statement. This attribute is optional because MyBatis can calculate the TypeHandler to use out of the actual parameter passed to the statement. Default is `unset`. | +| `parameterMap` | This is a deprecated approach to referencing an external `parameterMap`. Use inline parameter mappings and the `parameterType` attribute. | +| `resultType` | The fully qualified class name or alias for the expected type that will be returned from this statement. Note that in the case of collections, this should be the type that the collection contains, not the type of the collection itself. Use `resultType` OR `resultMap`, not both. | +| `resultMap` | A named reference to an external `resultMap`. Result maps are the most powerful feature of MyBatis, and with a good understanding of them, many difficult mapping cases can be solved. Use `resultMap` OR `resultType`, not both. | +| `flushCache` | Setting this to true will cause the local and 2nd level caches to be flushed whenever this statement is called. Default: `false` for select statements. | +| `useCache` | Setting this to true will cause the results of this statement to be cached in 2nd level cache. Default: `true` for select statements. | +| `timeout` | This sets the number of seconds the driver will wait for the database to return from a request, before throwing an exception. Default is `unset` (driver dependent). | +| `fetchSize` | This is a driver hint that will attempt to cause the driver to return results in batches of rows numbering in size equal to this setting. Default is `unset` (driver dependent). | +| `statementType` | Any one of `STATEMENT`, `PREPARED` or `CALLABLE`. This causes MyBatis to use `Statement`, `PreparedStatement` or `CallableStatement` respectively. Default: `PREPARED`. | +| `resultSetType` | Any one of `FORWARD_ONLY`|`SCROLL_SENSITIVE`|`SCROLL_INSENSITIVE`|`DEFAULT`(same as unset). Default is `unset` (driver dependent). | +| `databaseId` | In case there is a configured databaseIdProvider, MyBatis will load all statements with no `databaseId` attribute or with a `databaseId` that matches the current one. If case the same statement if found with and without the `databaseId` the latter will be discarded. | +| `resultOrdered` | This is only applicable for nested result select statements: If this is true, it is assumed that nested results are contained or grouped together such that when a new main result row is returned, no references to a previous result row will occur anymore. This allows nested results to be filled much more memory friendly. Required for `experimentalConstructorCollectionMapping` Default: `false`. | +| `resultSets` | This is only applicable for multiple result sets. It lists the result sets that will be returned by the statement and gives a name to each one. Names are separated by commas. | +| `affectData` | Set this to true when writing a INSERT, UPDATE or DELETE statement that returns data so that the transaction is controlled properly. Also see [Transaction Control Method](./java-api.html#transaction-control-methods). Default: `false` (since 3.5.12) | [Select Attributes] ### insert, update and delete @@ -638,7 +638,7 @@ public class User { } ``` -In order to inject the results into the constructor, MyBatis needs to identify the constructor for somehow. In the following example, MyBatis searches a constructor declared with three parameters: `java.lang.Integer`, `java.lang.String` and `int` in this order. +In order to inject the results into the constructor, MyBatis needs to identify the constructor somehow. In the following example, MyBatis searches a constructor declared with three parameters: `java.lang.Integer`, `java.lang.String` and `int` in this order. ```xml @@ -673,6 +673,89 @@ The rest of the attributes and rules are the same as for the regular id and resu | `resultMap` | This is the ID of a ResultMap that can map the nested results of this argument into an appropriate object graph. This is an alternative to using a call to another select statement. It allows you to join multiple tables together into a single `ResultSet`. Such a `ResultSet` will contain duplicated, repeating groups of data that needs to be decomposed and mapped properly to a nested object graph. To facilitate this, MyBatis lets you "chain" result maps together, to deal with the nested results. See the Association element below for more. | | `name` | The name of the constructor parameter. Specifying name allows you to write arg elements in any order. See the above explanation. Since 3.4.3. | +##### Nested Results for association or collection + +While the following sections describe how to use `association` and `collection` for both Nested selects and Nested results, Since 3.6.0 we can now inject both using `constructor` mapping. +This behaviour can be enabled by setting `experimentalConstructorCollectionMapping` to `true` in your configuration. + +Considering the following: + +```java +public class User { + //... + public User(Integer id, String username, List userRoles) { + //... + } +} + +public class UserRole { + // ... + public UserRole(Integer id, String role) { + // ... + } +} +``` + +We can map `UserRole` as a nested result, MyBatis will wait until the row has been fully 'completed' before creating the object, this means that by the time the `User` gets created, `userRoles` will be complete and cannot be modified anymore. + +```xml + + + + + + + +``` + +To achieve fully immutable objects in this example, we can also use constructor injection for `UserRole` + +```xml + + + + + + +``` + +MyBatis needs to be explicitly told that the results have been ordered in such a way, that when a new main row is retrieved from the result set, no previous row results will be retrieved again. This can be set on the statement with the `resultOrdered` attribute: + +```xml + +``` + +In this case, the results are by ordered correctly by default. We can imagine the output to look somthing like: + +| row_nr | u.id | u.username | r.id | r.role | +|--------|------|------------|------|-------------| +| 1 | 1 | John | 1 | Admins | +| 2 | 1 | John | 2 | Users | +| 3 | 2 | Jack | null | null | +| 4 | 3 | Peter | 2 | Users | +| 5 | 3 | Peter | 3 | Maintainers | +| 6 | 3 | Peter | 4 | Approvers | + +If the 5th row here would have somehow appeared below the first row (via some `ORDER BY`), MyBatis would not be able to fully construct the `John` user correctly using constructor collection mapping. + +After this query is run, we would have the following results: + +``` +User{username=John, roles=[Admins, Users]} +User{username=Jack, roles=[]} +User{username=Peter, roles=[Users, Maintainers, Approvers]} +``` + +This functionality is still experimental, please report any issues you may find on the issue tracker. #### association @@ -699,7 +782,6 @@ First, let's examine the properties of the element. As you'll see, it differs fr | `jdbcType` | The JDBC Type from the list of supported types that follows this table. The JDBC type is only required for nullable columns upon insert, update or delete. This is a JDBC requirement, not an MyBatis one. So even if you were coding JDBC directly, you'd need to specify this type – but only for nullable values. | | `typeHandler` | We discussed default type handlers previously in this documentation. Using this property you can override the default type handler on a mapping-by-mapping basis. The value is either a fully qualified class name of a TypeHandler implementation, or a type alias. | - #### Nested Select for Association | Attribute | Description | diff --git a/src/test/java/org/apache/ibatis/binding/BindingTest.java b/src/test/java/org/apache/ibatis/binding/BindingTest.java index 348ad18bb7f..85c23c48b1d 100644 --- a/src/test/java/org/apache/ibatis/binding/BindingTest.java +++ b/src/test/java/org/apache/ibatis/binding/BindingTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. @@ -17,6 +17,7 @@ import static com.googlecode.catchexception.apis.BDDCatchException.caughtException; import static com.googlecode.catchexception.apis.BDDCatchException.when; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.BDDAssertions.then; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -64,7 +65,6 @@ import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; class BindingTest { @@ -80,6 +80,10 @@ static void setup() throws Exception { Configuration configuration = new Configuration(environment); configuration.setLazyLoadingEnabled(true); configuration.setUseActualParamName(false); // to test legacy style reference (#{0} #{1}) + + // # issue 101 (explicitly show the set to false to test backward compatibility for this issue) + configuration.setExperimentalConstructorCollectionMapping(false); // (false is the default) + configuration.getTypeAliasRegistry().registerAlias(Blog.class); configuration.getTypeAliasRegistry().registerAlias(Post.class); configuration.getTypeAliasRegistry().registerAlias(Author.class); @@ -399,17 +403,13 @@ void shouldExecuteBoundSelectBlogUsingConstructorWithResultMapAndProperties() { } } - @Disabled - @Test // issue #480 and #101 - void shouldExecuteBoundSelectBlogUsingConstructorWithResultMapCollection() { + @Test // issue #480 and #101 (negative case with flag disabled) + void shouldNotExecuteBoundSelectBlogUsingConstructorWithResultMapCollectionWhenFlagDisabled() { try (SqlSession session = sqlSessionFactory.openSession()) { BoundBlogMapper mapper = session.getMapper(BoundBlogMapper.class); - Blog blog = mapper.selectBlogUsingConstructorWithResultMapCollection(1); - assertEquals(1, blog.getId()); - assertEquals("Jim Business", blog.getTitle()); - assertNotNull(blog.getAuthor(), "author should not be null"); - List posts = blog.getPosts(); - assertTrue(posts != null && !posts.isEmpty(), "posts should not be empty"); + assertThatThrownBy(() -> mapper.selectBlogUsingConstructorWithResultMapCollection(1)) + .isInstanceOf(PersistenceException.class) + .hasMessageContaining("Error instantiating class org.apache.ibatis.domain.blog.Blog with invalid types"); } } diff --git a/src/test/java/org/apache/ibatis/binding/BoundBlogMapper.java b/src/test/java/org/apache/ibatis/binding/BoundBlogMapper.java index bc41486bbdf..d794c2cc676 100644 --- a/src/test/java/org/apache/ibatis/binding/BoundBlogMapper.java +++ b/src/test/java/org/apache/ibatis/binding/BoundBlogMapper.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. diff --git a/src/test/java/org/apache/ibatis/binding/ConstructorCollectionBindingTest.java b/src/test/java/org/apache/ibatis/binding/ConstructorCollectionBindingTest.java new file mode 100644 index 00000000000..b82819b072a --- /dev/null +++ b/src/test/java/org/apache/ibatis/binding/ConstructorCollectionBindingTest.java @@ -0,0 +1,76 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.binding; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.util.List; + +import javax.sql.DataSource; + +import org.apache.ibatis.BaseDataTest; +import org.apache.ibatis.domain.blog.Author; +import org.apache.ibatis.domain.blog.Blog; +import org.apache.ibatis.domain.blog.Post; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.TransactionFactory; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class ConstructorCollectionBindingTest { + private static SqlSessionFactory sqlSessionFactory; + + @BeforeAll + static void setup() throws Exception { + DataSource dataSource = BaseDataTest.createBlogDataSource(); + BaseDataTest.runScript(dataSource, BaseDataTest.BLOG_DDL); + BaseDataTest.runScript(dataSource, BaseDataTest.BLOG_DATA); + TransactionFactory transactionFactory = new JdbcTransactionFactory(); + Environment environment = new Environment("Production", transactionFactory, dataSource); + Configuration configuration = new Configuration(environment); + configuration.setLazyLoadingEnabled(true); + configuration.setUseActualParamName(false); // to test legacy style reference (#{0} #{1}) + + configuration.setExperimentalConstructorCollectionMapping(true); // # issue 101 + + configuration.getTypeAliasRegistry().registerAlias(Blog.class); + configuration.getTypeAliasRegistry().registerAlias(Post.class); + configuration.getTypeAliasRegistry().registerAlias(Author.class); + configuration.addMapper(BoundBlogMapper.class); + configuration.addMapper(BoundAuthorMapper.class); + sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); + } + + @Test // issue #480 and #101 + void shouldExecuteBoundSelectBlogUsingConstructorWithResultMapCollection() { + try (SqlSession session = sqlSessionFactory.openSession()) { + BoundBlogMapper mapper = session.getMapper(BoundBlogMapper.class); + Blog blog = mapper.selectBlogUsingConstructorWithResultMapCollection(1); + assertEquals(1, blog.getId()); + assertEquals("Jim Business", blog.getTitle()); + assertNotNull(blog.getAuthor(), "author should not be null"); + List posts = blog.getPosts(); + assertThat(posts).isNotNull().hasSize(2); + } + } +} diff --git a/src/test/java/org/apache/ibatis/domain/blog/ImmutableAuthor.java b/src/test/java/org/apache/ibatis/domain/blog/ImmutableAuthor.java deleted file mode 100644 index ae51d59fc98..00000000000 --- a/src/test/java/org/apache/ibatis/domain/blog/ImmutableAuthor.java +++ /dev/null @@ -1,104 +0,0 @@ -/* - * Copyright 2009-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.apache.ibatis.domain.blog; - -import java.io.Serializable; - -public class ImmutableAuthor implements Serializable { - private static final long serialVersionUID = 1L; - protected final int id; - protected final String username; - protected final String password; - protected final String email; - protected final String bio; - protected final Section favouriteSection; - - public ImmutableAuthor(int id, String username, String password, String email, String bio, Section section) { - this.id = id; - this.username = username; - this.password = password; - this.email = email; - this.bio = bio; - this.favouriteSection = section; - } - - public int getId() { - return id; - } - - public String getUsername() { - return username; - } - - public String getPassword() { - return password; - } - - public String getEmail() { - return email; - } - - public String getBio() { - return bio; - } - - public Section getFavouriteSection() { - return favouriteSection; - } - - @Override - public boolean equals(Object o) { - if (this == o) { - return true; - } - if (!(o instanceof Author)) { - return false; - } - - Author author = (Author) o; - - if ((id != author.id) || (bio != null ? !bio.equals(author.bio) : author.bio != null) - || (email != null ? !email.equals(author.email) : author.email != null) - || (password != null ? !password.equals(author.password) : author.password != null)) { - return false; - } - if (username != null ? !username.equals(author.username) : author.username != null) { - return false; - } - if (favouriteSection != null ? !favouriteSection.equals(author.favouriteSection) - : author.favouriteSection != null) { - return false; - } - - return true; - } - - @Override - public int hashCode() { - int result; - result = id; - result = 31 * result + (username != null ? username.hashCode() : 0); - result = 31 * result + (password != null ? password.hashCode() : 0); - result = 31 * result + (email != null ? email.hashCode() : 0); - result = 31 * result + (bio != null ? bio.hashCode() : 0); - return 31 * result + (favouriteSection != null ? favouriteSection.hashCode() : 0); - } - - @Override - public String toString() { - return id + " " + username + " " + password + " " + email; - } -} diff --git a/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableAuthor.java b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableAuthor.java new file mode 100644 index 00000000000..0a6c1f78c75 --- /dev/null +++ b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableAuthor.java @@ -0,0 +1,67 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.domain.blog.immutable; + +import org.apache.ibatis.domain.blog.Section; + +public class ImmutableAuthor { + + private final int id; + private final String username; + private final String password; + private final String email; + private final String bio; + private final Section favouriteSection; + + public ImmutableAuthor(int id, String username, String password, String email, String bio, Section section) { + this.id = id; + this.username = username; + this.password = password; + this.email = email; + this.bio = bio; + this.favouriteSection = section; + } + + public int getId() { + return id; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public String getEmail() { + return email; + } + + public String getBio() { + return bio; + } + + public Section getFavouriteSection() { + return favouriteSection; + } + + @Override + public String toString() { + return "ImmutableAuthor{" + "id=" + id + ", username='" + username + '\'' + ", password='" + password + '\'' + + ", email='" + email + '\'' + ", bio='" + bio + '\'' + ", favouriteSection=" + favouriteSection + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableBlog.java b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableBlog.java new file mode 100644 index 00000000000..ece455a3547 --- /dev/null +++ b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableBlog.java @@ -0,0 +1,62 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.domain.blog.immutable; + +import java.util.ArrayList; +import java.util.List; + +public class ImmutableBlog { + + private final int id; + private final String title; + private final ImmutableAuthor author; + private final List posts; + + public ImmutableBlog(int id, String title, ImmutableAuthor author, List posts) { + this.id = id; + this.title = title; + this.author = author; + this.posts = posts; + } + + public ImmutableBlog(int id, String title, ImmutableAuthor author) { + this.id = id; + this.title = title; + this.author = author; + this.posts = new ArrayList<>(); + } + + public int getId() { + return id; + } + + public String getTitle() { + return title; + } + + public ImmutableAuthor getAuthor() { + return author; + } + + public List getPosts() { + return posts; + } + + @Override + public String toString() { + return "ImmutableBlog{" + "id=" + id + ", title='" + title + '\'' + ", author=" + author + ", posts=" + posts + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableComment.java b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableComment.java new file mode 100644 index 00000000000..bf734bad814 --- /dev/null +++ b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableComment.java @@ -0,0 +1,46 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.domain.blog.immutable; + +public class ImmutableComment { + + private final int id; + private final String name; + private final String comment; + + public ImmutableComment(int id, String name, String comment) { + this.id = id; + this.name = name; + this.comment = comment; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public String getComment() { + return comment; + } + + @Override + public String toString() { + return "ImmutableComment{" + "id=" + id + ", name='" + name + '\'' + ", comment='" + comment + '\'' + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutablePost.java b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutablePost.java new file mode 100644 index 00000000000..0762be84db3 --- /dev/null +++ b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutablePost.java @@ -0,0 +1,94 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.domain.blog.immutable; + +import java.util.Date; +import java.util.List; + +import org.apache.ibatis.domain.blog.Section; + +public class ImmutablePost { + + private final int id; + private final ImmutableAuthor author; + private final Date createdOn; + private final Section section; + private final String subject; + private final String body; + private final List comments; + private final List tags; + + public ImmutablePost(int id, ImmutableAuthor author, Date createdOn, Section section, String subject, String body, + List comments, List tags) { + this.id = id; + this.author = author; + this.createdOn = createdOn; + this.section = section; + this.subject = subject; + this.body = body; + this.comments = comments; + this.tags = tags; + } + + public ImmutablePost(int id, ImmutableAuthor author, Date createdOn, Section section, String subject, String body) { + this.id = id; + this.author = author; + this.createdOn = createdOn; + this.section = section; + this.subject = subject; + this.body = body; + this.comments = List.of(); + this.tags = List.of(); + } + + public List getTags() { + return tags; + } + + public int getId() { + return id; + } + + public ImmutableAuthor getAuthor() { + return author; + } + + public Date getCreatedOn() { + return createdOn; + } + + public Section getSection() { + return section; + } + + public String getSubject() { + return subject; + } + + public String getBody() { + return body; + } + + public List getComments() { + return comments; + } + + @Override + public String toString() { + return "ImmutablePost{" + "id=" + id + ", author=" + author + ", createdOn=" + createdOn + ", section=" + section + + ", subject='" + subject + '\'' + ", body='" + body + '\'' + ", comments=" + comments + ", tags=" + tags + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableTag.java b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableTag.java new file mode 100644 index 00000000000..9eec367ddb0 --- /dev/null +++ b/src/test/java/org/apache/ibatis/domain/blog/immutable/ImmutableTag.java @@ -0,0 +1,40 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.domain.blog.immutable; + +public class ImmutableTag { + + private final int id; + private final String name; + + public ImmutableTag(int id, String name) { + this.id = id; + this.name = name; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "ImmutableTag{" + "id=" + id + ", name='" + name + '\'' + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandlerTest.java b/src/test/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandlerTest.java index eaab2ec7536..96ba86c87ab 100644 --- a/src/test/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandlerTest.java +++ b/src/test/java/org/apache/ibatis/executor/resultset/DefaultResultSetHandlerTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. @@ -122,7 +122,8 @@ void shouldThrowExceptionWithColumnName() throws Exception { try { defaultResultSetHandler.createParameterizedResultObject(rsw, null/* resultType */, constructorMappings, - null/* constructorArgTypes */, null/* constructorArgs */, null/* columnPrefix */); + null/* constructorArgTypes */, null/* constructorArgs */, null/* columnPrefix */, false, + /* useCollectionConstructorInjection */ null/* parentRowKey */); Assertions.fail("Should have thrown ExecutorException"); } catch (Exception e) { Assertions.assertTrue(e instanceof ExecutorException, "Expected ExecutorException"); diff --git a/src/test/java/org/apache/ibatis/immutable/ImmutableBlogMapper.java b/src/test/java/org/apache/ibatis/immutable/ImmutableBlogMapper.java new file mode 100644 index 00000000000..3fd6ef4c22a --- /dev/null +++ b/src/test/java/org/apache/ibatis/immutable/ImmutableBlogMapper.java @@ -0,0 +1,36 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.immutable; + +import java.util.List; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.domain.blog.immutable.ImmutableBlog; + +@Mapper +public interface ImmutableBlogMapper { + + ImmutableBlog retrieveFullImmutableBlog(int i); + + List retrieveAllBlogsWithoutPosts(); + + List retrieveAllBlogsWithPostsButNoCommentsOrTags(); + + List retrieveAllBlogsWithMissingConstructor(); + + List retrieveAllBlogsAndPostsJoined(); + +} diff --git a/src/test/java/org/apache/ibatis/immutable/ImmutableConstructorTest.java b/src/test/java/org/apache/ibatis/immutable/ImmutableConstructorTest.java new file mode 100644 index 00000000000..1e9a030124f --- /dev/null +++ b/src/test/java/org/apache/ibatis/immutable/ImmutableConstructorTest.java @@ -0,0 +1,152 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.immutable; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.Assertions.tuple; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import javax.sql.DataSource; + +import org.apache.ibatis.BaseDataTest; +import org.apache.ibatis.domain.blog.Section; +import org.apache.ibatis.domain.blog.immutable.ImmutableAuthor; +import org.apache.ibatis.domain.blog.immutable.ImmutableBlog; +import org.apache.ibatis.domain.blog.immutable.ImmutablePost; +import org.apache.ibatis.exceptions.PersistenceException; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.reflection.ReflectionException; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.TransactionFactory; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; + +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class ImmutableConstructorTest { + + private SqlSessionFactory sqlSessionFactory; + + @BeforeAll + void setup() throws Exception { + final DataSource dataSource = BaseDataTest.createBlogDataSource(); + BaseDataTest.runScript(dataSource, BaseDataTest.BLOG_DDL); + BaseDataTest.runScript(dataSource, BaseDataTest.BLOG_DATA); + + final TransactionFactory transactionFactory = new JdbcTransactionFactory(); + final Environment environment = new Environment("Production", transactionFactory, dataSource); + final Configuration configuration = new Configuration(environment); + + configuration.setExperimentalConstructorCollectionMapping(true); // # issue 101 + configuration.addMapper(ImmutableBlogMapper.class); + + sqlSessionFactory = new SqlSessionFactoryBuilder().build(configuration); + } + + @Test + void shouldSelectImmutableBlogUsingCollectionInConstructor() { + try (SqlSession session = sqlSessionFactory.openSession()) { + final ImmutableBlogMapper mapper = session.getMapper(ImmutableBlogMapper.class); + final ImmutableBlog blog = mapper.retrieveFullImmutableBlog(1); + + assertEquals(1, blog.getId()); + assertEquals("Jim Business", blog.getTitle()); + + final ImmutableAuthor author = blog.getAuthor(); + assertThat(author).isNotNull().isInstanceOf(ImmutableAuthor.class); + assertThat(author.getEmail()).isEqualTo("jim@ibatis.apache.org"); + assertThat(author.getFavouriteSection()).isNotNull().isEqualTo(Section.NEWS); + assertThat(author.getUsername()).isEqualTo("jim"); + assertThat(author.getPassword()).isNotEmpty(); + assertThat(author.getId()).isEqualTo(101); + + final List posts = blog.getPosts(); + assertThat(posts).isNotNull().hasSize(2); + + final ImmutablePost postOne = posts.get(0); + assertThat(postOne).isNotNull().isInstanceOf(ImmutablePost.class); + assertThat(postOne.getCreatedOn()).isNotNull(); + assertThat(postOne.getAuthor()).isNotNull(); + assertThat(postOne.getSection()).isEqualTo(Section.NEWS); + assertThat(postOne.getSubject()).isEqualTo("Corn nuts"); + assertThat(postOne.getBody()).isEqualTo("I think if I never smelled another corn nut it would be too soon..."); + assertThat(postOne.getComments()).isNotNull().extracting("name", "comment").containsExactly( + tuple("troll", "I disagree and think..."), tuple("anonymous", "I agree and think troll is an...")); + assertThat(postOne.getTags()).isNotNull().extracting("name").containsExactly("funny", "cool", "food"); + + final ImmutablePost postTwo = posts.get(1); + assertThat(postTwo).isNotNull().isInstanceOf(ImmutablePost.class); + assertThat(postTwo.getCreatedOn()).isNotNull(); + assertThat(postTwo.getAuthor()).isNotNull(); + assertThat(postTwo.getSection()).isEqualTo(Section.VIDEOS); + assertThat(postTwo.getSubject()).isEqualTo("Paul Hogan on Toy Dogs"); + assertThat(postTwo.getBody()).isEqualTo("That's not a dog. THAT's a dog!"); + assertThat(postTwo.getComments()).isNotNull().isEmpty(); + + assertThat(postTwo.getTags()).isNotNull().extracting("name").containsExactly("funny"); + } + } + + @Test + void shouldSelectAllImmutableBlogsUsingCollectionInConstructor() { + try (SqlSession session = sqlSessionFactory.openSession()) { + ImmutableBlogMapper mapper = session.getMapper(ImmutableBlogMapper.class); + List blogs = mapper.retrieveAllBlogsAndPostsJoined(); + + assertThat(blogs).isNotNull().hasSize(2); + for (ImmutableBlog blog : blogs) { + assertThat(blog).isNotNull().isInstanceOf(ImmutableBlog.class).extracting(ImmutableBlog::getPosts).isNotNull(); + } + } + } + + @Test + void shouldSelectBlogWithoutPosts() { + try (SqlSession session = sqlSessionFactory.openSession()) { + ImmutableBlogMapper mapper = session.getMapper(ImmutableBlogMapper.class); + List blogs = mapper.retrieveAllBlogsWithoutPosts(); + + assertThat(blogs).isNotNull().hasSize(2); + } + } + + @Test + void shouldSelectBlogWithPostsButNoCommentsOrTags() { + try (SqlSession session = sqlSessionFactory.openSession()) { + ImmutableBlogMapper mapper = session.getMapper(ImmutableBlogMapper.class); + List blogs = mapper.retrieveAllBlogsWithPostsButNoCommentsOrTags(); + + assertThat(blogs).isNotNull().hasSize(2); + } + } + + @Test + void shouldFailToSelectBlogWithMissingConstructorForPostComments() { + try (SqlSession session = sqlSessionFactory.openSession()) { + ImmutableBlogMapper mapper = session.getMapper(ImmutableBlogMapper.class); + assertThatThrownBy(() -> mapper.retrieveAllBlogsWithMissingConstructor()).isInstanceOf(PersistenceException.class) + .hasCauseInstanceOf(ReflectionException.class).hasMessageContaining( + "Error resolving constructor for class org.apache.ibatis.domain.blog.immutable.ImmutablePost with invalid types"); + } + } +} diff --git a/src/test/java/org/apache/ibatis/session/SqlSessionTest.java b/src/test/java/org/apache/ibatis/session/SqlSessionTest.java index 0b72e1d385e..b5a6f88fe64 100644 --- a/src/test/java/org/apache/ibatis/session/SqlSessionTest.java +++ b/src/test/java/org/apache/ibatis/session/SqlSessionTest.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2023 the original author or authors. + * Copyright 2009-2024 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. @@ -38,10 +38,10 @@ import org.apache.ibatis.domain.blog.Blog; import org.apache.ibatis.domain.blog.Comment; import org.apache.ibatis.domain.blog.DraftPost; -import org.apache.ibatis.domain.blog.ImmutableAuthor; import org.apache.ibatis.domain.blog.Post; import org.apache.ibatis.domain.blog.Section; import org.apache.ibatis.domain.blog.Tag; +import org.apache.ibatis.domain.blog.immutable.ImmutableAuthor; import org.apache.ibatis.domain.blog.mappers.AuthorMapper; import org.apache.ibatis.domain.blog.mappers.AuthorMapperWithMultipleHandlers; import org.apache.ibatis.domain.blog.mappers.AuthorMapperWithRowBounds; diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/CollectionInjectionTest.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/CollectionInjectionTest.java new file mode 100644 index 00000000000..c6079320884 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/CollectionInjectionTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.Reader; + +import org.apache.ibatis.BaseDataTest; +import org.apache.ibatis.io.Resources; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.session.SqlSessionFactory; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableDefect; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableFurniture; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableHouse; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableHouseMapper; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableRoom; +import org.apache.ibatis.submitted.collection_injection.immutable.ImmutableRoomDetail; +import org.apache.ibatis.submitted.collection_injection.property.Defect; +import org.apache.ibatis.submitted.collection_injection.property.Furniture; +import org.apache.ibatis.submitted.collection_injection.property.House; +import org.apache.ibatis.submitted.collection_injection.property.HouseMapper; +import org.apache.ibatis.submitted.collection_injection.property.Room; +import org.apache.ibatis.submitted.collection_injection.property.RoomDetail; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class CollectionInjectionTest { + + private static SqlSessionFactory sqlSessionFactory; + + @BeforeAll + static void setUp() throws Exception { + try (Reader reader = Resources + .getResourceAsReader("org/apache/ibatis/submitted/collection_injection/mybatis_config.xml")) { + sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader); + } + + BaseDataTest.runScript(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), + "org/apache/ibatis/submitted/collection_injection/create_db.sql"); + BaseDataTest.runScript(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), + "org/apache/ibatis/submitted/collection_injection/data_load_small.sql"); + } + + @Test + void shouldSelectAllHousesUsingConstructorInjection() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + final ImmutableHouseMapper mapper = sqlSession.getMapper(ImmutableHouseMapper.class); + ImmutableHouse house = mapper.getHouse(1); + Assertions.assertNotNull(house); + + final StringBuilder builder = new StringBuilder(); + builder.append("\n").append(house.getName()); + for (ImmutableRoom room : house.getRooms()) { + ImmutableRoomDetail roomDetail = room.getRoomDetail(); + String detailString = String.format(" (size=%d, height=%d, type=%s)", roomDetail.getRoomSize(), + roomDetail.getWallHeight(), roomDetail.getWallType()); + builder.append("\n").append("\t").append(room.getName()).append(detailString); + for (ImmutableFurniture furniture : room.getFurniture()) { + builder.append("\n").append("\t\t").append(furniture.getDescription()); + for (ImmutableDefect defect : furniture.getDefects()) { + builder.append("\n").append("\t\t\t").append(defect.getDefect()); + } + } + } + + assertResult(builder); + } + } + + @Test + void shouldSelectAllHousesUsingPropertyInjection() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + final HouseMapper mapper = sqlSession.getMapper(HouseMapper.class); + final House house = mapper.getHouse(1); + Assertions.assertNotNull(house); + + final StringBuilder builder = new StringBuilder(); + builder.append("\n").append(house.getName()); + for (Room room : house.getRooms()) { + RoomDetail roomDetail = room.getRoomDetail(); + String detailString = String.format(" (size=%d, height=%d, type=%s)", roomDetail.getRoomSize(), + roomDetail.getWallHeight(), roomDetail.getWallType()); + builder.append("\n").append("\t").append(room.getName()).append(detailString); + for (Furniture furniture : room.getFurniture()) { + builder.append("\n").append("\t\t").append(furniture.getDescription()); + for (Defect defect : furniture.getDefects()) { + builder.append("\n").append("\t\t\t").append(defect.getDefect()); + } + } + } + + assertResult(builder); + } + } + + private static void assertResult(StringBuilder builder) { + String expected = "\nMyBatis Headquarters" + "\n\tKitchen (size=25, height=20, type=Brick)" + "\n\t\tCoffee machine" + + "\n\t\t\tDoes not work" + "\n\t\tFridge" + "\n\tDining room (size=100, height=10, type=Wood)" + "\n\t\tTable" + + "\n\tProgramming room (size=200, height=15, type=Steel)" + "\n\t\tBig screen" + "\n\t\tLaptop" + + "\n\t\t\tCannot run intellij"; + + assertThat(builder.toString()).isNotEmpty().isEqualTo(expected); + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableDefect.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableDefect.java new file mode 100644 index 00000000000..1c74eaced2f --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableDefect.java @@ -0,0 +1,39 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +public class ImmutableDefect { + private final int id; + private final String defect; + + public ImmutableDefect(int id, String defect) { + this.id = id; + this.defect = defect; + } + + public int getId() { + return id; + } + + public String getDefect() { + return defect; + } + + @Override + public String toString() { + return "ImmutableDefect{" + "id=" + id + ", defect='" + defect + '\'' + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableFurniture.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableFurniture.java new file mode 100644 index 00000000000..31b245b4640 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableFurniture.java @@ -0,0 +1,48 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +import java.util.List; + +public class ImmutableFurniture { + private final int id; + private final String description; + private final List defects; + + public ImmutableFurniture(int id, String description, List defects) { + this.id = id; + this.description = description; + this.defects = defects; + } + + public int getId() { + return id; + } + + public String getDescription() { + return description; + } + + public List getDefects() { + return defects; + } + + @Override + public String toString() { + return "ImmutableFurniture{" + "id=" + id + ", description='" + description + '\'' + ", defects='" + defects + '\'' + + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouse.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouse.java new file mode 100644 index 00000000000..7eb819a3efe --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouse.java @@ -0,0 +1,47 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +import java.util.List; + +public class ImmutableHouse { + private final int id; + private final String name; + private final List rooms; + + public ImmutableHouse(int id, String name, List rooms) { + this.id = id; + this.name = name; + this.rooms = rooms; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public List getRooms() { + return rooms; + } + + @Override + public String toString() { + return "ImmutableHouse{" + "id=" + id + ", name='" + name + '\'' + ", rooms=" + rooms + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.java new file mode 100644 index 00000000000..a9c85fc033b --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.java @@ -0,0 +1,26 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +import java.util.List; + +public interface ImmutableHouseMapper { + + List getAllHouses(); + + ImmutableHouse getHouse(int it); + +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoom.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoom.java new file mode 100644 index 00000000000..43c3c419212 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoom.java @@ -0,0 +1,54 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +import java.util.List; + +public class ImmutableRoom { + private final int id; + private final String name; + private final ImmutableRoomDetail roomDetail; + private final List furniture; + + public ImmutableRoom(int id, String name, ImmutableRoomDetail roomDetail, List furniture) { + this.id = id; + this.name = name; + this.roomDetail = roomDetail; + this.furniture = furniture; + } + + public int getId() { + return id; + } + + public String getName() { + return name; + } + + public ImmutableRoomDetail getRoomDetail() { + return roomDetail; + } + + public List getFurniture() { + return furniture; + } + + @Override + public String toString() { + return "ImmutableRoom{" + "id=" + id + ", name='" + name + '\'' + ", roomDetail=" + roomDetail + ", furniture=" + + furniture + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoomDetail.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoomDetail.java new file mode 100644 index 00000000000..63a0f04dff9 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableRoomDetail.java @@ -0,0 +1,47 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.immutable; + +public class ImmutableRoomDetail { + + private final String wallType; + private final int wallHeight; + private final int roomSize; + + public ImmutableRoomDetail(final String wallType, final int wallHeight, final int roomSize) { + this.wallType = wallType; + this.wallHeight = wallHeight; + this.roomSize = roomSize; + } + + public String getWallType() { + return wallType; + } + + public int getWallHeight() { + return wallHeight; + } + + public int getRoomSize() { + return roomSize; + } + + @Override + public String toString() { + return "ImmutableRoomDetail{" + "wallType='" + wallType + '\'' + ", wallHeight=" + wallHeight + ", roomSize=" + + roomSize + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Defect.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Defect.java new file mode 100644 index 00000000000..73a2624e68f --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Defect.java @@ -0,0 +1,43 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +public class Defect { + private int id; + private String defect; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getDefect() { + return defect; + } + + public void setDefect(String defect) { + this.defect = defect; + } + + @Override + public String toString() { + return "Defect{" + "id=" + id + ", defect='" + defect + '\'' + '}'; + + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Furniture.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Furniture.java new file mode 100644 index 00000000000..0253a36dddb --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Furniture.java @@ -0,0 +1,53 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +import java.util.List; + +public class Furniture { + private int id; + private String description; + private List defects; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public List getDefects() { + return defects; + } + + public void setDefects(List defects) { + this.defects = defects; + } + + @Override + public String toString() { + return "Furniture{" + "id=" + id + ", description='" + description + '\'' + ", defects='" + defects + '\'' + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/House.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/House.java new file mode 100644 index 00000000000..bcdb3bcc20b --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/House.java @@ -0,0 +1,53 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +import java.util.List; + +public class House { + private int id; + private String name; + private List rooms; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getRooms() { + return rooms; + } + + public void setRooms(List rooms) { + this.rooms = rooms; + } + + @Override + public String toString() { + return "House{" + "id=" + id + ", name='" + name + '\'' + ", rooms=" + rooms + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.java new file mode 100644 index 00000000000..5921ce6f87a --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.java @@ -0,0 +1,26 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +import java.util.List; + +public interface HouseMapper { + + List getAllHouses(); + + House getHouse(int it); + +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Room.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Room.java new file mode 100644 index 00000000000..d22f396d048 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/Room.java @@ -0,0 +1,63 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +import java.util.List; + +public class Room { + private int id; + private String name; + private RoomDetail roomDetail; + private List furniture; + + public int getId() { + return id; + } + + public void setId(int id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public RoomDetail getRoomDetail() { + return roomDetail; + } + + public void setRoomDetail(RoomDetail roomDetail) { + this.roomDetail = roomDetail; + } + + public List getFurniture() { + return furniture; + } + + public void setFurniture(List furniture) { + this.furniture = furniture; + } + + @Override + public String toString() { + return "Room{" + "id=" + id + ", name='" + name + '\'' + ", roomDetail=" + roomDetail + ", furniture=" + furniture + + '}'; + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/collection_injection/property/RoomDetail.java b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/RoomDetail.java new file mode 100644 index 00000000000..a0a8e55e9ec --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/collection_injection/property/RoomDetail.java @@ -0,0 +1,53 @@ +/* + * Copyright 2009-2024 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.apache.ibatis.submitted.collection_injection.property; + +public class RoomDetail { + + private String wallType; + private int wallHeight; + private int roomSize; + + public String getWallType() { + return wallType; + } + + public void setWallType(String wallType) { + this.wallType = wallType; + } + + public int getWallHeight() { + return wallHeight; + } + + public void setWallHeight(int wallHeight) { + this.wallHeight = wallHeight; + } + + public int getRoomSize() { + return roomSize; + } + + public void setRoomSize(int roomSize) { + this.roomSize = roomSize; + } + + @Override + public String toString() { + return "RoomDetail{" + "wallType='" + wallType + '\'' + ", wallHeight=" + wallHeight + ", roomSize=" + roomSize + + '}'; + } +} diff --git a/src/test/resources/org/apache/ibatis/binding/BoundBlogMapper.xml b/src/test/resources/org/apache/ibatis/binding/BoundBlogMapper.xml index 9a62131333c..8a3e4f8cd58 100644 --- a/src/test/resources/org/apache/ibatis/binding/BoundBlogMapper.xml +++ b/src/test/resources/org/apache/ibatis/binding/BoundBlogMapper.xml @@ -1,7 +1,7 @@ + + + + + + + + + + + @@ -71,7 +85,7 @@ - + @@ -122,7 +136,7 @@ + + where b.id = #{id} + + + + + + + + + + + diff --git a/src/test/resources/org/apache/ibatis/submitted/collection_injection/create_db.sql b/src/test/resources/org/apache/ibatis/submitted/collection_injection/create_db.sql new file mode 100644 index 00000000000..7fc3bb99933 --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/collection_injection/create_db.sql @@ -0,0 +1,55 @@ +-- +-- Copyright 2009-2024 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. +-- + +drop table defect if exists; +drop table furniture if exists; +drop table room if exists; +drop table house if exists; + +create table house ( + id int not null primary key, + name varchar(255) +); + +create table room ( + id int not null primary key, + name varchar(255), + house_id int, + size_m2 int, + wall_type varchar(10), + wall_height int +); + +alter table room add constraint fk_room_house_id + foreign key (house_id) references house (id); + +create table furniture ( + id int not null primary key, + description varchar(255), + room_id int +); + +alter table furniture add constraint fk_furniture_room_id + foreign key (room_id) references room (id); + +create table defect ( + id int not null primary key, + defect varchar(255), + furniture_id int +); + +alter table defect add constraint fk_defects_furniture_id + foreign key (furniture_id) references furniture (id); diff --git a/src/test/resources/org/apache/ibatis/submitted/collection_injection/data_load_small.sql b/src/test/resources/org/apache/ibatis/submitted/collection_injection/data_load_small.sql new file mode 100644 index 00000000000..d076fbd745f --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/collection_injection/data_load_small.sql @@ -0,0 +1,30 @@ +-- +-- Copyright 2009-2024 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. +-- + +insert into house (id, name) values ( 1, 'MyBatis Headquarters' ); + +insert into room (id, name, house_id, size_m2, wall_type, wall_height) VALUES ( 1, 'Kitchen', 1, 25, 'Brick', 20 ); +insert into room (id, name, house_id, size_m2, wall_type, wall_height) VALUES ( 2, 'Dining room', 1, 100, 'Wood', 10 ); +insert into room (id, name, house_id, size_m2, wall_type, wall_height) VALUES ( 3, 'Programming room', 1, 200, 'Steel', 15 ); + +insert into furniture (id, description, room_id) VALUES ( 1, 'Coffee machine', 1); +insert into furniture (id, description, room_id) VALUES ( 2, 'Fridge', 1); +insert into furniture (id, description, room_id) VALUES ( 3, 'Table', 2); +insert into furniture (id, description, room_id) VALUES ( 4, 'Big screen', 3); +insert into furniture (id, description, room_id) VALUES ( 5, 'Laptop', 3); + +insert into defect (id, defect, furniture_id) VALUES ( 1, 'Does not work', 1 ); +insert into defect (id, defect, furniture_id) VALUES ( 2, 'Cannot run intellij', 5 ); diff --git a/src/test/resources/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.xml b/src/test/resources/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.xml new file mode 100644 index 00000000000..dc8c52a7415 --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/collection_injection/immutable/ImmutableHouseMapper.xml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select h.* + + , r.id as room_id + , r.name as room_name + , r.size_m2 as room_size_m2 + , r.wall_type as room_wall_type + , r.wall_height as room_wall_height + + , f.id as room_furniture_id + , f.description as room_furniture_description + + , d.id as room_furniture_defect_id + , d.defect as room_furniture_defect_defect + + from house h + left join room r on r.house_id = h.id + left join furniture f on f.room_id = r.id + left join defect d on d.furniture_id = f.id + + + + + + + diff --git a/src/test/resources/org/apache/ibatis/submitted/collection_injection/mybatis_config.xml b/src/test/resources/org/apache/ibatis/submitted/collection_injection/mybatis_config.xml new file mode 100644 index 00000000000..60774338d9a --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/collection_injection/mybatis_config.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.xml b/src/test/resources/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.xml new file mode 100644 index 00000000000..53b332513c0 --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/collection_injection/property/HouseMapper.xml @@ -0,0 +1,84 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select h.* + + , r.id as room_id + , r.name as room_name + , r.size_m2 as room_size_m2 + , r.wall_type as room_wall_type + , r.wall_height as room_wall_height + + , f.id as room_furniture_id + , f.description as room_furniture_description + + , d.id as room_furniture_defect_id + , d.defect as room_furniture_defect_defect + + from house h + left join room r on r.house_id = h.id + left join furniture f on f.room_id = r.id + left join defect d on d.furniture_id = f.id + + + + + +