DomainSpecification.java
package com.wilzwert.myjobs.core.domain.shared.specification;
import com.wilzwert.myjobs.core.domain.shared.exception.DomainSpecificationException;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
/**
* @author Wilhelm Zwertvaegher
* <p>Specification for data querying / filtering</p>
* <p>These specification are meant to be converted by infra in any form suitable to retrieve data</p>
* <p>Specifications must be parameterized at least by an aggregate / entity class which is the domain model "target"</p>
* <p>If we have to name fields, they MUST be the names of aggregates properties ; as the domain should have no idea how persistence
* is handled, it makes no sense to use field names prefixed by their "collection" or "table",
* as it would assume that some kind of joins are made, which is not guaranteed at all
* </p>
* <p>Important : of course, this should be seen more as an exercise / experiment than a mature / complete solution :
* - it works only for very simple conditions
* - it is very naive after all
* </p>
* <p>Use example :
* if some domain use case had a business rule where it has to load Jobs that are currently in JobStatus.PENDING,
* have been created more than 7 days ago and have been updated more than 3 days ago, we could pass something like this :<br>
* jobService.findPaginated(DomainSpecification.And(List.of(
* DomainSpecification.Eq("status", JobStatus.PENDING),*
* DomainSpecification.Lt("createdAt", Instant.now() - 7 * 86_400_000),
* DomainSpecification.Lt("updatedAt", Instant.now() - 3 * 86_400_000)
* )), 0, 10)
* instead of adding a findByStatusPendingAndCreatedMoreThan7DaysAgoAndUpdatedMoreThan3DaysAgo method to our JobDataManager interface
* </p>
* On the other side, some simple rules would be very difficult to implement using these simple Specification
* For example, to load all "follow up late" Jobs, we have to filter jobs based on their status, status update date and related user preferences.
* This would be difficult to model using specification, and it would feel like reinventing the wheel, over-assuming how the infra
* persistence handles querying and how its data is stored : we would be tempted to create some sort of objects representing
* joins, relations, projections...
* </p>
* <p>In that case we can create explicit Specification, i.e. "contracts" the infra must then understand or implement, such as :
* DomainSpecification.UserJobFollowUpReminderThreshold
* With use of the contains method, infra could also change querying / retrieval strategy
* Of course it alsp remains possible and maybe more suitable to add methods to our data retrieval services interfaces,
* but it's conceptually interesting to have standalone specs, separated from services
* because it makes them easily reusable.
* </p>
*/
public abstract class DomainSpecification {
private final List<DomainSpecification.Sort> sort = new ArrayList<>();
public final List<DomainSpecification.Sort> getSort() {
if(sort.isEmpty()) {
// by convention, sort always defaults to createdAt desc
return List.of(new Sort("createdAt", SortDirection.DESC));
}
return sort;
}
public final void sortBy(DomainSpecification.Sort sort) {
this.sort.add(sort);
}
public static <T extends DomainSpecification> T applySort(T spec, Sort sort) {
spec.sortBy(sort);
return spec;
}
public static <T extends DomainSpecification> T applySort(T spec, List<Sort> sortList) {
for (Sort sort: sortList) {
spec.sortBy(sort);
}
return spec;
}
/**
* In some cases, infra may have to check if a certain specification exists in the current hierarchy
* to change implementation strategy
* @param classToFind the class to find in the current specification hierarchy
* @return true if class found
*/
public boolean contains(Class<?> classToFind) {
return classToFind.equals(getClass());
}
/**
* Sorting
*
*/
public enum SortDirection {
ASC, DESC
}
public static Sort sort(String sort) {
return new Sort(sort);
}
public static Sort sort(String fieldName, SortDirection sortDirection) {
return new Sort(fieldName, sortDirection);
}
public static class Sort extends DomainSpecification {
private final String fieldName;
private final SortDirection sortDirection;
Sort(String fieldName, SortDirection sortDirection) {
this.fieldName = fieldName;
this.sortDirection = sortDirection;
}
Sort(String sort) {
String[] parts = sort.split(",");
this.fieldName = parts[0];
this.sortDirection = parts.length < 2 || parts[1].equals("asc") ? SortDirection.ASC : SortDirection.DESC;
}
public String getFieldName() {
return fieldName;
}
public SortDirection getSortDirection() {
return sortDirection;
}
}
/**
* Field specs concern only one field
*/
public abstract static class FieldSpecification<V> extends DomainSpecification {
private final String field;
private final Class<V> valueClass;
private FieldSpecification(String field, Class<V> valueClass) {
this.field = field;
this.valueClass = valueClass;
}
public String getField() {
return field;
}
public Class<V> getValueClass() {
return valueClass;
}
}
public abstract static class FieldSpecificationWithSingleValue<V> extends FieldSpecification<V> {
private final V value;
protected FieldSpecificationWithSingleValue(String field, V value, Class<V> valueClass) {
super(field, valueClass);
this.value = value;
}
protected FieldSpecificationWithSingleValue(String field, V value) {
this(field, value, null);
}
public V getValue() {
return value;
}
@Override
public Class<V> getValueClass() {
Class<V> valueClass = super.getValueClass();
if(valueClass != null) {
return valueClass;
}
if(value == null) {
throw new IllegalStateException("Cannot determine valueClass: value is empty and valueClass is null");
}
@SuppressWarnings("unchecked")
Class<V> inferredClass = (Class<V>) value.getClass();
return inferredClass;
}
}
public abstract static class FieldSpecificationWithValuesList<V> extends FieldSpecification<V> {
private final List<V> values;
protected FieldSpecificationWithValuesList(String field, List<V> values, Class<V> valueClass) {
super(field, valueClass);
this.values = values;
}
protected FieldSpecificationWithValuesList(String field, List<V> values) {
this(field, values, null);
}
public List<V> getValues() {
return values;
}
@Override
public Class<V> getValueClass() {
Class<V> valueClass = super.getValueClass();
if(valueClass != null) {
return valueClass;
}
if (values == null || values.isEmpty()) {
throw new IllegalStateException("Cannot determine valueClass: values are empty and valueClass is null");
}
V firstValue = values.getFirst();
if (firstValue == null) {
throw new IllegalStateException("Cannot determine valueClass: first value is null");
}
@SuppressWarnings("unchecked")
Class<V> inferredClass = (Class<V>) firstValue.getClass();
return inferredClass;
}
}
public static <V> In<V> in(String field, List<V> values, Class<V> valueClass) {
return new In<>(field, values, valueClass);
}
public static <V> In<V> in(String field, List<V> values) {
return in(field, values, null);
}
public static final class In<V> extends FieldSpecificationWithValuesList<V> {
public In(String field, List<V> values, Class<V> valueClass) {
super(field, values, valueClass);
}
public In(String field, List<V> values) {
this(field, values, null);
}
}
public static <V> Eq<V> eq(String field, V value, Class<V> valueClass) {
return new Eq<>(field, value, valueClass);
}
public static <V> Eq<V> eq(String field, V value) {
return eq(field, value, null);
}
public static final class Eq<V> extends FieldSpecificationWithSingleValue<V> {
public Eq(String field, V value, Class<V> valueClass) {
super(field, value, valueClass);
}
public Eq(String field, V value) {
this(field, value, null);
}
}
public static <V extends Comparable<V>> Lt<V> lt(String field, V value, Class<V> valueClass) {
return new Lt<>(field, value, valueClass);
}
public static <V extends Comparable<V>> Lt<V> lt(String field, V value) {
return lt(field, value, null);
}
public static final class Lt<V extends Comparable<V>> extends FieldSpecificationWithSingleValue<V> {
public Lt(String field, V value, Class<V> valueClass) {
super(field, value, valueClass);
}
public Lt(String field, V value) {
this(field, value, null);
}
}
/**
* Conditions : And / Or
*/
public abstract static class ConditionSpecification extends DomainSpecification {
private final List<DomainSpecification> specifications;
private ConditionSpecification(List<DomainSpecification> specifications) {
if(specifications.stream().anyMatch(s -> s instanceof DomainSpecification.FullSpecification)) {
throw new DomainSpecificationException("Full specifications cannot be nested");
}
this.specifications = specifications;
}
public List<DomainSpecification> getSpecifications() {
return specifications;
}
@Override
public boolean contains(Class<?> classToFind) {
return super.contains(classToFind) || specifications.stream().anyMatch(s -> s.contains(classToFind));
}
}
public static Or or(List<DomainSpecification> criteriaList) {
return new Or(criteriaList);
}
public static final class Or extends ConditionSpecification {
public Or(List<DomainSpecification> criteriaList) {
super(criteriaList);
}
}
public static And and(List<DomainSpecification> criteriaList) {
return new And(criteriaList);
}
public static final class And extends ConditionSpecification {
public And(List<DomainSpecification> criteriaList) {
super(criteriaList);
}
}
/**
* Full specs : an object that encapsulates ALL specs needed to find something
* Used for complex specs that cannot be expressed through existing (basic) DomainSpecification
* The abstract FullSpecification is only used as a way to identify full specs
*
*/
public abstract static class FullSpecification extends DomainSpecification {
}
/**
* This specification is very specific, and should be understood and applied by the infra.
* in this case this specification is used to query users who have not received any job follow-up reminders after some
* "threshold" instant, which should be checked in infra, based on provided Instant and user's jobFollowUpReminderDelay
*/
public static UserJobFollowUpReminderThreshold UserJobFollowUpReminderThreshold(Instant referenceInstant) {
return new UserJobFollowUpReminderThreshold(referenceInstant);
}
public static final class UserJobFollowUpReminderThreshold extends FullSpecification {
private final Instant referenceInstant;
public UserJobFollowUpReminderThreshold(Instant referenceInstant) {
this.referenceInstant = referenceInstant;
}
public Instant getReferenceInstant() {
return referenceInstant;
}
}
/**
* This Specification is used to query a list of Job based on these criteria :
* - related users MUST have a not null jobFollowUpReminderDays
* - jobs are active (ie status IN JobStatus.activeStatuses())
* - have ever been updated less than user.jobFollowUpReminderDays days before provided referenceInstant
* - have not been reminded less than user.jobFollowUpReminderDays days before provided referenceInstant
* As this would be nearly impossible to effectively model it using DomainSpecification
* (especially because we would be assuming how the infra persistence layer works (joins in Sql based DBMS, collections in NoSql...)),
* we kindly ask (and trust) the infra to convert and handle it
* Rules :
* -
*/
public static JobFollowUpToRemind JobFollowUpToRemind(Instant referenceInstant) {
return new JobFollowUpToRemind(referenceInstant);
}
public static final class JobFollowUpToRemind extends FullSpecification {
private final Instant referenceInstant;
public JobFollowUpToRemind(Instant referenceInstant) {
super();
this.referenceInstant = referenceInstant;
super.sortBy(sort("userId", SortDirection.ASC));
}
public Instant getReferenceInstant() {
return referenceInstant;
}
}
}