diff --git a/vaadin-spring/pom.xml b/vaadin-spring/pom.xml index 54583311dae..ce207540a81 100644 --- a/vaadin-spring/pom.xml +++ b/vaadin-spring/pom.xml @@ -43,6 +43,12 @@ jakarta.servlet-api provided + + org.jspecify + jspecify + 1.0.0 + + com.vaadin @@ -97,6 +103,26 @@ spring-data-commons true + + org.springframework.data + spring-data-jpa + provided + + + aspectjrt + org.aspectj + + + jcl-over-slf4j + org.slf4j + + + + + jakarta.persistence + jakarta.persistence-api + provided + com.vaadin flow-data @@ -120,6 +146,22 @@ test + + com.h2database + h2 + test + + + org.springframework.boot + spring-boot-test-autoconfigure + test + + + org.springframework.boot + spring-boot-starter-data-jpa + test + + org.springframework.boot spring-boot-starter-test @@ -189,13 +231,13 @@ - - org.springframework.boot - spring-boot-dependencies - ${spring.boot.version} - pom - import - + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CountService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CountService.java new file mode 100644 index 00000000000..8105ea82da5 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CountService.java @@ -0,0 +1,21 @@ +package com.vaadin.flow.spring.data; + +import org.jspecify.annotations.Nullable; + +import com.vaadin.flow.spring.data.filter.Filter; + +/** + * A service that can count the number of items with a given filter. + */ +public interface CountService { + + /** + * Counts the number of items that match the given filter. + * + * @param filter + * the filter, or {@code null} to use no filter + * @return the number of items in the service that match the filter + */ + public long count(@Nullable Filter filter); + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CrudService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CrudService.java new file mode 100644 index 00000000000..72de511899a --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CrudService.java @@ -0,0 +1,12 @@ +package com.vaadin.flow.spring.data; + +/** + * A service that can create, read, update, and delete a given type of object. + * + * @param + * the type of object to manage + * @param + * the type of the object's identifier + */ +public interface CrudService extends ListService, FormService { +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/FormService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/FormService.java new file mode 100644 index 00000000000..b6b74d9a36e --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/FormService.java @@ -0,0 +1,33 @@ +package com.vaadin.flow.spring.data; + +/** + * A service that can update and delete a given type of object. + * + * @param + * the type of object to manage + * @param + * the type of the object's identifier + * + */ +public interface FormService { + + /** + * Saves the given object and returns the (potentially) updated object. + *

+ * If you store the object in a SQL database, the returned object might have + * a new id or updated consistency version. + * + * @param value + * the object to save + * @return the fresh object; will never be {@literal null}. + */ + T save(T value); + + /** + * Deletes the object with the given id. + * + * @param id + * the id of the object to delete + */ + void delete(ID id); +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/GetService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/GetService.java new file mode 100644 index 00000000000..1f7cf3eaca1 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/GetService.java @@ -0,0 +1,30 @@ +package com.vaadin.flow.spring.data; + +import java.util.Optional; + +/** + * A service that can fetch the given type of object. + */ +public interface GetService { + + /** + * Gets the object with the given id. + * + * @param id + * the id of the object + * @return the object, or an empty optional if no object with the given id + */ + Optional get(ID id); + + /** + * Checks if an object with the given id exists. + * + * @param id + * the id of the object + * @return {@code true} if the object exists, {@code false} otherwise + */ + default boolean exists(ID id) { + return get(id).isPresent(); + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/ListService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/ListService.java new file mode 100644 index 00000000000..237efd7afb6 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/ListService.java @@ -0,0 +1,29 @@ +package com.vaadin.flow.spring.data; + +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.domain.Pageable; + +import com.vaadin.flow.spring.data.filter.Filter; + +/** + * A service that can list the given type of object. + * + * @param + * the type of object to list + */ +public interface ListService { + /** + * Lists objects of the given type using the paging, sorting and filtering + * options provided in the parameters. + * + * @param pageable + * contains information about paging and sorting + * @param filter + * the filter to apply or {@code null} to not filter + * @return a list of objects or an empty list if no objects were found + */ + List list(Pageable pageable, @Nullable Filter filter); + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/AndFilter.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/AndFilter.java new file mode 100644 index 00000000000..9df1abea1aa --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/AndFilter.java @@ -0,0 +1,45 @@ +package com.vaadin.flow.spring.data.filter; + +import java.util.List; + +/** + * A filter that requires all children to pass. + *

+ * Custom filter implementations need to handle this filter by running all child + * filters and verifying that all of them pass. + */ +public class AndFilter extends Filter { + + private List children; + + /** + * Create an empty filter. + */ + public AndFilter() { + // Empty constructor is needed for serialization + } + + /** + * Create a filter with the given children. + * + * @param children + * the children of the filter + */ + public AndFilter(Filter... children) { + setChildren(List.of(children)); + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [children=" + children + "]"; + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/Filter.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/Filter.java new file mode 100644 index 00000000000..a4b98e715e8 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/Filter.java @@ -0,0 +1,27 @@ +package com.vaadin.flow.spring.data.filter; + +import java.io.Serializable; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Superclass for all filters to be used with CRUD services. This specific class + * is never used, instead a filter instance will be one of the following types: + *

    + *
  • {@link AndFilter} - Contains a list of nested filters, all of which need + * to pass.
  • + *
  • {@link OrFilter} - Contains a list of nested filters, of which at least + * one needs to pass.
  • + *
  • {@link PropertyStringFilter} - Matches a specific property, or nested + * property path, against a filter value, using a specific operator.
  • + *
+ */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY) +@JsonSubTypes({ @Type(value = OrFilter.class, name = "or"), + @Type(value = AndFilter.class, name = "and"), + @Type(value = PropertyStringFilter.class, name = "propertyString") }) +public class Filter implements Serializable { + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/OrFilter.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/OrFilter.java new file mode 100644 index 00000000000..4529658ff45 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/OrFilter.java @@ -0,0 +1,45 @@ +package com.vaadin.flow.spring.data.filter; + +import java.util.List; + +/** + * A filter that requires at least one of its children to pass. + *

+ * Custom filter implementations need to handle this filter by running all child + * filters and verifying that at least one of them passes. + */ +public class OrFilter extends Filter { + + private List children; + + /** + * Create an empty filter. + */ + public OrFilter() { + // Empty constructor is needed for serialization + } + + /** + * Create a filter with the given children. + * + * @param children + * the children of the filter + */ + public OrFilter(Filter... children) { + setChildren(List.of(children)); + } + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [children=" + children + "]"; + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/PropertyStringFilter.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/PropertyStringFilter.java new file mode 100644 index 00000000000..ca7cfd9f4e7 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/PropertyStringFilter.java @@ -0,0 +1,124 @@ +package com.vaadin.flow.spring.data.filter; + +/** + * A filter that matches a given property, or nested property path, against a + * filter value using the specified matcher. + *

+ * Custom filter implementations need to handle this filter by: + *

    + *
  • Extracting the property value from the object being filtered using + * {@link #getPropertyId()}.
  • + *
  • Convert the string representation of the filter value from + * {@link #getFilterValue()} into a type that can be used for implementing a + * comparison.
  • + *
  • Do the actual comparison using the matcher / operator provided by + * {@link #getMatcher()}
  • + *
+ */ +public class PropertyStringFilter extends Filter { + public enum Matcher { + EQUALS, CONTAINS, LESS_THAN, GREATER_THAN; + } + + private String propertyId; + private String filterValue; + private Matcher matcher; + + /** + * Create an empty filter. + */ + public PropertyStringFilter() { + // Empty constructor is needed for serialization + } + + /** + * Create a filter with the given property, matcher and filter value. + * + * @param propertyId + * the property id, or a nested property path where each property + * is separated by a dot + * @param matcher + * the matcher to use when comparing the property value to the + * filter value + * @param filterValue + * the filter value to compare against + */ + public PropertyStringFilter(String propertyId, Matcher matcher, + String filterValue) { + this.propertyId = propertyId; + this.matcher = matcher; + this.filterValue = filterValue; + } + + /** + * Gets the property, or nested property path, to filter by. For example + * {@code "name"} or {@code "address.city"}. + * + * @return the property name + */ + public String getPropertyId() { + return propertyId; + } + + /** + * Sets the property, or nested property path, to filter by. + * + * @param propertyId + * the property name + */ + public void setPropertyId(String propertyId) { + this.propertyId = propertyId; + } + + /** + * Gets the filter value to compare against. The filter value is always + * stored as a string, but can represent multiple types of values using + * specific formats. For example, when filtering a property of type + * {@code LocalDate}, the filter value could be {@code "2020-01-01"}. The + * actual filter implementation is responsible for parsing the filter value + * into the correct type to use for querying the underlying data layer. + * + * @return the filter value + */ + public String getFilterValue() { + return filterValue; + } + + /** + * Sets the filter value to compare against. + * + * @param filterValue + * the filter value + */ + public void setFilterValue(String filterValue) { + this.filterValue = filterValue; + } + + /** + * The matcher, or operator, to use when comparing the property value to the + * filter value. + * + * @return the matcher + */ + public Matcher getMatcher() { + return matcher; + } + + /** + * Sets the matcher, or operator, to use when comparing the property value + * to the filter value. + * + * @param type + * the matcher + */ + public void setMatcher(Matcher type) { + this.matcher = type; + } + + @Override + public String toString() { + return getClass().getSimpleName() + " [propertyId=" + propertyId + + ", matcher=" + matcher + ", filterValue=" + filterValue + "]"; + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/package-info.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/package-info.java new file mode 100644 index 00000000000..e93d497fe45 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package com.vaadin.flow.spring.data.filter; diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryService.java new file mode 100644 index 00000000000..c5ff6e269e6 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryService.java @@ -0,0 +1,71 @@ +package com.vaadin.flow.spring.data.jpa; + +import java.util.ArrayList; +import java.util.List; + +import org.jspecify.annotations.Nullable; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.repository.CrudRepository; + +import com.vaadin.flow.spring.data.CrudService; + +/** + * A service that delegates crud operations to a JPA repository. + * + * @param + * the type of object to manage + * @param + * the type of the object's identifier + * @param + * the type of the JPA repository + * + */ +public class CrudRepositoryService & JpaSpecificationExecutor> + extends ListRepositoryService implements CrudService { + + /** + * Creates the service using the given repository. + * + * @param repository + * the JPA repository + */ + public CrudRepositoryService(R repository) { + super(repository); + } + + @Override + public @Nullable T save(T value) { + return getRepository().save(value); + } + + /** + * Saves the given objects and returns the (potentially) updated objects. + *

+ * The returned objects might have new ids or updated consistency versions. + * + * @param values + * the objects to save + * @return the fresh objects + */ + public List saveAll(Iterable values) { + List saved = new ArrayList<>(); + getRepository().saveAll(values).forEach(saved::add); + return saved; + } + + @Override + public void delete(ID id) { + getRepository().deleteById(id); + } + + /** + * Deletes the objects with the given ids. + * + * @param ids + * the ids of the objects to delete + */ + public void deleteAll(Iterable ids) { + getRepository().deleteAllById(ids); + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/JpaFilterConverter.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/JpaFilterConverter.java new file mode 100644 index 00000000000..f9990a64d0a --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/JpaFilterConverter.java @@ -0,0 +1,66 @@ +package com.vaadin.flow.spring.data.jpa; + +import java.util.function.Function; +import java.util.function.Supplier; + +import org.springframework.data.jpa.domain.Specification; + +import com.vaadin.flow.spring.data.filter.AndFilter; +import com.vaadin.flow.spring.data.filter.Filter; +import com.vaadin.flow.spring.data.filter.OrFilter; +import com.vaadin.flow.spring.data.filter.PropertyStringFilter; + +/** + * Utility class for converting {@link Filter} specifications into JPA filter + * specifications. This class can be used to implement filtering for custom + * {@link com.vaadin.flow.spring.data.ListService} or + * {@link com.vaadin.flow.spring.data.CrudService} implementations that use JPA + * as the data source. + */ +public final class JpaFilterConverter { + + private JpaFilterConverter() { + // Utilities only + } + + /** + * Converts the given filter specification into a JPA filter specification + * for the specified entity class. + *

+ * If the filter contains {@link PropertyStringFilter} instances, their + * properties, or nested property paths, need to match the structure of the + * entity class. Likewise, their filter values should be in a format that + * can be parsed into the type that the property is of. + * + * @param + * the type of the entity + * @param rawFilter + * the filter to convert + * @param entity + * the entity class + * @param propertyStringFilterSupplier + * a function that can convert a PropertyStringFilter into a JPA + * filter specification + * @return a JPA filter specification for the given filter + */ + public static Specification toSpec(Filter rawFilter, Class entity, + Function> propertyStringFilterSupplier) { + if (rawFilter == null) { + return Specification.anyOf(); + } + if (rawFilter instanceof AndFilter filter) { + return Specification.allOf(filter.getChildren().stream() + .map(f -> toSpec(f, entity, propertyStringFilterSupplier)) + .toList()); + } else if (rawFilter instanceof OrFilter filter) { + return Specification.anyOf(filter.getChildren().stream() + .map(f -> toSpec(f, entity, propertyStringFilterSupplier)) + .toList()); + } else if (rawFilter instanceof PropertyStringFilter filter) { + return propertyStringFilterSupplier.apply(filter); + } else { + throw new IllegalArgumentException( + "Unknown filter type " + rawFilter.getClass().getName()); + } + } +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/ListRepositoryService.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/ListRepositoryService.java new file mode 100644 index 00000000000..4f1709c28a2 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/ListRepositoryService.java @@ -0,0 +1,116 @@ +package com.vaadin.flow.spring.data.jpa; + +import java.lang.reflect.Type; +import java.util.List; +import java.util.Optional; + +import com.googlecode.gentyref.GenericTypeReflector; +import org.jspecify.annotations.Nullable; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.repository.CrudRepository; + +import com.vaadin.flow.spring.data.CountService; +import com.vaadin.flow.spring.data.GetService; +import com.vaadin.flow.spring.data.ListService; +import com.vaadin.flow.spring.data.filter.Filter; + +/** + * A service that delegates list operations to a JPA repository. + * + * @param + * the type of object to list + * @param + * the type of the object's identifier + * @param + * the type of the JPA repository + */ +public class ListRepositoryService & JpaSpecificationExecutor> + implements ListService, GetService, CountService { + + private R repository; + + private final Class entityClass; + + /** + * Creates the service using the given repository and filter converter. + * + * @param repository + * the JPA repository + */ + public ListRepositoryService(R repository) { + this.repository = repository; + this.entityClass = resolveEntityClass(); + } + + /** + * Accessor for the repository instance. + * + * @return the repository instance + */ + protected R getRepository() { + return repository; + } + + @Deprecated + protected void internalSetRepository(R repository) { + // Only for Hilla backwards compatibility + this.repository = repository; + } + + @Override + public List list(Pageable pageable, @Nullable Filter filter) { + Specification spec = toSpec(filter); + return getRepository().findAll(spec, pageable).getContent(); + } + + @Override + public Optional get(ID id) { + return getRepository().findById(id); + } + + @Override + public boolean exists(ID id) { + return getRepository().existsById(id); + } + + /** + * Counts the number of entities that match the given filter. + * + * @param filter + * the filter, or {@code null} to use no filter + * @return + */ + @Override + public long count(@Nullable Filter filter) { + return getRepository().count(toSpec(filter)); + } + + /** + * Converts the given filter to a JPA specification. + * + * @param filter + * the filter to convert + * @return a JPA specification + */ + protected Specification toSpec(@Nullable Filter filter) { + return JpaFilterConverter.toSpec(filter, entityClass, + PropertyStringFilterSpecification::new); + } + + @SuppressWarnings("unchecked") + protected Class resolveEntityClass() { + var entityTypeParam = ListRepositoryService.class + .getTypeParameters()[0]; + Type entityType = GenericTypeReflector.getTypeParameter(getClass(), + entityTypeParam); + if (entityType == null) { + throw new IllegalStateException(String.format( + "Unable to detect the type for the class '%s' in the " + + "class '%s'.", + entityTypeParam, getClass())); + } + return (Class) GenericTypeReflector.erase(entityType); + } +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/PropertyStringFilterSpecification.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/PropertyStringFilterSpecification.java new file mode 100644 index 00000000000..53e12d1c893 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/PropertyStringFilterSpecification.java @@ -0,0 +1,253 @@ +package com.vaadin.flow.spring.data.jpa; + +import jakarta.persistence.criteria.CriteriaBuilder; +import jakarta.persistence.criteria.CriteriaQuery; +import jakarta.persistence.criteria.Expression; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Predicate; +import jakarta.persistence.criteria.Root; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +import org.springframework.data.jpa.domain.Specification; + +import com.vaadin.flow.spring.data.filter.PropertyStringFilter; + +/** + * A specification that can be used to filter entities based on a + * {@link PropertyStringFilter}. + */ +public class PropertyStringFilterSpecification implements Specification { + + private final PropertyStringFilter filter; + + /** + * Creates a new specification based on the given filter. + * + * @param filter + * the filter to use + */ + public PropertyStringFilterSpecification(PropertyStringFilter filter) { + this.filter = filter; + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, + CriteriaBuilder criteriaBuilder) { + Path propertyPath = getPath(filter.getPropertyId(), root); + + return toPredicate(root, criteriaBuilder, propertyPath); + } + + /** + * Converts a filter to a JPA predicate. + * + * @param root + * The root entity + * @param criteriaBuilder + * The criteria builder + * @param propertyPath + * The property path + * + */ + protected Predicate toPredicate(Root root, + CriteriaBuilder criteriaBuilder, Path propertyPath) { + Class javaType = propertyPath.getJavaType(); + if (javaType == String.class) { + return stringToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (isNumber(javaType)) { + return numberToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (isBoolean(javaType)) { + return booleanToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (javaType == java.time.LocalDate.class) { + return localDateToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (javaType == LocalTime.class) { + return localTimeToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (javaType == java.time.LocalDateTime.class) { + return localDateTimeToPredicate(root, criteriaBuilder, filter, + (Path) propertyPath); + } else if (javaType.isEnum()) { + return enumToPredicate(root, criteriaBuilder, filter, propertyPath); + + } + throw new IllegalArgumentException("No implementation for " + javaType + + " using " + filter.getMatcher() + "."); + } + + private static Predicate enumToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + var enumValue = Enum.valueOf( + propertyPath.getJavaType().asSubclass(Enum.class), + filter.getFilterValue()); + + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(propertyPath, enumValue); + case CONTAINS: + throw new IllegalArgumentException( + "An enum cannot be filtered using contains"); + case GREATER_THAN: + throw new IllegalArgumentException( + "An enum cannot be filtered using greater than"); + case LESS_THAN: + throw new IllegalArgumentException( + "An enum cannot be filtered using less than"); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + } + + private static Predicate localTimeToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + var timeValue = LocalTime.parse(filter.getFilterValue()); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(propertyPath, timeValue); + case CONTAINS: + throw new IllegalArgumentException( + "A time cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(propertyPath, timeValue); + case LESS_THAN: + return criteriaBuilder.lessThan(propertyPath, timeValue); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + } + + private static Predicate localDateTimeToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + var dateValue = LocalDate.parse(filter.getFilterValue()); + var minValue = LocalDateTime.of(dateValue, LocalTime.MIN); + var maxValue = LocalDateTime.of(dateValue, LocalTime.MAX); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.between(propertyPath, minValue, maxValue); + case CONTAINS: + throw new IllegalArgumentException( + "A datetime cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(propertyPath, maxValue); + case LESS_THAN: + return criteriaBuilder.lessThan(propertyPath, minValue); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + } + + private static Predicate localDateToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + var dateValue = LocalDate.parse(filter.getFilterValue()); + + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(propertyPath, dateValue); + case CONTAINS: + throw new IllegalArgumentException( + "A date cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(propertyPath, dateValue); + case LESS_THAN: + return criteriaBuilder.lessThan(propertyPath, dateValue); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + + } + + private static Predicate booleanToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + Boolean booleanValue = Boolean.valueOf(filter.getFilterValue()); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(propertyPath, booleanValue); + case CONTAINS: + throw new IllegalArgumentException( + "A boolean cannot be filtered using contains"); + case GREATER_THAN: + throw new IllegalArgumentException( + "A boolean cannot be filtered using greater than"); + case LESS_THAN: + throw new IllegalArgumentException( + "A boolean cannot be filtered using less than"); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + } + + private static Predicate numberToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + String value = filter.getFilterValue(); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(propertyPath, value); + case CONTAINS: + throw new IllegalArgumentException( + "A number cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(propertyPath, value); + case LESS_THAN: + return criteriaBuilder.lessThan(propertyPath, value); + } + throw new IllegalArgumentException( + "No implementation for " + filter.getMatcher() + "."); + } + + private static Predicate stringToPredicate(Root root, + CriteriaBuilder criteriaBuilder, PropertyStringFilter filter, + Path propertyPath) { + Expression expr = criteriaBuilder + .lower((Path) propertyPath); + var filterValueLowerCase = filter.getFilterValue().toLowerCase(); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(expr, filterValueLowerCase); + case CONTAINS: + return criteriaBuilder.like(expr, "%" + filterValueLowerCase + "%"); + case GREATER_THAN: + throw new IllegalArgumentException( + "A string cannot be filtered using greater than"); + case LESS_THAN: + throw new IllegalArgumentException( + "A string cannot be filtered using less than"); + } + throw new IllegalArgumentException( + "Unknown matcher type: " + filter.getMatcher()); + } + + private Path getPath(String propertyId, Root root) { + String[] parts = propertyId.split("\\."); + Path path = root.get(parts[0]); + int i = 1; + while (i < parts.length) { + path = path.get(parts[i]); + i++; + } + return path; + } + + private boolean isNumber(Class javaType) { + return javaType == int.class || javaType == Integer.class + || javaType == long.class || javaType == Long.class + || javaType == float.class || javaType == Float.class + || javaType == double.class || javaType == Double.class; + } + + private boolean isBoolean(Class javaType) { + return javaType == boolean.class || javaType == Boolean.class; + } + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/package-info.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/package-info.java new file mode 100644 index 00000000000..741ae7120fb --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/jpa/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package com.vaadin.flow.spring.data.jpa; diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/package-info.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/package-info.java new file mode 100644 index 00000000000..e3aa13fcc5e --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/package-info.java @@ -0,0 +1,2 @@ +@org.springframework.lang.NonNullApi +package com.vaadin.flow.spring.data; diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java index 886073b9e75..e2e917e7f3f 100644 --- a/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/SpringClassesSerializableTest.java @@ -93,6 +93,14 @@ protected Stream getExcludedPatterns() { "com\\.vaadin\\.flow\\.spring\\.SpringLookupInitializer(\\$.*)?", "com\\.vaadin\\.flow\\.spring\\.VaadinConfigurationProperties", "com\\.vaadin\\.flow\\.spring\\.SpringDevToolsPortHandler", + "com\\.vaadin\\.flow\\.spring\\.data\\.jpa\\.JpaFilterConverter", + "com\\.vaadin\\.flow\\.spring\\.data\\.jpa\\.ListRepositoryService", + "com\\.vaadin\\.flow\\.spring\\.data\\.jpa\\.CrudRepositoryService", + "com\\.vaadin\\.flow\\.spring\\.data\\.ListService", + "com\\.vaadin\\.flow\\.spring\\.data\\.CountService", + "com\\.vaadin\\.flow\\.spring\\.data\\.CrudService", + "com\\.vaadin\\.flow\\.spring\\.data\\.FormService", + "com\\.vaadin\\.flow\\.spring\\.data\\.GetService", "com\\.vaadin\\.flow\\.spring\\.springnative\\.AtmosphereHintsRegistrar", "com\\.vaadin\\.flow\\.spring\\.springnative\\.VaadinBeanFactoryInitializationAotProcessor", "com\\.vaadin\\.flow\\.spring\\.springnative\\.VaadinBeanFactoryInitializationAotProcessor\\$Marker", diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/TestApplication.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/TestApplication.java new file mode 100644 index 00000000000..56bff5fe674 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/TestApplication.java @@ -0,0 +1,7 @@ +package com.vaadin.flow.spring.data; + +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApplication { +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/filter/FilterTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/filter/FilterTest.java new file mode 100644 index 00000000000..e88f8caf998 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/filter/FilterTest.java @@ -0,0 +1,514 @@ +package com.vaadin.flow.spring.data.filter; + +import java.util.ArrayList; +import java.util.List; + +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.test.context.junit4.SpringRunner; + +import com.vaadin.flow.spring.data.filter.PropertyStringFilter.Matcher; +import com.vaadin.flow.spring.data.jpa.JpaFilterConverter; +import com.vaadin.flow.spring.data.jpa.NestedObject; +import com.vaadin.flow.spring.data.jpa.PropertyStringFilterSpecification; +import com.vaadin.flow.spring.data.jpa.SecondLevelNestedObject; +import com.vaadin.flow.spring.data.jpa.TestEnum; +import com.vaadin.flow.spring.data.jpa.TestObject; +import com.vaadin.flow.spring.data.jpa.TestRepository; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +@RunWith(SpringRunner.class) +@DataJpaTest() +public class FilterTest { + + @Autowired + private TestEntityManager entityManager; + + @Autowired + private TestRepository repository; + + @Test + public void filterStringPropertyUsingContains() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter = createFilter("name", Matcher.CONTAINS, + "Jo"); + assertFilteredNames(filter, "John", "Johnny", "Josh"); + } + + @Test + public void filterStringPropertyUsingEquals() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter = createFilter("name", Matcher.EQUALS, + "John"); + assertFilteredNames(filter, "John"); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterStringPropertyUsingLessThan() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter = createFilter("name", Matcher.LESS_THAN, + "John"); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterStringPropertyUsingGreaterThan() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter = createFilter("name", Matcher.GREATER_THAN, + "John"); + executeFilter(filter); + } + + @Test + public void filterNumberPropertyUsingContains() { + setupNumbers(); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("intValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("nullableIntValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("longValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("nullableLongValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("floatValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("nullableFloatValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("doubleValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + + assertThrows(InvalidDataAccessApiUsageException.class, () -> { + PropertyStringFilter filter = createFilter("nullableDoubleValue", + Matcher.CONTAINS, "2"); + executeFilter(filter); + }); + } + + @Test + public void filterNumberPropertyUsingEquals() { + List created = setupNumbers(); + + PropertyStringFilter filter = createFilter("intValue", Matcher.EQUALS, + "4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("nullableIntValue", Matcher.EQUALS, "4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("longValue", Matcher.EQUALS, "4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("nullableLongValue", Matcher.EQUALS, "4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("floatValue", Matcher.EQUALS, "0.4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("nullableFloatValue", Matcher.EQUALS, "0.4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("doubleValue", Matcher.EQUALS, "0.4"); + assertFilterResult(filter, List.of(created.get(4))); + + filter = createFilter("nullableDoubleValue", Matcher.EQUALS, "0.4"); + assertFilterResult(filter, List.of(created.get(4))); + } + + @Test + public void filterNumberPropertyUsingLessThan() { + List created = setupNumbers(); + + PropertyStringFilter filter = createFilter("intValue", + Matcher.LESS_THAN, "4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("nullableIntValue", Matcher.LESS_THAN, "4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("longValue", Matcher.LESS_THAN, "4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("nullableLongValue", Matcher.LESS_THAN, "4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("floatValue", Matcher.LESS_THAN, "0.4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("nullableFloatValue", Matcher.LESS_THAN, "0.4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("doubleValue", Matcher.LESS_THAN, "0.4"); + assertFilterResult(filter, created.subList(0, 4)); + + filter = createFilter("nullableDoubleValue", Matcher.LESS_THAN, "0.4"); + assertFilterResult(filter, created.subList(0, 4)); + } + + @Test + public void filterNumberPropertyUsingGreaterThan() { + List created = setupNumbers(); + + PropertyStringFilter filter = createFilter("intValue", + Matcher.GREATER_THAN, "4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("nullableIntValue", Matcher.GREATER_THAN, "4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("longValue", Matcher.GREATER_THAN, "4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("nullableLongValue", Matcher.GREATER_THAN, "4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("floatValue", Matcher.GREATER_THAN, "0.4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("nullableFloatValue", Matcher.GREATER_THAN, + "0.4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("doubleValue", Matcher.GREATER_THAN, "0.4"); + assertFilterResult(filter, created.subList(5, 10)); + + filter = createFilter("nullableDoubleValue", Matcher.GREATER_THAN, + "0.4"); + assertFilterResult(filter, created.subList(5, 10)); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterBooleanPropertyUsingContains() { + setupBooleans(); + PropertyStringFilter filter = createFilter("booleanValue", + Matcher.CONTAINS, "True"); + executeFilter(filter); + } + + @Test + public void filterBooleanPropertyUsingEquals() { + setupBooleans(); + + PropertyStringFilter filter = createFilter("booleanValue", + Matcher.EQUALS, "True"); + List testObjects = executeFilter(filter); + + assertEquals(1, testObjects.size()); + Assert.assertTrue(testObjects.get(0).getBooleanValue()); + + filter = createFilter("booleanValue", Matcher.EQUALS, "False"); + testObjects = executeFilter(filter); + + assertEquals(1, testObjects.size()); + Assert.assertFalse(testObjects.get(0).getBooleanValue()); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterBooleanPropertyUsingLessThan() { + setupBooleans(); + PropertyStringFilter filter = createFilter("booleanValue", + Matcher.LESS_THAN, "True"); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterBooleanPropertyUsingGreaterThan() { + setupBooleans(); + PropertyStringFilter filter = createFilter("booleanValue", + Matcher.GREATER_THAN, "True"); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterEnumPropertyUsingContains() { + setupEnums(); + PropertyStringFilter filter = createFilter("enumValue", + Matcher.CONTAINS, TestEnum.TEST1.name()); + executeFilter(filter); + } + + @Test + public void filterEnumPropertyUsingEquals() { + setupEnums(); + + PropertyStringFilter filter = createFilter("enumValue", Matcher.EQUALS, + TestEnum.TEST1.name()); + List testObjects = executeFilter(filter); + + assertEquals(1, testObjects.size()); + Assert.assertEquals(TestEnum.TEST1, testObjects.get(0).getEnumValue()); + + filter = createFilter("enumValue", Matcher.EQUALS, + TestEnum.TEST2.name()); + testObjects = executeFilter(filter); + + assertEquals(1, testObjects.size()); + Assert.assertEquals(TestEnum.TEST2, testObjects.get(0).getEnumValue()); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterEnumPropertyUsingLessThan() { + setupBooleans(); + PropertyStringFilter filter = createFilter("enumValue", + Matcher.LESS_THAN, TestEnum.TEST1.name()); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterEnumPropertyUsingGreaterThan() { + setupBooleans(); + PropertyStringFilter filter = createFilter("enumValue", + Matcher.GREATER_THAN, TestEnum.TEST1.name()); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterUnknownEnumValue() { + setupBooleans(); + PropertyStringFilter filter = createFilter("enumValue", Matcher.EQUALS, + "FOO"); + executeFilter(filter); + } + + @Test(expected = InvalidDataAccessApiUsageException.class) + public void filterNonExistingProperty() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter = createFilter("foo", Matcher.EQUALS, + "John"); + executeFilter(filter); + } + + @Test + public void basicOrFilter() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter1 = createFilter("name", Matcher.EQUALS, + "John"); + PropertyStringFilter filter2 = createFilter("name", Matcher.EQUALS, + "Polly"); + OrFilter filter = new OrFilter(); + filter.setChildren(List.of(filter1, filter2)); + assertFilteredNames(filter, "John", "Polly"); + } + + @Test + public void basicAndFilter() { + setupNames("Jack", "John", "Johnny", "Polly", "Josh"); + PropertyStringFilter filter1 = createFilter("name", Matcher.CONTAINS, + "Joh"); + PropertyStringFilter filter2 = createFilter("name", Matcher.CONTAINS, + "nny"); + AndFilter filter = new AndFilter(); + filter.setChildren(List.of(filter1, filter2)); + assertFilteredNames(filter, "Johnny"); + } + + @Test + public void nestedPropertyFilterString() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter("nestedObject.name", + Matcher.CONTAINS, "42"); + List result = executeFilter(filter); + assertEquals(1, result.size()); + TestObject testObject = result.get(0); + assertEquals("some name 1", testObject.getName()); + assertEquals(42, testObject.getNestedObject().getLuckyNumber()); + } + + @Test + public void nestedPropertyFilterNumber() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter("nestedObject.luckyNumber", + Matcher.EQUALS, "84"); + List result = executeFilter(filter); + assertEquals(1, result.size()); + TestObject testObject = result.get(0); + assertEquals("some name 2", testObject.getName()); + assertEquals(84, testObject.getNestedObject().getLuckyNumber()); + } + + @Test + public void nestedPropertyFilterNumberNoResult() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter("nestedObject.luckyNumber", + Matcher.EQUALS, "85"); + assertEquals(0, executeFilter(filter).size()); + } + + @Test + public void secondLevelNestedPropertyFilterString() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter( + "nestedObject.secondLevelNestedObject.name", Matcher.CONTAINS, + "second level nested object 1"); + List result = executeFilter(filter); + assertEquals(1, result.size()); + TestObject testObject = result.get(0); + assertEquals("some name 1", testObject.getName()); + assertEquals(42, testObject.getNestedObject().getLuckyNumber()); + } + + @Test + public void secondLevelNestedPropertyFilterNumber() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter( + "nestedObject.secondLevelNestedObject.luckyNumber", + Matcher.EQUALS, "2"); + List result = executeFilter(filter); + assertEquals(1, result.size()); + TestObject testObject = result.get(0); + assertEquals("some name 2", testObject.getName()); + assertEquals(84, testObject.getNestedObject().getLuckyNumber()); + } + + @Test + public void secondLevelNestedPropertyFilterNumberNoResult() { + setupNestedObjects(); + PropertyStringFilter filter = createFilter( + "nestedObject.secondLevelNestedObject.luckyNumber", + Matcher.EQUALS, "3"); + assertEquals(0, executeFilter(filter).size()); + } + + private PropertyStringFilter createFilter(String propertyPath, + Matcher matcher, String filterValue) { + PropertyStringFilter filter = new PropertyStringFilter(); + filter.setPropertyId(propertyPath); + filter.setFilterValue(filterValue); + filter.setMatcher(matcher); + return filter; + } + + private void assertFilteredNames(Filter filter, String... expectedNames) { + List result = executeFilter(filter); + assertEquals(expectedNames.length, result.size()); + Object[] actual = result.stream().map(TestObject::getName).toArray(); + Assert.assertArrayEquals(expectedNames, actual); + } + + private void assertFilterResult(Filter filter, List result) { + List actual = executeFilter(filter); + assertEquals(result, actual); + } + + private List executeFilter(Filter filter) { + Specification spec = JpaFilterConverter.toSpec(filter, + TestObject.class, PropertyStringFilterSpecification::new); + return repository.findAll(spec); + } + + private List setupNames(String... names) { + List created = new ArrayList<>(); + for (String name : names) { + TestObject testObject = new TestObject(); + testObject.setName(name); + created.add(entityManager.persist(testObject)); + } + entityManager.flush(); + return created; + } + + private void setupBooleans() { + TestObject testObject = new TestObject(); + testObject.setBooleanValue(true); + entityManager.persist(testObject); + testObject = new TestObject(); + testObject.setBooleanValue(false); + entityManager.persist(testObject); + entityManager.flush(); + } + + private List setupNumbers() { + List created = new ArrayList<>(); + for (int i = 0; i < 10; i++) { + TestObject testObject = new TestObject(); + testObject.setIntValue(i); + testObject.setNullableIntValue(i); + testObject.setLongValue(i); + testObject.setNullableLongValue((long) i); + testObject.setFloatValue((float) i / 10); + testObject.setNullableFloatValue((float) i / 10); + testObject.setDoubleValue((double) i / 10); + testObject.setNullableDoubleValue((double) i / 10); + entityManager.persist(testObject); + created.add(testObject); + } + entityManager.flush(); + return created; + } + + private void setupEnums() { + TestObject testObject = new TestObject(); + testObject.setEnumValue(TestEnum.TEST1); + entityManager.persist(testObject); + testObject = new TestObject(); + testObject.setEnumValue(TestEnum.TEST2); + entityManager.persist(testObject); + entityManager.flush(); + } + + private void setupNestedObjects() { + SecondLevelNestedObject secondLevelNestedObject1 = new SecondLevelNestedObject(); + secondLevelNestedObject1.setName("second level nested object 1"); + secondLevelNestedObject1.setLuckyNumber(1); + entityManager.persist(secondLevelNestedObject1); + SecondLevelNestedObject secondLevelNestedObject2 = new SecondLevelNestedObject(); + secondLevelNestedObject2.setName("second level nested object 2"); + secondLevelNestedObject2.setLuckyNumber(2); + entityManager.persist(secondLevelNestedObject2); + NestedObject nestedObject1 = new NestedObject(); + nestedObject1.setName("nested object 42"); + nestedObject1.setLuckyNumber(42); + nestedObject1.setSecondLevelNestedObject(secondLevelNestedObject1); + entityManager.persist(nestedObject1); + NestedObject nestedObject2 = new NestedObject(); + nestedObject2.setName("nested object 84"); + nestedObject2.setLuckyNumber(84); + nestedObject2.setSecondLevelNestedObject(secondLevelNestedObject2); + entityManager.persist(nestedObject2); + TestObject testObject1 = new TestObject(); + testObject1.setName("some name 1"); + testObject1.setNestedObject(nestedObject1); + entityManager.persist(testObject1); + TestObject testObject2 = new TestObject(); + testObject2.setName("some name 2"); + testObject2.setNestedObject(nestedObject2); + entityManager.persist(testObject2); + entityManager.flush(); + } + +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceJpaTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceJpaTest.java new file mode 100644 index 00000000000..17fbc359593 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceJpaTest.java @@ -0,0 +1,96 @@ +package com.vaadin.flow.spring.data.jpa; + +import java.util.ArrayList; +import java.util.List; + +import com.vaadin.flow.spring.data.filter.PropertyStringFilter; +import com.vaadin.flow.spring.data.filter.PropertyStringFilter.Matcher; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.domain.Pageable; +import org.springframework.test.context.junit4.SpringRunner; + +@RunWith(SpringRunner.class) +@DataJpaTest() +@Import(TestCrudRepositoryService.class) +public class CrudRepositoryServiceJpaTest { + + @Autowired + TestRepository jpaRepository; + @Autowired + private TestEntityManager entityManager; + @Autowired + TestCrudRepositoryService testCrudRepositoryService; + + private List testObjects; + + @Before + public void setupDB() { + String[] names = new String[] { "John", "Jeff", "Michael", "Michelle", + "Dana", "Lady" }; + testObjects = new ArrayList<>(); + for (String name : names) { + TestObject testObject = new TestObject(); + testObject.setName(name); + testObjects.add(entityManager.persist(testObject)); + } + entityManager.flush(); + } + + @Test + public void count() { + Assert.assertEquals(6, testCrudRepositoryService.count(null)); + + PropertyStringFilter filter = new PropertyStringFilter(); + filter.setPropertyId("name"); + filter.setMatcher(Matcher.CONTAINS); + filter.setFilterValue("Mich"); + Assert.assertEquals(2, testCrudRepositoryService.count(filter)); + } + + @Test + public void get() { + TestObject object = testObjects.get(2); + Assert.assertEquals(object.getName(), testCrudRepositoryService + .get(object.getId()).orElseThrow().getName()); + Assert.assertFalse( + testCrudRepositoryService.get(object.getId() + 10).isPresent()); + } + + @Test + public void exists() { + TestObject object = testObjects.get(3); + Assert.assertTrue(testCrudRepositoryService.exists(object.getId())); + Assert.assertFalse( + testCrudRepositoryService.exists(object.getId() + 10)); + } + + @Test + public void saveAll() { + TestObject o1 = new TestObject(); + o1.setName("Hello"); + TestObject o2 = new TestObject(); + o2.setName("World"); + + List saved = testCrudRepositoryService + .saveAll(List.of(o1, o2)); + Assert.assertEquals("World", saved.get(1).getName()); + Assert.assertTrue( + testCrudRepositoryService.exists(saved.get(1).getId())); + } + + @Test + public void deleteAll() { + testCrudRepositoryService.deleteAll(List.of(testObjects.get(3).getId(), + testObjects.get(4).getId())); + Assert.assertEquals(List.of("John", "Jeff", "Michael", "Lady"), + testCrudRepositoryService.list(Pageable.unpaged(), null) + .stream().map(o -> o.getName()).toList()); + } +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceTest.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceTest.java new file mode 100644 index 00000000000..db85139f0f8 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/CrudRepositoryServiceTest.java @@ -0,0 +1,390 @@ +package com.vaadin.flow.spring.data.jpa; + +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Example; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.repository.CrudRepository; +import org.springframework.data.repository.query.FluentQuery; +import org.springframework.stereotype.Repository; +import org.springframework.test.context.junit4.SpringRunner; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@RunWith(SpringRunner.class) +@SpringBootTest(classes = { + CrudRepositoryServiceTest.DefaultJpaRepositoryService.class, + CrudRepositoryServiceTest.CustomCrudRepository.class, + CrudRepositoryServiceTest.CustomCrudRepositoryService.class, + CrudRepositoryServiceTest.CustomJpaRepository.class, + CrudRepositoryServiceTest.CustomJpaRepositoryService.class }) +@EnableAutoConfiguration +public class CrudRepositoryServiceTest { + + @Repository + static class CustomCrudRepository + implements CrudRepository, + JpaSpecificationExecutor { + @Override + public Optional findOne(Specification spec) { + return Optional.empty(); + } + + @Override + public List findAll(Specification spec) { + return null; + } + + @Override + public Page findAll(Specification spec, + Pageable pageable) { + return null; + } + + @Override + public List findAll(Specification spec, + Sort sort) { + return null; + } + + @Override + public long count(Specification spec) { + return 0; + } + + @Override + public boolean exists(Specification spec) { + return false; + } + + @Override + public long delete(Specification spec) { + return 0; + } + + @Override + public R findBy( + Specification spec, + Function, R> queryFunction) { + return null; + } + + @Override + public S save(S entity) { + return null; + } + + @Override + public Iterable saveAll( + Iterable entities) { + return null; + } + + @Override + public Optional findById(Integer integer) { + return Optional.empty(); + } + + @Override + public boolean existsById(Integer integer) { + return false; + } + + @Override + public Iterable findAll() { + return null; + } + + @Override + public Iterable findAllById(Iterable integers) { + return null; + } + + @Override + public long count() { + return 0; + } + + @Override + public void deleteById(Integer integer) { + } + + @Override + public void delete(TestObject entity) { + } + + @Override + public void deleteAllById(Iterable integers) { + } + + @Override + public void deleteAll(Iterable entities) { + } + + @Override + public void deleteAll() { + } + } + + @Repository + static class CustomJpaRepository + implements JpaRepository, + JpaSpecificationExecutor { + @Override + public void flush() { + } + + @Override + public S saveAndFlush(S entity) { + return null; + } + + @Override + public List saveAllAndFlush( + Iterable entities) { + return null; + } + + @Override + public void deleteAllInBatch(Iterable entities) { + } + + @Override + public void deleteAllByIdInBatch(Iterable integers) { + } + + @Override + public void deleteAllInBatch() { + } + + @Override + public TestObject getOne(Integer integer) { + return null; + } + + @Override + public TestObject getById(Integer integer) { + return null; + } + + @Override + public TestObject getReferenceById(Integer integer) { + return null; + } + + @Override + public List findAll(Example example) { + return null; + } + + @Override + public List findAll(Example example, + Sort sort) { + return null; + } + + @Override + public Optional findOne(Specification spec) { + return Optional.empty(); + } + + @Override + public List findAll(Specification spec) { + return null; + } + + @Override + public Page findAll(Specification spec, + Pageable pageable) { + return null; + } + + @Override + public List findAll(Specification spec, + Sort sort) { + return null; + } + + @Override + public long count(Specification spec) { + return 0; + } + + @Override + public boolean exists(Specification spec) { + return false; + } + + @Override + public long delete(Specification spec) { + return 0; + } + + @Override + public R findBy( + Specification spec, + Function, R> queryFunction) { + return null; + } + + @Override + public List saveAll(Iterable entities) { + return null; + } + + @Override + public List findAll() { + return null; + } + + @Override + public List findAllById(Iterable integers) { + return null; + } + + @Override + public S save(S entity) { + return null; + } + + @Override + public Optional findById(Integer integer) { + return Optional.empty(); + } + + @Override + public boolean existsById(Integer integer) { + return false; + } + + @Override + public long count() { + return 0; + } + + @Override + public void deleteById(Integer integer) { + } + + @Override + public void delete(TestObject entity) { + } + + @Override + public void deleteAllById(Iterable integers) { + } + + @Override + public void deleteAll(Iterable entities) { + } + + @Override + public void deleteAll() { + } + + @Override + public List findAll(Sort sort) { + return null; + } + + @Override + public Page findAll(Pageable pageable) { + return null; + } + + @Override + public Optional findOne(Example example) { + return Optional.empty(); + } + + @Override + public Page findAll(Example example, + Pageable pageable) { + return null; + } + + @Override + public long count(Example example) { + return 0; + } + + @Override + public boolean exists(Example example) { + return false; + } + + @Override + public R findBy(Example example, + Function, R> queryFunction) { + return null; + } + } + + static class DefaultJpaRepositoryService + extends CrudRepositoryService { + DefaultJpaRepositoryService(TestRepository repository) { + super(repository); + } + } + + static class CustomCrudRepositoryService extends + CrudRepositoryService { + public CustomCrudRepositoryService(CustomCrudRepository repository) { + super(repository); + } + } + + static class CustomJpaRepositoryService extends + CrudRepositoryService { + public CustomJpaRepositoryService(CustomJpaRepository repository) { + super(repository); + } + } + + @Autowired + private DefaultJpaRepositoryService defaultJpaRepositoryService; + + @Autowired + private CustomCrudRepositoryService customCrudRepositoryService; + + @Autowired + private CustomJpaRepositoryService customJpaRepositoryService; + + @Test + public void when_serviceHasNoExplicitConstructor_then_getRepositoryResolvesRepositoryFromContext() { + assertNotNull(defaultJpaRepositoryService.getRepository()); + } + + @Test + public void when_serviceHasExplicitConstructor_then_getRepositoryReturnsTheProvidedRepositoryInstance() { + assertNotNull(customCrudRepositoryService.getRepository()); + assertEquals(CustomCrudRepository.class, customCrudRepositoryService + .getRepository().getClass().getSuperclass()); + } + + @Test + public void JpaRepository_Is_CompatibleWith_CrudRepositoryService() { + assertNotNull(defaultJpaRepositoryService.getRepository()); + } + + @Test + public void customJpaRepository_Is_CompatibleWith_CrudRepositoryService() { + assertNotNull(customJpaRepositoryService.getRepository()); + assertEquals(CustomJpaRepository.class, customJpaRepositoryService + .getRepository().getClass().getSuperclass()); + } + +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/NestedObject.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/NestedObject.java new file mode 100644 index 00000000000..aa3ecc995df --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/NestedObject.java @@ -0,0 +1,55 @@ +package com.vaadin.flow.spring.data.jpa; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; + +@Entity +public class NestedObject { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Integer id; + + private String name; + + private long luckyNumber; + + @OneToOne + private SecondLevelNestedObject secondLevelNestedObject; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getLuckyNumber() { + return luckyNumber; + } + + public void setLuckyNumber(long luckyNumber) { + this.luckyNumber = luckyNumber; + } + + public SecondLevelNestedObject getSecondLevelNestedObject() { + return secondLevelNestedObject; + } + + public void setSecondLevelNestedObject( + SecondLevelNestedObject secondLevelNestedObject) { + this.secondLevelNestedObject = secondLevelNestedObject; + } +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/SecondLevelNestedObject.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/SecondLevelNestedObject.java new file mode 100644 index 00000000000..75aa2ae2ddb --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/SecondLevelNestedObject.java @@ -0,0 +1,42 @@ +package com.vaadin.flow.spring.data.jpa; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; + +@Entity +public class SecondLevelNestedObject { + + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Integer id; + + private String name; + + private long luckyNumber; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public long getLuckyNumber() { + return luckyNumber; + } + + public void setLuckyNumber(long luckyNumber) { + this.luckyNumber = luckyNumber; + } +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestCrudRepositoryService.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestCrudRepositoryService.java new file mode 100644 index 00000000000..fd71a17d1f3 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestCrudRepositoryService.java @@ -0,0 +1,14 @@ +package com.vaadin.flow.spring.data.jpa; + +import org.springframework.stereotype.Service; + +import com.vaadin.flow.spring.data.jpa.CrudRepositoryService; + +@Service +public class TestCrudRepositoryService + extends CrudRepositoryService { + TestCrudRepositoryService(TestRepository repository) { + super(repository); + } + +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestEnum.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestEnum.java new file mode 100644 index 00000000000..10af48572bb --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestEnum.java @@ -0,0 +1,5 @@ +package com.vaadin.flow.spring.data.jpa; + +public enum TestEnum { + TEST1, TEST2, TEST3 +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestObject.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestObject.java new file mode 100644 index 00000000000..daf23712150 --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestObject.java @@ -0,0 +1,164 @@ +package com.vaadin.flow.spring.data.jpa; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.OneToOne; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +@Entity +public class TestObject { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Integer id; + + private String name; + private LocalDate localDate; + private LocalTime localTime; + private LocalDateTime localDateTime; + private Boolean booleanValue; + private int intValue; + private Integer nullableIntValue; + private long longValue; + private Long nullableLongValue; + private float floatValue; + private Float nullableFloatValue; + private double doubleValue; + private Double nullableDoubleValue; + private TestEnum enumValue; + + @OneToOne + private NestedObject nestedObject; + + public Integer getId() { + return id; + } + + public void setId(Integer id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public LocalDate getLocalDate() { + return localDate; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + public LocalDateTime getLocalDateTime() { + return localDateTime; + } + + public void setLocalDateTime(LocalDateTime localDateTime) { + this.localDateTime = localDateTime; + } + + public LocalTime getLocalTime() { + return localTime; + } + + public void setLocalTime(LocalTime localTime) { + this.localTime = localTime; + } + + public Boolean getBooleanValue() { + return booleanValue; + } + + public void setBooleanValue(Boolean aBoolean) { + this.booleanValue = aBoolean; + } + + public int getIntValue() { + return intValue; + } + + public void setIntValue(int intValue) { + this.intValue = intValue; + } + + public Integer getNullableIntValue() { + return nullableIntValue; + } + + public void setNullableIntValue(Integer nullableIntValue) { + this.nullableIntValue = nullableIntValue; + } + + public long getLongValue() { + return longValue; + } + + public void setLongValue(long longValue) { + this.longValue = longValue; + } + + public Long getNullableLongValue() { + return nullableLongValue; + } + + public void setNullableLongValue(Long nullableLongValue) { + this.nullableLongValue = nullableLongValue; + } + + public float getFloatValue() { + return floatValue; + } + + public void setFloatValue(float floatValue) { + this.floatValue = floatValue; + } + + public Float getNullableFloatValue() { + return nullableFloatValue; + } + + public void setNullableFloatValue(Float nullableFloatValue) { + this.nullableFloatValue = nullableFloatValue; + } + + public double getDoubleValue() { + return doubleValue; + } + + public void setDoubleValue(double doubleValue) { + this.doubleValue = doubleValue; + } + + public Double getNullableDoubleValue() { + return nullableDoubleValue; + } + + public void setNullableDoubleValue(Double nullableDoubleValue) { + this.nullableDoubleValue = nullableDoubleValue; + } + + public TestEnum getEnumValue() { + return enumValue; + } + + public void setEnumValue(TestEnum testEnum) { + this.enumValue = testEnum; + } + + public NestedObject getNestedObject() { + return nestedObject; + } + + public void setNestedObject(NestedObject nestedObject) { + this.nestedObject = nestedObject; + } +} diff --git a/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestRepository.java b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestRepository.java new file mode 100644 index 00000000000..1c6107eb6ab --- /dev/null +++ b/vaadin-spring/src/test/java/com/vaadin/flow/spring/data/jpa/TestRepository.java @@ -0,0 +1,9 @@ +package com.vaadin.flow.spring.data.jpa; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; + +public interface TestRepository extends JpaRepository, + JpaSpecificationExecutor { + +}