Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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. :/

like image 405
Eric J Turley Avatar asked Aug 31 '17 20:08

Eric J Turley


Video Answer


1 Answers

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(".*[^.]\\[.*[^\\[]");
like image 95
Eric J Turley Avatar answered Nov 11 '22 15:11

Eric J Turley