ResultMappingConstructorResolverTest.java

/*
 *    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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> 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<ResultMapping> mappingList = resolver.resolveWithConstructor();

    assertThat(mappingList.get(2).getTypeHandler().getClass()).isEqualTo(MyTypeHandler.class);
  }

  @Nested
  class MetaInfoTests {

    @Test
    void resolvesEmptyConstructor() {
      List<ResultMappingConstructorResolver.ConstructorMetaInfo> 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<ResultMappingConstructorResolver.ConstructorMetaInfo> 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<ResultMappingConstructorResolver.ConstructorMetaInfo> 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<ResultMappingConstructorResolver.ConstructorMetaInfo> 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<String> c) {

  }
}

class MyTypeHandler extends BaseTypeHandler<List<?>> {

  @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();
  }
}