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;
+ }
}