diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/EndingWith.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/EndingWith.java index 6cf285a..9809e8a 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/EndingWith.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/EndingWith.java @@ -35,27 +35,29 @@ *

In Criteria API, an equivalent expression might be: * *

{@code
- * cb.like(root.get(path), "%" + value);
+ * cb.like(root.get(path), "%" + value, '\\');
  * }
* *

This typically translates to SQL like: * *

- * {@code ... where x.firstname like %?}
+ * {@code ... where x.firstname like %? escape '\'}
  * 
* + *

The value is matched literally, see {@link LikePattern}. + * * @author Matt Ho * @see StartingWith */ public class EndingWith extends SimpleSpecification { 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 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); } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/In.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/In.java index 630e1d9..c255893 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/In.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/In.java @@ -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; @@ -45,11 +47,22 @@ * ... WHERE x.firstname IN (?, ?, ...) * } * + *

Collections larger than {@link #MAX_CHUNK_SIZE} are partitioned into OR-combined {@code IN} + * clauses. + * * @author Matt Ho * @see NotIn */ public class In extends SimpleSpecification { + /** + * The maximum number of elements expanded into a single {@code IN} clause. + * + *

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)) { @@ -60,7 +73,16 @@ public In(@NonNull Context context, @NonNull String path, @NonNull Object value) @Override public Predicate toPredicate( @NonNull Root 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(); + 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)); } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Like.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Like.java index f2a2ddb..bcb8642 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Like.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/Like.java @@ -35,27 +35,29 @@ *

In Criteria API, an equivalent expression might be: * *

{@code
- * cb.like(root.get(path), "%" + value + "%");
+ * cb.like(root.get(path), "%" + value + "%", '\\');
  * }
* *

This typically translates to SQL like: * *

{@code
- * ... where x.firstname like %?%
+ * ... where x.firstname like %?% escape '\'
  * }
* + *

The value is matched literally, see {@link LikePattern}. + * * @author Matt Ho * @see NotLike */ public class Like extends SimpleSpecification { 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 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); } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/LikePattern.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/LikePattern.java new file mode 100644 index 0000000..c12c4bd --- /dev/null +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/LikePattern.java @@ -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}. + * + *

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(); + } +} diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotIn.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotIn.java index 8e68e23..da151ae 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotIn.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotIn.java @@ -45,6 +45,9 @@ * ... where x.firstname not in (?, ?, ...) * } * + *

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 */ diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotLike.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotLike.java index e5b7260..b5ddd68 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotLike.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/NotLike.java @@ -36,27 +36,29 @@ *

In Criteria API, an equivalent expression might be: * *

{@code
- * cb.notLike(root.get(path), "%" + value + "%");
+ * cb.notLike(root.get(path), "%" + value + "%", '\\');
  * }
* *

This typically translates to SQL like: * *

{@code
- * ... where x.firstname not like %?%
+ * ... where x.firstname not like %?% escape '\'
  * }
* + *

The value is matched literally, see {@link LikePattern}. + * * @author Matt Ho * @see Like */ public class NotLike extends SimpleSpecification { 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 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); } } diff --git a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/StartingWith.java b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/StartingWith.java index 5ed4f41..b242792 100644 --- a/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/StartingWith.java +++ b/mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/StartingWith.java @@ -35,27 +35,29 @@ *

In Criteria API, an equivalent expression might be: * *

{@code
- * cb.like(root.get(path), value + "%");
+ * cb.like(root.get(path), value + "%", '\\');
  * }
* *

This typically translates to SQL like: * *

- * {@code ... where x.firstname like ?%}
+ * {@code ... where x.firstname like ?% escape '\'}
  * 
* + *

The value is matched literally, see {@link LikePattern}. + * * @author Matt Ho * @see EndingWith */ public class StartingWith extends SimpleSpecification { 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 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); } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DomainRuntimeHintsTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DomainRuntimeHintsTest.java index 395f95c..348b77c 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DomainRuntimeHintsTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/DomainRuntimeHintsTest.java @@ -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); diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/EndingWithTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/EndingWithTest.java index ada9f9f..5a015dc 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/EndingWithTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/EndingWithTest.java @@ -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; @@ -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; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java index b76149d..f182184 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/InTest.java @@ -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; @@ -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(); + IntStream.rangeClosed(1, In.MAX_CHUNK_SIZE).mapToObj(i -> "name-" + i).forEach(values::add); + values.add("matt"); + + var spec = new In(noopContext(), "name", values); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(1).contains(matt); + } + @Test void typeMismatch() { var context = noopContext(); diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/LikeTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/LikeTest.java index 3da6e05..21f46cb 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/LikeTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/LikeTest.java @@ -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; @@ -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 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; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotInTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotInTest.java index 68c3dd5..63fff44 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotInTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotInTest.java @@ -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 org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import tw.com.softleader.data.jpa.spec.IntegrationTest; @@ -47,6 +49,20 @@ void test() { assertThat(actual).hasSize(1).contains(mary); } + @Test + void moreValuesThanChunkSize() { + repository.save(Customer.builder().name("matt").build()); + var bob = repository.save(Customer.builder().name("bob").build()); + + var values = new ArrayList(); + IntStream.rangeClosed(1, In.MAX_CHUNK_SIZE).mapToObj(i -> "name-" + i).forEach(values::add); + values.add("matt"); + + var spec = new NotIn(noopContext(), "name", values); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(1).contains(bob); + } + @Test void typeMismatch() { var context = noopContext(); diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotLikeTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotLikeTest.java index 5d555a3..2961c9e 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotLikeTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/NotLikeTest.java @@ -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; @@ -44,4 +48,25 @@ void test() { var actual = repository.findAll(spec); assertThat(actual).hasSize(1).contains(matt); } + + @Test + void wildcardsMatchLiterally() { + 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()); + var plain = repository.save(Customer.builder().name("axb").build()); + + var mapper = SpecMapper.builder().build(); + var spec = mapper.toSpec(NotLikeCriteria.builder().name("a%b").build(), Customer.class); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(3).contains(underscore, backslash, plain); + } + + @Builder + @Data + static class NotLikeCriteria { + + @Spec(path = "name", value = NotLike.class) + String name; + } } diff --git a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/StartingWithTest.java b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/StartingWithTest.java index fe95989..87f81ae 100644 --- a/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/StartingWithTest.java +++ b/mapper/src/test/java/tw/com/softleader/data/jpa/spec/domain/StartingWithTest.java @@ -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; @@ -43,4 +47,25 @@ void test() { var actual = repository.findAll(spec); assertThat(actual).hasSize(1).contains(matt); } + + /** A bare {@code %} must not bypass the prefix scoping by matching every row. */ + @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(StartingWithCriteria.builder().name("%").build(), Customer.class); + var actual = repository.findAll(spec); + assertThat(actual).hasSize(1).contains(wildcard); + } + + @Builder + @Data + static class StartingWithCriteria { + + @Spec(path = "name", value = StartingWith.class) + String name; + } }