Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extract skipping strategy #68

Merged
merged 2 commits into from
Oct 25, 2023
Merged
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
21 changes: 20 additions & 1 deletion mapper/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ The executed SQL will be:
In the fields of the POJO, if any of the following conditions are met, they will be ignored during the conversion process:

- No Spec Annotation is attached.
- The value is *null*.
- If the type is *Iterable* and the value is *empty*.
- If the type is *Optional* and the value is *empty*.
- The value is *null*.
- If the type is *CharSequence* and the length of value is *0*.
- If the type is *Array* and the length of value is *0*.
- If the type is *Map* and the value is *empty*.

For example, after constructing the following POJO, if no values are set and it is directly converted into a `Specification` for querying:

Expand All @@ -70,6 +73,9 @@ public class CustomerCriteria {
String firstname;

String lastname = "Hello";

@Spec
String nickname;

@Spec(GreaterThat.class)
Optional<Integer> age = Optional.empty();
Expand All @@ -86,6 +92,17 @@ The executed SQL in the above example will not have any filtering conditions.

> If you are using the Builder Pattern (e.g., Lombok's *@Builder*), please pay special attention to the default values set in the builder.

If you want to customize the logic for skipping, you can implement a `SkippingStrategy` and pass it when constructing a `SpecMapper`:

```java
var mapper = SpecMapper.builder()
.defaultResolvers()
.skippingStrategy(fieldValue -> {
// Determine whether to skip the field value and return a boolean
})
.build();
```

## Simple Specifications

You can use `@Spec` on fields to define the implementation of the `Specification`, `Equals` spec is the default:
Expand Down Expand Up @@ -127,6 +144,8 @@ Here is a list of the built-in types for `@Spec`:
| `NotIn` | *Iterable of Any* | `@Spec(NotIn.class) Set<String> firstname;` | `... where x.firstname not in (?, ?, ...)` |
| `True` | *Boolean* | `@Spec(True.class) Boolean active;` | `... where x.active = true` *(if true)* <br> `... where x.active = false` *(if false)* |
| `False` | *Boolean* | `@Spec(False.class) Boolean active;` | `... where x.active = false` *(if true)* <br> `... where x.active = true` *(if false)* |
| `HasLength` | *Boolean* | `@Spec(HasLength.class) Boolean firstname;` | `... where x.firstname is not null and character_length(x.firstname)>0` *(if true)* <br> `... where not(x.firstname is not null and character_length(x.firstname)>0)` *(if false)* |
| `HasText` | *Boolean* | `@Spec(HasText) Boolean firstname;` | `... where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0` *(if true)* <br> `... where not(where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0)` *(if false)* |

> In order to facilitate the usage for those who are already familiar with Spring Data JPA, the specs are named as closely as possible with [Query Methods](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation)

Expand Down
23 changes: 21 additions & 2 deletions mapper/README.zh-tw.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,12 @@ customerRepository.findAll(specification);
在 POJO 中的欄位, 只要符合以下任一條件, 在轉換的過程中都將會忽略:

- 沒有掛任何 Spec Annotation
- 值為 *null*
- 若 Type 為 *Iterable* 且值為 *empty*
- 若 Type 為 *Optional* 且值為 *empty*
- 值為 *null*
- 若 Type 為 *CharSequence* 且長度為 *0*
- 若 Type 為 *Array* 且長度為 *0*
- 若 Type 為 *Map* 且值為 *empty*

例如, 將以下 POJO 建構後, 不 set 任何值就直接轉換成 `Specification` 及查詢

Expand All @@ -70,7 +73,10 @@ public class CustomerCriteria {
String firstname;

String lastname = "Hello";


@Spec
String nickname;

@Spec(GreaterThat.class)
Optional<Integer> age = Optional.empty();

Expand All @@ -86,6 +92,17 @@ customerRepository.findAll(mapper.toSpec(new CustomerCriteria()));

> 如果你有使用 Builder Pattern, (e.g. Lombok's *@Builder*), 請特別注意 Builder 的 Default Value!

若你想要客製化跳脫邏輯, 可以實作 `SkippingStrategy`, 並在建構 `SpecMapper` 時傳入:

```java
var mapper = SpecMapper.builder()
.defaultResolvers()
.skippingStrategy(fieldValue -> {
// 判斷是否要跳 field value, 回傳 boolean
})
.build();
```

## Simple Specifications

你可以在 Field 使用 `@Spec` 來定義 `Specification` 的實作, 預設是 `Equals`:
Expand Down Expand Up @@ -127,6 +144,8 @@ String firstname; // 預設使用欄位名稱
| `NotIn` | *Iterable of Any* | `@Spec(NotIn.class) Set<String> firstname;` | `... where x.firstname not in (?, ?, ...)` |
| `True` | *Boolean* | `@Spec(True.class) Boolean active;` | `... where x.active = true` *(if true)* <br> `... where x.active = false` *(if false)* |
| `False` | *Boolean* | `@Spec(False.class) Boolean active;` | `... where x.active = false` *(if true)* <br> `... where x.active = true` *(if false)* |
| `HasLength` | *Boolean* | `@Spec(HasLength.class) Boolean firstname;` | `... where x.firstname is not null and character_length(x.firstname)>0` *(if true)* <br> `... where not(x.firstname is not null and character_length(x.firstname)>0)` *(if false)* |
| `HasText` | *Boolean* | `@Spec(HasText) Boolean firstname;` | `... where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0` *(if true)* <br> `... where not(where x.firstname is not null and character_length(trim(BOTH from x.firstname))>0)` *(if false)* |

> 為了方便已經熟悉 Spring Data JPA 的人使用, 以上名稱都是儘量跟著 [Query Methods](https://docs.spring.io/spring-data/jpa/docs/current/reference/html/#jpa.query-methods.query-creation) 一樣

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/*
* 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;

import org.springframework.lang.Nullable;
import org.springframework.util.ObjectUtils;

/**
* @author Matt Ho
*/
record DefaultSkippingStrategy() implements SkippingStrategy {
@Override
public boolean shouldSkip(@Nullable Object fieldValue) {
if (fieldValue == null) {
return true;
}
if (fieldValue instanceof Iterable<?> iterable) {
return !iterable.iterator().hasNext();
}
return ObjectUtils.isEmpty(fieldValue);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
import java.lang.reflect.Field;
import java.util.Objects;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.lang.NonNull;
Expand All @@ -53,7 +52,6 @@ public boolean supports(@NonNull Databind databind) {
public Specification<Object> buildSpecification(@NonNull Context context,
@NonNull Databind databind) {
return databind.getFieldValue()
.filter(this::valuePresent)
.map(value -> {
var specs = Stream.concat(
joinDef(context, databind.getField()),
Expand All @@ -67,13 +65,6 @@ public Specification<Object> buildSpecification(@NonNull Context context,
}).orElse(null);
}

boolean valuePresent(Object value) {
if (value instanceof Iterable) {
return StreamSupport.stream(((Iterable<?>) value).spliterator(), false).findAny().isPresent();
}
return true;
}

private Stream<Specification<Object>> joinsDef(Context context, Field field) {
if (!field.isAnnotationPresent(Joins.class)) {
return Stream.empty();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

import static java.util.Collections.unmodifiableList;
import static java.util.Optional.ofNullable;
import static java.util.function.Predicate.not;

import static org.springframework.util.ReflectionUtils.doWithLocalFields;
import static org.springframework.util.ReflectionUtils.makeAccessible;
Expand All @@ -32,7 +33,6 @@
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.function.BiFunction;

import org.springframework.util.ReflectionUtils;

Expand All @@ -57,19 +57,23 @@ class ReflectionDatabind implements Databind {
@NonNull
private final Field field;

@NonNull
private final SkippingStrategy skippingStrategy;

private final AtomicBoolean loaded = new AtomicBoolean();
private final CountDownLatch latch = new CountDownLatch(1);
private Object value;

static List<Databind> of(@NonNull Object target) {
return of(target, ReflectionDatabind::new);
static List<Databind> of(@NonNull Object target, @NonNull SkippingStrategy skippingStrategy) {
return of(target, skippingStrategy, ReflectionDatabind::new);
}

static List<Databind> of(@NonNull Object target,
@NonNull BiFunction<Object, Field, Databind> factory) {
@NonNull SkippingStrategy skippingStrategy,
@NonNull ReflectionDatabindFactory<Object, Field, SkippingStrategy, Databind> factory) {
var lookup = new ArrayList<Databind>();
doWithLocalFields(target.getClass(),
field -> lookup.add(factory.apply(target, field)));
field -> lookup.add(factory.apply(target, field, skippingStrategy)));
return unmodifiableList(lookup);
}

Expand All @@ -82,7 +86,7 @@ public Optional<Object> getFieldValue() {
} else {
latch.await();
}
return ofNullable(value);
return ofNullable(value).filter(not(skippingStrategy::shouldSkip));
}

// Visible for testing
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/*
* 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;

/**
* This interface is designed to abstractly construct a {@link ReflectionDatabind} factory,
* primarily for facilitating spy intervention during testing.
*
* @author Matt Ho
*/
@FunctionalInterface
interface ReflectionDatabindFactory<T, U, V, R> {

R apply(T t, U u, V v);
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
import static tw.com.softleader.data.jpa.spec.AST.CTX_AST;
import static tw.com.softleader.data.jpa.spec.AST.CTX_DEPTH;

import java.util.stream.StreamSupport;

import org.springframework.data.jpa.domain.Specification;
import org.springframework.lang.NonNull;
import org.springframework.util.StringUtils;
Expand Down Expand Up @@ -57,7 +55,6 @@ public Specification<Object> buildSpecification(@NonNull Context context,
var ast = context.get(CTX_AST).map(AST.class::cast).get();
var depth = (int) context.get(CTX_DEPTH).get();
var built = databind.getFieldValue()
.filter(this::valuePresent)
.map(value -> buildSpecification(context, databind, def, value))
.orElse(null);
ast.add(depth, "| +-[%s.%s]: @Spec(value=%s, path=%s, not=%s) -> %s",
Expand Down Expand Up @@ -94,13 +91,6 @@ private Specification<Object> buildSpecification(@NonNull Context context,
return spec;
}

boolean valuePresent(Object value) {
if (value instanceof Iterable) {
return StreamSupport.stream(((Iterable<?>) value).spliterator(), false).findAny().isPresent();
}
return true;
}

@Override
public void preVisit(@lombok.NonNull SpecInvocation node) {
// 這隻自己處理 AST
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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;

import org.springframework.lang.Nullable;

/**
* This is an interface for defining strategies to skip certain conditions.
*
* @author Matt Ho
*/
public interface SkippingStrategy {

/**
* This method is used to determine whether a given field value should be skipped.
*
* @param fieldValue The field value to be checked. It can be an object of any type, and even null.
* @return True if the field value should be skipped, false otherwise.
*/
boolean shouldSkip(@Nullable Object fieldValue);
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@

import lombok.NoArgsConstructor;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import tw.com.softleader.data.jpa.spec.annotation.Or;
import tw.com.softleader.data.jpa.spec.domain.Conjunction;
Expand All @@ -50,9 +51,11 @@
* @author Matt Ho
*/
@Slf4j
@NoArgsConstructor(access = PACKAGE)
@RequiredArgsConstructor(access = PACKAGE)
public class SpecMapper implements SpecCodec {

@NonNull
private final SkippingStrategy skippingStrategy;
private Collection<SpecificationResolver> resolvers; // Order matters

public static SpecMapperBuilder builder() {
Expand Down Expand Up @@ -85,7 +88,7 @@ public Specification<Object> toSpec(@NonNull Context context, @Nullable Object r
if (rootObject == null) {
return null;
}
var specs = ReflectionDatabind.of(rootObject)
var specs = ReflectionDatabind.of(rootObject, skippingStrategy)
.stream()
.flatMap(databind -> resolveSpec(context, databind))
.filter(Objects::nonNull)
Expand Down Expand Up @@ -122,6 +125,12 @@ Specification<Object> resolveSpec(@NonNull Context context, @NonNull Databind da
public static class SpecMapperBuilder {

private final Collection<Function<SpecCodec, SpecificationResolver>> resolvers = new LinkedList<>();
private SkippingStrategy strategy = new DefaultSkippingStrategy();

public SpecMapperBuilder skippingStrategy(@NonNull SkippingStrategy strategy) {
this.strategy = strategy;
return this;
}

public SpecMapperBuilder resolver(
@NonNull Function<SpecCodec, SpecificationResolver> resolver) {
Expand Down Expand Up @@ -153,7 +162,7 @@ public SpecMapper build() {
if (this.resolvers.isEmpty()) {
defaultResolvers();
}
var mapper = new SpecMapper();
var mapper = new SpecMapper(strategy);
mapper.resolvers = this.resolvers.stream()
.map(resolver -> resolver.apply(mapper))
.collect(Collectors.collectingAndThen(
Expand Down
Loading