Search code examples
javaspringspring-data-jpaspring-data-restquerydsl

QueryDsl web query on the key of a Map field


Overview

Given

  • Spring Data JPA, Spring Data Rest, QueryDsl
  • a Meetup entity
    • with a Map<String,String> properties field
      • persisted in a MEETUP_PROPERTY table as an @ElementCollection
  • a MeetupRepository
    • that extends QueryDslPredicateExecutor<Meetup>

I'd expect

A web query of

GET /api/meetup?properties[aKey]=aValue

to return only Meetups with a property entry that has the specified key and value: aKey=aValue.

However, that's not working for me. What am I missing?

Tried

Simple Fields

Simple fields work, like name and description:

GET /api/meetup?name=whatever

Collection fields work, like participants:

GET /api/meetup?participants.name=whatever

But not this Map field.

Customize QueryDsl bindings

I've tried customizing the binding by having the repository

extend QuerydslBinderCustomizer<QMeetup>

and overriding the

customize(QuerydslBindings bindings, QMeetup meetup)

method, but while the customize() method is being hit, the binding code inside the lambda is not.

EDIT: Learned that's because QuerydslBindings means of evaluating the query parameter do not let it match up against the pathSpecs map it's internally holding - which has your custom bindings in it.

Some Specifics

Meetup.properties field

@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "MEETUP_PROPERTY", joinColumns = @JoinColumn(name = "MEETUP_ID"))
@MapKeyColumn(name = "KEY")
@Column(name = "VALUE", length = 2048)
private Map<String, String> properties = new HashMap<>();

customized querydsl binding

EDIT: See above; turns out, this was doing nothing for my code.

public interface MeetupRepository extends PagingAndSortingRepository<Meetup, Long>,
                                          QueryDslPredicateExecutor<Meetup>,
                                          QuerydslBinderCustomizer<QMeetup> {

    @Override
    default void customize(QuerydslBindings bindings, QMeetup meetup) {
        bindings.bind(meetup.properties).first((path, value) -> {
            BooleanBuilder builder = new BooleanBuilder();
            for (String key : value.keySet()) {
                builder.and(path.containsKey(key).and(path.get(key).eq(value.get(key))));
            }
            return builder;
        });
}

Additional Findings

  1. QuerydslPredicateBuilder.getPredicate() asks QuerydslBindings.getPropertyPath() to try 2 ways to return a path from so it can make a predicate that QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.postProcess() can use.
    • 1 is to look in the customized bindings. I don't see any way to express a map query there
    • 2 is to default to Spring's bean paths. Same expression problem there. How do you express a map? So it looks impossible to get QuerydslPredicateBuilder.getPredicate() to automatically create a predicate. Fine - I can do it manually, if I can hook into QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver.postProcess()

HOW can I override that class, or replace the bean? It's instantiated and returned as a bean in the RepositoryRestMvcConfiguration.repoRequestArgumentResolver() bean declaration.

  1. I can override that bean by declaring my own repoRequestArgumentResolver bean, but it doesn't get used.
    • It gets overridden by RepositoryRestMvcConfigurations. I can't force it by setting it @Primary or @Ordered(HIGHEST_PRECEDENCE).
    • I can force it by explicitly component-scanning RepositoryRestMvcConfiguration.class, but that also messes up Spring Boot's autoconfiguration because it causes RepositoryRestMvcConfiguration's bean declarations to be processed before any auto-configuration runs. Among other things, that results in responses that are serialized by Jackson in unwanted ways.

The Question

Well - looks like the support I expected just isn't there.

So the question becomes: HOW do I correctly override the repoRequestArgumentResolver bean?

BTW - QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver is awkwardly non-public. :/


Solution

  • Replace the Bean

    Implement ApplicationContextAware

    This is how I replaced the bean in the application context.

    It feels a little hacky. I'd love to hear a better way to do this.

    @Configuration
    public class CustomQuerydslHandlerMethodArgumentResolverConfig implements ApplicationContextAware {
    
        /**
         * This class is originally the class that instantiated QuerydslAwareRootResourceInformationHandlerMethodArgumentResolver and placed it into the Spring Application Context
         * as a {@link RootResourceInformationHandlerMethodArgumentResolver} by the name of 'repoRequestArgumentResolver'.<br/>
         * By injecting this bean, we can let {@link #meetupApiRepoRequestArgumentResolver} delegate as much as possible to the original code in that bean.
         */
        private final RepositoryRestMvcConfiguration repositoryRestMvcConfiguration;
    
        @Autowired
        public CustomQuerydslHandlerMethodArgumentResolverConfig(RepositoryRestMvcConfiguration repositoryRestMvcConfiguration) {
            this.repositoryRestMvcConfiguration = repositoryRestMvcConfiguration;
        }
    
        @Override
        public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
            DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) ((GenericApplicationContext) applicationContext).getBeanFactory();
            beanFactory.destroySingleton(REPO_REQUEST_ARGUMENT_RESOLVER_BEAN_NAME);
            beanFactory.registerSingleton(REPO_REQUEST_ARGUMENT_RESOLVER_BEAN_NAME,
                                          meetupApiRepoRequestArgumentResolver(applicationContext, repositoryRestMvcConfiguration));
        }
    
        /**
         * This code is mostly copied from {@link RepositoryRestMvcConfiguration#repoRequestArgumentResolver()}, except the if clause checking if the QueryDsl library is
         * present has been removed, since we're counting on it anyway.<br/>
         * That means that if that code changes in the future, we're going to need to alter this code... :/
         */
        @Bean
        public RootResourceInformationHandlerMethodArgumentResolver meetupApiRepoRequestArgumentResolver(ApplicationContext applicationContext,
                                                                                                         RepositoryRestMvcConfiguration repositoryRestMvcConfiguration) {
            QuerydslBindingsFactory factory = applicationContext.getBean(QuerydslBindingsFactory.class);
            QuerydslPredicateBuilder predicateBuilder = new QuerydslPredicateBuilder(repositoryRestMvcConfiguration.defaultConversionService(),
                                                                                     factory.getEntityPathResolver());
    
            return new CustomQuerydslHandlerMethodArgumentResolver(repositoryRestMvcConfiguration.repositories(),
                                                                   repositoryRestMvcConfiguration.repositoryInvokerFactory(repositoryRestMvcConfiguration.defaultConversionService()),
                                                                   repositoryRestMvcConfiguration.resourceMetadataHandlerMethodArgumentResolver(),
                                                                   predicateBuilder, factory);
        }
    }
    

    Create a Map-searching predicate from http params

    Extend RootResourceInformationHandlerMethodArgumentResolver

    And these are the snippets of code that create my own Map-searching predicate based on the http query parameters. Again - would love to know a better way.

    The postProcess method calls:

            predicate = addCustomMapPredicates(parameterMap, predicate, domainType).getValue();
    

    just before the predicate reference is passed into the QuerydslRepositoryInvokerAdapter constructor and returned.

    Here is that addCustomMapPredicates method:

        private BooleanBuilder addCustomMapPredicates(MultiValueMap<String, String> parameters, Predicate predicate, Class<?> domainType) {
            BooleanBuilder booleanBuilder = new BooleanBuilder();
            parameters.keySet()
                      .stream()
                      .filter(s -> s.contains("[") && matches(s) && s.endsWith("]"))
                      .collect(Collectors.toList())
                      .forEach(paramKey -> {
                          String property = paramKey.substring(0, paramKey.indexOf("["));
                          if (ReflectionUtils.findField(domainType, property) == null) {
                              LOGGER.warn("Skipping predicate matching on [%s]. It is not a known field on domainType %s", property, domainType.getName());
                              return;
                          }
                          String key = paramKey.substring(paramKey.indexOf("[") + 1, paramKey.indexOf("]"));
                          parameters.get(paramKey).forEach(value -> {
                              if (!StringUtils.hasLength(value)) {
                                  booleanBuilder.or(matchesProperty(key, null));
                              } else {
                                  booleanBuilder.or(matchesProperty(key, value));
                              }
                          });
                      });
            return booleanBuilder.and(predicate);
        }
    
        static boolean matches(String key) {
            return PATTERN.matcher(key).matches();
        }
    

    And the pattern:

        /**
         * disallow a . or ] from preceding a [
         */
        private static final Pattern PATTERN = Pattern.compile(".*[^.]\\[.*[^\\[]");