@QuerydslPredicate

김삼현·2022년 1월 10일
0

Spring data jpa 에서 제공하는 @QuerydslPredicate 을 사용하면 컨트롤러의 파라미터를 이용하여
간단하게 Querydsl 조건문을 생성할 수 있다.


    @GetMapping("/posts")
    public Page<PostDto> find(@QuerydslPredicate(root = Post.class) Predicate predicate,
                              @PageableDefault Pageable pageable) {
        ...
    }

위 코드와 같은 형태로 사용을 하며 아래의 예시와 같은 조건문을 얻을 수 있다.

/posts?name=test -> post.name.eq("test")

활용

기본적으로 파라미터 값이 하나일경우 eq, 여러개일경우 in 조건문이 생성된다.

예시

  • /posts?name=test -> post.name.eq("test")
  • /posts?name=test&name=test2 -> post.name.in("test", "test2")

다양한 조건문을 생성하기 위해서는 커스터마이징이 필요한데 QuerydslBinderCustomizer 인터페이스의 customize 메소드를 오버라이드 하여
원하는 조건문을 생성할 수 있다.


@Repository
public class PostRepository implements QuerydslBinderCustomizer<QPost> {

    @Override
    public void customize(QuerydslBindings bindings, QPost post) {
        bindings.bind(post.createdAt)
                .all(((path, value) -> {
                    List<? extends Date> dates = new ArrayList<>(value);

                    if (dates.size() == 1) {
                        return Optional.of(post.createdAt.eq(dates.get(0)));
                    } else {
                        Date from = dates.get(0);
                        Date to = dates.get(1);
                        return Optional.of(post.createdAt.between(from, to));
                    }
                }));
    }
}

위의 예시는 Post entity 의 생성일 검색 조건이 한개 일 경우 단순 equals 비교를 하고 두개 이상 일 경우 between 비교를 하는 조건문을 만들도록 커스터마이징 한 코드이다.

테스트

커스터마이징한 조건문이 내가 생각한대로 작성되었는지 검증하기 위해서 테스트가 필요하다.

우리가 작성한 커스터마이징 코드는 사용자가 보낸 요청이 컨트롤러에 전달될때 QuerydslPredicateArgumentResolver 통해서 파라미터가 조건문으로 변환되는 과정을 거친다.
컨트롤러를 통해 서비스를 거쳐 레파지토리에서 쿼리한 결과물과 수행된 쿼리를 통해 검증을 할 수도 있지만 이런식의 검증은 많은 리소스를 필요로 한다.

QuerydslPredicateArgumentResolver 와 MockHttpServletRequest 를 사용해서 변환된 조건문을 받아와 비교를 하면 좀 더 간단하게 테스트를 수행해 볼 수 있다.


public class PostQuerydslBindingTest {

    private QuerydslPredicateArgumentResolver resolver;

    private MockHttpServletRequest request;

    private DateFormat dateFormat;

    @BeforeEach
    void setUp() {
        this.resolver = new QuerydslPredicateArgumentResolver(new QuerydslBindingsFactory(SimpleEntityPathResolver.INSTANCE),
                Optional.of(new DefaultFormattingConversionService()));
        this.request = new MockHttpServletRequest();

        this.dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
    }

    @Test
    public void binding_date_parameters_test() throws Exception {
        Date startAt = Date.from(LocalDateTime.now().minus(30, ChronoUnit.DAYS).atZone(ZoneId.systemDefault()).toInstant());
        Date endAt = new Date();

        request.addParameter("createdAt", dateFormat.format(startAt));
        request.addParameter("createdAt", dateFormat.format(endAt));
        request.addParameter("modifiedAt", dateFormat.format(startAt));

        Object predicate = resolver.resolveArgument(
                new MethodParameter(PostApi.class.getMethod("find", Predicate.class), 0),
                null,
                new ServletWebRequest(request),
                null);

        assertNotNull(predicate);
        assertEquals(post.createdAt.between(startAt, endAt).and(post.modifiedAt.eq(startAt)), predicate);
    }

    interface PostApi {

        void find(@QuerydslPredicate(root = Post.class, bindings = PostRepository.class) Predicate predicate);
    }
}

위 예시는 QuerydslPredicateArgumentResolver 를 통해서 아까 위에서 작성된 커스터마이징 코드가 정상적으로 동작하는지 확인하는 테스트이다.
createdAt 조건이 두개 이상이므로 between 조건문이 생성되어야하고 modifiedAt은 따로 작성된 커스터마이징 코드가 없기때문에 eq 조건문이 생성되어야한다.

우리가 원하는대로 조건문이 생성된걸 확인할 수 있다.

데이터 타입을 사용한 바인딩

위의 코드와 같이 post.createdAt 처럼 path 를 지정하여 조건문을 바인딩 할 수도 있지만 타입을 통해서도 바인딩이 가능하다.


public interface CustomQuerydslBinderCustomizer<T extends EntityPath<?>> extends QuerydslBinderCustomizer<T> {

    void addCustomization(QuerydslBindings bindings, T entityPath);

    @Override
    default void customize(QuerydslBindings bindings, T entityPath) {
    
        /**
        * Date type 이고 
        *   전달된 파라미터가 1개일 경우 -> 단순 eq 비교 
        *   전달된 파라미터가 2개 이상일 경우 -> between 비교 
        */
        bindings.bind(Date.class).all((path, value) -> {
            List<? extends Date> dates = new ArrayList<>(value);
            if (dates.size() == 1) {
                return Optional.of(((DateTimePath) path).eq(dates.get(0)));
            } else {
                Date from = dates.get(0);
                Date to = dates.get(1);
                return Optional.of(((DateTimePath) path).between(from, to));
            }
        });

        /**
        * String type 이고 
        *   전달된 파라미터가 1개 이고 
        *       % 로 시작 할 경우 -> like 비교 
        *       단순 문자열 -> eq 비교  
        *   전달된 파라미터가 2개 이상일 경우 -> in 비교  
        */
        bindings.bind(String.class).all((path, value) -> {
            List<? extends String> values = new ArrayList<>(value);
            if (values.size() == 1) {
                String searchWord = values.get(0);
                if (searchWord.startsWith("%")) {
                    return Optional.of(((StringPath) path).like(searchWord));
                } else {
                    return Optional.of(((StringPath) path).eq(searchWord));
                }
            } else {
                return Optional.of(((StringPath) path).in(values));
            }
        });

        this.addCustomization(bindings, entityPath);
    }
}

parameter 들이 조건문으로 변환시에 먼저 path 로 작성된 bindings 가 있는지 확인 후 있으면 path 로 작성된 bindings 를 사용하여 조건문을 작성하고 없을경우 type 에 맞는 bindings 로 조건문이 작성되게 된다.
타입에 따른 기본적인 룰을 정해서 위와같이 타입으로 bindings 를 작성하고 기본적인 룰이 아닌 특별한 조건이 필요한 경우에 path 를 사용하여 bindings 를 작성하면 좀더 효율적으로 사용이 가능하다.


@Repository
public class PostRepository implements CustomQuerydslBinderCustomizer<QPost> {

    @Override
    public void addCustomization(QuerydslBindings bindings, QPost entityPath) {
        bindings.bind(post.content).first(StringExpression::contains);
    }
}

위에서 작성한 PostRepository 를 CustomQuerydslBinderCustomizer 를 implements 하게 바꿔주고 기존에 오버라이드 한 customize 는 지워준다.
기존의 customize 에서 하던 바인딩은 CustomQuerydslBinderCustomizer 에서 타입을 기반으로 공통적으로 해주고 있기 때문에 다시 바인딩 할 필요가 없다.
대신 addCustomization 함수를 오버라이드 하고 위와 같은 로직을 추가해주자.

작성한 코드가 정상적으로 동작하는지 확인하기 위해 테스트코드를 추가해준다.


import static com.example.querydsl.study.entity.QPost.post;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

public class PostQuerydslBindingTest {

    private QuerydslPredicateArgumentResolver resolver;

    private MockHttpServletRequest request;

    private DateFormat dateFormat;

    @BeforeEach
    void setUp() {
        this.resolver = new QuerydslPredicateArgumentResolver(new QuerydslBindingsFactory(SimpleEntityPathResolver.INSTANCE),
                Optional.of(new DefaultFormattingConversionService()));
        this.request = new MockHttpServletRequest();

        this.dateFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX");
    }

    @Test
    public void binding_date_parameters_test() throws Exception {
        Date startAt = Date.from(LocalDateTime.now().minus(30, ChronoUnit.DAYS).atZone(ZoneId.systemDefault()).toInstant());
        Date endAt = new Date();

        request.addParameter("createdAt", dateFormat.format(startAt));
        request.addParameter("createdAt", dateFormat.format(endAt));
        request.addParameter("modifiedAt", dateFormat.format(startAt));

        Object predicate = resolver.resolveArgument(
                new MethodParameter(PostApi.class.getMethod("find", Predicate.class), 0),
                null,
                new ServletWebRequest(request),
                null);

        assertNotNull(predicate);
        assertEquals(post.createdAt.between(startAt, endAt).and(post.modifiedAt.eq(startAt)), predicate);
    }

    @Test
    public void binding_string_parameters_test() throws Exception {
        request.addParameter("writer.nickname", "%test%");
        request.addParameter("title", "test");
        request.addParameter("title", "test1");
        request.addParameter("content", "test");
        Object predicate = resolver.resolveArgument(
                new MethodParameter(PostApi.class.getMethod("find", Predicate.class), 0),
                null,
                new ServletWebRequest(request),
                null);

        assertNotNull(predicate);
        assertEquals(post.writer.nickname.like("%test%")
                .and(post.title.in("test", "test1"))
                .and(post.content.contains("test")), predicate);
    }

    interface PostApi {

        void find(@QuerydslPredicate(root = Post.class, bindings = PostRepository.class) Predicate predicate);
    }
}

내가 기대한 대로 조건문이 작성된 것을 확인 할 수 있다.

참조

sample source repository

0개의 댓글