diff --git a/src/main/java/org/apache/ibatis/builder/MapperBuilderAssistant.java b/src/main/java/org/apache/ibatis/builder/MapperBuilderAssistant.java index ac60fafab5b..4067e81d899 100644 --- a/src/main/java/org/apache/ibatis/builder/MapperBuilderAssistant.java +++ b/src/main/java/org/apache/ibatis/builder/MapperBuilderAssistant.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2024 the original author or authors. + * Copyright 2009-2025 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. @@ -466,5 +466,4 @@ private Class resolveParameterJavaType(Class resultType, String property, } return javaType; } - } diff --git a/src/main/java/org/apache/ibatis/builder/ResultMappingConstructorResolver.java b/src/main/java/org/apache/ibatis/builder/ResultMappingConstructorResolver.java new file mode 100644 index 00000000000..223261c3fd5 --- /dev/null +++ b/src/main/java/org/apache/ibatis/builder/ResultMappingConstructorResolver.java @@ -0,0 +1,382 @@ +/* + * Copyright 2009-2025 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.builder; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Constructor; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.logging.Log; +import org.apache.ibatis.logging.LogFactory; +import org.apache.ibatis.mapping.ResultMapping; +import org.apache.ibatis.reflection.ParamNameUtil; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.type.TypeHandler; +import org.apache.ibatis.type.UnknownTypeHandler; + +public class ResultMappingConstructorResolver { + + private static final Log log = LogFactory.getLog(ResultMappingConstructorResolver.class); + + private final Configuration configuration; + private final List constructorResultMappings; + private final Class resultType; + private final String resultMapId; + + /** + * @param configuration + * the global configuration object + * @param constructorResultMappings + * the current mappings as resolved from xml or annotations + * @param resultType + * the result type of the object to be built + */ + public ResultMappingConstructorResolver(Configuration configuration, List constructorResultMappings, + Class resultType, String resultMapId) { + this.configuration = configuration; + this.constructorResultMappings = Objects.requireNonNull(constructorResultMappings); + this.resultType = Objects.requireNonNull(resultType); + this.resultMapId = resultMapId; + } + + /** + * Attempts to find a matching constructor for the supplied {@code resultType} and (possibly unordered mappings) by: + *
    + *
  1. Finding constructors with the same amount of arguments
  2. + *
  3. Rejecting candidates which have different argument names than our mappings
  4. + *
  5. Ensuring the type of each mapping matches the resolved constructor
  6. + *
  7. Rebuilding and sorting the mappings according to the found constructor
  8. + *
+ *

+ * Note that if there are multiple constructors which match, {@code javaType} is the only way to differentiate between + * them. i.e. if there is only one constructor, all types could be missing, however if there is more than one with the + * same amount of arguments, we need type on at least a few mappings to differentiate and select the correct + * constructor. + *

+ * Argument order can only be derived if all mappings have a {@code name} specified, as this is the only way we order + * against the constructor reliably, i.e. we cannot order {@code X(String, String, String)} based on types alone. + * + * @return ordered mappings based on the resolved constructor, original mappings if none were found, or original + * mappings if they did not have property names and a constructor could not be resolved + * + * @throws BuilderException + * when a constructor could not be resolved + * + * @see #2618 + * @see #721 + */ + public List resolveWithConstructor() { + if (constructorResultMappings.isEmpty()) { + // todo: AutoMapping works during runtime, we cannot resolve constructors yet + return constructorResultMappings; + } + + // retrieve constructors & trim selection down to parameter length + final List matchingConstructorCandidates = retrieveConstructorCandidates( + constructorResultMappings.size()); + + if (matchingConstructorCandidates.isEmpty()) { + return constructorResultMappings; + } + + // extract the property names we have + final Set constructorArgsByName = constructorResultMappings.stream().map(ResultMapping::getProperty) + .filter(Objects::nonNull).collect(Collectors.toCollection(LinkedHashSet::new)); + + // arg order can only be 'fixed' if all mappings have property names + final boolean allMappingsHavePropertyNames = verifyPropertyNaming(constructorArgsByName); + + // only do this if all property mappings were set + if (allMappingsHavePropertyNames) { + // while we have candidates, start selection + removeCandidatesBasedOnParameterNames(matchingConstructorCandidates, constructorArgsByName); + } + + // resolve final constructor by filtering out selection based on type info present (or missing) + final ConstructorMetaInfo matchingConstructorInfo = filterBasedOnType(matchingConstructorCandidates, + constructorResultMappings, allMappingsHavePropertyNames); + if (matchingConstructorInfo == null) { + // [backwards-compatibility] (we cannot find a constructor), + // but this used to get thrown ONLY when property mappings have been set + if (allMappingsHavePropertyNames) { + throw new BuilderException("Error in result map '" + resultMapId + "'. Failed to find a constructor in '" + + resultType.getName() + "' with arg names " + constructorArgsByName + + ". Note that 'javaType' is required when there is ambiguous constructors or there is no writable property with the same name ('name' is optional, BTW). There is more info in the debug log."); + } else { + if (log.isDebugEnabled()) { + log.debug("Constructor for '" + resultMapId + "' could not be resolved."); + } + // return un-modified original mappings + return constructorResultMappings; + } + } + + // only rebuild (auto-type) if required (any types are unidentified) + final boolean autoTypeRequired = constructorResultMappings.stream().map(ResultMapping::getJavaType) + .anyMatch(mappingType -> mappingType == null || Object.class.equals(mappingType)); + final List resultMappings = autoTypeRequired + ? autoTypeConstructorMappings(matchingConstructorInfo, constructorResultMappings, allMappingsHavePropertyNames) + : constructorResultMappings; + + if (allMappingsHavePropertyNames) { + // finally sort them based on the constructor meta info + sortConstructorMappings(matchingConstructorInfo, resultMappings); + } + + return resultMappings; + } + + private boolean verifyPropertyNaming(Set constructorArgsByName) { + final boolean allMappingsHavePropertyNames = constructorResultMappings.size() == constructorArgsByName.size(); + + // If property names have been partially specified, throw an exception, as this case does not make sense + // either specify all names and (optional random order), or type info. + if (!allMappingsHavePropertyNames && !constructorArgsByName.isEmpty()) { + throw new BuilderException("Error in result map '" + resultMapId + + "'. We do not support partially specifying a property name nor duplicates. Either specify all property names, or none."); + } + + return allMappingsHavePropertyNames; + } + + List retrieveConstructorCandidates(int withLength) { + return Arrays.stream(resultType.getDeclaredConstructors()) + .filter(constructor -> constructor.getParameterTypes().length == withLength).map(ConstructorMetaInfo::new) + .collect(Collectors.toList()); + } + + private static void removeCandidatesBasedOnParameterNames(List matchingConstructorCandidates, + Set constructorArgsByName) { + final Iterator candidateIterator = matchingConstructorCandidates.iterator(); + while (candidateIterator.hasNext()) { + // extract the names (and types) the constructor has + final ConstructorMetaInfo candidateInfo = candidateIterator.next(); + + // if all our param names contain all the derived names, keep candidate + if (!candidateInfo.isApplicableFor(constructorArgsByName)) { + if (log.isDebugEnabled()) { + log.debug("While resolving the constructor '" + candidateInfo + "', it was excluded from selection. " + + "' Required parameters: [" + constructorArgsByName + "] Actual: [" + + candidateInfo.constructorArgs.keySet() + "]"); + } + + candidateIterator.remove(); + } + } + } + + private static ConstructorMetaInfo filterBasedOnType(List matchingConstructorCandidates, + List resultMappings, boolean allMappingsHavePropertyNames) { + ConstructorMetaInfo matchingConstructorInfo = null; + for (ConstructorMetaInfo constructorMetaInfo : matchingConstructorCandidates) { + boolean matchesType = true; + + for (int i = 0; i < resultMappings.size(); i++) { + final ResultMapping constructorMapping = resultMappings.get(i); + final Class type = constructorMapping.getJavaType(); + final ConstructorArg matchingArg = allMappingsHavePropertyNames + ? constructorMetaInfo.getArgByPropertyName(constructorMapping.getProperty()) + : constructorMetaInfo.getArgByOriginalIndex(i); + + if (matchingArg == null) { + if (log.isDebugEnabled()) { + log.debug("While resolving the constructor '" + constructorMetaInfo + "', it was excluded from selection. " + + "' Could not find constructor argument for mapping: [" + constructorMapping + "], available [" + + constructorMetaInfo.constructorArgs + "]"); + } + + matchesType = false; + break; + } + + // pre-filled a type, check if it matches the constructor + if (type != null && !Object.class.equals(type) && !type.equals(matchingArg.getType())) { + if (log.isDebugEnabled()) { + log.debug("While resolving the constructor '" + constructorMetaInfo + "', it was excluded from selection. " + + "' Required mapping: [" + constructorMapping + "] does not match actual type: [" + matchingArg + "]"); + } + + matchesType = false; + break; + } + } + + if (!matchesType) { + continue; + } + + if (matchingConstructorInfo != null) { + if (log.isDebugEnabled()) { + log.debug("While resolving the constructor '" + constructorMetaInfo + "', it was excluded from selection. " + + "Match already found! Ambiguous constructors [" + matchingConstructorInfo + "]"); + } + + // multiple matches found, abort as we cannot reliably guess the correct one. + matchingConstructorInfo = null; + break; + } + + matchingConstructorInfo = constructorMetaInfo; + } + + return matchingConstructorInfo; + } + + private List autoTypeConstructorMappings(ConstructorMetaInfo matchingConstructorInfo, + List resultMappings, boolean allMappingsHavePropertyNames) { + final List adjustedAutoTypeResultMappings = new ArrayList<>(constructorResultMappings.size()); + for (int i = 0; i < resultMappings.size(); i++) { + final ResultMapping originalMapping = resultMappings.get(i); + final ConstructorArg matchingArg = allMappingsHavePropertyNames + ? matchingConstructorInfo.getArgByPropertyName(originalMapping.getProperty()) + : matchingConstructorInfo.getArgByOriginalIndex(i); + + final TypeHandler originalTypeHandler = originalMapping.getTypeHandler(); + final TypeHandler typeHandler = originalTypeHandler == null + || originalTypeHandler.getClass().isAssignableFrom(UnknownTypeHandler.class) ? null : originalTypeHandler; + + // given that we selected a new java type, overwrite the currently + // selected type handler so it can get retrieved again from the registry + adjustedAutoTypeResultMappings.add( + new ResultMapping.Builder(originalMapping).javaType(matchingArg.getType()).typeHandler(typeHandler).build()); + } + + return adjustedAutoTypeResultMappings; + } + + private static void sortConstructorMappings(ConstructorMetaInfo matchingConstructorInfo, + List resultMappings) { + final List orderedConstructorParameters = new ArrayList<>(matchingConstructorInfo.constructorArgs.keySet()); + resultMappings.sort((o1, o2) -> { + int paramIdx1 = orderedConstructorParameters.indexOf(o1.getProperty()); + int paramIdx2 = orderedConstructorParameters.indexOf(o2.getProperty()); + return paramIdx1 - paramIdx2; + }); + } + + /** + * Represents a {@link Constructor} with parameter names and types + */ + class ConstructorMetaInfo { + + final Map constructorArgs; + final List argsByIndex; + + private ConstructorMetaInfo(Constructor constructor) { + final List args = fromConstructor(constructor); + + this.constructorArgs = args.stream() + .collect(Collectors.toMap(ConstructorArg::getName, arg -> arg, (arg1, arg2) -> arg1, LinkedHashMap::new)); + this.argsByIndex = new ArrayList<>(this.constructorArgs.values()); + } + + boolean isApplicableFor(Set resultMappingProperties) { + return resultMappingProperties.containsAll(constructorArgs.keySet()); + } + + ConstructorArg getArgByPropertyName(String name) { + return constructorArgs.get(name); + } + + ConstructorArg getArgByOriginalIndex(int index) { + if (argsByIndex.isEmpty() || index >= argsByIndex.size()) { + return null; + } + + return argsByIndex.get(index); + } + + private List fromConstructor(Constructor constructor) { + final Class[] parameterTypes = constructor.getParameterTypes(); + final List argNames = getArgNames(constructor); + + final List constructorArgs = new ArrayList<>(argNames.size()); + for (int i = 0; i < argNames.size(); i++) { + constructorArgs.add(new ConstructorArg(parameterTypes[i], argNames.get(i))); + } + + return constructorArgs; + } + + private List getArgNames(Constructor constructor) { + List paramNames = new ArrayList<>(); + List actualParamNames = null; + + final Annotation[][] paramAnnotations = constructor.getParameterAnnotations(); + int paramCount = paramAnnotations.length; + for (int paramIndex = 0; paramIndex < paramCount; paramIndex++) { + String name = null; + for (Annotation annotation : paramAnnotations[paramIndex]) { + if (annotation instanceof Param) { + name = ((Param) annotation).value(); + break; + } + } + + if (name == null && configuration.isUseActualParamName()) { + if (actualParamNames == null) { + actualParamNames = ParamNameUtil.getParamNames(constructor); + } + if (actualParamNames.size() > paramIndex) { + name = actualParamNames.get(paramIndex); + } + } + + paramNames.add(name != null ? name : "arg" + paramIndex); + } + + return paramNames; + } + + @Override + public String toString() { + return "ConstructorMetaInfo{" + "args=" + constructorArgs + '}'; + } + } + + static class ConstructorArg { + private final Class type; + private final String name; + + private ConstructorArg(Class type, String name) { + this.type = type; + this.name = name; + } + + public Class getType() { + return type; + } + + public String getName() { + return name; + } + + @Override + public String toString() { + return "arg{" + "type=" + type.getName() + ", name='" + name + '\'' + '}'; + } + } +} diff --git a/src/main/java/org/apache/ibatis/builder/annotation/MapperAnnotationBuilder.java b/src/main/java/org/apache/ibatis/builder/annotation/MapperAnnotationBuilder.java index 0388256e5e7..a0d802f3d19 100644 --- a/src/main/java/org/apache/ibatis/builder/annotation/MapperAnnotationBuilder.java +++ b/src/main/java/org/apache/ibatis/builder/annotation/MapperAnnotationBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2024 the original author or authors. + * Copyright 2009-2025 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. @@ -63,6 +63,7 @@ import org.apache.ibatis.builder.CacheRefResolver; import org.apache.ibatis.builder.IncompleteElementException; import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.builder.ResultMappingConstructorResolver; import org.apache.ibatis.builder.xml.XMLMapperBuilder; import org.apache.ibatis.cursor.Cursor; import org.apache.ibatis.executor.keygen.Jdbc3KeyGenerator; @@ -237,7 +238,7 @@ private String generateResultMapName(Method method) { private void applyResultMap(String resultMapId, Class returnType, Arg[] args, Result[] results, TypeDiscriminator discriminator) { List resultMappings = new ArrayList<>(); - applyConstructorArgs(args, returnType, resultMappings); + applyConstructorArgs(args, returnType, resultMappings, resultMapId); applyResults(results, returnType, resultMappings); Discriminator disc = applyDiscriminator(resultMapId, returnType, discriminator); // TODO add AutoMappingBehaviour @@ -251,7 +252,7 @@ private void createDiscriminatorResultMaps(String resultMapId, Class resultTy String caseResultMapId = resultMapId + "-" + c.value(); List resultMappings = new ArrayList<>(); // issue #136 - applyConstructorArgs(c.constructArgs(), resultType, resultMappings); + applyConstructorArgs(c.constructArgs(), resultType, resultMappings, resultMapId); applyResults(c.results(), resultType, resultMappings); // TODO add AutoMappingBehaviour assistant.addResultMap(caseResultMapId, c.type(), resultMapId, null, resultMappings, null); @@ -461,7 +462,7 @@ private void applyResults(Result[] results, Class resultType, List 0 && result.many().resultMap().length() > 0) { + if (!result.one().resultMap().isEmpty() && !result.many().resultMap().isEmpty()) { throw new BuilderException("Cannot use both @One and @Many annotations in the same @Result"); } - return result.one().resultMap().length() > 0 || result.many().resultMap().length() > 0; + return !result.one().resultMap().isEmpty() || !result.many().resultMap().isEmpty(); } private String nestedSelectId(Result result) { String nestedSelect = result.one().select(); - if (nestedSelect.length() < 1) { + if (nestedSelect.isEmpty()) { nestedSelect = result.many().select(); } if (!nestedSelect.contains(".")) { @@ -498,22 +499,24 @@ private String nestedSelectId(Result result) { private boolean isLazy(Result result) { boolean isLazy = configuration.isLazyLoadingEnabled(); - if (result.one().select().length() > 0 && FetchType.DEFAULT != result.one().fetchType()) { + if (!result.one().select().isEmpty() && FetchType.DEFAULT != result.one().fetchType()) { isLazy = result.one().fetchType() == FetchType.LAZY; - } else if (result.many().select().length() > 0 && FetchType.DEFAULT != result.many().fetchType()) { + } else if (!result.many().select().isEmpty() && FetchType.DEFAULT != result.many().fetchType()) { isLazy = result.many().fetchType() == FetchType.LAZY; } return isLazy; } private boolean hasNestedSelect(Result result) { - if (result.one().select().length() > 0 && result.many().select().length() > 0) { + if (!result.one().select().isEmpty() && !result.many().select().isEmpty()) { throw new BuilderException("Cannot use both @One and @Many annotations in the same @Result"); } - return result.one().select().length() > 0 || result.many().select().length() > 0; + return !result.one().select().isEmpty() || !result.many().select().isEmpty(); } - private void applyConstructorArgs(Arg[] args, Class resultType, List resultMappings) { + private void applyConstructorArgs(Arg[] args, Class resultType, List resultMappings, + String resultMapId) { + final List mappings = new ArrayList<>(); for (Arg arg : args) { List flags = new ArrayList<>(); flags.add(ResultFlag.CONSTRUCTOR); @@ -527,12 +530,16 @@ private void applyConstructorArgs(Arg[] args, Class resultType, List addi if (typeClass == null) { typeClass = inheritEnclosingType(resultMapNode, enclosingType); } + + String id = resultMapNode.getStringAttribute("id", resultMapNode::getValueBasedIdentifier); + String extend = resultMapNode.getStringAttribute("extends"); + Boolean autoMapping = resultMapNode.getBooleanAttribute("autoMapping"); + Discriminator discriminator = null; List resultMappings = new ArrayList<>(additionalResultMappings); List resultChildren = resultMapNode.getChildren(); for (XNode resultChild : resultChildren) { if ("constructor".equals(resultChild.getName())) { - processConstructorElement(resultChild, typeClass, resultMappings); + processConstructorElement(resultChild, typeClass, resultMappings, id); } else if ("discriminator".equals(resultChild.getName())) { discriminator = processDiscriminatorElement(resultChild, typeClass, resultMappings); } else { @@ -239,9 +245,7 @@ private ResultMap resultMapElement(XNode resultMapNode, List addi resultMappings.add(buildResultMappingFromContext(resultChild, typeClass, flags)); } } - String id = resultMapNode.getStringAttribute("id", resultMapNode::getValueBasedIdentifier); - String extend = resultMapNode.getStringAttribute("extends"); - Boolean autoMapping = resultMapNode.getBooleanAttribute("autoMapping"); + ResultMapResolver resultMapResolver = new ResultMapResolver(builderAssistant, id, typeClass, extend, discriminator, resultMappings, autoMapping); try { @@ -265,16 +269,24 @@ protected Class inheritEnclosingType(XNode resultMapNode, Class enclosingT return null; } - private void processConstructorElement(XNode resultChild, Class resultType, List resultMappings) { + private void processConstructorElement(XNode resultChild, Class resultType, List resultMappings, + String id) { List argChildren = resultChild.getChildren(); + + final List mappings = new ArrayList<>(); for (XNode argChild : argChildren) { List flags = new ArrayList<>(); flags.add(ResultFlag.CONSTRUCTOR); if ("idArg".equals(argChild.getName())) { flags.add(ResultFlag.ID); } - resultMappings.add(buildResultMappingFromContext(argChild, resultType, flags)); + + mappings.add(buildResultMappingFromContext(argChild, resultType, flags)); } + + final ResultMappingConstructorResolver resolver = new ResultMappingConstructorResolver(configuration, mappings, + resultType, id); + resultMappings.addAll(resolver.resolveWithConstructor()); } private Discriminator processDiscriminatorElement(XNode context, Class resultType, diff --git a/src/main/java/org/apache/ibatis/mapping/ResultMap.java b/src/main/java/org/apache/ibatis/mapping/ResultMap.java index a7c8ebe494a..145d053fcc4 100644 --- a/src/main/java/org/apache/ibatis/mapping/ResultMap.java +++ b/src/main/java/org/apache/ibatis/mapping/ResultMap.java @@ -15,8 +15,6 @@ */ package org.apache.ibatis.mapping; -import java.lang.annotation.Annotation; -import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.HashSet; @@ -24,11 +22,6 @@ import java.util.Locale; import java.util.Set; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.builder.BuilderException; -import org.apache.ibatis.logging.Log; -import org.apache.ibatis.logging.LogFactory; -import org.apache.ibatis.reflection.ParamNameUtil; import org.apache.ibatis.session.Configuration; /** @@ -55,8 +48,6 @@ private ResultMap() { } public static class Builder { - private static final Log log = LogFactory.getLog(Builder.class); - private final ResultMap resultMap = new ResultMap(); public Builder(Configuration configuration, String id, Class type, List resultMappings) { @@ -85,16 +76,18 @@ public ResultMap build() { if (resultMap.id == null) { throw new IllegalArgumentException("ResultMaps must have an id"); } + resultMap.mappedColumns = new HashSet<>(); resultMap.mappedProperties = new HashSet<>(); resultMap.idResultMappings = new ArrayList<>(); resultMap.constructorResultMappings = new ArrayList<>(); resultMap.propertyResultMappings = new ArrayList<>(); - final List constructorArgNames = new ArrayList<>(); + for (ResultMapping resultMapping : resultMap.resultMappings) { resultMap.hasNestedQueries = resultMap.hasNestedQueries || resultMapping.getNestedQueryId() != null; resultMap.hasNestedResultMaps = resultMap.hasNestedResultMaps || resultMapping.getNestedResultMapId() != null && resultMapping.getResultSet() == null; + final String column = resultMapping.getColumn(); if (column != null) { resultMap.mappedColumns.add(column.toUpperCase(Locale.ENGLISH)); @@ -106,10 +99,12 @@ public ResultMap build() { } } } + final String property = resultMapping.getProperty(); if (property != null) { resultMap.mappedProperties.add(property); } + if (resultMapping.getFlags().contains(ResultFlag.CONSTRUCTOR)) { resultMap.constructorResultMappings.add(resultMapping); @@ -118,99 +113,27 @@ public ResultMap build() { resultMap.hasResultMapsUsingConstructorCollection = resultMap.hasResultMapsUsingConstructorCollection || (resultMapping.getNestedQueryId() == null && resultMapping.getTypeHandler() == null && javaType != null && resultMap.configuration.getObjectFactory().isCollection(javaType)); - - if (resultMapping.getProperty() != null) { - constructorArgNames.add(resultMapping.getProperty()); - } } else { resultMap.propertyResultMappings.add(resultMapping); } + if (resultMapping.getFlags().contains(ResultFlag.ID)) { resultMap.idResultMappings.add(resultMapping); } } + if (resultMap.idResultMappings.isEmpty()) { resultMap.idResultMappings.addAll(resultMap.resultMappings); } - if (!constructorArgNames.isEmpty()) { - final List actualArgNames = argNamesOfMatchingConstructor(constructorArgNames); - if (actualArgNames == null) { - throw new BuilderException("Error in result map '" + resultMap.id + "'. Failed to find a constructor in '" - + resultMap.getType().getName() + "' with arg names " + constructorArgNames - + ". Note that 'javaType' is required when there is no writable property with the same name ('name' is optional, BTW). There might be more info in debug log."); - } - resultMap.constructorResultMappings.sort((o1, o2) -> { - int paramIdx1 = actualArgNames.indexOf(o1.getProperty()); - int paramIdx2 = actualArgNames.indexOf(o2.getProperty()); - return paramIdx1 - paramIdx2; - }); - } + // lock down collections resultMap.resultMappings = Collections.unmodifiableList(resultMap.resultMappings); resultMap.idResultMappings = Collections.unmodifiableList(resultMap.idResultMappings); resultMap.constructorResultMappings = Collections.unmodifiableList(resultMap.constructorResultMappings); resultMap.propertyResultMappings = Collections.unmodifiableList(resultMap.propertyResultMappings); resultMap.mappedColumns = Collections.unmodifiableSet(resultMap.mappedColumns); - return resultMap; - } - - private List argNamesOfMatchingConstructor(List constructorArgNames) { - Constructor[] constructors = resultMap.type.getDeclaredConstructors(); - for (Constructor constructor : constructors) { - Class[] paramTypes = constructor.getParameterTypes(); - if (constructorArgNames.size() == paramTypes.length) { - List paramNames = getArgNames(constructor); - if (constructorArgNames.containsAll(paramNames) - && argTypesMatch(constructorArgNames, paramTypes, paramNames)) { - return paramNames; - } - } - } - return null; - } - - private boolean argTypesMatch(final List constructorArgNames, Class[] paramTypes, - List paramNames) { - for (int i = 0; i < constructorArgNames.size(); i++) { - Class actualType = paramTypes[paramNames.indexOf(constructorArgNames.get(i))]; - Class specifiedType = resultMap.constructorResultMappings.get(i).getJavaType(); - if (!actualType.equals(specifiedType)) { - if (log.isDebugEnabled()) { - log.debug("While building result map '" + resultMap.id + "', found a constructor with arg names " - + constructorArgNames + ", but the type of '" + constructorArgNames.get(i) - + "' did not match. Specified: [" + specifiedType.getName() + "] Declared: [" + actualType.getName() - + "]"); - } - return false; - } - } - return true; - } - private List getArgNames(Constructor constructor) { - List paramNames = new ArrayList<>(); - List actualParamNames = null; - final Annotation[][] paramAnnotations = constructor.getParameterAnnotations(); - int paramCount = paramAnnotations.length; - for (int paramIndex = 0; paramIndex < paramCount; paramIndex++) { - String name = null; - for (Annotation annotation : paramAnnotations[paramIndex]) { - if (annotation instanceof Param) { - name = ((Param) annotation).value(); - break; - } - } - if (name == null && resultMap.configuration.isUseActualParamName()) { - if (actualParamNames == null) { - actualParamNames = ParamNameUtil.getParamNames(constructor); - } - if (actualParamNames.size() > paramIndex) { - name = actualParamNames.get(paramIndex); - } - } - paramNames.add(name != null ? name : "arg" + paramIndex); - } - return paramNames; + return resultMap; } } diff --git a/src/main/java/org/apache/ibatis/mapping/ResultMapping.java b/src/main/java/org/apache/ibatis/mapping/ResultMapping.java index 9ec710679ca..a0968a8cf64 100644 --- a/src/main/java/org/apache/ibatis/mapping/ResultMapping.java +++ b/src/main/java/org/apache/ibatis/mapping/ResultMapping.java @@ -1,5 +1,5 @@ /* - * Copyright 2009-2024 the original author or authors. + * Copyright 2009-2025 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. @@ -72,6 +72,25 @@ public Builder(Configuration configuration, String property) { resultMapping.lazy = configuration.isLazyLoadingEnabled(); } + public Builder(ResultMapping otherMapping) { + this(otherMapping.configuration, otherMapping.property); + + resultMapping.flags.addAll(otherMapping.flags); + resultMapping.composites.addAll(otherMapping.composites); + + resultMapping.column = otherMapping.column; + resultMapping.javaType = otherMapping.javaType; + resultMapping.jdbcType = otherMapping.jdbcType; + resultMapping.typeHandler = otherMapping.typeHandler; + resultMapping.nestedResultMapId = otherMapping.nestedResultMapId; + resultMapping.nestedQueryId = otherMapping.nestedQueryId; + resultMapping.notNullColumns = otherMapping.notNullColumns; + resultMapping.columnPrefix = otherMapping.columnPrefix; + resultMapping.resultSet = otherMapping.resultSet; + resultMapping.foreignColumn = otherMapping.foreignColumn; + resultMapping.lazy = otherMapping.lazy; + } + public Builder javaType(Class javaType) { resultMapping.javaType = javaType; return this; @@ -243,6 +262,7 @@ public String getForeignColumn() { return foreignColumn; } + @Deprecated public void setForeignColumn(String foreignColumn) { this.foreignColumn = foreignColumn; } @@ -251,6 +271,7 @@ public boolean isLazy() { return lazy; } + @Deprecated public void setLazy(boolean lazy) { this.lazy = lazy; } diff --git a/src/test/java/org/apache/ibatis/builder/ResultMappingConstructorResolverTest.java b/src/test/java/org/apache/ibatis/builder/ResultMappingConstructorResolverTest.java new file mode 100644 index 00000000000..ac1912c09d9 --- /dev/null +++ b/src/test/java/org/apache/ibatis/builder/ResultMappingConstructorResolverTest.java @@ -0,0 +1,356 @@ +/* + * Copyright 2009-2025 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.builder; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.assertj.core.api.AssertionsForClassTypes.tuple; +import static org.assertj.core.api.AssertionsForInterfaceTypes.assertThat; + +import java.sql.CallableStatement; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.mapping.ResultMapping; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.type.BaseTypeHandler; +import org.apache.ibatis.type.JdbcType; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class ResultMappingConstructorResolverTest { + + static String TEST_ID = "testResultMapId"; + + Configuration configuration = new Configuration(); + + @Test + void testResolvesSingleArg() { + ResultMapping mapping = createConstructorMappingFor(Object.class, "type", "type"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType.class, TEST_ID, mapping); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, m -> m.getJavaType().getSimpleName()) + .containsExactly(tuple("type", "String")); + } + + @Test + void testResolvesTypeAndOrderWithSingleConstructor() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b1", "b1"); + ResultMapping mappingC = createConstructorMappingFor(Object.class, "c", "c"); + + // note the incorrect order provided here + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType2.class, TEST_ID, mappingC, mappingA, + mappingB); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a", "long"), tuple("b1", "long"), tuple("c", "String")); + } + + @Test + void testCannotResolveAmbiguous() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b", "b"); + ResultMapping mappingC = createConstructorMappingFor(Object.class, "c", "c"); + + // there are two matching constructors here, we need to clarify with type info + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingA, mappingB, + mappingC); + + assertThatThrownBy(resolver::resolveWithConstructor).isNotNull().isInstanceOf(BuilderException.class) + .hasMessageContaining( + "Failed to find a constructor in 'org.apache.ibatis.builder.ResultType1' with arg names [a, b, c]"); + } + + @Test + void testCanResolveAmbiguousWithMinimalTypeInfo() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b", "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, "c", "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingA, mappingB, + mappingC); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a", "long"), tuple("b", "String"), tuple("c", "LocalDate")); + } + + @Test + void testCanResolveAmbiguousWithAllTypeInfo() { + ResultMapping mappingA = createConstructorMappingFor(long.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(String.class, "b", "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, "c", "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingA, mappingB, + mappingC); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a", "long"), tuple("b", "String"), tuple("c", "LocalDate")); + } + + @Test + void testCanResolveAmbiguousRandomOrderWithMinimalTypeInfo() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b", "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, "c", "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingC, mappingA, + mappingB); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a", "long"), tuple("b", "String"), tuple("c", "LocalDate")); + } + + @Test + void testCanResolveAmbiguousRandomOrderWithAllTypeInfo() { + ResultMapping mappingA = createConstructorMappingFor(long.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(String.class, "b", "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, "c", "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingC, mappingA, + mappingB); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a", "long"), tuple("b", "String"), tuple("c", "LocalDate")); + } + + @Test + void testCanResolveOutOfOrderWhenParamIsUsed() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a1", "a1"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b1", "b1"); + ResultMapping mappingC = createConstructorMappingFor(Object.class, "c1", "c1"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingC, mappingA, + mappingB); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple("a1", "long"), tuple("b1", "long"), tuple("c1", "String")); + } + + @Test + void doesNotResolveWithNoMappingsAsInput() { + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID); + Assertions.assertThat(resolver.resolveWithConstructor()).isNotNull().isEmpty(); + } + + @Test + void testReturnOriginalMappingsWhenNoPropertyNamesDefinedAndCannotResolveConstructor() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, null, "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, null, "b"); + ResultMapping mappingC = createConstructorMappingFor(Object.class, null, "c"); + ResultMapping[] constructorMappings = new ResultMapping[] { mappingA, mappingB, mappingC }; + + // [backwards-compatibility] the mappings do not have type info, or name defined, the original mappings should be + // returned + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, + constructorMappings); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).containsExactly(constructorMappings); + } + + @Test + void testThrowExceptionWithPartialPropertyNameSpecified() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, null, "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, null, "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingA, mappingB, + mappingC); + + assertThatThrownBy(resolver::resolveWithConstructor).isInstanceOf(BuilderException.class) + .hasMessageContaining("Either specify all property names, or none."); + } + + @Test + void testThrowExceptionWithDuplicatedPropertyNames() { + ResultMapping mappingA = createConstructorMappingFor(Object.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "a", "b"); + ResultMapping mappingC = createConstructorMappingFor(LocalDate.class, "c", "c"); + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, mappingA, mappingB, + mappingC); + + assertThatThrownBy(resolver::resolveWithConstructor).isInstanceOf(BuilderException.class) + .hasMessageContaining("Either specify all property names, or none."); + } + + @Test + void testCanResolveWithMissingPropertyNameAndAllTypeInfo() { + ResultMapping mappingA = createConstructorMappingFor(long.class, null, "a"); + ResultMapping mappingB = createConstructorMappingFor(String.class, null, "b"); + ResultMapping mappingC = createConstructorMappingFor(String.class, null, "c"); + ResultMapping[] constructorMappings = new ResultMapping[] { mappingA, mappingB, mappingC }; + + final ResultMappingConstructorResolver resolver = createResolverFor(ResultType1.class, TEST_ID, + constructorMappings); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList).extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName()) + .containsExactly(tuple(null, "long"), tuple(null, "String"), tuple(null, "String")); + } + + @Test + void doesNotChangeCustomTypeHandlerAfterAutoTypeAndOrdering() { + ResultMapping mappingA = createConstructorMappingFor(String.class, "a", "a"); + ResultMapping mappingB = createConstructorMappingFor(Object.class, "b", "b"); + ResultMapping mappingC = new ResultMapping.Builder(configuration, "c", "c", Object.class) + .typeHandler(new MyTypeHandler()).build(); + + final ResultMappingConstructorResolver resolver = createResolverFor(CustomObj.class, TEST_ID, mappingB, mappingA, + mappingC); + final List mappingList = resolver.resolveWithConstructor(); + + assertThat(mappingList) + .extracting(ResultMapping::getProperty, mapping -> mapping.getJavaType().getSimpleName(), + mapping -> mapping.getTypeHandler().getClass().getSimpleName()) + .containsExactly(tuple("a", "String", "StringTypeHandler"), tuple("b", "int", "IntegerTypeHandler"), + tuple("c", "List", "MyTypeHandler")); + } + + @Nested + class MetaInfoTests { + + @Test + void resolvesEmptyConstructor() { + List constructorMetaInfos = new ResultMappingConstructorResolver( + configuration, List.of(), Result.class, TEST_ID).retrieveConstructorCandidates(0); + + Assertions.assertThat(constructorMetaInfos).isNotNull().hasSize(1); + + ResultMappingConstructorResolver.ConstructorMetaInfo constructorMetaInfo = constructorMetaInfos.get(0); + Assertions.assertThat(constructorMetaInfo.getArgByOriginalIndex(0)).isNull(); + Assertions.assertThat(constructorMetaInfo.constructorArgs).isEmpty(); + } + + @Test + void resolvesNormalConstructor() { + List constructorMetaInfos = new ResultMappingConstructorResolver( + configuration, List.of(), ResultType.class, TEST_ID).retrieveConstructorCandidates(1); + + assertThat(constructorMetaInfos).isNotNull().hasSize(1).satisfiesExactlyInAnyOrder( + metaInfo0 -> assertThat(metaInfo0.constructorArgs).extractingFromEntries(Map.Entry::getKey, + entry -> entry.getValue().getType(), entry -> entry.getValue().getName()) + .containsExactly(tuple("type", String.class, "type"))); + } + + @Test + void resolvesConstructorsWithParams() { + List constructorMetaInfos = new ResultMappingConstructorResolver( + configuration, List.of(), ResultType1.class, TEST_ID).retrieveConstructorCandidates(3); + + assertThat(constructorMetaInfos).isNotNull().hasSize(3).satisfiesExactlyInAnyOrder( + metaInfo0 -> assertThat(metaInfo0.constructorArgs).extractingFromEntries(Map.Entry::getKey, + entry -> entry.getValue().getType(), entry -> entry.getValue().getName()).containsExactly( + tuple("a1", long.class, "a1"), tuple("b1", long.class, "b1"), tuple("c1", String.class, "c1")), + metaInfo1 -> assertThat(metaInfo1.constructorArgs).extractingFromEntries(Map.Entry::getKey, + entry -> entry.getValue().getType(), entry -> entry.getValue().getName()).containsExactly( + tuple("a", long.class, "a"), tuple("b", String.class, "b"), tuple("c", String.class, "c")), + metaInfo1 -> assertThat(metaInfo1.constructorArgs).extractingFromEntries(Map.Entry::getKey, + entry -> entry.getValue().getType(), entry -> entry.getValue().getName()).containsExactly( + tuple("a", long.class, "a"), tuple("b", String.class, "b"), tuple("c", LocalDate.class, "c"))); + } + + @Test + void resolvesConstructorsWithMixedParams() { + List constructorMetaInfos = new ResultMappingConstructorResolver( + configuration, List.of(), ResultType2.class, TEST_ID).retrieveConstructorCandidates(3); + + assertThat(constructorMetaInfos).isNotNull().hasSize(1).satisfiesExactlyInAnyOrder( + metaInfo0 -> assertThat(metaInfo0.constructorArgs).extractingFromEntries(Map.Entry::getKey, + entry -> entry.getValue().getType(), entry -> entry.getValue().getName()).containsExactly( + tuple("a", long.class, "a"), tuple("b1", long.class, "b1"), tuple("c", String.class, "c"))); + } + } + + private ResultMappingConstructorResolver createResolverFor(Class resultType, String identifier, + ResultMapping... mappings) { + return new ResultMappingConstructorResolver(configuration, mappings == null ? List.of() : Arrays.asList(mappings), + resultType, identifier); + } + + private ResultMapping createConstructorMappingFor(Class javaType, String property, String column) { + return new ResultMapping.Builder(configuration, property, column, javaType).build(); + } +} + +record Result() { +} + +record ResultType(String type) { +} + +record ResultType1(long a, String b, String c) { + + ResultType1(@Param("a1") long a, @Param("b1") long b, @Param("c1") String c) { + this(a, c, c); + } + + ResultType1(long a, String b, LocalDate c) { + this(a, b, c.toString()); + } + + ResultType1(long a, String b, LocalDate c, String d) { + this(a, b, c.toString()); + } +} + +record ResultType2(long a, @Param("b1") long b, String c) { +} + +class CustomObj { + CustomObj(String a, int b, List c) { + + } +} + +class MyTypeHandler extends BaseTypeHandler> { + + @Override + public void setNonNullParameter(PreparedStatement ps, int i, List parameter, JdbcType jdbcType) + throws SQLException { + + } + + @Override + public List getNullableResult(ResultSet rs, String columnName) throws SQLException { + return List.of(); + } + + @Override + public List getNullableResult(ResultSet rs, int columnIndex) throws SQLException { + return List.of(); + } + + @Override + public List getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { + return List.of(); + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account.java new file mode 100644 index 00000000000..40905ee1755 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account.java @@ -0,0 +1,20 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public record Account(long accountId, String accountName, String accountType) { + +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account1.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account1.java new file mode 100644 index 00000000000..7323d49574f --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account1.java @@ -0,0 +1,22 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public record Account1(long accountId, String accountName, String accountType) { + public Account1(long accountId, String accountName, int accountType) { + this(accountId, accountName, String.valueOf(accountType)); + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account2.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account2.java new file mode 100644 index 00000000000..c7664104ad5 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account2.java @@ -0,0 +1,23 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public record Account2(long accountId, String accountName, String accountType) { + + public Account2(long accountId, String accountName, String accountType, String extraInfo) { + this(accountId, accountName, accountType); + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account3.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account3.java new file mode 100644 index 00000000000..17184147e97 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account3.java @@ -0,0 +1,27 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public record Account3(long accountId, String accountName, String accountType) { + + public Account3(long accountId, int mismatch, String accountType) { + this(accountId, "MismatchedAccountI", accountType); + } + + public Account3(long accountId, long mismatch, String accountType) { + this(accountId, "MismatchedAccountL", accountType); + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account4.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account4.java new file mode 100644 index 00000000000..8d27da5fbf1 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Account4.java @@ -0,0 +1,21 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +import java.time.LocalDate; + +public record Account4(long accountId, String accountName, LocalDate accountDob) { +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorFailingTest.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorFailingTest.java new file mode 100644 index 00000000000..f6cb3d612e5 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorFailingTest.java @@ -0,0 +1,39 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +import java.io.IOException; +import java.io.Reader; + +import org.apache.ibatis.builder.BuilderException; +import org.apache.ibatis.io.Resources; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +class AutoTypeFromNonAmbiguousConstructorFailingTest { + + @Test + void testCannotResolveAmbiguousConstructor() throws IOException { + // Account1 has more than 1 matching constructor, and auto type cannot decide which one to use + try (Reader reader = Resources.getResourceAsReader( + "org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config-failing.xml")) { + + Assertions.assertThatThrownBy(() -> new SqlSessionFactoryBuilder().build(reader)).isNotNull() + .hasCauseInstanceOf(BuilderException.class).hasMessageContaining("Failed to find a constructor"); + } + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorTest.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorTest.java new file mode 100644 index 00000000000..1c5407458f8 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/AutoTypeFromNonAmbiguousConstructorTest.java @@ -0,0 +1,124 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +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.assertj.core.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +class AutoTypeFromNonAmbiguousConstructorTest { + + private static SqlSessionFactory sqlSessionFactory; + + @BeforeAll + static void setUp() throws Exception { + try (Reader reader = Resources.getResourceAsReader( + "org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config.xml")) { + sqlSessionFactory = new SqlSessionFactoryBuilder().build(reader); + } + + BaseDataTest.runScript(sqlSessionFactory.getConfiguration().getEnvironment().getDataSource(), + "org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/CreateDB.sql"); + } + + @Test + void testNormalCaseWhereAllTypesAreProvided() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + Mapper mapper = sqlSession.getMapper(Mapper.class); + Account account = mapper.getAccountNonAmbiguous(1); + Assertions.assertThat(account).isNotNull(); + Assertions.assertThat(account.accountId()).isEqualTo(1); + Assertions.assertThat(account.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account.accountType()).isEqualTo("Current"); + } + } + + @Test + void testNoTypesAreProvidedAndUsesAutoType() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + Mapper mapper = sqlSession.getMapper(Mapper.class); + Account account = mapper.getAccountJavaTypesMissing(1); + Assertions.assertThat(account).isNotNull(); + Assertions.assertThat(account.accountId()).isEqualTo(1); + Assertions.assertThat(account.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account.accountType()).isEqualTo("Current"); + + Mapper1 mapper1 = sqlSession.getMapper(Mapper1.class); + Account account1 = mapper1.getAccountJavaTypesMissing(1); + Assertions.assertThat(account1).isNotNull(); + Assertions.assertThat(account1.accountId()).isEqualTo(1); + Assertions.assertThat(account1.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account1.accountType()).isEqualTo("Current"); + } + } + + @Test + void testSucceedsWhenConstructorWithMoreTypesAreFound() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + Mapper mapper = sqlSession.getMapper(Mapper.class); + Account2 account = mapper.getAccountExtraParameter(1); + Assertions.assertThat(account).isNotNull(); + Assertions.assertThat(account.accountId()).isEqualTo(1); + Assertions.assertThat(account.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account.accountType()).isEqualTo("Current"); + } + } + + @Test + void testChoosesCorrectConstructorWhenPartialTypesAreProvided() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + Mapper mapper = sqlSession.getMapper(Mapper.class); + Account3 account = mapper.getAccountPartialTypesProvided(1); + Assertions.assertThat(account).isNotNull(); + Assertions.assertThat(account.accountId()).isEqualTo(1); + Assertions.assertThat(account.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account.accountType()).isEqualTo("Current"); + + Mapper1 mapper1 = sqlSession.getMapper(Mapper1.class); + Account3 account1 = mapper1.getAccountPartialTypesProvided(1); + Assertions.assertThat(account1).isNotNull(); + Assertions.assertThat(account1.accountId()).isEqualTo(1); + Assertions.assertThat(account1.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account1.accountType()).isEqualTo("Current"); + } + } + + @Test + void testSucceedsWhenConstructorArgsAreInWrongOrderAndTypesAreNotProvided() { + try (SqlSession sqlSession = sqlSessionFactory.openSession()) { + Mapper mapper = sqlSession.getMapper(Mapper.class); + Account4 account = mapper.getAccountWrongOrder(1); + Assertions.assertThat(account).isNotNull(); + Assertions.assertThat(account.accountId()).isEqualTo(1); + Assertions.assertThat(account.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account.accountDob()).isEqualTo("2025-01-05"); + + Mapper1 mapper1 = sqlSession.getMapper(Mapper1.class); + Account4 account4 = mapper1.getAccountWrongOrder(1); + Assertions.assertThat(account4).isNotNull(); + Assertions.assertThat(account4.accountId()).isEqualTo(1); + Assertions.assertThat(account4.accountName()).isEqualTo("Account 1"); + Assertions.assertThat(account4.accountDob()).isEqualTo("2025-01-05"); + } + } +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/FailingMapper.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/FailingMapper.java new file mode 100644 index 00000000000..19903294978 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/FailingMapper.java @@ -0,0 +1,22 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public interface FailingMapper { + + Account1 getAccountAmbiguous(long id); + +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.java new file mode 100644 index 00000000000..349aed08c95 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.java @@ -0,0 +1,29 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +public interface Mapper { + + Account getAccountNonAmbiguous(long id); + + Account getAccountJavaTypesMissing(long id); + + Account2 getAccountExtraParameter(long id); + + Account3 getAccountPartialTypesProvided(long id); + + Account4 getAccountWrongOrder(int i); +} diff --git a/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper1.java b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper1.java new file mode 100644 index 00000000000..ed595439cb2 --- /dev/null +++ b/src/test/java/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper1.java @@ -0,0 +1,41 @@ +/* + * Copyright 2009-2025 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.auto_type_from_non_ambiguous_constructor; + +import org.apache.ibatis.annotations.Arg; +import org.apache.ibatis.annotations.ConstructorArgs; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Select; + +@Mapper +public interface Mapper1 { + + String SELECT_SQL = "select a.id, a.name, a.type from account a where a.id = #{id}"; + String SELECT_WITH_DOB_SQL = "select id, name, type, date '2025-01-05' dob from account where id = #{id}"; + + @ConstructorArgs({ @Arg(column = "id"), @Arg(column = "name"), @Arg(column = "type") }) + @Select(SELECT_SQL) + Account getAccountJavaTypesMissing(long id); + + @ConstructorArgs({ @Arg(column = "id"), @Arg(column = "name", javaType = String.class), @Arg(column = "type") }) + @Select(SELECT_SQL) + Account3 getAccountPartialTypesProvided(long id); + + @ConstructorArgs({ @Arg(column = "name", name = "accountName"), @Arg(column = "dob", name = "accountDob"), + @Arg(column = "id", name = "accountId") }) + @Select(SELECT_WITH_DOB_SQL) + Account4 getAccountWrongOrder(long id); +} diff --git a/src/test/resources/org/apache/ibatis/immutable/ImmutableBlogMapper.xml b/src/test/resources/org/apache/ibatis/immutable/ImmutableBlogMapper.xml index 41c8f3f56d3..b3989f00cb6 100644 --- a/src/test/resources/org/apache/ibatis/immutable/ImmutableBlogMapper.xml +++ b/src/test/resources/org/apache/ibatis/immutable/ImmutableBlogMapper.xml @@ -1,7 +1,7 @@ + + + + + + + + + + + + + select a.id + , a.name + , a.type + from account a + where a.id = #{id} + + + + \ No newline at end of file diff --git a/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.xml b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.xml new file mode 100644 index 00000000000..584eacaaa6d --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/Mapper.xml @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + select a.id + , a.name + , a.type + from account a + where a.id = #{id} + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config-failing.xml b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config-failing.xml new file mode 100644 index 00000000000..3aec2aeb62c --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config-failing.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config.xml b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config.xml new file mode 100644 index 00000000000..0b9e0c18dc8 --- /dev/null +++ b/src/test/resources/org/apache/ibatis/submitted/auto_type_from_non_ambiguous_constructor/mybatis-config.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + +