diff --git a/doma-spring-boot-core/pom.xml b/doma-spring-boot-core/pom.xml index b0577d6c..9c0d738a 100644 --- a/doma-spring-boot-core/pom.xml +++ b/doma-spring-boot-core/pom.xml @@ -79,6 +79,19 @@ + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.seasar.doma + doma-processor + ${doma.version} + + + + diff --git a/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/PropertyMetamodelResolver.java b/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/PropertyMetamodelResolver.java new file mode 100644 index 00000000..0c06a6db --- /dev/null +++ b/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/PropertyMetamodelResolver.java @@ -0,0 +1,21 @@ +package org.seasar.doma.boot; + +import java.util.Optional; + +import org.seasar.doma.jdbc.criteria.metamodel.PropertyMetamodel; + +/** + * A resolver that maps property names to {@link PropertyMetamodel} + */ +@FunctionalInterface +public interface PropertyMetamodelResolver { + /** + * Resolves the specified property name into a {@link PropertyMetamodel}. + * + * @param propertyName the name of the property to resolve + * @return an {@link Optional} containing the resolved {@link PropertyMetamodel} + * if found, + * or an empty {@link Optional} if the property name cannot be resolved + */ + Optional> resolve(String propertyName); +} diff --git a/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/UnifiedQueryPageable.java b/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/UnifiedQueryPageable.java new file mode 100644 index 00000000..67882d7f --- /dev/null +++ b/doma-spring-boot-core/src/main/java/org/seasar/doma/boot/UnifiedQueryPageable.java @@ -0,0 +1,230 @@ +package org.seasar.doma.boot; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.seasar.doma.jdbc.criteria.declaration.OrderByNameDeclaration; +import org.seasar.doma.jdbc.criteria.metamodel.EntityMetamodel; +import org.seasar.doma.jdbc.criteria.metamodel.PropertyMetamodel; +import org.seasar.doma.jdbc.criteria.statement.EntityQueryable; +import org.springframework.data.domain.Pageable; + +/** + * An adapter that integrates {@link Pageable} with Doma Criteria API. + *

+ * This class allows converting {@link Pageable} pagination and sort information + * into Doma Criteria API's limit, offset, and order-by specifications. + *

+ * Example usage: + *

{@code
+ * public Page getPage(Pageable pageable) {
+ *     final var task_ = new Task_();
+ *     final var p = UnifiedQueryPageable.from(pageable, task_);
+ *     final var content = this.queryDsl
+ *         .from(task_)
+ *         .offset(p.offset())
+ *         .limit(p.limit())
+ *         .orderBy(p.orderBy())
+ *         .fetch();
+ *     final var total = this.queryDsl
+ *         .from(task_)
+ *         .select(Expressions.count())
+ *         .fetchOne();
+ *     return new PageImpl<>(content, pageable, total);
+ * }
+ * }
+ * + * @author mazeneko + */ +public class UnifiedQueryPageable { + private final Pageable pageable; + private final SortConfig sortConfig; + + /** + * A configuration holder for sort resolution. + */ + public record SortConfig( + /** a resolver that maps property names to {@link PropertyMetamodel} */ + PropertyMetamodelResolver propertyMetamodelResolver, + /** a consumer that applies default ordering when no valid sort can be determined */ + Consumer defaultOrder) { + } + + private UnifiedQueryPageable( + Pageable pageable, + SortConfig sortConfig) { + this.pageable = pageable; + this.sortConfig = sortConfig; + } + + public Pageable getPageable() { + return pageable; + } + + public SortConfig getSortConfig() { + return sortConfig; + } + + /** + * Creates a {@link UnifiedQueryPageable}, resolving sort properties based on the entity's property names. + * + * @param pageable {@link Pageable} object to convert + * @param sortTargetEntity the target entity whose properties are used for sorting + * @return the {@link UnifiedQueryPageable} + */ + public static UnifiedQueryPageable from( + Pageable pageable, + EntityMetamodel sortTargetEntity) { + return UnifiedQueryPageable.from(pageable, sortTargetEntity, c -> { + }); + } + + /** + * Creates a {@link UnifiedQueryPageable}, resolving sort properties based on the entity's property names. + * + * @param pageable {@link Pageable} object to convert + * @param sortTargetEntity the target entity whose properties are used for sorting + * @param defaultOrder a consumer that applies default ordering when no valid sort can be determined + * @return the {@link UnifiedQueryPageable} + */ + public static UnifiedQueryPageable from( + Pageable pageable, + EntityMetamodel sortTargetEntity, + Consumer defaultOrder) { + final var nameToMetamodel = sortTargetEntity + .allPropertyMetamodels() + .stream() + .collect(Collectors.toMap(PropertyMetamodel::getName, Function.identity())); + final var sortConfig = new SortConfig( + propertyName -> Optional.ofNullable(nameToMetamodel.get(propertyName)), + defaultOrder); + return new UnifiedQueryPageable( + pageable, + sortConfig); + } + + /** + * Creates a {@link UnifiedQueryPageable} + * + * @param pageable {@link Pageable} object to convert + * @param sortConfig sort configuration + * @return the {@link UnifiedQueryPageable} + */ + public static UnifiedQueryPageable of(Pageable pageable, SortConfig sortConfig) { + return new UnifiedQueryPageable(pageable, sortConfig); + } + + /** + * Creates a {@link UnifiedQueryPageable} + * + * @param pageable {@link Pageable} object to convert + * @param propertyMetamodelResolver a resolver that maps property names to {@link PropertyMetamodel} + * @return the {@link UnifiedQueryPageable} + */ + public static UnifiedQueryPageable of( + Pageable pageable, + PropertyMetamodelResolver propertyMetamodelResolver) { + return UnifiedQueryPageable.of(pageable, propertyMetamodelResolver, c -> { + }); + } + + /** + * Creates a {@link UnifiedQueryPageable} + * + * @param pageable {@link Pageable} object to convert + * @param propertyMetamodelResolver a resolver that maps property names to {@link PropertyMetamodel} + * @param defaultOrder a consumer that applies default ordering when no valid sort can be determined + * @return the {@link UnifiedQueryPageable} + */ + public static UnifiedQueryPageable of( + Pageable pageable, + PropertyMetamodelResolver propertyMetamodelResolver, + Consumer defaultOrder) { + final var sortConfig = new SortConfig( + propertyMetamodelResolver, + defaultOrder); + return new UnifiedQueryPageable(pageable, sortConfig); + } + + /** + * Converts {@link Pageable} to {@link EntityQueryable#limit(Integer)} + * + * @return the limit. + * if {@link Pageable#isUnpaged()} is {@code true} then null. + */ + public Integer limit() { + return pageable.isUnpaged() ? null : pageable.getPageSize(); + } + + /** + * Converts {@link Pageable} to {@link EntityQueryable#offset(Integer)} + * + * @return the offset. + * if {@link Pageable#isUnpaged()} is {@code true} then null. + */ + public Integer offset() { + return pageable.isUnpaged() + ? null + : Math.multiplyExact(pageable.getPageNumber(), pageable.getPageSize()); + } + + /** + * Creates an {@link OrderByNameDeclaration} consumer based on the + * {@link Pageable}'s sort information using the provided {@link PropertyMetamodelResolver}. + *

+ * If the {@link Pageable} is unsorted or no matching {@link PropertyMetamodel} is found, + * a default ordering is applied. + * + * @return a consumer that configures ordering based on the resolved {@link PropertyMetamodel} instances + */ + public Consumer orderBy() { + return orderBy(missingProperties -> { + }); + } + + /** + * Creates an {@link OrderByNameDeclaration} consumer based on the + * {@link Pageable}'s sort information using the provided {@link PropertyMetamodelResolver}. + *

+ * If the {@link Pageable} is unsorted, or if no {@link PropertyMetamodel} can be resolved + * for a given sort property, a default ordering is applied. + *

+ * The provided {@code handleMissingProperties} consumer is called with a list of + * property names that could not be resolved. This can be used to throw an exception, + * log a warning, or handle the situation in a custom way. + * + * @param handleMissingProperties a callback that handles property names which could not be resolved + * @return a consumer that configures ordering based on the resolved {@link PropertyMetamodel} instances + */ + public Consumer orderBy( + Consumer> handleMissingProperties) { + if (pageable.getSort().isUnsorted()) { + return sortConfig.defaultOrder(); + } + final var orderSpecifiers = new ArrayList>(); + final var missingProperties = new ArrayList(); + for (final var order : pageable.getSort()) { + sortConfig + .propertyMetamodelResolver() + .resolve(order.getProperty()) + .ifPresentOrElse( + propertyMetamodel -> orderSpecifiers.add( + switch (order.getDirection()) { + case ASC -> c -> c.asc(propertyMetamodel); + case DESC -> c -> c.desc(propertyMetamodel); + }), + () -> missingProperties.add(order.getProperty())); + } + if (!missingProperties.isEmpty()) { + handleMissingProperties.accept(missingProperties); + } + if (orderSpecifiers.isEmpty()) { + return sortConfig.defaultOrder(); + } + return c -> orderSpecifiers.forEach(orderSpecifier -> orderSpecifier.accept(c)); + } +} diff --git a/doma-spring-boot-core/src/test/java/org/seasar/doma/boot/UnifiedQueryPageableTest.java b/doma-spring-boot-core/src/test/java/org/seasar/doma/boot/UnifiedQueryPageableTest.java new file mode 100644 index 00000000..dacaef06 --- /dev/null +++ b/doma-spring-boot-core/src/test/java/org/seasar/doma/boot/UnifiedQueryPageableTest.java @@ -0,0 +1,190 @@ +package org.seasar.doma.boot; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; + +import java.util.Optional; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.seasar.doma.Entity; +import org.seasar.doma.Id; +import org.seasar.doma.Metamodel; +import org.seasar.doma.jdbc.criteria.declaration.OrderByNameDeclaration; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +public class UnifiedQueryPageableTest { + @ParameterizedTest + @CsvSource(value = { + "0 | 10 | 0 | 10", + "2 | 10 | 20 | 10", + "2 | 5 | 10 | 5", + }, delimiter = '|') + public void testOffsetAndLimit( + int pageNumber, int pageSize, int expectedOffset, int expectedLimit) { + Pageable pageable = PageRequest.of(pageNumber, pageSize); + UnifiedQueryPageable p = UnifiedQueryPageable.of(pageable, c -> Optional.empty()); + + Integer offset = p.offset(); + Integer limit = p.limit(); + + assertThat(offset, is(expectedOffset)); + assertThat(limit, is(expectedLimit)); + } + + @Test + public void testOffsetAndLimitWhenUnpaged() { + Pageable pageable = Pageable.unpaged(); + UnifiedQueryPageable p = UnifiedQueryPageable.of(pageable, c -> Optional.empty()); + + Integer offset = p.offset(); + Integer limit = p.limit(); + + assertThat(offset, nullValue()); + assertThat(limit, nullValue()); + } + + @Test + public void testOrderBy() { + Pageable pageable = PageRequest.of(0, 10, Sort.by("name").ascending()); + Person_ entity = new Person_(); + UnifiedQueryPageable p = UnifiedQueryPageable.of( + pageable, + propertyName -> switch (propertyName) { + case "name" -> Optional.of(entity.name); + default -> Optional.empty(); + }); + + Consumer consumer = p.orderBy(); + + OrderByNameDeclaration orderByNameDeclaration = mock(OrderByNameDeclaration.class); + consumer.accept(orderByNameDeclaration); + verify(orderByNameDeclaration, times(1)).asc(entity.name); + } + + @Test + public void testOrderBy2() { + Pageable pageable = PageRequest.of(0, 10, + Sort.by("name").descending().and(Sort.by("age").ascending())); + Person_ entity = new Person_(); + UnifiedQueryPageable p = UnifiedQueryPageable.of( + pageable, + propertyName -> switch (propertyName) { + case "name" -> Optional.of(entity.name); + case "age" -> Optional.of(entity.age); + default -> Optional.empty(); + }); + + Consumer consumer = p.orderBy(); + + OrderByNameDeclaration orderByNameDeclaration = mock(OrderByNameDeclaration.class); + consumer.accept(orderByNameDeclaration); + var sortOrderVerifier = inOrder(orderByNameDeclaration); + sortOrderVerifier.verify(orderByNameDeclaration, times(1)).desc(entity.name); + sortOrderVerifier.verify(orderByNameDeclaration, times(1)).asc(entity.age); + } + + @Test + public void testOrderByWhenNonSort() { + Pageable pageable = PageRequest.of(0, 10); + UnifiedQueryPageable p = UnifiedQueryPageable.of( + pageable, + propertyName -> Optional.empty()); + + Consumer consumer = p.orderBy(); + + OrderByNameDeclaration orderByNameDeclaration = mock(OrderByNameDeclaration.class); + consumer.accept(orderByNameDeclaration); + verifyNoMoreInteractions(orderByNameDeclaration); + } + + @Test + public void testOrderByWhenNonSortAndSetDefault() { + Pageable pageable = PageRequest.of(0, 10); + Person_ entity = new Person_(); + Consumer defaultOrder = c -> c.asc(entity.id); + UnifiedQueryPageable p = UnifiedQueryPageable.of( + pageable, + propertyName -> Optional.empty(), + defaultOrder); + + Consumer consumer = p.orderBy(); + + assertThat(consumer, sameInstance(defaultOrder)); + } + + @Test + public void testOrderBySingleEntity() { + Pageable pageable = PageRequest.of(0, 10, + Sort.by("name").descending().and(Sort.by("age").ascending())); + Person_ entity = new Person_(); + UnifiedQueryPageable p = UnifiedQueryPageable.from(pageable, entity); + + Consumer consumer = p.orderBy(); + + OrderByNameDeclaration orderByNameDeclaration = mock(OrderByNameDeclaration.class); + consumer.accept(orderByNameDeclaration); + var sortOrderVerifier = inOrder(orderByNameDeclaration); + sortOrderVerifier.verify(orderByNameDeclaration, times(1)).desc(entity.name); + sortOrderVerifier.verify(orderByNameDeclaration, times(1)).asc(entity.age); + } + + @Test + public void testOrderByWhenMissingProperties() { + Pageable pageable = PageRequest.of(0, 10, + Sort.by("dog").and(Sort.by("name")).and(Sort.by("cat"))); + Person_ entity = new Person_(); + UnifiedQueryPageable p = UnifiedQueryPageable.from(pageable, entity); + + Consumer consumer = p.orderBy(); + + OrderByNameDeclaration orderByNameDeclaration = mock(OrderByNameDeclaration.class); + consumer.accept(orderByNameDeclaration); + verify(orderByNameDeclaration, times(1)).asc(entity.name); + } + + @Test + public void testOrderByWhenMissingAllProperties() { + Pageable pageable = PageRequest.of(0, 10, + Sort.by("dog").and(Sort.by("cat"))); + Person_ entity = new Person_(); + Consumer defaultOrder = c -> c.desc(entity.age); + UnifiedQueryPageable p = UnifiedQueryPageable.from(pageable, entity, defaultOrder); + + Consumer consumer = p.orderBy(); + + assertThat(consumer, sameInstance(defaultOrder)); + } + + @Test + public void testOrderByWhenMissingPropertiesHandle() { + Pageable pageable = PageRequest.of(0, 10, + Sort.by("dog").and(Sort.by("name")).and(Sort.by("cat"))); + Person_ entity = new Person_(); + UnifiedQueryPageable p = UnifiedQueryPageable.from(pageable, entity); + + assertThatThrownBy(() -> p.orderBy(missingProperties -> { + throw new IllegalArgumentException( + missingProperties.stream().collect(Collectors.joining(","))); + })) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("dog,cat"); + } +} + +@Entity(metamodel = @Metamodel) +record Person(@Id String id, String name, Integer age) { +} \ No newline at end of file