Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,29 @@
* <p>In Criteria API, an equivalent expression might be:
*
* <pre>{@code
* cb.like(root.get(path), "%" + value);
* cb.like(root.get(path), "%" + value, '\\');
* }</pre>
*
* <p>This typically translates to SQL like:
*
* <pre>
* {@code ... where x.firstname like %?}
* {@code ... where x.firstname like %? escape '\'}
* </pre>
*
* <p>The value is matched literally, see {@link LikePattern}.
*
* @author Matt Ho
* @see StartingWith
*/
public class EndingWith<T> extends SimpleSpecification<T> {

public EndingWith(@NonNull Context context, @NonNull String path, @NonNull Object value) {
super(context, path, "%" + value);
super(context, path, "%" + LikePattern.escape(value));
}

@Override
public Predicate toPredicate(
@NonNull Root<T> root, @Nullable CriteriaQuery<?> query, @NonNull CriteriaBuilder builder) {
return builder.like(getPath(root), Objects.toString(value));
return builder.like(getPath(root), Objects.toString(value), LikePattern.ESCAPE_CHAR);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
import jakarta.persistence.criteria.CriteriaQuery;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.criteria.Root;
import java.util.ArrayList;
import java.util.Arrays;
import lombok.NonNull;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.lang.Nullable;
Expand All @@ -45,11 +47,22 @@
* ... WHERE x.firstname IN (?, ?, ...)
* }</pre>
*
* <p>Collections larger than {@link #MAX_CHUNK_SIZE} are partitioned into OR-combined {@code IN}
* clauses.
*
* @author Matt Ho
* @see NotIn
*/
public class In<T> extends SimpleSpecification<T> {

/**
* The maximum number of elements expanded into a single {@code IN} clause.
*
* <p>Several RDBMS cap the number of elements of an {@code IN} clause, commonly at 1000, and huge
* lists degrade the query plan; tune this to the target RDBMS if needed.
*/
public static final int MAX_CHUNK_SIZE = 1000;

public In(@NonNull Context context, @NonNull String path, @NonNull Object value) {
super(context, path, value);
if (!(value instanceof Iterable)) {
Expand All @@ -60,7 +73,16 @@ public In(@NonNull Context context, @NonNull String path, @NonNull Object value)
@Override
public Predicate toPredicate(
@NonNull Root<T> root, @Nullable CriteriaQuery<?> query, @NonNull CriteriaBuilder builder) {
return getPath(root)
.in(stream(((Iterable<?>) value).spliterator(), false).toArray(Object[]::new));
var path = getPath(root);
var values = stream(((Iterable<?>) value).spliterator(), false).toArray(Object[]::new);
if (values.length <= MAX_CHUNK_SIZE) {
return path.in(values);
}
var chunks = new ArrayList<Predicate>();
for (var from = 0; from < values.length; from += MAX_CHUNK_SIZE) {
var to = Math.min(from + MAX_CHUNK_SIZE, values.length);
chunks.add(path.in(Arrays.copyOfRange(values, from, to)));
}
return builder.or(chunks.toArray(Predicate[]::new));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,29 @@
* <p>In Criteria API, an equivalent expression might be:
*
* <pre>{@code
* cb.like(root.get(path), "%" + value + "%");
* cb.like(root.get(path), "%" + value + "%", '\\');
* }</pre>
*
* <p>This typically translates to SQL like:
*
* <pre>{@code
* ... where x.firstname like %?%
* ... where x.firstname like %?% escape '\'
* }</pre>
*
* <p>The value is matched literally, see {@link LikePattern}.
*
* @author Matt Ho
* @see NotLike
*/
public class Like<T> extends SimpleSpecification<T> {

public Like(@NonNull Context context, @NonNull String path, @NonNull Object value) {
super(context, path, "%" + value + "%");
super(context, path, "%" + LikePattern.escape(value) + "%");
}

@Override
public Predicate toPredicate(
@NonNull Root<T> root, @Nullable CriteriaQuery<?> query, @NonNull CriteriaBuilder builder) {
return builder.like(getPath(root), Objects.toString(value));
return builder.like(getPath(root), Objects.toString(value), LikePattern.ESCAPE_CHAR);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/*
* Copyright © 2022 SoftLeader
*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package tw.com.softleader.data.jpa.spec.domain;

import java.util.Objects;
import lombok.NonNull;

/**
* Composes the {@code LIKE} patterns used by {@link Like}, {@link NotLike}, {@link StartingWith}
* and {@link EndingWith}.
*
* <p>User supplied values are matched literally: the wildcards {@code %} and {@code _}, as well as
* the {@link #ESCAPE_CHAR escape character} itself, are escaped before being composed into a
* pattern.
*
* @author Matt Ho
*/
public final class LikePattern {

/** The escape character declared by every {@code LIKE} predicate of this package. */
public static final char ESCAPE_CHAR = '\\';

private LikePattern() {}

/**
* Escapes the {@code LIKE} wildcards of the given value, so that it is matched literally.
*
* @param value the value to escape
* @return the escaped value
*/
public static String escape(@NonNull Object value) {
var text = Objects.toString(value);
var escaped = new StringBuilder(text.length());
for (var i = 0; i < text.length(); i++) {
var c = text.charAt(i);
if (c == ESCAPE_CHAR || c == '%' || c == '_') {
escaped.append(ESCAPE_CHAR);
}
escaped.append(c);
}
return escaped.toString();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@
* ... where x.firstname not in (?, ?, ...)
* }</pre>
*
* <p>Collections larger than {@link In#MAX_CHUNK_SIZE} are partitioned the same way {@link In}
* does, negating the OR-combined chunks as a whole.
*
* @author Matt Ho
* @see In
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,27 +36,29 @@
* <p>In Criteria API, an equivalent expression might be:
*
* <pre>{@code
* cb.notLike(root.get(path), "%" + value + "%");
* cb.notLike(root.get(path), "%" + value + "%", '\\');
* }</pre>
*
* <p>This typically translates to SQL like:
*
* <pre>{@code
* ... where x.firstname not like %?%
* ... where x.firstname not like %?% escape '\'
* }</pre>
*
* <p>The value is matched literally, see {@link LikePattern}.
*
* @author Matt Ho
* @see Like
*/
public class NotLike<T> extends SimpleSpecification<T> {

public NotLike(@NonNull Context context, @NonNull String path, @NonNull Object value) {
super(context, path, "%" + value + "%");
super(context, path, "%" + LikePattern.escape(value) + "%");
}

@Override
public Predicate toPredicate(
@NonNull Root<T> root, @Nullable CriteriaQuery<?> query, @NonNull CriteriaBuilder builder) {
return builder.notLike(getPath(root), Objects.toString(value));
return builder.notLike(getPath(root), Objects.toString(value), LikePattern.ESCAPE_CHAR);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,27 +35,29 @@
* <p>In Criteria API, an equivalent expression might be:
*
* <pre>{@code
* cb.like(root.get(path), value + "%");
* cb.like(root.get(path), value + "%", '\\');
* }</pre>
*
* <p>This typically translates to SQL like:
*
* <pre>
* {@code ... where x.firstname like ?%}
* {@code ... where x.firstname like ?% escape '\'}
* </pre>
*
* <p>The value is matched literally, see {@link LikePattern}.
*
* @author Matt Ho
* @see EndingWith
*/
public class StartingWith<T> extends SimpleSpecification<T> {

public StartingWith(@NonNull Context context, @NonNull String path, @NonNull Object value) {
super(context, path, value + "%");
super(context, path, LikePattern.escape(value) + "%");
}

@Override
public Predicate toPredicate(
@NonNull Root<T> root, @Nullable CriteriaQuery<?> query, @NonNull CriteriaBuilder builder) {
return builder.like(getPath(root), Objects.toString(value));
return builder.like(getPath(root), Objects.toString(value), LikePattern.ESCAPE_CHAR);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ void shouldRegisterHints() {
assertThat(reflection().onType(LessThan.class)).accepts(hints);
assertThat(reflection().onType(LessThanEqual.class)).accepts(hints);
assertThat(reflection().onType(Like.class)).accepts(hints);
assertThat(reflection().onType(LikePattern.class)).accepts(hints);
assertThat(reflection().onType(Not.class)).accepts(hints);
assertThat(reflection().onType(NotEquals.class)).accepts(hints);
assertThat(reflection().onType(NotIn.class)).accepts(hints);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,13 @@
import static org.assertj.core.api.Assertions.assertThat;
import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext;

import lombok.Builder;
import lombok.Data;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import tw.com.softleader.data.jpa.spec.IntegrationTest;
import tw.com.softleader.data.jpa.spec.SpecMapper;
import tw.com.softleader.data.jpa.spec.annotation.Spec;
import tw.com.softleader.data.jpa.spec.usecase.Customer;
import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository;

Expand All @@ -43,4 +47,24 @@ void test() {
var actual = repository.findAll(spec);
assertThat(actual).hasSize(1).contains(matt);
}

@Test
void wildcardsMatchLiterally() {
var wildcard = repository.save(Customer.builder().name("bypass_").build());
repository.save(Customer.builder().name("matt").build());
repository.save(Customer.builder().name("bob").build());

var mapper = SpecMapper.builder().build();
var spec = mapper.toSpec(EndingWithCriteria.builder().name("_").build(), Customer.class);
var actual = repository.findAll(spec);
assertThat(actual).hasSize(1).contains(wildcard);
}

@Builder
@Data
static class EndingWithCriteria {

@Spec(path = "name", value = EndingWith.class)
String name;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.stream.IntStream;
import lombok.Builder;
import lombok.Data;
import org.junit.jupiter.api.Test;
Expand All @@ -51,6 +53,20 @@ void test() {
assertThat(actual).hasSize(2).contains(matt, bob);
}

@Test
void moreValuesThanChunkSize() {
var matt = repository.save(Customer.builder().name("matt").build());
repository.save(Customer.builder().name("bob").build());

var values = new ArrayList<String>();
IntStream.rangeClosed(1, In.MAX_CHUNK_SIZE).mapToObj(i -> "name-" + i).forEach(values::add);
values.add("matt");

var spec = new In<Customer>(noopContext(), "name", values);
var actual = repository.findAll(spec);
assertThat(actual).hasSize(1).contains(matt);
}

@Test
void typeMismatch() {
var context = noopContext();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,14 @@
import static org.assertj.core.api.Assertions.assertThat;
import static tw.com.softleader.data.jpa.spec.IntegrationTest.TestApplication.noopContext;

import lombok.Builder;
import lombok.Data;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.jpa.domain.Specification;
import tw.com.softleader.data.jpa.spec.IntegrationTest;
import tw.com.softleader.data.jpa.spec.SpecMapper;
import tw.com.softleader.data.jpa.spec.annotation.Spec;
import tw.com.softleader.data.jpa.spec.usecase.Customer;
import tw.com.softleader.data.jpa.spec.usecase.CustomerRepository;

Expand All @@ -44,4 +49,29 @@ void test() {
var actual = repository.findAll(spec);
assertThat(actual).hasSize(1).contains(matt);
}

@Test
void wildcardsMatchLiterally() {
var percent = repository.save(Customer.builder().name("a%b").build());
var underscore = repository.save(Customer.builder().name("a_b").build());
var backslash = repository.save(Customer.builder().name("a\\b").build());
repository.save(Customer.builder().name("axb").build());

var mapper = SpecMapper.builder().build();
assertThat(repository.findAll(toSpec(mapper, "a%b"))).hasSize(1).contains(percent);
assertThat(repository.findAll(toSpec(mapper, "a_b"))).hasSize(1).contains(underscore);
assertThat(repository.findAll(toSpec(mapper, "a\\b"))).hasSize(1).contains(backslash);
}

private Specification<Customer> toSpec(SpecMapper mapper, String name) {
return mapper.toSpec(LikeCriteria.builder().name(name).build(), Customer.class);
}

@Builder
@Data
static class LikeCriteria {

@Spec(path = "name", value = Like.class)
String name;
}
}
Loading