15

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

Eric J Turley
  • 350
  • 1
  • 5
  • 20

2 Answers2

4

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(".*[^.]\\[.*[^\\[]");
Eric J Turley
  • 350
  • 1
  • 5
  • 20
1

I spent a few days looking into how to do this. In the end I just went with manually adding to the predicate. This solution feels simple and elegant.

So you access the map via

GET /api/meetup?properties.aKey=aValue

On the controller I injected the request parameters and the predicate.

public List<Meetup> getMeetupList(@QuerydslPredicate(root = Meetup.class) Predicate predicate,
                                                @RequestParam Map<String, String> allRequestParams,
                                                Pageable page) {
    Predicate builder = createPredicateQuery(predicate, allRequestParams);
    return meetupRepo.findAll(builder, page);
}

I then just simply parsed the query parameters and added contains

private static final String PREFIX = "properties.";

private BooleanBuilder createPredicateQuery(Predicate predicate, Map<String, String> allRequestParams) {
    BooleanBuilder builder = new BooleanBuilder();
    builder.and(predicate);
    allRequestParams.entrySet().stream()
            .filter(e -> e.getKey().startsWith(PREFIX))
            .forEach(e -> {
                var key = e.getKey().substring(PREFIX.length());
                builder.and(QMeetup.meetup.properties.contains(key, e.getValue()));
            });
    return builder;
}
Sean Burns
  • 96
  • 3
  • I do like your option, the contains works. But I'm still unable to increase the complexity of the predicate with results. For instance: ```if(key.startsWith("min")){builder.and(QServiceLimit.serviceLimit.attribute.get(key).gt(Double.parseDouble(e.getValue())));}``` – Ilhicas Jul 11 '19 at 17:35
  • Could you expand further? what is your request param for your example? Complex queries on the map (like gt, lt, between etc) works for me... Your query param will probably contain the "function you want to execute" eg. `properties.age:min=45&properties.age:max=55` I already use this kind of query for date ranges `from:prop.date=10-11-2019&to:prop.date=11-11-2019` – Sean Burns Jul 30 '19 at 19:42