diff --git a/flow-server/src/main/java/com/vaadin/flow/Nonnull.java b/flow-server/src/main/java/com/vaadin/flow/Nonnull.java new file mode 100644 index 00000000000..7700dda1bd1 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/Nonnull.java @@ -0,0 +1,36 @@ +/* + * Copyright 2000-2022 Vaadin Ltd. + * + * Licensed 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 com.vaadin.flow; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark either field, method, parameter or type parameter as + * non-nullable. It is used by Typescript Generator as a source of type + * nullability information. + * + * This annotation exists because the traditional + * `jakarta.annotation.Nonnull` annotation is not applicable to type parameters. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE_USE }) +public @interface Nonnull { +} diff --git a/flow-server/src/main/java/com/vaadin/flow/Nullable.java b/flow-server/src/main/java/com/vaadin/flow/Nullable.java new file mode 100644 index 00000000000..ec21fc9e7b9 --- /dev/null +++ b/flow-server/src/main/java/com/vaadin/flow/Nullable.java @@ -0,0 +1,37 @@ +/* + * Copyright 2000-2022 Vaadin Ltd. + * + * Licensed 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 com.vaadin.flow; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to mark either field, method, parameter or type parameter as + * nullable. It is used by Typescript Generator as a source of type nullability + * information. + * + * This annotation exists because the traditional + * `jakarta.annotation.Nullable` annotation is not applicable to type + * parameters. + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE_USE }) +public @interface Nullable { +} diff --git a/vaadin-spring/pom.xml b/vaadin-spring/pom.xml index 54583311dae..5a214b00dd7 100644 --- a/vaadin-spring/pom.xml +++ b/vaadin-spring/pom.xml @@ -97,6 +97,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 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..e680caee741 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CountService.java @@ -0,0 +1,22 @@ +package com.vaadin.flow.spring.data; + +import org.springframework.lang.Nullable; + +import com.vaadin.flow.spring.data.filter.Filter; + +/** + * A browser-callable service that can count the given type of objects with a + * given filter. + */ +public interface CountService { + + /** + * Counts the number of entities that match the given filter. + * + * @param filter + * the filter, or {@code null} to use no filter + * @return + */ + 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..3874150a6f4 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/CrudService.java @@ -0,0 +1,8 @@ +package com.vaadin.flow.spring.data; + +/** + * A browser-callable service that can create, read, update, and delete a given + * type of object. + */ +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..c8a70ca68ae --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/FormService.java @@ -0,0 +1,31 @@ +package com.vaadin.flow.spring.data; + +import org.springframework.lang.Nullable; + +/** + * A browser-callable service that can create, update, and delete a given type + * of object. + */ +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 or {@code null} if no object was found to update + */ + @Nullable + 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..4f7265d8892 --- /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 browser-callable 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..f246b68c38e --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/ListService.java @@ -0,0 +1,28 @@ +package com.vaadin.flow.spring.data; + +import java.util.List; + +import com.vaadin.flow.Nullable; +import com.vaadin.flow.Nonnull; + +import com.vaadin.flow.spring.data.filter.Filter; +import org.springframework.data.domain.Pageable; + +/** + * A browser-callable service that can list the given type of object. + */ +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 + */ + @Nonnull + List<@Nonnull T> list(Pageable pageable, @Nullable Filter filter); + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/PropertyStringFilterSpecification.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/PropertyStringFilterSpecification.java new file mode 100644 index 00000000000..5c3618760b2 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/PropertyStringFilterSpecification.java @@ -0,0 +1,189 @@ +package com.vaadin.flow.spring.data; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; + +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 com.vaadin.flow.spring.data.filter.PropertyStringFilter; +import org.springframework.data.jpa.domain.Specification; + +public class PropertyStringFilterSpecification implements Specification { + + private final PropertyStringFilter filter; + private final Class javaType; + + public PropertyStringFilterSpecification(PropertyStringFilter filter, + Class javaType) { + this.filter = filter; + this.javaType = javaType; + } + + @Override + public Predicate toPredicate(Root root, CriteriaQuery query, + CriteriaBuilder criteriaBuilder) { + String value = filter.getFilterValue(); + Path propertyPath = getPath(filter.getPropertyId(), root); + if (javaType == String.class) { + Expression expr = criteriaBuilder.lower(propertyPath); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(expr, value.toLowerCase()); + case CONTAINS: + return criteriaBuilder.like(expr, + "%" + value.toLowerCase() + "%"); + 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"); + default: + break; + } + + } else if (isNumber(javaType)) { + 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); + default: + break; + } + } else if (isBoolean(javaType)) { + Boolean booleanValue = Boolean.valueOf(value); + 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"); + default: + break; + } + } else if (isLocalDate(javaType)) { + var path = root. get(filter.getPropertyId()); + var dateValue = LocalDate.parse(value); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(path, dateValue); + case CONTAINS: + throw new IllegalArgumentException( + "A date cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(path, dateValue); + case LESS_THAN: + return criteriaBuilder.lessThan(path, dateValue); + default: + break; + } + } else if (isLocalTime(javaType)) { + var path = root. get(filter.getPropertyId()); + var timeValue = LocalTime.parse(value); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.equal(path, timeValue); + case CONTAINS: + throw new IllegalArgumentException( + "A time cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(path, timeValue); + case LESS_THAN: + return criteriaBuilder.lessThan(path, timeValue); + default: + break; + } + } else if (isLocalDateTime(javaType)) { + var path = root. get(filter.getPropertyId()); + var dateValue = LocalDate.parse(value); + var minValue = LocalDateTime.of(dateValue, LocalTime.MIN); + var maxValue = LocalDateTime.of(dateValue, LocalTime.MAX); + switch (filter.getMatcher()) { + case EQUALS: + return criteriaBuilder.between(path, minValue, maxValue); + case CONTAINS: + throw new IllegalArgumentException( + "A datetime cannot be filtered using contains"); + case GREATER_THAN: + return criteriaBuilder.greaterThan(path, maxValue); + case LESS_THAN: + return criteriaBuilder.lessThan(path, minValue); + default: + break; + } + } else if (javaType.isEnum()) { + var enumValue = Enum.valueOf(javaType.asSubclass(Enum.class), + value); + + 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"); + default: + break; + } + } + throw new IllegalArgumentException("No implementation for " + javaType + + " using " + filter.getMatcher() + "."); + } + + 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 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 isBoolean(Class javaType) { + return javaType == boolean.class || javaType == Boolean.class; + } + + private boolean isLocalDate(Class javaType) { + return javaType == java.time.LocalDate.class; + } + + private boolean isLocalTime(Class javaType) { + return javaType == java.time.LocalTime.class; + } + + private boolean isLocalDateTime(Class javaType) { + return javaType == java.time.LocalDateTime.class; + } +} 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..a5b73fdcca0 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/AndFilter.java @@ -0,0 +1,27 @@ +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; + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + @Override + public String toString() { + return "AndFilter [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..10d2b932a0e --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/Filter.java @@ -0,0 +1,25 @@ +package com.vaadin.flow.spring.data.filter; + +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 { + +} diff --git a/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/FilterTransformer.java b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/FilterTransformer.java new file mode 100644 index 00000000000..56581410a38 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/FilterTransformer.java @@ -0,0 +1,125 @@ +package com.vaadin.flow.spring.data.filter; + +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +/** + * Utility class for transforming property names in filters and pageable + * objects. + */ +public class FilterTransformer { + + private final Map mappings = new HashMap<>(); + private Function filterTransformation; + + /** + * Declares a mapping from one property name to another. If a filter or + * pageable is transformed, all occurrences of the property name will be + * replaced with the new name. + * + * @param from + * The original property name. + * @param to + * The new property name. + * @return This instance. + */ + public FilterTransformer withMapping(String from, String to) { + mappings.put(from, to); + return this; + } + + /** + * Declares a function that will be applied to all + * {@link PropertyStringFilter} instances, allowing any kind of + * customization, including a replacement of the filter itself. This can be + * used to modify the filter value in a more complex way than a simple + * mapping. + *

+ * Note: The passed in {@code filterTransformation} function is applied + * after all other mappings are applied. + * + * @param filterTransformation + * The function to apply. + * @return This instance. + */ + public FilterTransformer withFilterTransformation( + Function filterTransformation) { + this.filterTransformation = filterTransformation; + return this; + } + + /** + * Applies registered property mappings and transformation function on the + * provided filter instance. + * + * @param filter + * The filter instance to transform. + * @return The transformed filter. + */ + public Filter apply(Filter filter) { + if (filter == null) { + return null; + } + + if (filter instanceof AndFilter andFilter) { + var newAndFilter = new AndFilter(); + newAndFilter.setChildren( + andFilter.getChildren().stream().map(this::apply).toList()); + return newAndFilter; + } else if (filter instanceof OrFilter orFilter) { + var newOrFilter = new OrFilter(); + newOrFilter.setChildren( + orFilter.getChildren().stream().map(this::apply).toList()); + return newOrFilter; + } else if (filter instanceof PropertyStringFilter propertyStringFilter) { + var property = propertyStringFilter.getPropertyId(); + var mappedProperty = mappings.get(property); + + var newFilter = new PropertyStringFilter(); + newFilter.setPropertyId( + mappedProperty == null ? property : mappedProperty); + newFilter.setFilterValue(propertyStringFilter.getFilterValue()); + newFilter.setMatcher(propertyStringFilter.getMatcher()); + + if (filterTransformation != null) { + newFilter = filterTransformation.apply(newFilter); + } + + return newFilter; + } + + // unknown filters are returned as-is: they are supposed to be already + // customized according to the use case + return filter; + } + + /** + * Applies registered property mappings on the provided pageable instance. + *

+ * Note: The passed in {@code filterTransformation} function is not applied + * on pageables. + * + * @param pageable + * The pageable instance to transform. + * @return The transformed pageable. + */ + public Pageable apply(Pageable pageable) { + if (pageable == null) { + return null; + } + + var orders = pageable.getSort().stream().map(order -> { + var mappedProperty = mappings.get(order.getProperty()); + return mappedProperty == null ? order + : new Sort.Order(order.getDirection(), mappedProperty); + }).toList(); + + return PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), + Sort.by(orders)); + } +} 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..83544f81919 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/OrFilter.java @@ -0,0 +1,28 @@ +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; + + public List getChildren() { + return children; + } + + public void setChildren(List children) { + this.children = children; + } + + @Override + public String toString() { + return "OrFilter [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..8111a245a76 --- /dev/null +++ b/vaadin-spring/src/main/java/com/vaadin/flow/spring/data/filter/PropertyStringFilter.java @@ -0,0 +1,98 @@ +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; + + /** + * 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 "PropertyStringFilter [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/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;