Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Anomalous behavior of spring-data custom repositories returning io.vavr.control.Try #3257

Open
luismunizsparkers opened this issue Mar 20, 2025 · 0 comments
Assignees
Labels
status: waiting-for-triage An issue we've not yet triaged

Comments

@luismunizsparkers
Copy link

luismunizsparkers commented Mar 20, 2025

Context

Reference spring boot 3.4.3 application on github: https://github.com/luismunizsparkers/spring-data-jdbc-try

Problem

The above application demonstrates the following weird behavior in an integration test:

When defining a method in a custom Repository fragment, whose return type is a io.vavr.control.Try, the framework wraps the returned Try objects in an extraneous Try, modifying the declared contract of the Custom repository fragment, from Try<T> to Try<Try<T>>.

Client code then needs to resort to erasing the type of the returned method and force casting tricks, to correct the over-eager behavior of the framework.

The current example can be implemented with a @Query annotation, but a more typical use case would be custom method constructing a complex query dynamically, which would like to return a Try.Failure in case of business logic failures or bad arguments. This seems totally legitimate to me.

Defining a method in a custom repository fragment that returns a io.vavr.control.Try

CustomOrderRepository.java

package com.example.spring_data_jdbc_try.order.repository;

import io.vavr.control.Try;

public interface CustomOrderRepository {
    public Try<Boolean> tryUpdateStatus(Integer orderId, String newStatus);
    public Boolean updateStatus(Integer orderId, String newStatus);
}

The contract of the method Try<Boolean> tryUpdateStatus is to return a Try object that contains the result of the update operation. However, when the method is called, it returns a Try<Try<Boolean>>.

This is the implementation of the custom repository fragment:

CustomOrderRepositoryImpl.java

package com.example.spring_data_jdbc_try.order.repository;

import io.vavr.control.Try;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;

@Repository
class CustomOrderRepositoryImpl implements CustomOrderRepository {
    private final JdbcTemplate jdbcTemplate;

    public CustomOrderRepositoryImpl(JdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }

    public Boolean updateStatus(Integer orderId, String newStatus) {
        String sql = "UPDATE orders SET status = ? WHERE id = ?";
        var updated = jdbcTemplate.update(sql, newStatus, orderId);
        if (updated == 0) {
            throw new RuntimeException("Order " + orderId + " not found");
        } else {
            return true;
        }
    }

    public Try<Boolean> tryUpdateStatus(Integer orderId, String newStatus) {
        return Try.of(() -> updateStatus(orderId, newStatus));
    }
}

This fragment provides 2 versions of the functionality:

  • tryUpdateStatus - which returns a Try<Boolean> object
  • updateStatus - which returns a Boolean object

tryUpdateStatus just invokes the updateStatus method and wraps the result in a Try object.

Defining a method in the BASE repository that returns a io.vavr.control.Try

In contradiction with the behaviour of placing the method in the custom fragment, when we add the signature in the BASE repository, it works as expected. This is the signature of the repository method:

OrderRepository.java

package com.example.spring_data_jdbc_try.order.repository;

import com.example.spring_data_jdbc_try.order.data.Order;
import io.vavr.control.Try;
import org.springframework.data.jdbc.repository.query.Modifying;
import org.springframework.data.jdbc.repository.query.Query;
import org.springframework.data.repository.ListCrudRepository;

public interface OrderRepository extends ListCrudRepository<Order, Integer>, CustomOrderRepository {
    @Modifying
    @Query("UPDATE orders SET status = :newStatus WHERE id = :orderId")
    public Try<Boolean> tryUpdateStatus2(Integer orderId, String newStatus);
}

It’s the same signature as the one in the custom fragment, but this time it is placed in the base repository. and uses the @query annotation so that spring-data-jdbc can generate the implementation for us.

Tests

I have provided an integration test that exposes the weird behaviour:

OrderRepositoryIntegrationSpec.groovy

package com.example.spring_data_jdbc_try.order.repository

import com.example.spring_data_jdbc_try.SpringDataJdbcTryApplication
import com.example.spring_data_jdbc_try.order.data.Order
import io.vavr.control.Try
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.transaction.annotation.Transactional
import spock.lang.Specification
import spock.lang.Subject

@Subject(OrderRepository)
@SpringBootTest(classes = [SpringDataJdbcTryApplication])
@Transactional
class OrderRepositoryIntegrationSpec extends Specification {
    @Subject
    @Autowired
    private OrderRepository orderRepository

    def "It succeeds to update an order's status if the custom repository method returns a Boolean"() {
        given:
        def order = new Order(null, anyString(), "NEW")
        var saved = orderRepository.save(order)


        when:
        def newStatus = orderRepository.updateStatus(saved.id(), "OUT_OF_STOCK")

        then:
        newStatus
    }

    def "It fails to correctly return the type of a CUSTOM repository method that returns a Try instance. Instead returns a Try<Try<?>>"() {
        given:
        def order = new Order(null, anyString(), "NEW")
        var saved = orderRepository.save(order)


        when:
        def attempt = orderRepository.tryUpdateStatus(saved.id(), "OUT_OF_STOCK")

        then:
        attempt.success
        Try.isAssignableFrom(attempt.class)
        def wrappedValueShouldBeBoolean = attempt.get() as Try// this should be a Boolean, but is a Try<Boolean>
        Try.isAssignableFrom(wrappedValueShouldBeBoolean.class)
        wrappedValueShouldBeBoolean.success
        wrappedValueShouldBeBoolean.get() == true
    }

    def "It correctly returns the type of a BASE repository method that returns a Try instance."() {
        given:
        def order = new Order(null, anyString(), "NEW")
        var saved = orderRepository.save(order)


        when:
        def attempt = orderRepository.tryUpdateStatus2(saved.id(), "OUT_OF_STOCK")

        then:
        attempt.success
        Try.isAssignableFrom(attempt.class)
        def wrappedValueShouldBeBoolean = attempt.get()
        wrappedValueShouldBeBoolean.class == Boolean
        wrappedValueShouldBeBoolean == true
    }


    static String anyString() {
        UUID.randomUUID().toString()
    }
}

Conclusion

Both methods have the same type signature, and should behave the same. However, the method in the custom repository fragment returns a Try<Try<Boolean>> object, while the method in the base repository returns a Try<Boolean> object.

This looks like a bug, or I have failed in finding any documentation explaining hwo to use it.

Suggested fix (IMO)

Some digging around and step-debugging indicates that it looks like the issue lies around this code in class org.springframework.data.repository.util.QueryExecutionConverters:

		if (VAVR_PRESENT) {

			// Try support
			WRAPPER_TYPES.add(WrapperType.singleValue(io.vavr.control.Try.class));
			EXECUTION_ADAPTER.put(io.vavr.control.Try.class, it -> io.vavr.control.Try.of(it::get));
		}

I think this should check if the returned type is already a Try instance and not wrap it in that case.

@spring-projects-issues spring-projects-issues added the status: waiting-for-triage An issue we've not yet triaged label Mar 20, 2025
@luismunizsparkers luismunizsparkers changed the title Anomalous behavior of spring-data custom repositories returning io.vav.control.Try Anomalous behavior of spring-data custom repositories returning io.vavr.control.Try Mar 20, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: waiting-for-triage An issue we've not yet triaged
Projects
None yet
Development

No branches or pull requests

3 participants