diff --git a/CONTRIBUTE.md b/CONTRIBUTE.md index dc0b527..9956fe6 100644 --- a/CONTRIBUTE.md +++ b/CONTRIBUTE.md @@ -82,7 +82,7 @@ tarantool-java-sdk (parent POM) ├── tarantool-spring-data-core ├── tarantool-spring-data-27 ├── ... - ├── tarantool-spring-data-34 + ├── tarantool-spring-data-35 ├── testcontainers └── jacoco-coverage-aggregate-report ``` diff --git a/documentation/docs/documentation/spring-data/index.en.md b/documentation/docs/documentation/spring-data/index.en.md index 45829d6..4d5f654 100644 --- a/documentation/docs/documentation/spring-data/index.en.md +++ b/documentation/docs/documentation/spring-data/index.en.md @@ -23,9 +23,9 @@ using [Tarantool](https://www.tarantool.io) as a data store. ## Project Status -| tarantool-java-sdk Version | tarantool-spring-data Version | Spring Boot Version | -|:-------------------------:|:----------------------------:|:-----------------------------------------:| -| 1.5.x | 1.5.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 | +| tarantool-java-sdk Version | tarantool-spring-data Version | Spring Boot Version | +|:-------------------------:|:----------------------------:|:-------------------------------------------------:| +| 1.5.x | 1.5.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 / 3.5.7 | ### Tarantool Version and Supported Client Modules @@ -88,7 +88,7 @@ Include the module in your project as follows: io.tarantool - tarantool-spring-data-34 + tarantool-spring-data-35 ${tarantool-spring-data.version} diff --git a/documentation/docs/documentation/spring-data/index.md b/documentation/docs/documentation/spring-data/index.md index 297e1db..17a52c8 100644 --- a/documentation/docs/documentation/spring-data/index.md +++ b/documentation/docs/documentation/spring-data/index.md @@ -23,9 +23,9 @@ hide: ## Статус проекта -| Версия tarantool-java-sdk | Версия tarantool-spring-data | Версия Spring Boot | -|:-------------------------:|:----------------------------:|:-----------------------------------------:| -| 1.5.x | 1.5.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 | +| Версия tarantool-java-sdk | Версия tarantool-spring-data | Версия Spring Boot | +|:-------------------------:|:----------------------------:|:-------------------------------------------------:| +| 1.5.x | 1.5.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 / 3.5.7 | ### Версия Tarantool и поддерживаемые модули-клиенты @@ -84,11 +84,11 @@ Tarantool можно найти org.springframework.boot spring-boot-starter - 3.4.12 + 3.5.7 io.tarantool - tarantool-spring-data-34 + tarantool-spring-data-35 ${tarantool-spring-data.version} diff --git a/tarantool-spring-data/README.md b/tarantool-spring-data/README.md index 4d9c02d..7269d4e 100644 --- a/tarantool-spring-data/README.md +++ b/tarantool-spring-data/README.md @@ -20,13 +20,14 @@ data storage. ## Project Status -| tarantool-java-sdk Version | tarantool-spring-data Version | Spring Boot Version | -|:--------------------------:|:-----------------------------:|:------------------------------------------:| -| 1.0.0 | 1.0.0 | 2.7.18 | -| 1.1.x | 1.1.x | 2.7.18 / 3.1.10 / 3.2.4 | -| 1.2.x | 1.2.x | 2.7.18 / 3.1.10 / 3.2.4 | -| 1.3.x | 1.3.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.11 / 3.4.5 | -| 1.4.x | 1.4.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 | +| tarantool-java-sdk Version | tarantool-spring-data Version | Spring Boot Version | +|:--------------------------:|:-----------------------------:|:-------------------------------------------------:| +| 1.0.0 | 1.0.0 | 2.7.18 | +| 1.1.x | 1.1.x | 2.7.18 / 3.1.10 / 3.2.4 | +| 1.2.x | 1.2.x | 2.7.18 / 3.1.10 / 3.2.4 | +| 1.3.x | 1.3.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.11 / 3.4.5 | +| 1.4.x | 1.4.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 | +| 1.5.x | 1.5.x | 2.7.18 / 3.1.10 / 3.2.4 / 3.3.13 / 3.4.10 / 3.5.7 | ### Tarantool Version and Supported Client Modules diff --git a/tarantool-spring-data/pom.xml b/tarantool-spring-data/pom.xml index bb09ae2..c13f03b 100644 --- a/tarantool-spring-data/pom.xml +++ b/tarantool-spring-data/pom.xml @@ -23,6 +23,7 @@ tarantool-spring-data-32 tarantool-spring-data-33 tarantool-spring-data-34 + tarantool-spring-data-35 diff --git a/tarantool-spring-data/tarantool-spring-data-27/src/test/java/io/tarantool/spring/data27/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-27/src/test/java/io/tarantool/spring/data27/integration/BaseIntegrationTest.java index f36b92e..88d7b04 100644 --- a/tarantool-spring-data/tarantool-spring-data-27/src/test/java/io/tarantool/spring/data27/integration/BaseIntegrationTest.java +++ b/tarantool-spring-data/tarantool-spring-data-27/src/test/java/io/tarantool/spring/data27/integration/BaseIntegrationTest.java @@ -25,7 +25,7 @@ import io.tarantool.spring.data27.config.properties.TarantoolProperties; @Testcontainers -@Timeout(20) +@Timeout(60) public abstract class BaseIntegrationTest { protected static TarantoolContainerOperations clusterContainer; diff --git a/tarantool-spring-data/tarantool-spring-data-31/src/test/java/io/tarantool/spring/data31/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-31/src/test/java/io/tarantool/spring/data31/integration/BaseIntegrationTest.java index 8b3f3e2..e2d4683 100644 --- a/tarantool-spring-data/tarantool-spring-data-31/src/test/java/io/tarantool/spring/data31/integration/BaseIntegrationTest.java +++ b/tarantool-spring-data/tarantool-spring-data-31/src/test/java/io/tarantool/spring/data31/integration/BaseIntegrationTest.java @@ -25,7 +25,7 @@ import io.tarantool.spring.data31.config.properties.TarantoolProperties; @Testcontainers -@Timeout(20) +@Timeout(60) public abstract class BaseIntegrationTest { protected static TarantoolContainerOperations clusterContainer; diff --git a/tarantool-spring-data/tarantool-spring-data-32/src/test/java/io/tarantool/spring/data32/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-32/src/test/java/io/tarantool/spring/data32/integration/BaseIntegrationTest.java index 5f80bbe..f519b07 100644 --- a/tarantool-spring-data/tarantool-spring-data-32/src/test/java/io/tarantool/spring/data32/integration/BaseIntegrationTest.java +++ b/tarantool-spring-data/tarantool-spring-data-32/src/test/java/io/tarantool/spring/data32/integration/BaseIntegrationTest.java @@ -25,7 +25,7 @@ import io.tarantool.spring.data32.config.properties.TarantoolProperties; @Testcontainers -@Timeout(20) +@Timeout(60) public abstract class BaseIntegrationTest { protected static TarantoolContainerOperations clusterContainer; diff --git a/tarantool-spring-data/tarantool-spring-data-33/src/test/java/io/tarantool/spring/data33/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-33/src/test/java/io/tarantool/spring/data33/integration/BaseIntegrationTest.java index 2de2974..1f92238 100644 --- a/tarantool-spring-data/tarantool-spring-data-33/src/test/java/io/tarantool/spring/data33/integration/BaseIntegrationTest.java +++ b/tarantool-spring-data/tarantool-spring-data-33/src/test/java/io/tarantool/spring/data33/integration/BaseIntegrationTest.java @@ -25,7 +25,7 @@ import io.tarantool.spring.data33.config.properties.TarantoolProperties; @Testcontainers -@Timeout(20) +@Timeout(60) public abstract class BaseIntegrationTest { protected static TarantoolContainerOperations clusterContainer; diff --git a/tarantool-spring-data/tarantool-spring-data-34/src/test/java/io/tarantool/spring/data34/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-34/src/test/java/io/tarantool/spring/data34/integration/BaseIntegrationTest.java index 532985a..d776956 100644 --- a/tarantool-spring-data/tarantool-spring-data-34/src/test/java/io/tarantool/spring/data34/integration/BaseIntegrationTest.java +++ b/tarantool-spring-data/tarantool-spring-data-34/src/test/java/io/tarantool/spring/data34/integration/BaseIntegrationTest.java @@ -25,7 +25,7 @@ import io.tarantool.spring.data34.config.properties.TarantoolProperties; @Testcontainers -@Timeout(20) +@Timeout(60) public abstract class BaseIntegrationTest { protected static TarantoolContainerOperations clusterContainer; diff --git a/tarantool-spring-data/tarantool-spring-data-35/pom.xml b/tarantool-spring-data/tarantool-spring-data-35/pom.xml new file mode 100644 index 0000000..dea9185 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + tarantool-spring-data-35 + Minimalistic java connector for Tarantool versions 2.11+ based on Netty framework + https://tarantool.io + + + io.tarantool + tarantool-spring-data + 2.0.0-SNAPSHOT + + + tarantool-spring-data-35 + 2.0.0-SNAPSHOT + jar + + + 17 + 17 + 3.5.7 + ${project.parent.parent.basedir}/tarantool-shared-resources/ + ${project.parent.parent.basedir}/LICENSE_HEADER.txt + + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + io.tarantool + tarantool-spring-data-core + + + diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolBoxKeyValueAdapter.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolBoxKeyValueAdapter.java new file mode 100644 index 0000000..7ca3136 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolBoxKeyValueAdapter.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35; + +import java.util.Map; + +import org.springframework.data.keyvalue.core.AbstractKeyValueAdapter; +import org.springframework.data.util.CloseableIterator; +import org.springframework.lang.NonNull; + +import io.tarantool.client.box.TarantoolBoxClient; +import io.tarantool.spring.data.ProxyTarantoolBoxKeyValueAdapter; + +public class TarantoolBoxKeyValueAdapter extends AbstractKeyValueAdapter { + + private final ProxyTarantoolBoxKeyValueAdapter adapter; + + public TarantoolBoxKeyValueAdapter(@NonNull TarantoolBoxClient tarantoolBoxClient) { + adapter = new ProxyTarantoolBoxKeyValueAdapter(tarantoolBoxClient); + } + + @Override + public Object put(Object id, Object item, String keyspace) { + return adapter.put(id, item, keyspace); + } + + @Override + public boolean contains(Object id, String keyspace) { + return adapter.contains(id, keyspace); + } + + @Override + public Object get(Object id, String keyspace) { + return adapter.get(id, keyspace); + } + + @Override + public Object delete(Object id, String keyspace) { + return adapter.delete(id, keyspace); + } + + @Override + public Iterable getAllOf(String keyspace) { + return adapter.getAllOf(keyspace); + } + + @Override + public void deleteAllOf(String keyspace) { + adapter.deleteAllOf(keyspace); + } + + @Override + public void clear() { + adapter.clear(); + } + + @Override + public long count(String keyspace) { + return adapter.count(keyspace); + } + + @Override + public void destroy() throws Exception { + adapter.destroy(); + } + + @Override + public CloseableIterator> entries(String keyspace) { + throw new UnsupportedOperationException("Not implemented yet"); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolCrudKeyValueAdapter.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolCrudKeyValueAdapter.java new file mode 100644 index 0000000..215c908 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolCrudKeyValueAdapter.java @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35; + +import java.util.Collections; +import java.util.Map.Entry; + +import com.fasterxml.jackson.annotation.JsonFormat; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.data.keyvalue.core.AbstractKeyValueAdapter; +import org.springframework.data.util.CloseableIterator; +import org.springframework.lang.NonNull; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data.ProxyTarantoolCrudKeyValueAdapter; +import io.tarantool.spring.data.mapping.model.CompositeKey; + +public class TarantoolCrudKeyValueAdapter extends AbstractKeyValueAdapter { + + private final ProxyTarantoolCrudKeyValueAdapter adapter; + + public TarantoolCrudKeyValueAdapter(@NonNull TarantoolCrudClient client) { + super(new TarantoolQueryEngine(client)); + this.adapter = new ProxyTarantoolCrudKeyValueAdapter(client); + } + + @Override + public Object put(Object id, Object item, String keyspace) { + return adapter.put(convertId(id), item, keyspace); + } + + @Override + public boolean contains(Object id, String keyspace) { + return adapter.contains(convertId(id), keyspace); + } + + @Override + public Object get(Object id, String keyspace) { + return adapter.get(convertId(id), keyspace); + } + + @Override + public T get(Object id, String keyspace, Class type) { + return adapter.get(convertId(id), keyspace, type); + } + + @Override + public Object delete(Object id, String keyspace) { + return adapter.delete(convertId(id), keyspace); + } + + @Override + public T delete(Object id, String keyspace, Class type) { + return adapter.delete(convertId(id), keyspace, type); + } + + @Override + public Iterable getAllOf(String keyspace) { + return adapter.getAllOf(keyspace); + } + + @Override + public void deleteAllOf(String keyspace) { + adapter.deleteAllOf(keyspace); + } + + @Override + public void clear() { + adapter.clear(); + } + + @Override + public void destroy() throws Exception { + adapter.destroy(); + } + + @Override + public long count(String keyspace) { + return adapter.count(keyspace); + } + + @Override + public CloseableIterator> entries(String keyspace) { + throw new UnsupportedOperationException("Not implemented yet"); + } + + /** + * Convert the identifier to the form required by the tarantool-java-sdk driver. + * + * @param id identifier object + * @return identifier in the required form + */ + private Object convertId(Object id) { + if (id instanceof CompositeKey || hasJsonFormatArrayAnnotation(id)) { + return id; + } + return Collections.singletonList(id); + } + + /** + * Determine whether the identifier type is annotated with the {@link JsonFormat} annotation. + * + * @param id identifier object + * @return return true if the annotation is present, false otherwise + */ + private boolean hasJsonFormatArrayAnnotation(Object id) { + final JsonFormat jsonFormatAnnotation = + AnnotatedElementUtils.findMergedAnnotation(id.getClass(), JsonFormat.class); + + return jsonFormatAnnotation != null + && JsonFormat.Shape.ARRAY.equals(jsonFormatAnnotation.shape()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolQueryEngine.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolQueryEngine.java new file mode 100644 index 0000000..843d979 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/TarantoolQueryEngine.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35; + +import java.util.Collection; +import java.util.Comparator; +import java.util.Map.Entry; + +import org.springframework.data.keyvalue.core.QueryEngine; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data.ProxyTarantoolQueryEngine; +import io.tarantool.spring.data.query.TarantoolCriteria; +import io.tarantool.spring.data35.query.TarantoolCriteriaAccessor; +import io.tarantool.spring.data35.query.TarantoolSortAccessor; + +/** + * Implementation of {@code findBy*()} and {@code countBy*{}} queries. + * + * @author Artyom Dubinin + */ +public class TarantoolQueryEngine + extends QueryEngine>> { + + private final ProxyTarantoolQueryEngine engine; + + public TarantoolQueryEngine(TarantoolCrudClient client) { + super(new TarantoolCriteriaAccessor(), new TarantoolSortAccessor()); + this.engine = new ProxyTarantoolQueryEngine(client); + } + + @Override + @NonNull + public Collection execute( + @Nullable final TarantoolCriteria criteria, + @Nullable final Comparator> sort, + final long offset, + final int rows, + @NonNull final String keyspace) { + return engine.execute(criteria, sort, offset, rows, keyspace); + } + + /** + * Construct the final query predicate for Tarantool to execute, from the base query plus any + * paging and sorting. + * + *

Variations here allow the base query predicate to be omitted, sorting to be omitted, and + * paging to be omitted. + * + * @param criteria Search criteria, null means match everything + * @param sort Possibly null collation + * @param offset Start point of returned page, -1 if not used + * @param rows Size of page, -1 if not used + * @param keyspace The map name + * @return Results from Tarantool + */ + @Override + @NonNull + public Collection execute( + @Nullable final TarantoolCriteria criteria, + @Nullable final Comparator> sort, + final long offset, + final int rows, + @NonNull final String keyspace, + @NonNull Class type) { + return engine.execute(criteria, sort, offset, rows, keyspace, type); + } + + /** + * Execute {@code countBy*()} queries against a Tarantool space. + * + * @param criteria Predicate to use, not null + * @param keyspace The map name + * @return Results from Tarantool + */ + @Override + public long count(@Nullable final TarantoolCriteria criteria, @NonNull final String keyspace) { + return engine.count(criteria, keyspace); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolBoxConfiguration.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolBoxConfiguration.java new file mode 100644 index 0000000..de7f7dd --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolBoxConfiguration.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_BOX_CLIENT_BEAN_REF; +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_BOX_KEY_VALUE_ADAPTER_REF; +import io.tarantool.client.box.TarantoolBoxClient; +import io.tarantool.client.factory.TarantoolBoxClientBuilder; +import io.tarantool.spring.data.config.BaseTarantoolBoxConfiguration; +import io.tarantool.spring.data35.TarantoolBoxKeyValueAdapter; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; + +@Configuration(proxyBeanMethods = false) +public class TarantoolBoxConfiguration extends BaseTarantoolBoxConfiguration { + + public TarantoolBoxConfiguration( + ObjectProvider properties, + ObjectProvider tarantoolBoxClientBuilder) { + super(properties.getIfAvailable(), tarantoolBoxClientBuilder.getIfAvailable()); + } + + @Bean(name = DEFAULT_TARANTOOL_BOX_KEY_VALUE_ADAPTER_REF) + @ConditionalOnMissingBean(TarantoolBoxKeyValueAdapter.class) + public TarantoolBoxKeyValueAdapter tarantoolCrudKeyValueAdapter( + TarantoolBoxClient tarantoolBoxClient) { + return new TarantoolBoxKeyValueAdapter(tarantoolBoxClient); + } + + @Bean(name = DEFAULT_TARANTOOL_BOX_CLIENT_BEAN_REF) + @ConditionalOnMissingBean(TarantoolBoxClient.class) + public TarantoolBoxClient tarantoolBoxClient() throws Exception { + return super.tarantoolBoxClient(); + } + + @Override + public TarantoolBoxClientBuilder getClientBuilder() { + return super.getClientBuilder(); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolCrudConfiguration.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolCrudConfiguration.java new file mode 100644 index 0000000..fd4f4fe --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/TarantoolCrudConfiguration.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_CRUD_CLIENT_BEAN_REF; +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.client.factory.TarantoolCrudClientBuilder; +import io.tarantool.spring.data.config.BaseTarantoolCrudConfiguration; +import io.tarantool.spring.data35.TarantoolCrudKeyValueAdapter; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; + +@Configuration(proxyBeanMethods = false) +public class TarantoolCrudConfiguration extends BaseTarantoolCrudConfiguration { + + public TarantoolCrudConfiguration( + ObjectProvider properties, + ObjectProvider tarantoolClientConfiguration) { + super(properties.getIfAvailable(), tarantoolClientConfiguration.getIfAvailable()); + } + + @Bean(name = DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF) + @ConditionalOnMissingBean(TarantoolCrudKeyValueAdapter.class) + public TarantoolCrudKeyValueAdapter tarantoolCrudKeyValueAdapter( + TarantoolCrudClient tarantoolCrudClient) { + return new TarantoolCrudKeyValueAdapter(tarantoolCrudClient); + } + + @Bean(name = DEFAULT_TARANTOOL_CRUD_CLIENT_BEAN_REF) + @ConditionalOnMissingBean(TarantoolCrudClient.class) + public TarantoolCrudClient tarantoolCrudClient() throws Exception { + return super.tarantoolCrudClient(); + } + + public TarantoolCrudClientBuilder getClientBuilder() { + return super.getClientBuilder(); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/package-info.java new file mode 100644 index 0000000..0f89063 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/package-info.java @@ -0,0 +1,3 @@ +/** Config package. */ +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.config; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/properties/TarantoolProperties.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/properties/TarantoolProperties.java new file mode 100644 index 0000000..59ad089 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/config/properties/TarantoolProperties.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties; + +/** + * Configuration properties for Tarantool. + * + * @author Nikolay Belonogov + */ +@ConfigurationProperties(prefix = "spring.data.tarantool") +public class TarantoolProperties extends BaseTarantoolProperties {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/TarantoolTemplate.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/TarantoolTemplate.java new file mode 100644 index 0000000..64df77a --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/TarantoolTemplate.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core; + +import org.springframework.data.keyvalue.core.IdentifierGenerator; +import org.springframework.data.keyvalue.core.KeyValueAdapter; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.IdentifierAccessor; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.util.ClassUtils; + +import io.tarantool.spring.data35.core.annotation.DefaultIdClassResolver; +import io.tarantool.spring.data35.core.mapping.TarantoolMappingContext; + +public class TarantoolTemplate extends KeyValueTemplate { + + public TarantoolTemplate(KeyValueAdapter adapter) { + super(adapter, new TarantoolMappingContext<>()); + } + + public TarantoolTemplate( + KeyValueAdapter adapter, + MappingContext< + ? extends KeyValuePersistentEntity, ? extends KeyValuePersistentProperty> + mappingContext) { + super(adapter, mappingContext); + } + + public TarantoolTemplate( + KeyValueAdapter adapter, + MappingContext< + ? extends KeyValuePersistentEntity, ? extends KeyValuePersistentProperty> + mappingContext, + IdentifierGenerator identifierGenerator) { + super(adapter, mappingContext, identifierGenerator); + } + + @Override + public T insert(T objectToInsert) { + if (DefaultIdClassResolver.INSTANCE.resolveIdClassType(objectToInsert.getClass()) == null) { + return super.insert(objectToInsert); + } + + PersistentEntity entity = + getMappingContext().getRequiredPersistentEntity(ClassUtils.getUserClass(objectToInsert)); + + IdentifierAccessor identifierAccessor = entity.getIdentifierAccessor(objectToInsert); + Object id = identifierAccessor.getRequiredIdentifier(); + return insert(id, objectToInsert); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/DefaultIdClassResolver.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/DefaultIdClassResolver.java new file mode 100644 index 0000000..c0dc13b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/DefaultIdClassResolver.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.annotation; + +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import io.tarantool.spring.data.core.annotation.IdClass; +import io.tarantool.spring.data.core.annotation.IdClassResolver; + +/** Default implementation of {@link IdClassResolver}. */ +public enum DefaultIdClassResolver implements IdClassResolver { + INSTANCE; + + public static final String ANNOTATION_TYPE_EXCEPTION = + "The class of a composite identifier specified in the @IdClass annotation cannot be" + + " annotation!"; + + @Nullable + @Override + public Class resolveIdClassType(Class type) { + Assert.notNull(type, "Type for IdClass must be not null!"); + + IdClass idClassTypeAnnotation = AnnotatedElementUtils.findMergedAnnotation(type, IdClass.class); + + if (idClassTypeAnnotation == null) { + return null; + } + + Class idClassTypeValue = idClassTypeAnnotation.value(); + Assert.isTrue(!idClassTypeValue.isAnnotation(), ANNOTATION_TYPE_EXCEPTION); + + return idClassTypeValue; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/package-info.java new file mode 100644 index 0000000..93a1a06 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/annotation/package-info.java @@ -0,0 +1,4 @@ +/** Core annotations of data mapping. */ +@org.springframework.lang.NonNullFields +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.core.annotation; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/BasicKeyValueCompositePersistentEntity.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/BasicKeyValueCompositePersistentEntity.java new file mode 100644 index 0000000..17724d7 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/BasicKeyValueCompositePersistentEntity.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Persistable; +import org.springframework.data.keyvalue.core.mapping.BasicKeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.mapping.IdentifierAccessor; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.support.IsNewStrategy; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import io.tarantool.spring.data.core.annotation.IdClass; +import io.tarantool.spring.data.mapping.model.CompositeKey; +import io.tarantool.spring.data35.core.mapping.model.CompositeIdPropertyAccessor; +import io.tarantool.spring.data35.core.mapping.model.PersistentCompositeIdIsNewStrategy; + +public class BasicKeyValueCompositePersistentEntity> + extends BasicKeyValuePersistentEntity implements KeyValueCompositePersistentEntity { + + public static final String TYPE_MISMATCH = + "Target bean of type %s is not of type of the persistent entity (%s)"; + + private final Class idClassTypeValue; + private @Nullable P idProperty; + + private Map entityIdClassFields; + + /** + * @param information must not be {@literal null}. + * @param fallbackKeySpaceResolver can be {@literal null}. + * @param idClassTypeValue class that specified in {@link IdClass} + */ + public BasicKeyValueCompositePersistentEntity( + TypeInformation information, + @Nullable KeySpaceResolver fallbackKeySpaceResolver, + Class idClassTypeValue) { + super(information, fallbackKeySpaceResolver); + + Assert.notNull(idClassTypeValue, "idClassTypeValue must be not null for this class!"); + this.idClassTypeValue = idClassTypeValue; + } + + @Override + public IdentifierAccessor getIdentifierAccessor(Object bean) { + verifyBeanType(bean); + + if (Persistable.class.isAssignableFrom(getType())) { + throw new IllegalArgumentException( + "Persistable override is not currently supported for entities with a composite key."); + } + + return hasIdProperty() + ? new CompositeIdPropertyAccessor(bean, this.entityIdClassFields, getIdClassType()) + : new AbsentIdentifierAccessor(); + } + + @Override + public Class getIdClassType() { + return this.idClassTypeValue; + } + + @Override + public void verify() { + super.verify(); + + if (this.idProperty != null) { + this.entityIdClassFields = KeyPartTypeChecker.getFieldMapIfTypesValid(this, this.idProperty); + } + } + + @Override + protected IsNewStrategy getFallbackIsNewStrategy() { + return PersistentCompositeIdIsNewStrategy.of(this); + } + + @Override + protected P returnPropertyIfBetterIdPropertyCandidateOrNull(P property) { + Assert.isInstanceOf(Identifier.class, property, "property must be Identifier for this class"); + + if (!property.isIdProperty()) { + return null; + } + + if (this.idProperty == null) { + this.idProperty = property; + return this.idProperty; + } + + this.idProperty.addPart(property); + + return this.idProperty; + } + + /** + * Verifies the given bean type to no be {@literal null} and of the type of the current {@link + * PersistentEntity}. + * + * @param bean must not be {@literal null}. + */ + private void verifyBeanType(Object bean) { + + Assert.notNull(bean, "Target bean must not be null"); + Assert.isInstanceOf( + getType(), + bean, + () -> String.format(TYPE_MISMATCH, bean.getClass().getName(), getType().getName())); + } + + /** + * A null-object implementation of {@link IdentifierAccessor} to be able to return an accessor for + * entities that do not have an identifier property. + */ + private static class AbsentIdentifierAccessor implements IdentifierAccessor { + + /* + * (non-Javadoc) + * @see org.springframework.data.mapping.IdentifierAccessor#getIdentifier() + */ + @Override + @Nullable + public Object getIdentifier() { + return null; + } + } + + /** + * A class that checks the types of fields of a composite key. In addition, the correspondence + * between types and field names marked with {@code @Id}. + */ + public static class KeyPartTypeChecker { + + public static final String COMPOSITE_KEY_FIELDS_NUMBER_EXCEPTION = + "Number of fields specified in domain class and composite class the key is different!"; + + public static final String COMPOSITE_KEY_FIELD_DIFFERENT_EXCEPTION = + "Domain class fields marked with @Id differ from fields specified in the composite key" + + " class"; + + /** + * Check the number and types of entity fields, annotated {@code @Id} and fields {@link + * CompositeKey} and if the types and quantities match return the id part fields mapping. + * + * @param persistentEntity persistent entity + * @param idProperty id property + * @return mapping between fields of composite key class and fields annotated {@code @Id} in + * entity. + */ + public static Map getFieldMapIfTypesValid( + KeyValueCompositePersistentEntity persistentEntity, Identifier idProperty) { + Map entityIdClassFields = new HashMap<>(); + + List compositeKeyFields = persistentEntity.getIdClassTypeFields(); + + Field[] entityFields = idProperty.getFields(); + + if (compositeKeyFields.size() != entityFields.length) { + throw new IllegalArgumentException(COMPOSITE_KEY_FIELDS_NUMBER_EXCEPTION); + } + + for (int i = 0; i < compositeKeyFields.size(); i++) { + Field compositeKeyField = compositeKeyFields.get(i); + Field entityField = entityFields[i]; + + if (!equalFields(compositeKeyField, entityField)) { + throw new IllegalArgumentException(COMPOSITE_KEY_FIELD_DIFFERENT_EXCEPTION); + } + + entityIdClassFields.put(entityField, compositeKeyField); + } + return entityIdClassFields; + } + + /** + * Compares two class fields by name and type. + * + * @param firstField first field + * @param secondField second field + * @return true if fields are equal + */ + private static boolean equalFields(Field firstField, Field secondField) { + return firstField.getName().equals(secondField.getName()) + && firstField.getType().equals(secondField.getType()); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/Identifier.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/Identifier.java new file mode 100644 index 0000000..843b72b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/Identifier.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.Collection; + +import org.springframework.data.mapping.PersistentProperty; + +/** The interface of the identifier class that will contain information about the composite key. */ +public interface Identifier

> { + + /** + * Add a {@link PersistentProperty} tagged with {@code Id}. + * + * @param property {@link PersistentProperty} tagged {@code Id} + */ + void addPart(P property); + + /** + * Return the parts of a composite key that are {@link PersistentProperty}. + * + * @return parts of a composite key + */ + Collection

getParts(); + + /** + * Return a list {@link Field} of fields that are annotated {@code @Id}. Unlike method {@link + * #getParts()} this method returns an array {@link Field}, where {@link Field} is taken from each + * element of the collection returned by the {@link #getParts()} method. Necessary in order not to + * write polluting code. + * + * @return fields of a composite key + */ + Field[] getFields(); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePersistentEntity.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePersistentEntity.java new file mode 100644 index 0000000..dc0c7e9 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePersistentEntity.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.model.MutablePersistentEntity; + +import io.tarantool.spring.data.core.annotation.IdClass; + +/** + * An interface to enable {@link PersistentEntity}-specific operations with a composite key. + * + * @param domain class type + * @param

{@link PersistentProperty} type + */ +public interface KeyValueCompositePersistentEntity> + extends MutablePersistentEntity { + + /** + * Returns the fields of the class specified in the {@link IdClass} annotation. + * + * @return all fields that are in composite key class + */ + default List getIdClassTypeFields() { + List fields = new ArrayList<>(); + + for (Field field : getIdClassType().getDeclaredFields()) { + if (!field.isSynthetic()) { + fields.add(field); + } + } + return fields; + } + + /** + * Returns the type of the class specified in the {@link IdClass} annotation. + * + * @return class that specified in {@link IdClass} + */ + Class getIdClassType(); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositeProperty.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositeProperty.java new file mode 100644 index 0000000..9f53060 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositeProperty.java @@ -0,0 +1,107 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.util.Assert; + +public class KeyValueCompositeProperty

> + extends KeyValuePersistentProperty

implements Identifier

{ + + private final List

identifierPartsWithoutFirst; + + public KeyValueCompositeProperty( + Property property, PersistentEntity owner, SimpleTypeHolder simpleTypeHolder) { + super(property, owner, simpleTypeHolder); + this.identifierPartsWithoutFirst = new ArrayList<>(); + } + + @Override + public void addPart(P property) { + Assert.notNull(property, "property must be not null"); + + if (equals(property) || this.identifierPartsWithoutFirst.contains(property)) { + return; + } + this.identifierPartsWithoutFirst.add(property); + } + + @Override + @SuppressWarnings("unchecked") + public Collection

getParts() { + List

resultList = new ArrayList<>(this.identifierPartsWithoutFirst); + resultList.add(0, (P) this); + + return resultList; + } + + @Override + public Field[] getFields() { + int totalSize = this.identifierPartsWithoutFirst.size() + 1; + + final Field[] fields = new Field[totalSize]; + fields[0] = getField(); + + for (int i = 1; i < totalSize; i++) { + fields[i] = this.identifierPartsWithoutFirst.get(i - 1).getField(); + } + return fields; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + KeyValueCompositeProperty other = (KeyValueCompositeProperty) o; + + if (this.identifierPartsWithoutFirst.isEmpty() && other.identifierPartsWithoutFirst.isEmpty()) { + return super.equals(o); + } + + if (this.identifierPartsWithoutFirst.size() != other.identifierPartsWithoutFirst.size() + || !other.getProperty().equals(getProperty())) { + return false; + } + + for (int i = 0; i < this.identifierPartsWithoutFirst.size(); i++) { + // take Property from both parts + Property thisKeyPartProperty = + ((KeyValueCompositeProperty) this.identifierPartsWithoutFirst.get(i)).getProperty(); + Property otherKeyPartProperty = + ((KeyValueCompositeProperty) other.identifierPartsWithoutFirst.get(i)).getProperty(); + + if (!thisKeyPartProperty.equals(otherKeyPartProperty)) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int hashCode = super.hashCode(); + + if (this.identifierPartsWithoutFirst.isEmpty()) { + return hashCode; + } + + // sublist to avoid endless recursion + return Objects.hash(hashCode, this.identifierPartsWithoutFirst); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContext.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContext.java new file mode 100644 index 0000000..7428f41 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContext.java @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import org.springframework.data.keyvalue.core.mapping.BasicKeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.keyvalue.core.mapping.context.KeyValueMappingContext; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; + +import io.tarantool.spring.data.core.annotation.IdClassResolver; +import io.tarantool.spring.data35.core.annotation.DefaultIdClassResolver; + +public class TarantoolMappingContext< + E extends KeyValuePersistentEntity, P extends KeyValuePersistentProperty

> + extends KeyValueMappingContext { + + private final IdClassResolver idClassResolver; + + private @Nullable KeySpaceResolver keySpaceResolver; + + public TarantoolMappingContext() { + super(); + this.idClassResolver = DefaultIdClassResolver.INSTANCE; + } + + @Override + public void setKeySpaceResolver(KeySpaceResolver keySpaceResolver) { + super.setKeySpaceResolver(keySpaceResolver); + this.keySpaceResolver = keySpaceResolver; + } + + @SuppressWarnings("unchecked") + @Override + protected E createPersistentEntity(TypeInformation typeInformation) { + final Class idClassTypeValue = + this.idClassResolver.resolveIdClassType(typeInformation.getType()); + if (idClassTypeValue == null) { + return (E) new BasicKeyValuePersistentEntity(typeInformation, this.keySpaceResolver); + } + return (E) + new BasicKeyValueCompositePersistentEntity<>( + typeInformation, this.keySpaceResolver, idClassTypeValue); + } + + @SuppressWarnings("unchecked") + @Override + protected P createPersistentProperty( + Property property, E owner, SimpleTypeHolder simpleTypeHolder) { + if (KeyValueCompositePersistentEntity.class.isAssignableFrom(owner.getClass())) { + return (P) new KeyValueCompositeProperty<>(property, owner, simpleTypeHolder); + } + return (P) new KeyValuePersistentProperty<>(property, owner, simpleTypeHolder); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/CompositeIdPropertyAccessor.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/CompositeIdPropertyAccessor.java new file mode 100644 index 0000000..56b211b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/CompositeIdPropertyAccessor.java @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping.model; + +import java.lang.reflect.Field; +import java.lang.reflect.InvocationTargetException; +import java.util.Map; + +import org.springframework.data.mapping.TargetAwareIdentifierAccessor; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; + +import io.tarantool.spring.data.mapping.model.CompositeKey; + +/** Class that allows to convert {@code @Id} annotated fields in an entity into a composite key. */ +public class CompositeIdPropertyAccessor extends TargetAwareIdentifierAccessor { + + private final Map entityIdClassFields; + + private final Class idClassType; + + private final Object target; + + public CompositeIdPropertyAccessor( + Object target, @NonNull Map entityIdClassFields, Class idClassType) { + + super(target); + this.target = target; + this.entityIdClassFields = entityIdClassFields; + this.idClassType = idClassType; + } + + @Nullable + public Object getIdentifier() { + try { + return generateIdentifier(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw new RuntimeException(e); + } + } + + /** + * Generates a composite identifier from those marked with the {@code @Id} annotation in a domain + * class based on fields, specified in the composite key class. + * + * @return identifier + * @throws IllegalAccessException if the class or its nullary constructor is not accessible. + * @throws InstantiationException if this {@code Class} represents an abstract class, an + * interface, an array class, a primitive type, or void; or if the class has no nullary + * constructor; or if the instantiation fails for some other reason. + */ + private Object generateIdentifier() + throws InstantiationException, + IllegalAccessException, + NoSuchMethodException, + InvocationTargetException { + Assert.notNull(this.target, "target object must be not null!"); + Object compositeKey = idClassType.getDeclaredConstructor().newInstance(); + for (Map.Entry fieldPair : this.entityIdClassFields.entrySet()) { + writeValuesFromEntityId(compositeKey, fieldPair.getKey(), fieldPair.getValue()); + } + return compositeKey; + } + + /** + * Write the composite primary key fields provided in the entity to the class which implements + * {@link CompositeKey}. + * + * @param compositeKey object of the composite key into which the values are written. + * @param entityField field from the represented entity. + * @param compositeKeyField field from the composite key class. + */ + private void writeValuesFromEntityId( + Object compositeKey, Field entityField, Field compositeKeyField) { + ReflectionUtils.makeAccessible(entityField); + ReflectionUtils.makeAccessible(compositeKeyField); + + Object value = ReflectionUtils.getField(entityField, this.target); + ReflectionUtils.setField(compositeKeyField, compositeKey, value); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategy.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategy.java new file mode 100644 index 0000000..1c1867d --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategy.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping.model; + +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.support.IsNewStrategy; +import org.springframework.util.Assert; + +/** + * Strategy class for entity with composite key to determine whether a given entity is to be + * considered new. + */ +public class PersistentCompositeIdIsNewStrategy implements IsNewStrategy { + + /** + * Create a new {@link PersistentCompositeIdIsNewStrategy} for the given entity. + * + * @param entity must not be {@literal null}. + * @param idOnly check only id to determine + */ + private PersistentCompositeIdIsNewStrategy(PersistentEntity entity, boolean idOnly) { + // TODO сделать реализацию версионирования + // TODO add idOnly support + Assert.notNull(entity, "PersistentEntity must not be null"); + } + + /** + * Create a new {@link PersistentCompositeIdIsNewStrategy} to only consider the identifier of the + * given entity. + * + * @param entity must not be {@literal null}. + * @return strategy to determine whether entity is new or not + */ + public static PersistentCompositeIdIsNewStrategy forIdOnly(PersistentEntity entity) { + return new PersistentCompositeIdIsNewStrategy(entity, true); + } + + /** + * Create a new {@link PersistentCompositeIdIsNewStrategy} to consider version properties before + * falling back to the identifier. + * + * @param entity must not be {@literal null}. + * @return strategy to determine whether entity is new or not + */ + public static PersistentCompositeIdIsNewStrategy of(PersistentEntity entity) { + return new PersistentCompositeIdIsNewStrategy(entity, false); + } + + /** + * Determine whether the current domain entity type object is new. Currently stub and always + * returns false. + * + * @param entity must not be {@literal null}. + * @return result of the question of entity is new or not + */ + @Override + public boolean isNew(Object entity) { + return false; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/package-info.java new file mode 100644 index 0000000..2e491de --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/model/package-info.java @@ -0,0 +1,4 @@ +/** Core implementation of the mapping subsystem's model. */ +@org.springframework.lang.NonNullFields +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.core.mapping.model; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/package-info.java new file mode 100644 index 0000000..df37bf9 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/mapping/package-info.java @@ -0,0 +1,4 @@ +/** Base package for the mapping subsystem. */ +@org.springframework.lang.NonNullFields +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.core.mapping; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/package-info.java new file mode 100644 index 0000000..ec08e37 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/core/package-info.java @@ -0,0 +1,7 @@ +/** + * Core package for integrating Tarantool with Spring + * concepts. + */ +@org.springframework.lang.NonNullFields +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.core; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/Field.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/Field.java new file mode 100644 index 0000000..66e69ab --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/Field.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.core.annotation.AliasFor; + +/** + * Allows adding some metadata to the class fields relevant for storing them in the Tarantool space + * + * @author Artyom Dubinin + */ +@Inherited +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE}) +public @interface Field { + + /** + * The target Tarantool space field for storing the marked class field. Alias for {@link #name()}. + * + * @return the name of a field in space + */ + @AliasFor("name") + String value() default ""; + + /** + * The target Tarantool space field for storing the marked class field. Alias for {@link + * #value()}. + * + * @return the name of a field in space + */ + @AliasFor("value") + String name() default ""; + + /** + * The order in which fields shall be stored in the tuple. Has to be a positive integer. + * + * @return the order the field shall have in the tuple or -1 if undefined. + */ + int order() default -1; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/PaginationUtils.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/PaginationUtils.java new file mode 100644 index 0000000..9db34a3 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/PaginationUtils.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.keyvalue.core.IterableConverter; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.util.Assert; + +import io.tarantool.spring.data.query.PaginationDirection; +import io.tarantool.spring.data.query.TarantoolCriteria; + +public class PaginationUtils { + + private static final String CRITERIA_NULL_EXC_MSG = "TarantoolCriteria must be not null"; + + /** + * Returns {@link TarantoolPageable} cast from {@link Pageable} with type checking. + * + * @param sliceParams pageable + * @return {@link TarantoolPageable} + */ + public static TarantoolPageable castToTarantoolPageable(Pageable sliceParams) { + Assert.isInstanceOf(TarantoolPageable.class, sliceParams, "Pageable must be TarantoolPageable"); + return (TarantoolPageable) sliceParams; + } + + /** + * A general method for retrieving data for paginated queries. + * + * @param query query + * @param pageRequest {@link TarantoolPageable} + * @param pageSize page size + * @return selection result. + */ + public static List doPaginationQuery( + final KeyValueQuery query, + TarantoolPageable pageRequest, + int pageSize, + KeyValueOperations keyValueOperations, + Class targetType) { + + TarantoolCriteria criteria = (TarantoolCriteria) query.getCriteria(); + PaginationDirection paginationDirection = pageRequest.getPaginationDirection(); + + Assert.notNull(criteria, CRITERIA_NULL_EXC_MSG); + criteria.withAfter(pageRequest.getTupleCursor()); + + query.setRows(pageSize * paginationDirection.getMultiplier()); + + return IterableConverter.toList(keyValueOperations.find(query, targetType)); + } + + public static Page doPageQuery( + Pageable pageable, + KeyValueQuery query, + KeyValueOperations keyValueOperations, + Class targetType) { + if (pageable.isUnpaged()) { + return new TarantoolPageImpl<>(); + } + TarantoolPageable resultSliceParams = castToTarantoolPageable(pageable); + + int pageSize = resultSliceParams.getPageSize(); + + // non transactional calls + List content = + doPaginationQuery(query, resultSliceParams, pageSize, keyValueOperations, targetType); + + if (content.isEmpty()) { + return new TarantoolPageImpl<>(); + } + + long totalElements = keyValueOperations.count(query, targetType); + return new TarantoolPageImpl<>(content, resultSliceParams, totalElements); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolChunk.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolChunk.java new file mode 100644 index 0000000..91ab95f --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolChunk.java @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +/** + * A chunk of data restricted by the configured {@link Pageable} for Tarantool. + * + * @author Nikolay Belonogov + */ +abstract class TarantoolChunk implements Slice, Serializable { + + private static final long serialVersionUID = 867755909294344406L; + + private final List content = new ArrayList<>(); + + private final Pageable pageable; + + /** + * Creates a new {@link TarantoolChunk} with the given content and the given governing {@link + * TarantoolPageable}. + * + * @param content must not be {@literal null}. + * @param pageable must not be {@literal null}. + */ + public TarantoolChunk(List content, Pageable pageable) { + + Assert.notNull(content, "Content must not be null"); + Assert.notNull(pageable, "Pageable must not be null"); + + if (pageable.isPaged() && !(pageable instanceof TarantoolPageable)) { + throw new IllegalArgumentException("Pageable must be TarantoolPageable or Unpaged type"); + } + + this.content.addAll(content); + this.pageable = pageable; + } + + @Override + public boolean isFirst() { + return !hasPrevious(); + } + + @Override + public boolean isLast() { + return !hasNext(); + } + + @Override + public boolean hasPrevious() { + return getNumber() > 0 && hasContent(); + } + + @Override + public boolean hasContent() { + return !content.isEmpty(); + } + + /** + * Returns the {@link Pageable} to request the next {@link Slice}. Can be {@link + * Pageable#unpaged()} in case the current {@link Slice} is already the last one or when data + * content is empty. Clients should check {@link #hasNext()} and {@link #hasContent()} before + * calling this method. + * + * @see #nextOrLastPageable() + */ + @NonNull + @Override + @SuppressWarnings("unchecked") + public Pageable nextPageable() { + if (hasNext() && pageable.isPaged()) { + return ((TarantoolPageable) pageable).next(content.get(content.size() - 1)); + } + return Pageable.unpaged(); + } + + /** + * Returns the {@link Pageable} to request the previous {@link Slice}. Can be {@link + * Pageable#unpaged()} in case the current {@link Slice} is already the first one or data content + * is empty. Clients should check {@link #hasPrevious()} and {@link #hasContent()} before calling + * this method. + * + * @see #previousOrFirstPageable() + */ + @NonNull + @Override + @SuppressWarnings("unchecked") + public Pageable previousPageable() { + if (hasPrevious() && pageable.isPaged()) { + return ((TarantoolPageable) pageable).previousOrFirst(content.get(0)); + } + return Pageable.unpaged(); + } + + @Override + public Iterator iterator() { + return content.iterator(); + } + + @NonNull + @Override + public List getContent() { + return Collections.unmodifiableList(content); + } + + @NonNull + @Override + public Pageable getPageable() { + return pageable; + } + + @NonNull + @Override + public Sort getSort() { + return pageable.getSort(); + } + + @Override + public int getNumber() { + if (pageable.isPaged()) { + return pageable.getPageNumber(); + } + return 0; + } + + @Override + public int getSize() { + if (pageable.isPaged()) { + return pageable.getPageSize(); + } + return content.size(); + } + + @Override + public int getNumberOfElements() { + return content.size(); + } + + /** + * Applies the given {@link Function} to the content of the {@link TarantoolChunk}. + * + * @param converter must not be {@literal null}. + */ + protected List getConvertedContent(Function converter) { + + Assert.notNull(converter, "Function must not be null"); + + return this.stream().map(converter).collect(Collectors.toList()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TarantoolChunk)) { + return false; + } + TarantoolChunk that = (TarantoolChunk) o; + return content.equals(that.content) && pageable.equals(that.pageable); + } + + @Override + public int hashCode() { + return Objects.hash(content, pageable); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolCriteriaAccessor.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolCriteriaAccessor.java new file mode 100644 index 0000000..11ac6ed --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolCriteriaAccessor.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import org.springframework.data.keyvalue.core.CriteriaAccessor; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; + +import io.tarantool.spring.data.query.TarantoolCriteria; + +/** + * Provide a mechanism to convert the abstract query into the direct implementation in Tarantool. + * + * @author Artyom Dubinin + */ +public class TarantoolCriteriaAccessor implements CriteriaAccessor { + + /** + * @param query in Spring form + * @return The same in Tarantool form + */ + public TarantoolCriteria resolve(KeyValueQuery query) { + return (TarantoolCriteria) query.getCriteria(); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPosition.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPosition.java new file mode 100644 index 0000000..6e1aed6 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPosition.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.List; +import java.util.Objects; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +import static io.tarantool.spring.data.query.PaginationDirection.BACKWARD; +import static io.tarantool.spring.data.query.PaginationDirection.FORWARD; +import io.tarantool.spring.data.query.PaginationDirection; +import io.tarantool.spring.data.utils.Pair; + +final class TarantoolKeysetScrollPosition implements TarantoolScrollPosition { + + private final Pair indexKey; + + private final PaginationDirection direction; + + private final Object cursor; + + TarantoolKeysetScrollPosition( + Pair indexKey, PaginationDirection direction, @Nullable Object cursor) { + + Assert.notNull(direction, "PaginationDirection must not be null"); + Assert.notNull(indexKey, "indexKey must not be null"); + + this.indexKey = indexKey; + this.direction = direction; + this.cursor = cursor; + } + + /** + * Creates a new {@link TarantoolKeysetScrollPosition} from a key set and {@link + * PaginationDirection}. + * + * @param indexKey must not be {@literal null}. + * @return will never be {@literal null}. + */ + static TarantoolScrollPosition forward(Pair indexKey) { + return new TarantoolKeysetScrollPosition(indexKey, FORWARD, null); + } + + static TarantoolScrollPosition backward(Pair indexKey) { + return new TarantoolKeysetScrollPosition(indexKey, BACKWARD, null); + } + + /** + * Returns whether the current scroll position is the initial one (from begin or from end) (see + * {@link PaginationDirection}). + * + * @return {@link Boolean} object. + */ + @Override + public boolean isInitial() { + if (indexKey.getSecond() instanceof List startingList) { + return startingList.isEmpty() && cursor == null; + } + return false; + } + + @Override + public TarantoolScrollPosition reverse() { + Pair newIndexKey = Pair.of(indexKey.getFirst(), indexKey.getSecond()); + return new TarantoolKeysetScrollPosition(newIndexKey, direction.reverse(), cursor); + } + + @Override + public boolean isScrollsBackward() { + return direction.equals(BACKWARD); + } + + /** + * Return the cursor relative to which the data is being found. + * + * @return returns cursor + */ + Object getCursor() { + return cursor; + } + + /** + * @return the scroll direction. + */ + PaginationDirection getDirection() { + return direction; + } + + /** + * @return the indexKey. + */ + Pair getIndexKey() { + return indexKey; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TarantoolKeysetScrollPosition that)) { + return false; + } + return indexKey.equals(that.indexKey) + && direction == that.direction + && Objects.equals(cursor, that.cursor); + } + + @Override + public int hashCode() { + return Objects.hash(indexKey, direction, cursor); + } + + @Override + public String toString() { + return String.format("TarantoolKeysetScrollPosition [%s, %s, %s]", direction, indexKey, cursor); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageImpl.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageImpl.java new file mode 100644 index 0000000..680f63d --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageImpl.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.io.Serial; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; + +/** + * Basic {@code Page} implementation for Tarantool. + * + * @param domain class type. + * @author Nikolay Belonogov + */ +class TarantoolPageImpl extends TarantoolChunk implements Page { + + @Serial private static final long serialVersionUID = 867755909294344406L; + + private final long total; + + /** + * Creates a new {@link TarantoolPageImpl} with empty content. This will result in the created + * {@link Page} being identical to the entire {@link List}. + */ + public TarantoolPageImpl() { + this(Collections.emptyList(), Pageable.unpaged(), 0L); + } + + /** + * Creates a new {@link TarantoolPageImpl} with the given content. This will result in the created + * {@link Page} being identical to the entire {@link List}. + * + * @param content must not be {@literal null}. + */ + public TarantoolPageImpl(List content) { + this(content, Pageable.unpaged(), content == null ? 0L : content.size()); + } + + /** + * Constructor of {@link TarantoolPageImpl}. + * + * @param content the content of this page, must not be {@literal null}. + * @param pageable the paging information, must not be {@literal null}. + * @param total the total amount of items available. The total might be adapted considering the + * length of the content given, if it is going to be the content of the last page. This is in + * place to mitigate inconsistencies. + */ + public TarantoolPageImpl(List content, Pageable pageable, long total) { + + super(content, pageable); + + this.total = + pageable + .toOptional() + .filter(it -> !content.isEmpty()) + .filter(it -> it.getOffset() + it.getPageSize() > total) + .map(it -> it.getOffset() + content.size()) + .orElse(total); + } + + @Override + public int getTotalPages() { + return getSize() == 0 ? 1 : (int) Math.ceil((double) total / (double) getSize()); + } + + @Override + public long getTotalElements() { + return total; + } + + @Override + public boolean hasNext() { + return getNumber() + 1 < getTotalPages() && hasContent(); + } + + @Override + public boolean isLast() { + return !hasNext(); + } + + @Override + @NonNull + public Page map(@NonNull Function converter) { + return new PageImpl<>(getConvertedContent(converter), getPageable(), total); + } + + @Override + public String toString() { + + List content = getContent(); + boolean canGetContentType = !content.isEmpty() && content.get(0) != null; + + String contentType = canGetContentType ? content.get(0).getClass().getName() : "UNKNOWN"; + + return String.format( + "Page %s of %d containing %s instances", getNumber() + 1, getTotalPages(), contentType); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TarantoolPageImpl that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + return total == that.total; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), total); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageRequest.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageRequest.java new file mode 100644 index 0000000..98be0fa --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageRequest.java @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.io.Serial; +import java.util.Objects; + +import org.springframework.data.domain.AbstractPageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +import static io.tarantool.spring.data.query.PaginationDirection.BACKWARD; +import static io.tarantool.spring.data.query.PaginationDirection.FORWARD; +import io.tarantool.spring.data.query.PaginationDirection; + +/** + * Basic implementation of {@link TarantoolPageable} for Tarantool. + * + *

Important: When specifying a cursor tuple, you must adhere to the following + * rules: + * + *

1. To specify a page starting from the beginning of {@code space} (the first element of the + * page is the first element found in {@code space}), use {@code tupleCursor == null}. + * + *

2. To specify an arbitrary page (the first element of the page is the next/previous one from + * the given one tuple cursor depending on the methods used and page number), use {@code tupleCursor + * == someTupleCursor} . + * + * @param domain entity type + */ +public final class TarantoolPageRequest extends AbstractPageRequest + implements TarantoolPageable { + + @Serial private static final long serialVersionUID = -4541509938956089562L; + + private final Sort sort; + + private final T tupleCursor; + + private final PaginationDirection paginationDirection; + + /** + * Creates a new unsorted {@link TarantoolPageRequest} from begin of {@code space}. + * + * @param pageSize the size of the page to be returned, must be greater than 0. + */ + public TarantoolPageRequest(int pageSize) { + this(0, pageSize, null); + } + + /** + * Creates a new unsorted {@link TarantoolPageRequest}. + * + *

Important: Use this method with caution! It is important to know the exact + * match of number pages and the tuple after (before) which the page goes (without including this + * tuple in the page itself). A mistake in compliance results in the appearance of blank pages + * with unpaged pageable even when using methods {@link + * TarantoolPageImpl#previousOrFirstPageable()} / {@link TarantoolPageImpl#nextOrLastPageable()} + * and {@link TarantoolSliceImpl#previousOrFirstPageable()} / {@link + * TarantoolSliceImpl#nextOrLastPageable()}. + * + * @param page zero-based page index (virtual), must not be negative. + * @param size the size of the page to be returned, must be greater than 0. + * @param tupleCursor tuple cursor from which the page count begins. More: {@link + * TarantoolPageRequest}. + */ + public TarantoolPageRequest(int page, int size, T tupleCursor) { + this(page, size, Sort.unsorted(), tupleCursor, FORWARD); + } + + /** + * Private constructor to create an instance with a specific {@link PaginationDirection}. + * + * @param page zero-based page index, must not be negative. + * @param size the size of the page to be returned, must be greater than 0. + * @param sort must not be {@literal null}, use {@link Sort#unsorted()} instead. + * @param tupleCursor tuple cursor from which the page count begins. More: {@link + * TarantoolPageRequest}. + * @param direction direction of pagination. + */ + TarantoolPageRequest( + int page, int size, Sort sort, T tupleCursor, @NonNull PaginationDirection direction) { + super(page, size); + Assert.notNull(sort, "Sort must not be null"); + Assert.notNull(direction, "PaginationDirection must not be null"); + + this.sort = sort; + this.tupleCursor = tupleCursor; + this.paginationDirection = direction; + } + + /** Non supported for Tarantool. */ + @Override + @NonNull + public TarantoolPageRequest withPage(int pageNumber) throws UnsupportedOperationException { + throw new UnsupportedOperationException("method \"withPage(int pageNumber)\" unsupported"); + } + + @Override + @NonNull + public TarantoolPageable first() { + return new TarantoolPageRequest<>(getPageSize()); + } + + /** Non supported for Tarantool. Use {@link #next(Object)}. */ + @Override + @NonNull + public TarantoolPageRequest next() throws UnsupportedOperationException { + throw new UnsupportedOperationException( + "method \"next()\" unsupported, use next(T nextTupleCursor)"); + } + + @Override + public TarantoolPageRequest next(T tupleCursor) { + return new TarantoolPageRequest<>( + getPageNumber() + 1, getPageSize(), getSort(), tupleCursor, FORWARD); + } + + /** Non supported for Tarantool. Use {@link #previous(Object)}. */ + @Override + @NonNull + public TarantoolPageRequest previous() throws UnsupportedOperationException { + throw new UnsupportedOperationException( + "method \"previous()\" unsupported, use previous(T prevTupleCursor)"); + } + + private TarantoolPageRequest previous(T tupleCursor) { + if (hasPrevious()) { + return new TarantoolPageRequest<>( + getPageNumber() - 1, getPageSize(), getSort(), tupleCursor, BACKWARD); + } + return this; + } + + /** Non supported for Tarantool. Use {@link #previousOrFirst(Object)}. */ + @Override + @NonNull + public TarantoolPageable previousOrFirst() throws UnsupportedOperationException { + throw new UnsupportedOperationException( + "method \"previousOrFirst()\" unsupported, use previousOrFirst(T prevTupleCursor)"); + } + + @Override + public TarantoolPageable previousOrFirst(T tupleCursor) { + if (hasPrevious()) { + return previous(tupleCursor); + } + return new TarantoolPageRequest<>(0, getPageSize(), getSort(), null, BACKWARD); + } + + @Override + public PaginationDirection getPaginationDirection() { + return this.paginationDirection; + } + + @Override + @NonNull + public Sort getSort() { + return this.sort; + } + + @Override + public T getTupleCursor() { + return this.tupleCursor; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TarantoolPageRequest that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + return sort.equals(that.sort) + && Objects.equals(tupleCursor, that.tupleCursor) + && paginationDirection == that.paginationDirection; + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), sort, tupleCursor, paginationDirection); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageable.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageable.java new file mode 100644 index 0000000..528b3ac --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPageable.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import org.springframework.data.domain.Pageable; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; + +import io.tarantool.spring.data.query.PaginationDirection; + +/** + * Abstract interface for pagination information in Tarantool. + * + * @param domain class type. + * @author Nikolay Belonogov + */ +public interface TarantoolPageable extends Pageable { + + /** + * Returns tuple cursor of domain type. Can be {@code null}. If the cursor is {@code null} then + * the page is counted from the first tuple in Tarantool. + * + * @return tuple cursor of domain type. + */ + @Nullable + T getTupleCursor(); + + /** + * Returns the {@link TarantoolPageable} for previous page or the {@link TarantoolPageable} for + * first page if the current one already is the first one. Important: this method + * always returns {@link TarantoolPageable} c {@link PaginationDirection#BACKWARD}. + * + * @param tupleCursor tuple cursor of domain type for previous page. + * @return {@link TarantoolPageable} instance. + */ + TarantoolPageable previousOrFirst(T tupleCursor); + + /** + * Returns the {@link TarantoolPageable} requesting the next {@link + * org.springframework.data.domain.Page}. + * + *

Important: method always creates a new {@link TarantoolPageable} with the + * same parameters Sort and page size. Pagination direction is {@link + * PaginationDirection#FORWARD}, the page number always has value {@code n+1}, n is the current + * page number. + * + * @param tupleCursor tuple cursor of domain type for next page. + * @return {@link TarantoolPageable} object + */ + TarantoolPageable next(T tupleCursor); + + /** + * Returns pagination direction by {@link PaginationDirection} enumeration. + * + * @return {@link PaginationDirection}. + */ + PaginationDirection getPaginationDirection(); + + /** + * Returns a {@link TarantoolPageable} that points to the first page with {@link + * PaginationDirection#FORWARD} pagination direction. + * + * @return {@link TarantoolPageable} which has {@code cursor == null}, page number is 0, the rest + * parameters are equivalent to the parameters of the current {@link TarantoolPageable}. + */ + @NonNull + TarantoolPageable first(); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPartTreeQuery.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPartTreeQuery.java new file mode 100644 index 0000000..9e2ff41 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolPartTreeQuery.java @@ -0,0 +1,506 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; +import org.springframework.data.keyvalue.core.IterableConverter; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery; +import org.springframework.data.repository.query.Parameter; +import org.springframework.data.repository.query.Parameters; +import org.springframework.data.repository.query.ParametersParameterAccessor; +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.data.util.StreamUtils; +import org.springframework.lang.NonNull; +import org.springframework.util.Assert; + +import static io.tarantool.client.crud.ConditionOperator.GREATER_EQ; +import static io.tarantool.client.crud.ConditionOperator.LESS_EQ; +import static io.tarantool.spring.data.query.PaginationDirection.FORWARD; +import static io.tarantool.spring.data35.query.PaginationUtils.doPageQuery; +import static io.tarantool.spring.data35.query.PaginationUtils.doPaginationQuery; +import io.tarantool.client.crud.Condition; +import io.tarantool.client.crud.options.SelectOptions; +import io.tarantool.spring.data.query.PaginationDirection; +import io.tarantool.spring.data.query.TarantoolCriteria; +import io.tarantool.spring.data.utils.Pair; + +/** + * There is one instance for each query method defined for a repository, providing a query from the + * bind parameters. + * + * @author Artyom Dubinin + */ +public class TarantoolPartTreeQuery extends KeyValuePartTreeQuery { + + public static final String ILLEGAL_RETURN_TYPE_FOR_DELETE = + "Illegal returned type: %s. The operation 'deleteBy' accepts only 'long' and 'Collection' as" + + " the returned object type"; + public static final String QUERY_METHOD_S_NOT_SUPPORTED = "Query method '%s' not supported."; + private final QueryMethod queryMethod; + private final KeyValueOperations keyValueOperations; + private final PartTree tree; + + private final boolean isCount; + private final boolean isDelete; + private final boolean isDistinct; + private final boolean isExists; + private final Class targetType; + private final Class returnType; + + private boolean isRearrangeKnown; + private boolean isRearrangeRequired; + private int[] rearrangeIndex; + + private final List EMPTY_KEY = Collections.emptyList(); + + /** + * Create a {@link RepositoryQuery} implementation for each query method defined in a tarantool + * repository. + * + * @param queryMethod Method defined in Tarantool Repositories + * @param evaluationContextProvider Not used + * @param keyValueOperations Interface to Tarantool + * @param queryCreator Not used + */ + public TarantoolPartTreeQuery( + QueryMethod queryMethod, + QueryMethodEvaluationContextProvider evaluationContextProvider, + KeyValueOperations keyValueOperations, + Class> queryCreator) { + super(queryMethod, evaluationContextProvider, keyValueOperations, queryCreator); + this.queryMethod = queryMethod; + this.keyValueOperations = keyValueOperations; + this.isRearrangeKnown = false; + this.targetType = queryMethod.getEntityInformation().getJavaType(); + this.returnType = queryMethod.getReturnedObjectType(); + this.tree = new PartTree(getQueryMethod().getName(), targetType); + if (queryMethod.getParameters().getNumberOfParameters() > 0) { + this.isCount = tree.isCountProjection(); + this.isDelete = tree.isDelete(); + this.isDistinct = tree.isDistinct(); + this.isExists = tree.isExistsProjection(); + } else { + this.isCount = false; + this.isDelete = false; + this.isDistinct = false; + this.isExists = false; + } + } + + /** + * Execute this query instance, using any invocation parameters. + * + *

Expecting {@code findBy...()}, {@code countBy...()} or {@code deleteBy...()} + * + * @param parameters Any parameters + * @return Query result + */ + @Override + public Object execute(@NonNull Object[] parameters) { + ParametersParameterAccessor accessor = this.prepareAccessor(parameters, tree); + + KeyValueQuery query = prepareQuery(accessor); + + if (this.isCount) { + if (this.isDistinct) { + final Iterable iterable = this.keyValueOperations.find(query, targetType); + return StreamUtils.createStreamFromIterator(iterable.iterator()).distinct().count(); + } + return this.keyValueOperations.count(query, targetType); + } + + if (this.isDelete) { + return this.executeDeleteQuery(query); + } + + if (this.isExists) { + query.setOffset(0); + query.setRows(1); + final Iterable result = this.keyValueOperations.find(query, targetType); + return result.iterator().hasNext(); + } + + if (queryMethod.isPageQuery()) { + return this.executePageQuery(query, accessor); + } + + if (queryMethod.isSliceQuery()) { + return this.executeSliceQuery(query, accessor); + } + + if (queryMethod.isScrollQuery()) { + return this.executeScrollQuery(query, accessor); + } + + if (queryMethod.isCollectionQuery() + || queryMethod.isQueryForEntity() + || queryMethod.isStreamQuery()) { + return this.executeFindQuery(query); + } + + throw new UnsupportedOperationException( + String.format(QUERY_METHOD_S_NOT_SUPPORTED, queryMethod.getName())); + } + + /** + * Execute a "delete" query, not really a query more of an operation. + * + *

+ * + * @param query The query to run + * @return Collection of deleted objects or the number of deleted objects + */ + private Object executeDeleteQuery(final KeyValueQuery query) { + + Iterable resultSet = this.keyValueOperations.find(query, targetType); + Iterator iterator = resultSet.iterator(); + + List result = new ArrayList<>(); + while (iterator.hasNext()) { + result.add(this.keyValueOperations.delete(iterator.next())); + } + + if (queryMethod.isCollectionQuery()) { + return result; + } + if (long.class.equals(returnType) || Long.class.equals(returnType)) { + return result.size(); + } + throw new UnsupportedOperationException( + String.format(ILLEGAL_RETURN_TYPE_FOR_DELETE, returnType)); + } + + /** + * Execute a retrieval query. The query engine will return this in an iterator, which may need + * conversion to a single domain entity or a stream. + * + * @param query The query to run + * @return Query result + */ + private Object executeFindQuery(final KeyValueQuery query) { + + Iterable resultSet = this.keyValueOperations.find(query, targetType); + + if (!queryMethod.isCollectionQuery() + && !queryMethod.isPageQuery() + && !queryMethod.isSliceQuery() + && !queryMethod.isStreamQuery()) { + // Singleton result + return resultSet.iterator().hasNext() ? resultSet.iterator().next() : null; + } + + Stream stream = StreamUtils.createStreamFromIterator(resultSet.iterator()); + if (this.isDistinct) { + stream = stream.distinct(); + } + + if (queryMethod.isStreamQuery()) { + return stream; + } + + // optimization: + // we can omit if condition + // but this will recreate result even if it's not distinct and isn't stream query + if (this.isDistinct) { + return stream.collect(Collectors.toList()); + } + + return resultSet; + } + + private Object executeScrollQuery( + final KeyValueQuery query, ParametersParameterAccessor accessor) { + ScrollPosition scrollPosition = accessor.getScrollPosition(); + + Assert.notNull( + scrollPosition, + "The ScrollPosition parameter must be specified. Method: " + queryMethod.getName()); + + if (!(scrollPosition instanceof TarantoolKeysetScrollPosition keysetScrollPosition)) { + throw new IllegalArgumentException( + "ScrollPosition must be an instance of TarantoolKeysetScrollPosition. Method: " + + queryMethod.getName()); + } + + int pageSize = query.getRows(); + List content = doScrollQuery(query, keysetScrollPosition, pageSize + 1); + + boolean hasNext = content.size() > pageSize; + + final List finalResult = getPaginationSubResult(content, pageSize, FORWARD, hasNext); + return Window.from( + finalResult, + value -> + new TarantoolKeysetScrollPosition( + keysetScrollPosition.getIndexKey(), + keysetScrollPosition.getDirection(), + finalResult.get(value)), + hasNext); + } + + private List doScrollQuery( + final KeyValueQuery query, TarantoolKeysetScrollPosition scrollPosition, int pageSize) { + TarantoolCriteria criteria = castToTarantoolCriteria(query.getCriteria()); + PaginationDirection paginationDirection = scrollPosition.getDirection(); + + Object cursor = scrollPosition.getCursor(); + Pair indexKey = scrollPosition.getIndexKey(); + + Object second = EMPTY_KEY; + if (cursor == null) { + second = indexKey.getSecond(); + } + + switch (paginationDirection) { + case FORWARD -> + criteria.addCondition(0, Condition.create(GREATER_EQ, indexKey.getFirst(), second)); + case BACKWARD -> + criteria.addCondition(0, Condition.create(LESS_EQ, indexKey.getFirst(), second)); + } + + criteria.withAfter(cursor); + query.setRows(pageSize); + return IterableConverter.toList(this.keyValueOperations.find(query, targetType)); + } + + /** + * Execute the slice request. + * + * @param query query + * @param accessor accessor + * @return slice selection result. + */ + private Object executePageQuery( + final KeyValueQuery query, ParametersParameterAccessor accessor) { + + Pageable pageParams = accessor.getPageable(); + + return doPageQuery(pageParams, query, this.keyValueOperations, targetType); + } + + private Object executeSliceQuery( + final KeyValueQuery query, ParametersParameterAccessor accessor) { + + Pageable sliceParams = accessor.getPageable(); + + if (sliceParams.isUnpaged()) { + return new TarantoolSliceImpl<>(Collections.emptyList()); + } + + TarantoolPageable resultSliceParams = castToTarantoolPageable(sliceParams); + + int pageSize = resultSliceParams.getPageSize(); + + List content = + doPaginationQuery( + query, resultSliceParams, pageSize + 1, this.keyValueOperations, targetType); + + if (content.isEmpty()) { + return new TarantoolSliceImpl<>(); + } + + boolean hasNext = content.size() > resultSliceParams.getPageSize(); + PaginationDirection paginationDirection = resultSliceParams.getPaginationDirection(); + + List result = getPaginationSubResult(content, pageSize, paginationDirection, hasNext); + return new TarantoolSliceImpl<>(result, resultSliceParams, hasNext); + } + + /** + * Returns {@link TarantoolPageable} cast from {@link Pageable} with type checking. + * + * @param sliceParams pageable + * @return {@link TarantoolPageable} + */ + private TarantoolPageable castToTarantoolPageable(Pageable sliceParams) { + Assert.isInstanceOf(TarantoolPageable.class, sliceParams, "Pageable must be TarantoolPageable"); + return (TarantoolPageable) sliceParams; + } + + /** + * The method allows you to get {@link TarantoolCriteria} even when pagination is used through + * methods the base repository (not via derived methods). + * + * @param criteria criteria object + * @return An instance of {@link TarantoolCriteria}, or creates a new one if null. + */ + private TarantoolCriteria castToTarantoolCriteria(Object criteria) { + if (criteria == null) { + return new TarantoolCriteria(); + } + + if (!(criteria instanceof TarantoolCriteria)) { + throw new IllegalArgumentException("criteria must be instance of TarantoolCriteria"); + } + return (TarantoolCriteria) criteria; + } + + private List getPaginationSubResult( + List content, int pageSize, PaginationDirection direction, boolean hasNext) { + if (hasNext) { + switch (direction) { + case FORWARD -> { + return content.subList(0, pageSize); + } + case BACKWARD -> { + return content.subList(1, content.size()); + } + } + } + return content; + } + + /** + * Create the query from the bind parameters. + * + * @return A ready-to-use query + */ + @NonNull + protected KeyValueQuery prepareQuery(ParametersParameterAccessor accessor) { + KeyValueQuery query = createQuery(accessor); + + /* + If there is no limitation in the name of the method, we always put a + limit so that don't think about this in the engine, especially when distinguishing between a regular query and a + paginated query. + */ + if (!tree.isLimiting()) { + query.setRows(SelectOptions.DEFAULT_LIMIT); + } + + Limit limit = accessor.getLimit(); + if (limit.isLimited()) { + final int max = limit.max(); + if (max < 0) { + throw new IllegalArgumentException( + "The max number of potential results should be positive! Method: " + + queryMethod + + ". Limit: " + + max); + } + query.setRows(limit.max()); + } + + if (accessor.getSort() != Sort.unsorted()) { + query.setSort(accessor.getSort()); + } + + return query; + } + + /** + * Handle {@code @Param}. + * + *
    + *
  1. Without {@code @Param} + *

    Arguments to the call are assumed to follow the same sequence as cited in the method + * name.
    + * Eg. + *

    +   *     findByOneAndTwo(String one, String two);
    +   *     
    + *
  2. With {@code @Param} + *

    Arguments to the call are use the {@code @Param} to match them against the fields. + *

    Eg. + *

    +   *   findByOneAndTwo(@Param("two") String two, @Param("one") String one);
    +   *   
    + *
+ * + * @param originalParameters Possibly empty + * @param partTree Query tree to traverse + * @return Parameters in correct order + */ + private ParametersParameterAccessor prepareAccessor( + final Object[] originalParameters, final PartTree partTree) { + + if (!this.isRearrangeKnown) { + this.prepareRearrange(partTree, this.queryMethod.getParameters().getBindableParameters()); + this.isRearrangeKnown = true; + } + + Object[] parameters = originalParameters; + Assert.notNull(parameters, "Parameters must not be null."); + + if (this.isRearrangeRequired) { + parameters = new Object[originalParameters.length]; + + for (int i = 0; i < parameters.length; i++) { + int index = (i < rearrangeIndex.length) ? rearrangeIndex[i] : i; + parameters[i] = originalParameters[index]; + } + } + + return new ParametersParameterAccessor(this.queryMethod.getParameters(), parameters); + } + + /** + * Determine if the arguments to the method need reordered. + * + *

For searches such as {@code findBySomethingNotNull} there may be more parts than parameters + * needed to be bound to them. + * + * @param partTree Query parts + * @param bindableParameters Parameters expected + */ + @SuppressWarnings("unchecked") + private void prepareRearrange( + final PartTree partTree, final Parameters bindableParameters) { + + this.isRearrangeRequired = false; + if (partTree == null || bindableParameters == null) { + return; + } + + List queryParams = new ArrayList<>(); + List methodParams = new ArrayList<>(); + + for (Part part : partTree.getParts()) { + queryParams.add(part.getProperty().getSegment()); + } + + Iterator bindableParameterIterator = + (Iterator) bindableParameters.iterator(); + while (bindableParameterIterator.hasNext()) { + Parameter parameter = bindableParameterIterator.next(); + parameter.getName().ifPresent(methodParams::add); + } + + this.rearrangeIndex = new int[queryParams.size()]; + + String[] paramsExpected = queryParams.toArray(new String[0]); + String[] paramsProvided = methodParams.toArray(new String[0]); + + for (int i = 0; i < this.rearrangeIndex.length; i++) { + this.rearrangeIndex[i] = i; + + for (int j = 0; j < paramsProvided.length; j++) { + if (paramsProvided[j] != null && paramsProvided[j].equals(paramsExpected[i])) { + this.rearrangeIndex[i] = j; + this.isRearrangeRequired = true; + } + } + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryCreator.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryCreator.java new file mode 100644 index 0000000..a757cea --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryCreator.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Iterator; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Sort; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.data.mapping.PropertyPath; +import org.springframework.data.repository.query.ParameterAccessor; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.repository.query.parser.PartTree; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StringUtils; + +import static io.tarantool.client.crud.ConditionOperator.EQ; +import static io.tarantool.client.crud.ConditionOperator.GREATER; +import static io.tarantool.client.crud.ConditionOperator.GREATER_EQ; +import static io.tarantool.client.crud.ConditionOperator.LESS; +import static io.tarantool.client.crud.ConditionOperator.LESS_EQ; +import io.tarantool.client.crud.Condition; +import io.tarantool.client.crud.ConditionOperator; +import io.tarantool.spring.data.query.TarantoolCriteria; + +public class TarantoolQueryCreator + extends AbstractQueryCreator, TarantoolCriteria> { + + private final Map cache = new ConcurrentHashMap<>(); + + public static final String INVALID_DATA_ACCESS_API_USAGE_EXCEPTION_MESSAGE_TEMPLATE = + "Logic error for '%s' in query. "; + + public TarantoolQueryCreator(PartTree tree, ParameterAccessor parameters) { + super(tree, parameters); + } + + private String getFieldName(Part part) { + PropertyPath property = part.getProperty(); + String segment = property.toDotPath(); + Class domainType = property.getOwningType().getType(); + java.lang.reflect.Field field = ReflectionUtils.findField(domainType, segment); + + if (field == null) { + throw new IllegalArgumentException("No such field: " + segment + " in " + domainType); + } + Field annotationField = field.getAnnotation(Field.class); + + if (annotationField != null) { + String value = annotationField.value(); + if (StringUtils.hasText(value)) { + return value; + } + String name = annotationField.name(); + if (StringUtils.hasText(name)) { + return name; + } + } + + return segment; + } + + @Override + @NonNull + protected TarantoolCriteria create(Part part, @NonNull Iterator iterator) { + final TarantoolCriteria tarantoolCriteria = new TarantoolCriteria(); + + Part.Type type = part.getType(); + if (isIgnoreCase(part)) { + throw new InvalidDataAccessApiUsageException( + String.format( + INVALID_DATA_ACCESS_API_USAGE_EXCEPTION_MESSAGE_TEMPLATE + + "IgnoreCase isn't supported yet", + type)); + } + String property = cache.computeIfAbsent(part, this::getFieldName); + + switch (type) { + case SIMPLE_PROPERTY: + generateConditions(tarantoolCriteria, type, property, iterator, EQ); + break; + case LESS_THAN: + case BEFORE: + generateConditions(tarantoolCriteria, type, property, iterator, LESS); + break; + case LESS_THAN_EQUAL: + generateConditions(tarantoolCriteria, type, property, iterator, LESS_EQ); + break; + case GREATER_THAN: + case AFTER: + generateConditions(tarantoolCriteria, type, property, iterator, GREATER); + break; + case GREATER_THAN_EQUAL: + generateConditions(tarantoolCriteria, type, property, iterator, GREATER_EQ); + break; + case BETWEEN: + generateConditions(tarantoolCriteria, type, property, iterator, GREATER, LESS); + break; + case TRUE: + tarantoolCriteria.addCondition(Condition.create(EQ, property, true)); + break; + case FALSE: + tarantoolCriteria.addCondition(Condition.create(EQ, property, false)); + break; + case IS_EMPTY: + tarantoolCriteria.addCondition(Condition.create(EQ, property, "")); + break; + case IS_NULL: + tarantoolCriteria.addCondition(Condition.create(EQ, property, null)); + break; + default: + throw new InvalidDataAccessApiUsageException(String.format("Unsupported type '%s'", type)); + } + + return tarantoolCriteria; + } + + private void generateConditions( + TarantoolCriteria tarantoolCriteria, + Part.Type type, + String property, + Iterator iterator, + ConditionOperator... operators) { + for (int i = 0; i < type.getNumberOfArguments(); i++) { + if (!iterator.hasNext()) { + throw new InvalidDataAccessApiUsageException( + String.format( + INVALID_DATA_ACCESS_API_USAGE_EXCEPTION_MESSAGE_TEMPLATE + + "Transmitted not enough arguments (%d of %d)", + type, + i, + type.getNumberOfArguments())); + } + tarantoolCriteria.addCondition(Condition.create(operators[i], property, iterator.next())); + } + } + + private boolean isIgnoreCase(Part part) { + switch (part.shouldIgnoreCase()) { + case ALWAYS: + Assert.state( + canUpperCase(part.getProperty()), + String.format( + "Unable to ignore case of %s types, the property '%s' must reference a String", + part.getProperty().getType().getName(), part.getProperty().getSegment())); + return true; + case WHEN_POSSIBLE: + return canUpperCase(part.getProperty()); + case NEVER: + default: + return false; + } + } + + private boolean canUpperCase(PropertyPath path) { + return String.class.equals(path.getType()); + } + + @Override + @NonNull + protected TarantoolCriteria and( + @NonNull Part part, @NonNull TarantoolCriteria base, @NonNull Iterator iterator) { + throw new UnsupportedOperationException(); + } + + @Override + @NonNull + protected TarantoolCriteria or( + @NonNull TarantoolCriteria base, @NonNull TarantoolCriteria criteria) { + throw new UnsupportedOperationException(); + } + + @Override + @NonNull + protected KeyValueQuery complete( + @Nullable final TarantoolCriteria criteria, @NonNull Sort sort) { + return new KeyValueQuery<>(criteria); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryMethodImpl.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryMethodImpl.java new file mode 100644 index 0000000..bea1a3c --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolQueryMethodImpl.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.lang.reflect.Method; + +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryMethod; + +import io.tarantool.spring.data.query.Query; +import io.tarantool.spring.data.query.TarantoolQueryMethod; + +/** Tarantool {@link QueryMethod} Implementation */ +public class TarantoolQueryMethodImpl extends QueryMethod implements TarantoolQueryMethod { + + private final Method method; + + public TarantoolQueryMethodImpl( + Method method, RepositoryMetadata metadata, ProjectionFactory factory) { + super(method, metadata, factory); + this.method = method; + } + + public boolean hasAnnotatedQuery() { + return getQuery() != null; + } + + public String getQueryValue(Query query) { + return (String) AnnotationUtils.getValue(query); + } + + public Query getQuery() { + return method.getAnnotation(Query.class); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolRepositoryQuery.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolRepositoryQuery.java new file mode 100644 index 0000000..35747d0 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolRepositoryQuery.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import org.springframework.data.repository.query.QueryMethod; +import org.springframework.data.repository.query.RepositoryQuery; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data.query.ProxyTarantoolRepositoryQuery; + +/** {@link RepositoryQuery} using String based function name to call function in Tarantool. */ +public class TarantoolRepositoryQuery implements RepositoryQuery { + + private final ProxyTarantoolRepositoryQuery proxy; + private final TarantoolQueryMethodImpl queryMethod; + + public TarantoolRepositoryQuery( + TarantoolCrudClient client, TarantoolQueryMethodImpl queryMethod) { + this.queryMethod = queryMethod; + this.proxy = new ProxyTarantoolRepositoryQuery(client, queryMethod); + } + + @Override + public Object execute(Object[] parameters) { + return proxy.execute(parameters); + } + + @Override + public QueryMethod getQueryMethod() { + return queryMethod; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolScrollPosition.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolScrollPosition.java new file mode 100644 index 0000000..98c94b5 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolScrollPosition.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import org.springframework.data.domain.ScrollPosition; + +import io.tarantool.spring.data.utils.Pair; + +/** + * Use keyset pagination adapted for Tarantool crud module.. + * + * @author Nikolay Belonogov + * @see #forward(Pair) + * @see #backward(Pair) + * @see #reverse() + */ +public sealed interface TarantoolScrollPosition extends ScrollPosition + permits TarantoolKeysetScrollPosition { + + /** + * Creates a new {@link ScrollPosition} based on the key index scrolling forward. + * + * @param indexKey pair, where the first element is the name of the index on which you want to + * perform pagination. Second element is the value of the indexed field from which you want to + * paginate (exclusive). To perform pagination from the beginning of the index, pass an empty + * list as the second element of the pair. indexKey must be not {@code null}. + * @return new {@link TarantoolScrollPosition} instance. + */ + static TarantoolScrollPosition forward(Pair indexKey) { + return TarantoolKeysetScrollPosition.forward(indexKey); + } + + /** + * Creates a new {@link ScrollPosition} based on the key index scrolling backward. + * + * @param indexKey pair, where the first element is the name of the index on which you want to + * perform pagination. Second element is the value of the indexed field from which you want to + * paginate (exclusive). To perform pagination from the end of the index, pass an empty list + * as the second element of the pair. indexKey must be not {@code null}. + * @return new {@link TarantoolScrollPosition} instance. + */ + static TarantoolScrollPosition backward(Pair indexKey) { + return TarantoolKeysetScrollPosition.backward(indexKey); + } + + /** + * Return {@link TarantoolScrollPosition} with the same parameters, but with the opposite + * pagination direction. If the current {@link TarantoolScrollPosition} was set from the beginning + * of the index, the method will return the {@link TarantoolScrollPosition} set from the end of + * the index. Returns the {@link TarantoolScrollPosition} set from the beginning of the index if + * the current {@link TarantoolScrollPosition} is set from the end of the index. + * + *

Important: when this method is called, the condition specified by the index in + * the current {@link TarantoolScrollPosition} is overridden to unconditional (i.e. it will be + * searched to the end or beginning of the index) + * + * @return {@link TarantoolScrollPosition} instance. + */ + TarantoolScrollPosition reverse(); + + /** + * Return {@code true} if scrolling has backward motion. This means that the current {@link + * TarantoolScrollPosition} has the direction of movement {@link + * io.tarantool.spring.data.query.PaginationDirection#BACKWARD}. Return {@code false} if scrolling + * has forward motion ({@link io.tarantool.spring.data.query.PaginationDirection#FORWARD}). + * + * @return {@link Boolean} primitive. + */ + boolean isScrollsBackward(); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSliceImpl.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSliceImpl.java new file mode 100644 index 0000000..c52a312 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSliceImpl.java @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.io.Serial; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import java.util.function.Function; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.lang.NonNull; + +/** + * Default implementation of {@link Slice} for Tarantool. + * + * @author Nikolay Belonogov + */ +class TarantoolSliceImpl extends TarantoolChunk { + + @Serial private static final long serialVersionUID = 867755909294344406L; + + private final boolean hasNext; + + private final Pageable pageable; + + /** + * Creates a new {@link TarantoolSliceImpl} with the empty content. This will result in the + * created {@link Slice} being identical to the entire {@link List}. + */ + public TarantoolSliceImpl() { + this(Collections.emptyList(), Pageable.unpaged(), false); + } + + /** + * Creates a new {@link TarantoolSliceImpl} with the given content. This will result in the + * created {@link Slice} being identical to the entire {@link List}. + * + * @param content must not be {@literal null}. + */ + public TarantoolSliceImpl(List content) { + this(content, Pageable.unpaged(), false); + } + + /** + * Creates a new {@link TarantoolSliceImpl} with the given content and {@link Pageable}. + * + * @param content the content of this {@link Slice}, must not be {@literal null}. + * @param pageable the paging information, must not be {@literal null}. + * @param hasNext whether there's another slice following the current one. + */ + public TarantoolSliceImpl(List content, Pageable pageable, boolean hasNext) { + super(content, pageable); + + this.hasNext = hasContent() && hasNext; + this.pageable = pageable; + } + + /** + * Returns true if there is a next data slice. No data content means that the next slice does not + * exist and method will return false. + * + * @return Returns true if there is a next data slice. No data content means that the next slice + * does not exist and method will return false. + */ + @Override + public boolean hasNext() { + return hasNext && hasContent(); + } + + @Override + @NonNull + public Slice map(@NonNull Function converter) { + return new TarantoolSliceImpl<>(getConvertedContent(converter), pageable, hasNext); + } + + @Override + public String toString() { + + String contentType = "UNKNOWN"; + List content = getContent(); + + if (content.size() > 0) { + contentType = content.get(0).getClass().getName(); + } + + return String.format("Slice %d containing %s instances", getNumber(), contentType); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof TarantoolSliceImpl that)) { + return false; + } + if (!super.equals(o)) { + return false; + } + return hasNext == that.hasNext && pageable.equals(that.pageable); + } + + @Override + public int hashCode() { + return Objects.hash(super.hashCode(), hasNext, pageable); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSortAccessor.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSortAccessor.java new file mode 100644 index 0000000..731c23f --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolSortAccessor.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Comparator; +import java.util.Map.Entry; + +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Sort.Order; +import org.springframework.data.keyvalue.core.SortAccessor; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; + +/** + * Implements sorting for Tarantool repository queries. + * + * @author Artyom Dubinin + */ +public class TarantoolSortAccessor implements SortAccessor>> { + + /** + * Sort on a sequence of fields, possibly none. + * + * @param query If not null, will contain one of more {@link Order} objects. + * @return A sequence of comparators or {@code null} + */ + public Comparator> resolve(KeyValueQuery query) { + if (query == null || query.getSort() == Sort.unsorted()) { + return null; + } + + throw new UnsupportedOperationException(); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolWindowIterator.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolWindowIterator.java new file mode 100644 index 0000000..c70d08b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/query/TarantoolWindowIterator.java @@ -0,0 +1,146 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Iterator; +import java.util.NoSuchElementException; +import java.util.function.Function; + +import org.springframework.data.domain.Window; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * The class is a complete analogue of {@link org.springframework.data.support.WindowIterator} for + * Tarantool. + * + *

Example of usage: + * + *

+ * + *
{@code
+ * TarantoolScrollPosition initialScrollPosition =
+ *     TarantoolScrollPosition.forward(Pair.of("pk", Collections.emptyList()));
+ *
+ * TarantoolWindowIterator personIterator =
+ *     TarantoolWindowIterator.of(scrollPosition -> repository.findFirst10ByAge(10, scrollPosition))
+ *         .startingAt(initialScrollPosition);
+ *
+ * List result = new ArrayList<>();
+ *
+ * while (personIterator.hasNext()) {
+ *   result.add(personIterator.next());
+ * }
+ * }
+ * + *
+ * + * @param domain class type. + */ +public class TarantoolWindowIterator implements Iterator { + + private final Function> windowFunction; + + private TarantoolScrollPosition currentPosition; + + private @Nullable Window currentWindow; + + private @Nullable Iterator currentIterator; + + /** + * Entrypoint to create a new {@link TarantoolWindowIterator} for the given windowFunction. + * + * @param windowFunction must not be {@literal null}. + * @param domain class type. + * @return new instance of {@link TarantoolWindowIteratorBuilder}. + */ + public static TarantoolWindowIteratorBuilder of( + Function> windowFunction) { + return new TarantoolWindowIteratorBuilder<>(windowFunction); + } + + TarantoolWindowIterator( + Function> windowFunction, + TarantoolScrollPosition position) { + + this.windowFunction = windowFunction; + this.currentPosition = position; + } + + @Override + public boolean hasNext() { + + // use while loop instead of recursion to fetch the next window. + do { + if (currentWindow == null) { + currentWindow = windowFunction.apply(currentPosition); + } + + if (currentIterator == null) { + if (currentWindow != null) { + currentIterator = currentWindow.iterator(); + } + } + + if (currentIterator != null) { + + if (currentIterator.hasNext()) { + return true; + } + + if (currentWindow != null && currentWindow.hasNext()) { + + currentPosition = getNextPosition(currentWindow); + currentIterator = null; + currentWindow = null; + continue; + } + } + + return false; + } while (true); + } + + @Override + public T next() { + + if (!hasNext()) { + throw new NoSuchElementException(); + } + + return currentIterator.next(); + } + + private static TarantoolScrollPosition getNextPosition(Window window) { + return (TarantoolScrollPosition) window.positionAt(window.size() - 1); + } + + /** Builder API to construct a {@link TarantoolWindowIterator}. */ + public static class TarantoolWindowIteratorBuilder { + + private final Function> windowFunction; + + TarantoolWindowIteratorBuilder(Function> windowFunction) { + + Assert.notNull(windowFunction, "WindowFunction must not be null"); + + this.windowFunction = windowFunction; + } + + /** + * Create a {@link TarantoolWindowIterator} given {@link TarantoolScrollPosition}. + * + * @param position {@link TarantoolScrollPosition} instance. Must be not null. + * @return {@link TarantoolWindowIterator} instance. + */ + public TarantoolWindowIterator startingAt(TarantoolScrollPosition position) { + + Assert.notNull(position, "TarantoolScrollPosition must not be null"); + + return new TarantoolWindowIterator<>(windowFunction, position); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/TarantoolRepositoryFactory.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/TarantoolRepositoryFactory.java new file mode 100644 index 0000000..d72ae36 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/TarantoolRepositoryFactory.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository; + +import java.util.Optional; + +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.query.SpelQueryCreator; +import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data35.query.TarantoolPartTreeQuery; +import io.tarantool.spring.data35.repository.config.TarantoolQueryLookupStrategy; +import io.tarantool.spring.data35.repository.support.TarantoolSimpleRepository; + +/** + * Tarantool version of {@link KeyValueRepositoryFactory}, a factory to build tarantool repository + * instances. + * + *

The purpose of extending is to ensure that the {@link #getQueryLookupStrategy} method returns + * a {@link TarantoolQueryLookupStrategy} rather than the default. + * + *

The end goal of this bean is for {@link TarantoolPartTreeQuery} to be used for query + * preparation. + * + * @author Artyom Dubinin + */ +public class TarantoolRepositoryFactory extends KeyValueRepositoryFactory { + + private static final Class DEFAULT_QUERY_CREATOR = SpelQueryCreator.class; + + private final KeyValueOperations keyValueOperations; + private final Class> queryCreator; + private final TarantoolCrudClient client; + + /* Mirror functionality of super, to ensure private + * fields are set. + */ + public TarantoolRepositoryFactory( + TarantoolCrudClient client, KeyValueOperations keyValueOperations) { + this(client, keyValueOperations, DEFAULT_QUERY_CREATOR); + } + + /* Capture KeyValueOperations and QueryCreator objects after passing to super. + */ + public TarantoolRepositoryFactory( + TarantoolCrudClient client, + KeyValueOperations keyValueOperations, + Class> queryCreator) { + + super(keyValueOperations, queryCreator); + + this.client = client; + this.keyValueOperations = keyValueOperations; + this.queryCreator = queryCreator; + } + + /** + * Ensure the mechanism for query evaluation is Tarantool specific, as the original {@code + * KeyValueQueryLookupStrategy} does not function correctly for Tarantool. + */ + @Override + protected Optional getQueryLookupStrategy( + QueryLookupStrategy.Key key, QueryMethodEvaluationContextProvider evaluationContextProvider) { + return Optional.of( + new TarantoolQueryLookupStrategy( + client, key, evaluationContextProvider, keyValueOperations, queryCreator)); + } + + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + return TarantoolSimpleRepository.class; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/EnableTarantoolRepositories.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/EnableTarantoolRepositories.java new file mode 100644 index 0000000..5a270f9 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/EnableTarantoolRepositories.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.config; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.Import; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.config.QueryCreatorType; +import org.springframework.data.repository.config.DefaultRepositoryBaseClass; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryLookupStrategy.Key; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; +import io.tarantool.client.ClientType; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.query.TarantoolQueryCreator; +import io.tarantool.spring.data35.repository.support.TarantoolRepositoryFactoryBean; + +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) +@Retention(RetentionPolicy.RUNTIME) +@Documented +@Inherited +@Import(TarantoolRepositoriesRegistrar.class) +@EnableConfigurationProperties(TarantoolProperties.class) +@QueryCreatorType(TarantoolQueryCreator.class) +public @interface EnableTarantoolRepositories { + + /** + * Alias for the {@link #basePackages()} attribute. Allows for more concise annotation + * declarations e.g.: {@code @EnableJpaRepositories("org.my.pkg")} instead of + * {@code @EnableJpaRepositories(basePackages="org.my.pkg")}. + * + * @return value + */ + String[] value() default {}; + + /** + * Base packages to scan for annotated components. {@link #value()} is an alias for (and mutually + * exclusive with) this attribute. Use {@link #basePackageClasses()} for a type-safe alternative + * to String-based package names. + * + * @return base packages + */ + String[] basePackages() default {}; + + /** + * Type-safe alternative to {@link #basePackages()} for specifying the packages to scan for + * annotated components. The package of each class specified will be scanned. Consider creating a + * special no-op marker class or interface in each package that serves no purpose other than being + * referenced by this attribute. + * + * @return classes + */ + Class[] basePackageClasses() default {}; + + /** + * Specifies which types are not eligible for component scanning. + * + * @return filters + */ + Filter[] excludeFilters() default {}; + + /** + * Specifies which types are eligible for component scanning. Further, narrows the set of + * candidate components from everything in {@link #basePackages()} to everything in the base + * packages that matches the given filter or filters. + * + * @return filters + */ + Filter[] includeFilters() default {}; + + /** + * Returns the postfix to be used when looking up custom repository implementations. Defaults to + * {@literal Impl}. So for a repository named {@code PersonRepository} the corresponding + * implementation class will be looked up scanning for {@code PersonRepositoryImpl}. + * + * @return postfix + */ + String repositoryImplementationPostfix() default "Impl"; + + /** + * Configures the location of where to find the Spring Data named queries properties file. + * + * @return location + */ + String namedQueriesLocation() default ""; + + /** + * Returns the key of the {@link QueryLookupStrategy} to be used for lookup queries for query + * methods. Defaults to {@link Key#CREATE_IF_NOT_FOUND}. + * + * @return strategy + */ + Key queryLookupStrategy() default Key.CREATE_IF_NOT_FOUND; + + Class repositoryFactoryBeanClass() default TarantoolRepositoryFactoryBean.class; + + /** + * Allow custom base classes, for generic behavior shared amongst selected repositories. + * + * @return base class + */ + Class repositoryBaseClass() default DefaultRepositoryBaseClass.class; + + /** + * Configures the name of the {@link KeyValueOperations} bean to be used with the repositories + * detected. + * + * @return reference to {@link KeyValueOperations} bean. + */ + String keyValueTemplateRef() default DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; + + /** + * Configures whether nested repository-interfaces (e.g. defined as inner classes) should be + * discovered by the repositories' infrastructure. + * + * @return result of consideration + */ + boolean considerNestedRepositories() default false; + + /** + * Configures the {@link ClientType} to be used as driver client type to connection to Tarantool. + * Must match the referenced type in {@link #keyValueTemplateRef()}} if these values differ from + * the default ones. + * + * @return the type of client through which communication with Tarantool will be made. + */ + ClientType clientType() default ClientType.CRUD; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolQueryLookupStrategy.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolQueryLookupStrategy.java new file mode 100644 index 0000000..5dbdb2e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolQueryLookupStrategy.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.config; + +import java.lang.reflect.Method; + +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.query.KeyValuePartTreeQuery; +import org.springframework.data.projection.ProjectionFactory; +import org.springframework.data.repository.core.NamedQueries; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.query.QueryLookupStrategy; +import org.springframework.data.repository.query.QueryMethodEvaluationContextProvider; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; +import org.springframework.util.Assert; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data35.query.TarantoolPartTreeQuery; +import io.tarantool.spring.data35.query.TarantoolQueryMethodImpl; +import io.tarantool.spring.data35.query.TarantoolRepositoryQuery; + +/** + * Ensures {@link TarantoolPartTreeQuery} is used for query preparation rather than {@link + * KeyValuePartTreeQuery} or other alternatives. + * + * @author Artyom Dubinin + */ +public class TarantoolQueryLookupStrategy implements QueryLookupStrategy { + + private final QueryMethodEvaluationContextProvider evaluationContextProvider; + private final KeyValueOperations keyValueOperations; + private final Class> queryCreator; + private final TarantoolCrudClient client; + + /** + * Required constructor, capturing arguments for use in {@link #resolveQuery}. + * + * @param client tarantool crud client + * @param key Not used + * @param evaluationContextProvider For evaluation of query expressions + * @param keyValueOperations Bean to use for Key/Value operations on Tarantool repos + * @param queryCreator Query creator + */ + public TarantoolQueryLookupStrategy( + TarantoolCrudClient client, + Key key, + QueryMethodEvaluationContextProvider evaluationContextProvider, + KeyValueOperations keyValueOperations, + Class> queryCreator) { + + Assert.notNull(evaluationContextProvider, "EvaluationContextProvider must not be null!"); + Assert.notNull(keyValueOperations, "KeyValueOperations must not be null!"); + Assert.notNull(queryCreator, "Query creator type must not be null!"); + + this.client = client; + this.evaluationContextProvider = evaluationContextProvider; + this.keyValueOperations = keyValueOperations; + this.queryCreator = queryCreator; + } + + /** + * Use {@link TarantoolPartTreeQuery} for resolving queries against Tarantool repositories. + * + * @param method, the query method + * @param metadata, not used + * @param projectionFactory, not used + * @param namedQueries, not used + * @return A mechanism for querying Tarantool repositories + */ + public RepositoryQuery resolveQuery( + Method method, + RepositoryMetadata metadata, + ProjectionFactory projectionFactory, + NamedQueries namedQueries) { + + TarantoolQueryMethodImpl queryMethod = + new TarantoolQueryMethodImpl(method, metadata, projectionFactory); + + if (queryMethod.hasAnnotatedQuery()) { + return new TarantoolRepositoryQuery(client, queryMethod); + } + + return new TarantoolPartTreeQuery( + queryMethod, evaluationContextProvider, this.keyValueOperations, this.queryCreator); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoriesRegistrar.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoriesRegistrar.java new file mode 100644 index 0000000..ee6ed83 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoriesRegistrar.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.config; + +import java.lang.annotation.Annotation; + +import org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport; +import org.springframework.data.repository.config.RepositoryConfigurationExtension; + +class TarantoolRepositoriesRegistrar extends RepositoryBeanDefinitionRegistrarSupport { + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoriesRegistrar#getAnnotation() + */ + @Override + protected Class getAnnotation() { + return EnableTarantoolRepositories.class; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.repository.config.RepositoryBeanDefinitionRegistrarSupport#getExtension() + */ + @Override + protected RepositoryConfigurationExtension getExtension() { + return new TarantoolRepositoryConfigurationExtension(); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoryConfigurationExtension.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoryConfigurationExtension.java new file mode 100644 index 0000000..7c7687e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/TarantoolRepositoryConfigurationExtension.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.config; + +import org.springframework.beans.factory.config.ConstructorArgumentValues; +import org.springframework.beans.factory.config.RuntimeBeanReference; +import org.springframework.beans.factory.support.AbstractBeanDefinition; +import org.springframework.beans.factory.support.BeanDefinitionRegistry; +import org.springframework.beans.factory.support.RootBeanDefinition; +import org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension; +import org.springframework.data.repository.config.RepositoryConfigurationSource; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF; +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; +import io.tarantool.client.ClientType; +import io.tarantool.spring.data35.config.TarantoolCrudConfiguration; +import io.tarantool.spring.data35.core.TarantoolTemplate; +import io.tarantool.spring.data35.core.mapping.TarantoolMappingContext; + +public class TarantoolRepositoryConfigurationExtension + extends KeyValueRepositoryConfigurationExtension { + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension + * #getModuleName() + */ + @Override + public String getModuleName() { + return "Tarantool"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension + * #getModulePrefix() + */ + @Override + public String getModulePrefix() { + return "tarantool"; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension + * #getDefaultKeyValueTemplateRef() + */ + @Override + public String getDefaultKeyValueTemplateRef() { + return DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; + } + + /* + * (non-Javadoc) + * @see org.springframework.data.keyvalue.repository.config.KeyValueRepositoryConfigurationExtension + * #registerBeansForRoot() + */ + @Override + public void registerBeansForRoot( + BeanDefinitionRegistry registry, RepositoryConfigurationSource configurationSource) { + // register a default set of beans based on the passed annotation attributes. + final ClientType clientTypeAttribute = + configurationSource.getRequiredAttribute("clientType", ClientType.class); + + if (clientTypeAttribute == ClientType.CRUD) { + final String defaultCrudConfigurationBeanName = + TarantoolCrudConfiguration.class.getCanonicalName(); + registerIfNotAlreadyRegistered( + () -> new RootBeanDefinition(TarantoolCrudConfiguration.class), + registry, + defaultCrudConfigurationBeanName, + configurationSource); + } else { + throw new IllegalArgumentException("The Box client is not yet supported."); + } + super.registerBeansForRoot(registry, configurationSource); + + // remove KeyValueMappingContext to add TarantoolMappingContext + registry.removeBeanDefinition(getMappingContextBeanRef()); + registerIfNotAlreadyRegistered( + () -> { + RootBeanDefinition definition = new RootBeanDefinition(TarantoolMappingContext.class); + definition.setSource(configurationSource.getSource()); + + return definition; + }, + registry, + getMappingContextBeanRef(), + configurationSource); + } + + @Override + protected AbstractBeanDefinition getDefaultKeyValueTemplateBeanDefinition( + RepositoryConfigurationSource configurationSource) { + final RootBeanDefinition keyValueTemplateBeanDefinition = + new RootBeanDefinition(TarantoolTemplate.class); + + final ConstructorArgumentValues constructorArgumentValues = new ConstructorArgumentValues(); + constructorArgumentValues.addIndexedArgumentValue( + 0, new RuntimeBeanReference(DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF)); + constructorArgumentValues.addIndexedArgumentValue( + 1, new RuntimeBeanReference(getMappingContextBeanRef())); + + keyValueTemplateBeanDefinition.setConstructorArgumentValues(constructorArgumentValues); + keyValueTemplateBeanDefinition.setSource(configurationSource); + keyValueTemplateBeanDefinition.setDependsOn(DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF); + keyValueTemplateBeanDefinition.setDependsOn(getMappingContextBeanRef()); + + return keyValueTemplateBeanDefinition; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/package-info.java new file mode 100644 index 0000000..e980ea2 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/config/package-info.java @@ -0,0 +1,3 @@ +/** Repository config package. */ +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.repository.config; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/package-info.java new file mode 100644 index 0000000..4f7d348 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/package-info.java @@ -0,0 +1,3 @@ +/** Repository package. */ +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.repository; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolRepositoryFactoryBean.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolRepositoryFactoryBean.java new file mode 100644 index 0000000..390a916 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolRepositoryFactoryBean.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.support; + +import java.io.Serializable; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactory; +import org.springframework.data.keyvalue.repository.support.KeyValueRepositoryFactoryBean; +import org.springframework.data.repository.Repository; +import org.springframework.data.repository.query.RepositoryQuery; +import org.springframework.data.repository.query.parser.AbstractQueryCreator; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data35.repository.TarantoolRepositoryFactory; + +public class TarantoolRepositoryFactoryBean, S, ID extends Serializable> + extends KeyValueRepositoryFactoryBean { + + @Autowired private TarantoolCrudClient client; + + public TarantoolRepositoryFactoryBean(Class repositoryInterface) { + super(repositoryInterface); + } + + /** + * Return a {@link TarantoolRepositoryFactory}. + * + *

{@code super} would return {@link KeyValueRepositoryFactory} which in turn builds {@code + * KeyValueRepository} instances, and these have a private method that implement querying in a + * manner that does not fit with Tarantool. More details are in {@link + * TarantoolRepositoryFactory}. + * + * @param operations operations + * @param queryCreator creator + * @param repositoryQueryType, not used + * @return A {@link TarantoolRepositoryFactory} that creates tarantool repository instances. + */ + @Override + protected KeyValueRepositoryFactory createRepositoryFactory( + KeyValueOperations operations, + Class> queryCreator, + Class repositoryQueryType) { + return new TarantoolRepositoryFactory(client, operations, queryCreator); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolSimpleRepository.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolSimpleRepository.java new file mode 100644 index 0000000..309d783 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/TarantoolSimpleRepository.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.repository.support; + +import java.util.List; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.keyvalue.core.KeyValueOperations; +import org.springframework.data.keyvalue.core.query.KeyValueQuery; +import org.springframework.data.keyvalue.repository.support.SimpleKeyValueRepository; +import org.springframework.data.repository.core.EntityInformation; +import org.springframework.util.Assert; + +import static io.tarantool.spring.data35.query.PaginationUtils.doPageQuery; +import io.tarantool.spring.data.query.TarantoolCriteria; + +public class TarantoolSimpleRepository extends SimpleKeyValueRepository { + + private static final String FIND_ALL_EXC_MSG = + "This method is not supported to avoid sampling huge data massive. " + + "Please use derived methods with scrolling or pagination to select all data."; + + private static final String FIND_ALL_SORT_EXC_MSG = + "This method is not supported because sorting is not supported " + + "in Tarantool. Organize sorting of results using Java tools."; + + private static final String PAGEABLE_NULL_EXC_MSG = "Pageable must be not null"; + + private final KeyValueOperations operations; + private final EntityInformation entityInformation; + + /** + * Creates a new {@link SimpleKeyValueRepository} for the given {@link EntityInformation} and + * {@link KeyValueOperations}. + * + * @param metadata must not be {@literal null}. + * @param operations must not be {@literal null}. + */ + public TarantoolSimpleRepository( + EntityInformation metadata, KeyValueOperations operations) { + super(metadata, operations); + this.entityInformation = metadata; + this.operations = operations; + } + + @Override + public List findAll() { + throw new UnsupportedOperationException(FIND_ALL_EXC_MSG); + } + + @Override + public List findAll(Sort sort) { + throw new UnsupportedOperationException(FIND_ALL_SORT_EXC_MSG); + } + + @Override + @SuppressWarnings("unchecked") + public Page findAll(Pageable pageable) { + Assert.notNull(pageable, PAGEABLE_NULL_EXC_MSG); + + KeyValueQuery query = new KeyValueQuery<>(new TarantoolCriteria()); + return (Page) + doPageQuery(pageable, query, this.operations, this.entityInformation.getJavaType()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/package-info.java b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/package-info.java new file mode 100644 index 0000000..d878a12 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/main/java/io/tarantool/spring/data35/repository/support/package-info.java @@ -0,0 +1,3 @@ +/** Repository support package. */ +@org.springframework.lang.NonNullApi +package io.tarantool.spring.data35.repository.support; diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/GenericTarantoolConfigurationTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/GenericTarantoolConfigurationTest.java new file mode 100644 index 0000000..71ff771 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/GenericTarantoolConfigurationTest.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import java.io.IOException; +import java.nio.file.Files; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationContextAware; +import org.springframework.test.context.TestPropertySource; + +import io.tarantool.spring.data.utils.Constants; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.utils.TarantoolTestSupport; + +@TestPropertySource(properties = {Constants.DEFAULT_PROPERTY_FILE_LOCATION_CLASSPATH}) +public abstract class GenericTarantoolConfigurationTest implements ApplicationContextAware { + + protected static TarantoolProperties testProperties; + + protected ApplicationContext applicationContext; + + @Autowired protected TarantoolProperties properties; + + @Override + public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { + this.applicationContext = applicationContext; + } + + @BeforeAll + static void beforeAll() throws IOException { + testProperties = + TarantoolTestSupport.writeTestPropertiesYaml(Constants.DEFAULT_PROPERTY_FILE_NAME); + } + + @AfterAll + static void afterAll() throws IOException { + Files.deleteIfExists( + TarantoolTestSupport.DEFAULT_TEST_PROPERTY_DIR.resolve( + Constants.DEFAULT_PROPERTY_FILE_NAME)); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolBoxConfigurationTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolBoxConfigurationTest.java new file mode 100644 index 0000000..542a6ac --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolBoxConfigurationTest.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import java.io.IOException; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.RepeatedTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import io.tarantool.client.box.TarantoolBoxClient; +import io.tarantool.client.factory.TarantoolBoxClientBuilder; +import io.tarantool.pool.HeartbeatOpts; +import io.tarantool.pool.InstanceConnectionGroup; +import io.tarantool.spring.data.utils.Constants; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.utils.TarantoolTestSupport; + +@SpringBootTest(classes = {TarantoolBoxConfigurationTest.Config.class}) +public class TarantoolBoxConfigurationTest extends GenericTarantoolConfigurationTest { + + @MockitoBean TarantoolBoxClient client; + + @Autowired private TarantoolBoxConfiguration tarantoolBoxConfiguration; + + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @RepeatedTest(10) + void testGetBoxClientBuilder() throws IOException { + assertEquals(properties, tarantoolBoxConfiguration.getProperties()); + + final TarantoolBoxClientBuilder boxClientBuilder = tarantoolBoxConfiguration.getClientBuilder(); + + if (properties.getConnectionGroups() == null) { + assertNull(boxClientBuilder.getGroups()); + } else { + Assertions.assertEquals( + properties.getConnectionGroups().size(), boxClientBuilder.getGroups().size()); + List connectionGroupsFromBuilder = boxClientBuilder.getGroups(); + List connectionGroupsFromProperty = + properties.getInstanceConnectionGroups(); + + assertNotNull(connectionGroupsFromBuilder); + assertNotNull(connectionGroupsFromProperty); + for (int i = 0; i < connectionGroupsFromBuilder.size(); i++) { + InstanceConnectionGroup groupFromBuilder = connectionGroupsFromBuilder.get(i); + InstanceConnectionGroup groupFromProperty = connectionGroupsFromProperty.get(i); + + assertEquals(groupFromProperty.getAuthType(), groupFromBuilder.getAuthType()); + assertEquals(groupFromProperty.getTag(), groupFromBuilder.getTag()); + assertEquals(groupFromProperty.getSize(), groupFromBuilder.getSize()); + assertEquals(groupFromProperty.getUser(), groupFromBuilder.getUser()); + assertEquals(groupFromProperty.getPassword(), groupFromBuilder.getPassword()); + assertEquals(groupFromProperty.getPort(), groupFromBuilder.getPort()); + assertEquals(groupFromProperty.getHost(), groupFromBuilder.getHost()); + } + } + + final HeartbeatOpts propertiesHeartbeatOpts = properties.getHeartbeatOpts(); + if (propertiesHeartbeatOpts == null) { + assertNull(boxClientBuilder.getHeartbeatOpts()); + } else { + HeartbeatOpts builderHeartbeatOpts = boxClientBuilder.getHeartbeatOpts(); + assertEquals( + propertiesHeartbeatOpts.getPingInterval(), builderHeartbeatOpts.getPingInterval()); + assertEquals( + propertiesHeartbeatOpts.getDeathThreshold(), builderHeartbeatOpts.getDeathThreshold()); + assertEquals( + propertiesHeartbeatOpts.getInvalidationThreshold(), + builderHeartbeatOpts.getInvalidationThreshold()); + assertEquals(propertiesHeartbeatOpts.getWindowSize(), builderHeartbeatOpts.getWindowSize()); + } + + Assertions.assertEquals(properties.getUserName(), boxClientBuilder.getUser()); + Assertions.assertEquals(properties.getPort(), boxClientBuilder.getPort()); + Assertions.assertEquals(properties.getEventLoopThreadsCount(), boxClientBuilder.getnThreads()); + Assertions.assertEquals(properties.getBalancerClass(), boxClientBuilder.getBalancerClass()); + Assertions.assertEquals( + properties.isGracefulShutdownEnabled(), boxClientBuilder.isGracefulShutdown()); + Assertions.assertEquals(properties.getConnectTimeout(), boxClientBuilder.getConnectTimeout()); + Assertions.assertEquals(properties.getReconnectAfter(), boxClientBuilder.getReconnectAfter()); + Assertions.assertEquals(properties.getHost(), boxClientBuilder.getHost()); + Assertions.assertEquals(properties.getPassword(), boxClientBuilder.getPassword()); + Assertions.assertEquals(properties.isFetchSchema(), boxClientBuilder.isFetchSchema()); + Assertions.assertEquals( + properties.isIgnoreOldSchemaVersion(), boxClientBuilder.isIgnoreOldSchemaVersion()); + + testProperties = + TarantoolTestSupport.writeTestPropertiesYaml(Constants.DEFAULT_PROPERTY_FILE_NAME); + } + + @Configuration + @EnableConfigurationProperties(TarantoolProperties.class) + @Import(TarantoolBoxConfiguration.class) + static class Config {} +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolCrudConfigurationTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolCrudConfigurationTest.java new file mode 100644 index 0000000..f3f0c13 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolCrudConfigurationTest.java @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import java.io.IOException; +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; +import static io.tarantool.spring.data.utils.Constants.DEFAULT_PROPERTY_FILE_NAME; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.writeTestPropertiesYaml; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.client.factory.TarantoolCrudClientBuilder; +import io.tarantool.pool.HeartbeatOpts; +import io.tarantool.pool.InstanceConnectionGroup; +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties.PropertyHeartbeatOpts; +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties.PropertyInstanceConnectionGroup; +import io.tarantool.spring.data35.repository.config.EnableTarantoolRepositories; + +@SpringBootTest(classes = {TarantoolCrudConfigurationTest.Config.class}) +public class TarantoolCrudConfigurationTest extends GenericTarantoolConfigurationTest { + + @MockitoBean TarantoolCrudClient client; + + @Autowired private TarantoolCrudConfiguration tarantoolCrudConfiguration; + + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @RepeatedTest(10) + void testGetCrudClientBuilder() throws IOException { + assertEquals(properties, tarantoolCrudConfiguration.getProperties()); + + final TarantoolCrudClientBuilder crudClientBuilder = + tarantoolCrudConfiguration.getClientBuilder(); + + if (properties.getConnectionGroups() == null) { + assertNull(crudClientBuilder.getGroups()); + } else { + assertEquals(properties.getConnectionGroups().size(), crudClientBuilder.getGroups().size()); + List connectionGroupsFromBuilder = crudClientBuilder.getGroups(); + List connectionGroupsFromProperty = + properties.getConnectionGroups(); + assertNotNull(connectionGroupsFromBuilder); + assertNotNull(connectionGroupsFromProperty); + for (int i = 0; i < connectionGroupsFromBuilder.size(); i++) { + InstanceConnectionGroup groupFromBuilder = connectionGroupsFromBuilder.get(i); + PropertyInstanceConnectionGroup groupFromProperty = connectionGroupsFromProperty.get(i); + + assertEquals(groupFromProperty.getAuthType(), groupFromBuilder.getAuthType()); + assertEquals(groupFromProperty.getTag(), groupFromBuilder.getTag()); + assertEquals(groupFromProperty.getConnectionGroupSize(), groupFromBuilder.getSize()); + assertEquals(groupFromProperty.getUserName(), groupFromBuilder.getUser()); + assertEquals(groupFromProperty.getPassword(), groupFromBuilder.getPassword()); + assertEquals(groupFromProperty.getPort(), groupFromBuilder.getPort()); + assertEquals(groupFromProperty.getHost(), groupFromBuilder.getHost()); + } + } + + final PropertyHeartbeatOpts propertiesHeartbeatOpts = properties.getHeartbeat(); + if (propertiesHeartbeatOpts == null) { + assertNull(crudClientBuilder.getHeartbeatOpts()); + } else { + HeartbeatOpts builderHeartbeatOpts = crudClientBuilder.getHeartbeatOpts(); + + assertEquals( + propertiesHeartbeatOpts.getPingInterval(), builderHeartbeatOpts.getPingInterval()); + assertEquals( + propertiesHeartbeatOpts.getDeathThreshold(), builderHeartbeatOpts.getDeathThreshold()); + assertEquals( + propertiesHeartbeatOpts.getInvalidationThreshold(), + builderHeartbeatOpts.getInvalidationThreshold()); + assertEquals(propertiesHeartbeatOpts.getWindowSize(), builderHeartbeatOpts.getWindowSize()); + } + + assertEquals(properties.getUserName(), crudClientBuilder.getUser()); + assertEquals(properties.getPort(), crudClientBuilder.getPort()); + assertEquals(properties.getEventLoopThreadsCount(), crudClientBuilder.getnThreads()); + assertEquals(properties.getBalancerClass(), crudClientBuilder.getBalancerClass()); + assertEquals(properties.isGracefulShutdownEnabled(), crudClientBuilder.isGracefulShutdown()); + assertEquals(properties.getConnectTimeout(), crudClientBuilder.getConnectTimeout()); + assertEquals(properties.getReconnectAfter(), crudClientBuilder.getReconnectAfter()); + assertEquals(properties.getHost(), crudClientBuilder.getHost()); + assertEquals(properties.getPassword(), crudClientBuilder.getPassword()); + + testProperties = writeTestPropertiesYaml(DEFAULT_PROPERTY_FILE_NAME); + } + + @Test + void testKeyValueTemplate() { + final List beanDefinitions = Arrays.asList(applicationContext.getBeanDefinitionNames()); + assertTrue(beanDefinitions.contains(DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF)); + + final KeyValueTemplate keyValueTemplate = + applicationContext.getBean( + DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF, KeyValueTemplate.class); + assertNotNull(keyValueTemplate); + } + + @EnableTarantoolRepositories + static class Config {} +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolEmptyPropertiesTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolEmptyPropertiesTest.java new file mode 100644 index 0000000..265f8bb --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolEmptyPropertiesTest.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.TestPropertySource; + +import io.tarantool.spring.data.utils.Constants; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.repository.config.EnableTarantoolRepositories; +import io.tarantool.spring.data35.utils.TarantoolTestSupport; + +/** + * @see TarantoolPropertiesTest + */ +@SpringBootTest(classes = {TarantoolEmptyPropertiesTest.Config.class}) +@TestPropertySource(properties = {Constants.DEFAULT_PROPERTY_FILE_LOCATION_CLASSPATH}) +@Timeout(5) +public class TarantoolEmptyPropertiesTest { + + @Autowired private TarantoolProperties springParsedProperties; + + @BeforeAll + static void beforeAll() throws IOException { + TarantoolTestSupport.writeTestEmptyPropertiesYaml(Constants.DEFAULT_PROPERTY_FILE_NAME); + } + + /* + * The test checks that if property processing is enabled via the @EnableConfigurationProperties annotation, then + * TarantoolProperty always exists and has default values. + */ + @Test + void testParseEmptyProperties() { + assertNotNull(springParsedProperties); + } + + @AfterAll + static void afterAll() throws IOException { + Files.deleteIfExists( + TarantoolTestSupport.DEFAULT_TEST_PROPERTY_DIR.resolve( + Constants.DEFAULT_PROPERTY_FILE_NAME)); + } + + @EnableTarantoolRepositories + static class Config {} +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolPropertiesTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolPropertiesTest.java new file mode 100644 index 0000000..4157888 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/config/TarantoolPropertiesTest.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.config; + +import java.io.IOException; +import java.nio.file.Files; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.RepeatedTest; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.spring.data.utils.Constants; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.repository.config.EnableTarantoolRepositories; +import io.tarantool.spring.data35.utils.TarantoolTestSupport; + +/** + * The test checks whether the Spring framework parses a file with the specified properties + * correctly. Before creating the test class, a random file with properties is written to the + * classpath:properties-test.yaml file. Next, when creating a test class the Spring context is + * raised and parses the file with properties. A test is launched to check the uniqueness of the + * current properties and properties that were specified in the previous iteration. It is checked + * that the generated Spring class TarantoolProperty is equal to the class written to the file. + * Next, the next entry to the file occurs, after which there is a forced reload of the Spring + * context -> the file with properties is parsed again and everything happens according to circle. + */ +@SpringBootTest(classes = {TarantoolPropertiesTest.Config.class}) +@TestPropertySource(properties = {Constants.DEFAULT_PROPERTY_FILE_LOCATION_CLASSPATH}) +@Timeout(5) +public class TarantoolPropertiesTest { + + private static TarantoolProperties writtenToFileProperties; + private static TarantoolProperties prevSpringParsedProperties; + private static TarantoolProperties prevWrittenToFileProperties; + @Autowired private TarantoolProperties springParsedProperties; + + @MockitoBean private TarantoolCrudClient client; + + @BeforeAll + static void beforeAll() throws IOException { + writtenToFileProperties = + TarantoolTestSupport.writeTestPropertiesYaml(Constants.DEFAULT_PROPERTY_FILE_NAME); + } + + @AfterAll + static void afterAll() throws IOException { + Files.deleteIfExists( + TarantoolTestSupport.DEFAULT_TEST_PROPERTY_DIR.resolve( + Constants.DEFAULT_PROPERTY_FILE_NAME)); + } + + /** + * The annotation allows you to tell Spring that the context is dirty and needs to be reloaded + * after the test. + */ + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @RepeatedTest(10) + void testParseProperty() throws IOException { + assertNotEquals(prevWrittenToFileProperties, writtenToFileProperties); + assertNotEquals(prevSpringParsedProperties, springParsedProperties); + assertEquals(writtenToFileProperties, springParsedProperties); + + prevWrittenToFileProperties = writtenToFileProperties; + prevSpringParsedProperties = springParsedProperties; + + // Write the Properties class that will be parsed by Spring in the next iteration. + writtenToFileProperties = + TarantoolTestSupport.writeTestPropertiesYaml(Constants.DEFAULT_PROPERTY_FILE_NAME); + } + + @EnableTarantoolRepositories + static class Config {} +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/annotation/IdClassResolverTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/annotation/IdClassResolverTest.java new file mode 100644 index 0000000..bdf52d1 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/annotation/IdClassResolverTest.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.annotation; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.CompositePersonKey; +import io.tarantool.spring.data35.utils.entity.EntityWithAnnotationAsIdClass; +import io.tarantool.spring.data35.utils.entity.Person; + +@Timeout(5) +class IdClassResolverTest { + + public static final DefaultIdClassResolver ID_CLASS_RESOLVER = DefaultIdClassResolver.INSTANCE; + + @Test + void testResolveIdClassTypeWithNullEntity() { + + final IllegalArgumentException throwable = + assertThrows( + IllegalArgumentException.class, () -> ID_CLASS_RESOLVER.resolveIdClassType(null)); + final String EXCEPTION_MESSAGE = "Type for IdClass must be not null!"; + assertEquals(EXCEPTION_MESSAGE, throwable.getMessage()); + } + + @Test + void testResolveIdClassTypeWithEntityWithoutAnnotation() { + assertNull(ID_CLASS_RESOLVER.resolveIdClassType(Person.class)); + } + + @Test + void testResolveIdClassTypeWithEntityWithAnnotation() { + assertEquals( + ID_CLASS_RESOLVER.resolveIdClassType(ComplexPerson.class), CompositePersonKey.class); + } + + @Test + void testResolveIdClassTypeWithCrossAnnotation() { + final IllegalArgumentException throwable = + assertThrows( + IllegalArgumentException.class, + () -> ID_CLASS_RESOLVER.resolveIdClassType(EntityWithAnnotationAsIdClass.class)); + + assertEquals(DefaultIdClassResolver.ANNOTATION_TYPE_EXCEPTION, throwable.getMessage()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePropertyTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePropertyTest.java new file mode 100644 index 0000000..4a83e16 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/KeyValueCompositePropertyTest.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.core.mapping.BasicKeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.ReflectionUtils; + +public class KeyValueCompositePropertyTest

> { + + private @Nullable Identifier

identifier; + + @BeforeEach + public void setUp() { + this.identifier = null; + } + + /** Check methods for adding and retrieving parts of a composite key. */ + @Test + public void testAddAndGetParts() { + Class entityClass = TestEntity.class; + + PersistentEntity owner = + new BasicKeyValuePersistentEntity<>(TypeInformation.of(TestEntity.class), null); + + ReflectionUtils.FieldFilter isIdFieldFiler = field -> field.isAnnotationPresent(Id.class); + ReflectionUtils.doWithFields(entityClass, field -> addPart(field, owner), isIdFieldFiler); + + assertNotNull(identifier); + List identifierFieldsFromProperties = + this.identifier.getParts().stream() + .map(PersistentProperty::getField) + .collect(Collectors.toList()); + + compareFieldCollection(identifierFieldsFromProperties, getExpectedIdFields(entityClass)); + } + + /** Check methods for adding and retrieving parts of a composite key. */ + @Test + public void testAddAndGetFields() { + Class entityClass = TestEntity.class; + + PersistentEntity owner = + new BasicKeyValuePersistentEntity<>(TypeInformation.of(TestEntity.class), null); + + ReflectionUtils.FieldFilter isIdFieldFiler = field -> field.isAnnotationPresent(Id.class); + ReflectionUtils.doWithFields(entityClass, field -> addPart(field, owner), isIdFieldFiler); + + assertNotNull(identifier); + List fieldsFromIdentifier = Arrays.asList(identifier.getFields()); + + compareFieldCollection(fieldsFromIdentifier, getExpectedIdFields(entityClass)); + } + + /** + * Check methods for adding and getting parts of a composite key for an object without + * {@code @Id}. + */ + @Test + public void testAddAndGetPartsWithEntityWithoutId() { + Class entityClass = TestEntityWithoutId.class; + + PersistentEntity owner = + new BasicKeyValuePersistentEntity<>(TypeInformation.of(TestEntity.class), null); + ReflectionUtils.doWithFields( + entityClass, field -> addPart(field, owner), field -> field.isAnnotationPresent(Id.class)); + + assertNull(identifier); + } + + static class TestEntity { + + @Id private long id; + + @Id private int secondId; + } + + static class TestEntityWithoutId { + + private long id; + + @Nullable private String name; + + private int secondId; + } + + /** Create a PersistentProperty and add to first id property. */ + @SuppressWarnings("unchecked") + private void addPart(Field field, PersistentEntity owner) { + Property property = Property.of(owner.getTypeInformation(), field); + Identifier

persistentProperty = + new KeyValueCompositeProperty<>(property, owner, SimpleTypeHolder.DEFAULT); + + if (this.identifier == null) { + this.identifier = persistentProperty; + } + this.identifier.addPart((P) persistentProperty); + } + + /** Compare fields by name and type. */ + private void compareFieldCollection(List first, List second) { + assertEquals(first.size(), second.size()); + + first.sort(Comparator.comparing(Field::getName)); + second.sort(Comparator.comparing(Field::getName)); + + for (int i = 0; i < first.size(); i++) { + assertEquals(first.get(i).getName(), second.get(i).getName()); + assertEquals(first.get(i).getType(), second.get(i).getType()); + } + } + + /** Get the expected fields marked with the {@code @Id} annotation. */ + private List getExpectedIdFields(Class entityClass) { + return Arrays.stream(entityClass.getDeclaredFields()) + .filter(field -> field.isAnnotationPresent(Id.class)) + .collect(Collectors.toList()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContextTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContextTest.java new file mode 100644 index 0000000..e6e408d --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/TarantoolMappingContextTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping; + +import java.lang.reflect.Field; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.springframework.core.annotation.AnnotatedElementUtils; +import org.springframework.core.annotation.AnnotationUtils; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.annotation.KeySpace; +import org.springframework.data.keyvalue.core.mapping.KeySpaceResolver; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentEntity; +import org.springframework.data.keyvalue.core.mapping.KeyValuePersistentProperty; +import org.springframework.data.mapping.PersistentEntity; +import org.springframework.data.mapping.PersistentProperty; +import org.springframework.data.mapping.context.MappingContext; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; + +import io.tarantool.spring.data35.core.mapping.BasicKeyValueCompositePersistentEntity.KeyPartTypeChecker; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.EntityWithWrongCompositeKeyPartsCount; +import io.tarantool.spring.data35.utils.entity.EntityWithWrongFieldTypes; +import io.tarantool.spring.data35.utils.entity.Person; + +class TarantoolMappingContextTest { + + @Test + void testSetFallbackKeySpaceResolver() throws NoSuchFieldException { + final KeySpaceResolver RESOLVER = TestResolver.INSTANCE; + final TarantoolMappingContext mappingContext = new TarantoolMappingContext<>(); + + mappingContext.setKeySpaceResolver(RESOLVER); + + final Field field = TarantoolMappingContext.class.getDeclaredField("keySpaceResolver"); + ReflectionUtils.makeAccessible(field); + + final Object resolverFieldValue = ReflectionUtils.getField(field, mappingContext); + + assertEquals(RESOLVER, resolverFieldValue); + } + + @Test + void testCreatePersistentEntity() { + final TarantoolMappingContext mappingContext = new TarantoolMappingContext<>(); + + final TypeInformation informationForSimpleClass = TypeInformation.of(Person.class); + + final TypeInformation informationForIdentifierClass = + TypeInformation.of(ComplexPerson.class); + + assertInstanceOf( + KeyValueCompositePersistentEntity.class, createEntityForClass(ComplexPerson.class)); + + assertInstanceOf(KeyValuePersistentEntity.class, createEntityForClass(ComplexPerson.class)); + } + + @Test + void testCreatePersistentProperty() { + ReflectionUtils.FieldFilter idFilter = field -> field.isAnnotationPresent(Id.class); + + PersistentProperty simpleIdProperty = createProperty(Person.class, idFilter); + assertInstanceOf(KeyValuePersistentProperty.class, simpleIdProperty); + + PersistentProperty compositeIdProperty = createProperty(ComplexPerson.class, idFilter); + assertInstanceOf(KeyValueCompositeProperty.class, compositeIdProperty); + } + + @Test + void testCreateEntityWithWrongCompositeKeyPartTypes() { + Set> initialSet = + new HashSet>() { + { + add(EntityWithWrongFieldTypes.class); + } + }; + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> initEntities(initialSet)); + + Assertions.assertEquals( + KeyPartTypeChecker.COMPOSITE_KEY_FIELD_DIFFERENT_EXCEPTION, exception.getMessage()); + } + + @Test + void testCreateEntityWithWrongCompositeKeyPartCount() { + Set> initialSet = + new HashSet>() { + { + add(EntityWithWrongCompositeKeyPartsCount.class); + } + }; + IllegalArgumentException exception = + assertThrows(IllegalArgumentException.class, () -> initEntities(initialSet)); + + assertEquals(KeyPartTypeChecker.COMPOSITE_KEY_FIELDS_NUMBER_EXCEPTION, exception.getMessage()); + } + + /** + * Create a mappingContext from the passed domain classes. After initialize - create for them + * PersistentEntities and add PersistentProperties to them. + * + * @param entitySet + * @return + */ + private MappingContext initEntities(Set> entitySet) { + TarantoolMappingContext mappingContext = new TarantoolMappingContext<>(); + mappingContext.setInitialEntitySet(entitySet); + mappingContext.initialize(); + return mappingContext; + } + + private enum TestResolver implements KeySpaceResolver { + INSTANCE; + + @Override + @Nullable + public String resolveKeySpace(Class type) { + + Assert.notNull(type, "Type for keyspace for null!"); + + Class userClass = ClassUtils.getUserClass(type); + Object keySpace = getKeySpace(userClass); + + return keySpace != null ? keySpace.toString() : null; + } + + @Nullable + private static Object getKeySpace(Class type) { + + KeySpace keyspace = AnnotatedElementUtils.findMergedAnnotation(type, KeySpace.class); + + if (keyspace != null) { + return AnnotationUtils.getValue(keyspace); + } + + return null; + } + } + + private PersistentEntity createEntityForClass(Class entityClass) { + final TarantoolMappingContext mappingContext = new TarantoolMappingContext<>(); + + final TypeInformation informationForClass = TypeInformation.of(entityClass); + return mappingContext.createPersistentEntity(informationForClass); + } + + private

> PersistentProperty createProperty( + Class classType, ReflectionUtils.FieldFilter fieldFilter) { + + final TarantoolMappingContext, P> context = + new TarantoolMappingContext<>(); + KeyValuePersistentEntity entity = + context.createPersistentEntity(TypeInformation.of(classType)); + Field field = org.springframework.data.util.ReflectionUtils.findField(classType, fieldFilter); + assertNotNull(field); + + return context.createPersistentProperty( + Property.of(entity.getTypeInformation(), field), entity, SimpleTypeHolder.DEFAULT); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategyTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategyTest.java new file mode 100644 index 0000000..0c270c3 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/core/mapping/model/PersistentCompositeIdIsNewStrategyTest.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.core.mapping.model; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import org.junit.jupiter.api.Test; +import org.springframework.data.mapping.model.Property; +import org.springframework.data.mapping.model.SimpleTypeHolder; +import org.springframework.data.util.TypeInformation; +import org.springframework.util.ReflectionUtils; + +import io.tarantool.spring.data35.core.annotation.DefaultIdClassResolver; +import io.tarantool.spring.data35.core.mapping.BasicKeyValueCompositePersistentEntity; +import io.tarantool.spring.data35.core.mapping.KeyValueCompositeProperty; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; + +public class PersistentCompositeIdIsNewStrategyTest

> { + + /** Check the strategy creation method and check whether the domain entity is new. */ + @Test + public void testForIdOnly() { + + // Currently identical like of 'of' method (version doesn't support) + PersistentCompositeIdIsNewStrategy strategy = + PersistentCompositeIdIsNewStrategy.forIdOnly(createPersistentEntity(ComplexPerson.class)); + + // Now is stub - always is false + ComplexPerson emptyEntity = new ComplexPerson(); + assertFalse(strategy.isNew(emptyEntity)); + } + + /** Check the strategy creation method and check whether the domain entity is new. */ + @Test + public void testIsNew() { + PersistentCompositeIdIsNewStrategy strategy = + PersistentCompositeIdIsNewStrategy.of(createPersistentEntity(ComplexPerson.class)); + + // Now is stub - always is false + ComplexPerson emptyEntity = new ComplexPerson(); + assertFalse(strategy.isNew(emptyEntity)); + } + + private BasicKeyValueCompositePersistentEntity createPersistentEntity(Class entityType) { + Class compositeKeyType = DefaultIdClassResolver.INSTANCE.resolveIdClassType(entityType); + assertNotNull(compositeKeyType); + + BasicKeyValueCompositePersistentEntity persistentEntity = + new BasicKeyValueCompositePersistentEntity<>( + TypeInformation.of(entityType), null, compositeKeyType); + ReflectionUtils.doWithFields(entityType, field -> addProperty(field, persistentEntity)); + return persistentEntity; + } + + /** Add Persistent property into PersistentEntity. */ + @SuppressWarnings("unchecked") + private void addProperty(Field field, BasicKeyValueCompositePersistentEntity owner) { + Property property = Property.of(owner.getTypeInformation(), field); + P persistentProperty = + (P) new KeyValueCompositeProperty<>(property, owner, SimpleTypeHolder.DEFAULT); + owner.addPersistentProperty(persistentProperty); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/BaseIntegrationTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/BaseIntegrationTest.java new file mode 100644 index 0000000..818d742 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/BaseIntegrationTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration; + +import java.io.IOException; +import java.nio.file.Files; +import java.time.Duration; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Timeout; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.TarantoolCartridgeContainer; +import org.testcontainers.containers.TarantoolContainerOperations; +import org.testcontainers.containers.VshardClusterContainer; +import org.testcontainers.containers.output.Slf4jLogConsumer; +import org.testcontainers.junit.jupiter.Testcontainers; + +import static io.tarantool.spring.data.utils.Constants.DEFAULT_PROPERTY_FILE_NAME; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.DEFAULT_TEST_PROPERTY_DIR; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.writeTestPropertiesYaml; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; + +@Testcontainers +@Timeout(60) +public abstract class BaseIntegrationTest { + + protected static TarantoolContainerOperations clusterContainer; + + private static final String dockerRegistry = + System.getenv().getOrDefault("TESTCONTAINERS_HUB_IMAGE_NAME_PREFIX", ""); + + @BeforeAll + protected static void beforeAll() throws IOException { + configureContainer(); + writeConfigurationFile(); + } + + private static void configureContainer() { + if (!isCartridgeAvailable()) { + VshardClusterContainer vshardClusterContainer = + new VshardClusterContainer( + "vshard_cluster/Dockerfile", + dockerRegistry + "vshard-cluster-java", + "vshard_cluster/instances.yaml", + "vshard_cluster/config.yaml", + "tarantool/tarantool"); + + if (!vshardClusterContainer.isRunning()) { + vshardClusterContainer.withPrivilegedMode(true); + vshardClusterContainer.start(); + } + clusterContainer = vshardClusterContainer; + } else { + TarantoolCartridgeContainer cartridgeContainer = + new TarantoolCartridgeContainer( + "Dockerfile", + dockerRegistry + "cartridge", + "cartridge/instances.yml", + "cartridge/replicasets.yml", + org.testcontainers.containers.Arguments.get("tarantool/tarantool")) + .withStartupTimeout(Duration.ofMinutes(5)) + .withLogConsumer( + new Slf4jLogConsumer(LoggerFactory.getLogger(BaseIntegrationTest.class))); + + if (!cartridgeContainer.isRunning()) { + cartridgeContainer.start(); + } + clusterContainer = cartridgeContainer; + } + } + + private static void writeConfigurationFile() throws IOException { + final TarantoolProperties properties = new TarantoolProperties(); + properties.setHost(clusterContainer.getHost()); + properties.setPort(clusterContainer.getPort()); + + writeTestPropertiesYaml(DEFAULT_PROPERTY_FILE_NAME, properties); + } + + @AfterAll + protected static void afterAll() throws IOException { + Files.deleteIfExists(DEFAULT_TEST_PROPERTY_DIR.resolve(DEFAULT_PROPERTY_FILE_NAME)); + } + + protected static String getHost() { + return clusterContainer.getHost(); + } + + protected static int getPort() { + return clusterContainer.getPort(); + } + + protected static boolean isCartridgeAvailable() { + return System.getenv().getOrDefault("TARANTOOL_VERSION", "").matches("2.*"); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/CrudConfigurations.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/CrudConfigurations.java new file mode 100644 index 0000000..3639f66 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/CrudConfigurations.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.test.context.TestPropertySource; + +import static io.tarantool.client.TarantoolClient.DEFAULT_TAG; +import static io.tarantool.client.crud.TarantoolCrudClient.DEFAULT_CRUD_PASSWORD; +import static io.tarantool.client.crud.TarantoolCrudClient.DEFAULT_CRUD_USERNAME; +import static io.tarantool.spring.data.utils.Constants.DEFAULT_PROPERTY_FILE_LOCATION_CLASSPATH; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.COMPLEX_PERSON_SPACE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSON_SPACE; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.client.crud.TarantoolCrudSpace; +import io.tarantool.client.factory.TarantoolCrudClientBuilder; +import io.tarantool.client.factory.TarantoolFactory; +import io.tarantool.pool.InstanceConnectionGroup; +import io.tarantool.spring.data35.integration.BaseIntegrationTest; +import io.tarantool.spring.data35.repository.config.EnableTarantoolRepositories; + +@TestPropertySource(properties = {DEFAULT_PROPERTY_FILE_LOCATION_CLASSPATH}) +abstract class CrudConfigurations extends BaseIntegrationTest { + + @Autowired protected TarantoolCrudClient client; + + private static InstanceConnectionGroup groupFromJavaConfig; + + private static final String PACKAGE_PATH = "io.tarantool.spring.data35.utils.core"; + + @BeforeAll + protected static void beforeAll() throws IOException { + BaseIntegrationTest.beforeAll(); + groupFromJavaConfig = + InstanceConnectionGroup.builder() + .withHost(clusterContainer.getHost()) + .withPort(clusterContainer.getPort()) + .withUser(DEFAULT_CRUD_USERNAME) + .withPassword(DEFAULT_CRUD_PASSWORD) + .withTag(DEFAULT_TAG) + .withSize(4) + .build(); + } + + @BeforeEach + public void truncateSpaces() { + client.space(PERSON_SPACE).truncate().join(); + client.space(COMPLEX_PERSON_SPACE).truncate().join(); + } + + @EnableTarantoolRepositories(basePackages = {PACKAGE_PATH}) + @Configuration + static class JavaConfigConfiguration { + + @Bean + TarantoolCrudClientBuilder crudClientBuilder() { + return TarantoolFactory.crud().withGroups(Collections.singletonList(groupFromJavaConfig)); + } + } + + @EnableTarantoolRepositories(basePackages = {PACKAGE_PATH}) + @Configuration + static class ViaPropertyFileConfiguration {} + + @Test + void testSimpleClient() { + TarantoolCrudSpace space = client.space(PERSON_SPACE); + + assertEquals(0, space.select().join().size()); + + List tuple = Arrays.asList(0, true, "petya"); + List insertTuple = space.insert(tuple).join().get(); + + assertEquals(tuple, insertTuple.subList(0, insertTuple.size() - 1)); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericRepositoryTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericRepositoryTest.java new file mode 100644 index 0000000..4dcc18a --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericRepositoryTest.java @@ -0,0 +1,2095 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.BiConsumer; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.stream.StreamSupport; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.dao.InvalidDataAccessApiUsageException; +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; +import org.springframework.data.repository.query.parser.Part; +import org.springframework.data.util.Streamable; + +import static io.tarantool.spring.data.ProxyTarantoolQueryEngine.unwrapTuples; +import static io.tarantool.spring.data35.query.TarantoolQueryCreator.INVALID_DATA_ACCESS_API_USAGE_EXCEPTION_MESSAGE_TEMPLATE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.COMPLEX_PERSON_SPACE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSONS_COUNT; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSON_SPACE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.UNKNOWN_PERSON; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generateAndInsertComplexPersons; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generateAndInsertPersons; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.client.crud.TarantoolCrudSpace; +import io.tarantool.client.crud.options.SelectOptions; +import io.tarantool.mapping.Tuple; +import io.tarantool.spring.data.query.Conditions; +import io.tarantool.spring.data.utils.GenericPerson; +import io.tarantool.spring.data.utils.GenericRepositoryMethods; +import io.tarantool.spring.data.utils.Pair; +import io.tarantool.spring.data35.query.TarantoolPageRequest; +import io.tarantool.spring.data35.query.TarantoolScrollPosition; +import io.tarantool.spring.data35.query.TarantoolWindowIterator; +import io.tarantool.spring.data35.utils.TarantoolTestSupport; +import io.tarantool.spring.data35.utils.core.ComplexPersonRepository; +import io.tarantool.spring.data35.utils.core.GenericPaginationMethods; +import io.tarantool.spring.data35.utils.core.PersonRepository; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.Person; + +abstract class GenericRepositoryTest extends CrudConfigurations { + + @Autowired protected ApplicationContext context; + + protected static BiFunction> + complexPersonGenerateAndInsertFunction = + (context, personCount) -> + generateAndInsertComplexPersons( + personCount, context.getBean(TarantoolCrudClient.class)); + + protected static BiFunction> + personGenerateAndInsertFunction = + (context, personCount) -> + generateAndInsertPersons(personCount, context.getBean(TarantoolCrudClient.class)); + + protected static Function> complexPersonGenerateFunction = + TarantoolTestSupport::generateComplexPersons; + + protected static Function> personGenerateFunction = + TarantoolTestSupport::generatePersons; + + protected static final int INDEX_ID_VALUE = 50; + + protected static final Pair PERSON_INDEX_KEY = Pair.of("pk", INDEX_ID_VALUE); + + protected static final Pair PERSON_INITIAL_INDEX_KEY = + Pair.of("pk", Collections.emptyList()); + + protected static Stream dataForTestRepositoryWithFindByIs() { + return Stream.of( + Arguments.of(personGenerateAndInsertFunction, PersonRepository.class), + Arguments.of(complexPersonGenerateAndInsertFunction, ComplexPersonRepository.class)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByIs") + , ID> void testRepositoryWithFindByIs( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final PERSON person = persons.get(0); + List foundPersons = repository.findByName(person.getName()); + + foundPersons.sort(Comparator.comparing(PERSON::getId)); + assertEquals(1, foundPersons.size()); + assertEquals(person, foundPersons.get(0)); + } + + protected static Stream dataForTestRepositoryWithFindById() { + return dataForTestRepositoryWithFindByIs(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindById") + , ID> void testRepositoryWithFindById( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final PERSON person = persons.get(0); + Optional foundPerson = repository.findById(person.generateFullKey()); + + assertTrue(foundPerson.isPresent()); + assertEquals(person, foundPerson.get()); + } + + protected static Stream dataForTestRepositoryWithFindAllById() { + return dataForTestRepositoryWithFindByIs(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindAllById") + , ID> void testRepositoryWithFindAllById( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + Iterable foundPersons = + repository.findAllById( + persons.stream().map(PERSON::generateFullKey).collect(Collectors.toList())); + List foundPersonsAsList = + StreamSupport.stream(foundPersons.spliterator(), false) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()); + + assertEquals(persons, foundPersonsAsList); + } + + protected static Stream dataForTestRepositoryWithCount() { + return dataForTestRepositoryWithFindByIs(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithCount") + , ID> void testRepositoryWithCount( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + assertEquals(PERSONS_COUNT, context.getBean(repositoryClass).count()); + } + + protected static Stream dataForTestRepositoryWithCountBy() { + return dataForTestRepositoryWithFindByIs(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithCountBy") + , ID> void testRepositoryWithCountBy( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final PERSON person = persons.get(0); + assertEquals(1, repository.countByName(person.getName())); + } + + protected static Stream dataForTestRepositoryWithDelete() { + return Stream.of( + Arguments.of( + personGenerateFunction, + PersonRepository.class, + PERSON_SPACE, + (BiFunction>) + (space, key) -> { + Tuple tuple = + space.get(Collections.singletonList(key), Person.class).join(); + if (tuple != null) { + return tuple.get(); + } + return null; + }), + Arguments.of( + complexPersonGenerateFunction, + ComplexPersonRepository.class, + COMPLEX_PERSON_SPACE, + (BiFunction>) + (space, key) -> { + Tuple tuple = space.get(key, ComplexPerson.class).join(); + if (tuple != null) { + return tuple.get(); + } + return null; + })); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithDelete") + , ID> void testRepositoryWithDelete( + Function> generateFunction, + Class> repositoryClass, + String spaceName, + BiFunction spaceGetFunction) { + + final List persons = generateFunction.apply(PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + space.insertMany(persons).join(); + + final PERSON deletingPerson = persons.get(0); + + assertEquals(deletingPerson, spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + // by object + repository.delete(deletingPerson); + assertNull(spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + + space.insert(deletingPerson).join(); + assertEquals(deletingPerson, spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + + // by id + repository.deleteById(deletingPerson.generateFullKey()); + assertNull(spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + + space.insert(deletingPerson).join(); + assertEquals(deletingPerson, spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + + // by name + repository.deleteByName(deletingPerson.getName()); + assertNull(spaceGetFunction.apply(space, deletingPerson.generateFullKey())); + } + + protected static Stream dataForTestRepositoryWithExistId() { + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiConsumer) + (space, key) -> space.delete(Collections.singletonList(key)).join(), + PERSON_SPACE), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + (BiConsumer) (space, key) -> space.delete(key).join(), + COMPLEX_PERSON_SPACE)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithExistId") + , ID> void testRepositoryWithExistId( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + BiConsumer deleteConsumer, + String spaceName) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + final PERSON person = persons.get(0); + + assertTrue(repository.existsById(person.generateFullKey())); + deleteConsumer.accept(space, person.generateFullKey()); + assertFalse(repository.existsById(person.generateFullKey())); + } + + protected static Stream dataForTestQueryAnnotationAsCall() { + return Stream.of( + Arguments.of(PersonRepository.class), Arguments.of(ComplexPersonRepository.class)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestQueryAnnotationAsCall") + , ID> void testQueryAnnotationAsCall( + Class> repositoryClass) { + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + assertEquals(Arrays.asList(1, "hi", false), repository.getStatic().get()); + } + + protected static Stream dataForTestQueryAnnotationAsCallWithArgs() { + return dataForTestQueryAnnotationAsCall(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestQueryAnnotationAsCallWithArgs") + , ID> void testQueryAnnotationAsCallWithArgs( + Class> repositoryClass) { + + GenericRepositoryMethods repository = context.getBean(repositoryClass); + assertEquals(Arrays.asList("hello", true, 13), repository.echo("hello", true, 13).get()); + } + + protected static Stream dataForTestQueryAnnotationAsEval() { + return dataForTestQueryAnnotationAsCall(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestQueryAnnotationAsEval") + , ID> void testQueryAnnotationAsEval( + Class> repositoryClass) { + + GenericRepositoryMethods repository = context.getBean(repositoryClass); + assertEquals(Arrays.asList("hello", 123, true), repository.evalGetStatic().get()); + } + + protected static Stream dataForTestQueryAnnotationAsEvalWithArgs() { + return dataForTestQueryAnnotationAsCall(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestQueryAnnotationAsEvalWithArgs") + , ID> void testQueryAnnotationAsEvalWithArgs( + Class> repositoryClass) { + + GenericRepositoryMethods repository = context.getBean(repositoryClass); + assertEquals( + Arrays.asList("hello", true, 13), repository.evalWithArgs("hello", true, 13).get()); + } + + protected static Stream dataForTestRepositoryWithExistName() { + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiConsumer) + (space, key) -> space.delete(Collections.singletonList(key)).join(), + PERSON_SPACE), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + (BiConsumer) (space, key) -> space.delete(key).join(), + COMPLEX_PERSON_SPACE)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithExistName") + , ID> void testRepositoryWithExistName( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + BiConsumer deleteConsumer, + String spaceName) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + final PERSON person = persons.get(0); + + assertTrue(repository.existsByName(person.getName())); + deleteConsumer.accept(space, person.generateFullKey()); + assertFalse(repository.existsByName(person.getName())); + } + + protected static Stream dataForTestRepositoryWithFindByLessThan() { + return Stream.of( + Arguments.of(personGenerateAndInsertFunction, PersonRepository.class), + Arguments.of(complexPersonGenerateAndInsertFunction, ComplexPersonRepository.class)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByLessThan") + , ID> void testRepositoryWithFindByLessThan( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int findLimiter = 10; + final List foundPersons = repository.findAllByIdIsLessThan(findLimiter); + + foundPersons.sort(Comparator.comparing(PERSON::getId)); + + assertEquals(findLimiter, foundPersons.size()); + assertEquals(persons.subList(0, findLimiter), foundPersons); + } + + protected static Stream dataForTestRepositoryWithFindAllByIsMarriedIsTrue() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindAllByIsMarriedIsTrue") + , ID> void testRepositoryWithFindAllByIsMarriedIsTrue( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final List foundPersons = repository.findAllByIsMarriedIsTrue(); + assertEquals(PERSONS_COUNT / 3, foundPersons.size()); + assertTrue(foundPersons.stream().allMatch(GenericPerson::getIsMarried)); + } + + protected static Stream dataForTestRepositoryWithFindAllByIsMarriedIsFalse() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindAllByIsMarriedIsFalse") + , ID> void testRepositoryWithFindAllByIsMarriedIsFalse( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final List foundPersons = repository.findAllByIsMarriedIsFalse(); + assertEquals(PERSONS_COUNT / 3, foundPersons.size()); + assertTrue(foundPersons.stream().noneMatch(GenericPerson::getIsMarried)); + } + + protected static Stream dataForTestRepositoryWithFindAllByIsMarriedIsNull() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindAllByIsMarriedIsNull") + , ID> void testRepositoryWithFindAllByIsMarriedIsNull( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final List foundPersons = repository.findAllByIsMarriedIsNull(); + assertEquals(PERSONS_COUNT / 3 + 1, foundPersons.size()); + assertTrue(foundPersons.stream().allMatch(p -> p.getIsMarried() == null)); + } + + protected static Stream dataForTestRepositoryWithFindAllByNameIsEmpty() { + return Stream.of( + Arguments.of(personGenerateFunction, PersonRepository.class), + Arguments.of(complexPersonGenerateFunction, ComplexPersonRepository.class)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindAllByNameIsEmpty") + , ID> void testRepositoryWithFindAllByNameIsEmpty( + Function> generateFunction, + Class> repositoryClass) { + + List persons = generateFunction.apply(PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + PERSON person = persons.get(0); + person.setName(""); + persons.set(0, person); + + repository.saveAll(persons); + + final List foundPersons = repository.findAllByNameIsEmpty(); + assertEquals(1, foundPersons.size()); + assertTrue(foundPersons.stream().allMatch(p -> p.getName().isEmpty())); + } + + protected static Stream dataForTestRepositoryWithFindByLessThanEqual() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByLessThanEqual") + , ID> void testRepositoryWithFindByLessThanEqual( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int findLimiter = 10; + final List foundPersonsWithEqual = repository.findAllByIdLessThanEqual(findLimiter); + + foundPersonsWithEqual.sort(Comparator.comparing(PERSON::getId)); + + assertEquals(findLimiter + 1, foundPersonsWithEqual.size()); + assertEquals(persons.subList(0, findLimiter + 1), foundPersonsWithEqual); + } + + protected static Stream dataForTestRepositoryWithFindByGreaterThan() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByGreaterThan") + , ID> void testRepositoryWithFindByGreaterThan( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int findLimiter = 90; + final List foundPersons = repository.findAllByIdIsGreaterThan(findLimiter); + + foundPersons.sort(Comparator.comparing(PERSON::getId)); + + assertEquals(PERSONS_COUNT - findLimiter - 1, foundPersons.size()); + assertEquals(persons.subList(findLimiter + 1, PERSONS_COUNT), foundPersons); + } + + protected static Stream dataForTestRepositoryWithFindByGreaterThanEqual() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByGreaterThanEqual") + , ID> void testRepositoryWithFindByGreaterThanEqual( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int findLimiter = 90; + final List foundPersonsWithEqual = repository.findAllByIdGreaterThanEqual(findLimiter); + + foundPersonsWithEqual.sort(Comparator.comparing(PERSON::getId)); + + assertEquals(PERSONS_COUNT - findLimiter, foundPersonsWithEqual.size()); + assertEquals(persons.subList(findLimiter, PERSONS_COUNT), foundPersonsWithEqual); + } + + protected static Stream dataForTestRepositoryWithFindByBetween() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByBetween") + , ID> void testRepositoryWithFindByBetween( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int startFindLimiter = 50; + final int endFindLimiter = 90; + final List foundPersons = + repository.findAllByIdBetween(startFindLimiter, endFindLimiter); + + foundPersons.sort(Comparator.comparing(PERSON::getId)); + + final int foundPersonsCount = endFindLimiter - startFindLimiter - 1; + assertEquals(foundPersonsCount, foundPersons.size()); + assertEquals(persons.subList(startFindLimiter + 1, endFindLimiter), foundPersons); + } + + protected static Stream dataForTestRepositoryWithFindByBetweenNegativeInts() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithFindByBetweenNegativeInts") + , ID> void testRepositoryWithFindByBetweenNegativeInts( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int startFindLimiter = -1; + final int endFindLimiter = -10; + final List foundPersons = + repository.findAllByIdBetween(startFindLimiter, endFindLimiter); + assertTrue(foundPersons.isEmpty()); + } + + protected static Stream dataForTestRepositoryIgnoreCase() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryIgnoreCase") + , ID> void testRepositoryIgnoreCase( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + final String name = persons.get(0).getName(); + + final InvalidDataAccessApiUsageException exception = + assertThrows( + InvalidDataAccessApiUsageException.class, () -> repository.findByNameIgnoreCase(name)); + final String EXCEPTION_MESSAGE = + String.format( + INVALID_DATA_ACCESS_API_USAGE_EXCEPTION_MESSAGE_TEMPLATE + + "IgnoreCase isn't supported yet", + Part.Type.SIMPLE_PROPERTY); + assertEquals(EXCEPTION_MESSAGE, exception.getMessage()); + } + + protected static Stream dataForTestRepositoryWithTarantoolNamingNotation() { + return dataForTestRepositoryWithFindByLessThan(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryWithTarantoolNamingNotation") + , ID> void testRepositoryWithTarantoolNamingNotation( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final List marriedPersons = repository.findByIsMarried(true); + final List nonMarriedPersons = repository.findByIsMarried(false); + final int expectedCount = PERSONS_COUNT / 3; + assertEquals(expectedCount, marriedPersons.size()); + assertEquals(expectedCount, nonMarriedPersons.size()); + } + + protected static Stream dataForTestRepositoryNotEnoughArguments() { + return dataForTestQueryAnnotationAsCall(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryNotEnoughArguments") + , ID> void testRepositoryNotEnoughArguments( + Class> repositoryClass) { + + GenericRepositoryMethods repository = context.getBean(repositoryClass); + assertThrows(InvalidDataAccessApiUsageException.class, repository::findById); + } + + protected static Stream dataForTestRepositorySave() { + return dataForTestRepositoryWithDelete(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositorySave") + , ID> void testRepositorySave( + Function> generateFunction, + Class> repositoryClass, + String spaceName, + BiFunction getFunction) { + + List persons = generateFunction.apply(PERSONS_COUNT); + GenericRepositoryMethods repository = context.getBean(repositoryClass); + TarantoolCrudSpace space = client.space(spaceName); + final PERSON person = persons.get(0); + + repository.save(person); + + PERSON gotPerson = getFunction.apply(space, person.generateFullKey()); + assertEquals(person, gotPerson); + } + + protected static Stream dataForTestRepositorySaveAll() { + Conditions conditions = new Conditions(); + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + PERSON_SPACE, + (Function>) + (space) -> unwrapTuples(space.select(conditions, Person.class).join())), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + COMPLEX_PERSON_SPACE, + (Function>) + (space) -> unwrapTuples(space.select(conditions, ComplexPerson.class).join()))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositorySaveAll") + , ID> void testRepositorySaveAll( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + String spaceName, + Function> selectFunction) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + repository.saveAll(persons); + + List selectedPersons = selectFunction.apply(space); + selectedPersons.sort(Comparator.comparing(GenericPerson::getId)); + + assertEquals(persons, selectedPersons); + } + + protected static Stream dataForTestDeleteAll() { + return Stream.of( + Arguments.of(personGenerateAndInsertFunction, PersonRepository.class, PERSON_SPACE), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + COMPLEX_PERSON_SPACE)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestDeleteAll") + , ID> void testDeleteAll( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + String spaceName) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + repository.saveAll(persons); + assertEquals(PERSONS_COUNT, space.count(new Conditions()).join()); + assertDoesNotThrow(() -> repository.deleteAll()); + assertEquals(0, space.count(new Conditions()).join()); + + repository.saveAll(persons); + assertEquals(PERSONS_COUNT, space.count(new Conditions()).join()); + assertDoesNotThrow(() -> repository.deleteAll(persons)); + assertEquals(0, space.count(new Conditions()).join()); + + repository.saveAll(persons); + assertEquals(PERSONS_COUNT, space.count(new Conditions()).join()); + assertDoesNotThrow( + () -> + repository.deleteAllById( + persons.stream().map(PERSON::generateFullKey).collect(Collectors.toList()))); + assertEquals(0, space.count(new Conditions()).join()); + } + + protected static Stream dataForTestRepositoryFindByAfter() { + return Stream.of( + Arguments.of(personGenerateAndInsertFunction, PersonRepository.class), + Arguments.of(complexPersonGenerateAndInsertFunction, ComplexPersonRepository.class)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryFindByAfter") + , ID> void testRepositoryFindByAfter( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final PERSON person = persons.get(ThreadLocalRandom.current().nextInt(0, persons.size())); + final List selectedPersons = repository.findByNameAfter(person.getName()); + selectedPersons.sort(Comparator.comparing(PERSON::getName)); + + final List expectedList = + persons.stream() + .filter(p -> p.getName().compareTo(person.getName()) > 0) + .limit(SelectOptions.DEFAULT_LIMIT) + .sorted(Comparator.comparing(PERSON::getName)) + .collect(Collectors.toList()); + + assertEquals(expectedList.size(), selectedPersons.size()); + assertEquals(expectedList, selectedPersons); + } + + protected static Stream dataForTestRepositoryFindByAfterWithLimit() { + return dataForTestRepositoryFindByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryFindByAfterWithLimit") + , ID> void testRepositoryFindByAfterWithLimit( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int LIMIT = 5; + final PERSON person = persons.get(ThreadLocalRandom.current().nextInt(0, persons.size())); + + final List selectedPersons = repository.findFirst5ByIdAfter(person.getId()); + selectedPersons.sort(Comparator.comparing(PERSON::getId)); + + final List selectedPersonsWithTopKeyword = repository.findTop5ByIdAfter(person.getId()); + selectedPersonsWithTopKeyword.sort(Comparator.comparing(PERSON::getId)); + + final List expectedList = + persons.stream() + .filter(p -> p.getId().compareTo(person.getId()) > 0) + .limit(LIMIT) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()); + + assertEquals(expectedList.size(), selectedPersons.size()); + assertEquals(expectedList, selectedPersons); + + assertEquals(expectedList.size(), selectedPersonsWithTopKeyword.size()); + assertEquals(expectedList, selectedPersonsWithTopKeyword); + } + + protected static Stream dataForTestRepositoryFindByBefore() { + return dataForTestRepositoryFindByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryFindByBefore") + , ID> void testRepositoryFindByBefore( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final PERSON person = persons.get(ThreadLocalRandom.current().nextInt(0, persons.size())); + + final List selectedPersons = repository.findByNameBefore(person.getName()); + selectedPersons.sort(Comparator.comparing(PERSON::getName)); + + final List expectedList = + persons.stream() + .filter(p -> p.getName().compareTo(person.getName()) < 0) + .limit(SelectOptions.DEFAULT_LIMIT) + .sorted(Comparator.comparing(PERSON::getName)) + .collect(Collectors.toList()); + + assertEquals(expectedList.size(), selectedPersons.size()); + assertEquals(expectedList, selectedPersons); + } + + protected static Stream dataForTestRepositoryFindByBeforeWithLimit() { + return dataForTestRepositoryFindByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryFindByBeforeWithLimit") + , ID> void testRepositoryFindByBeforeWithLimit( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int LIMIT = 5; + final PERSON person = persons.get(ThreadLocalRandom.current().nextInt(0, persons.size())); + + final List selectedPersons = repository.findFirst5ByIdBefore(person.getId()); + selectedPersons.sort(Comparator.comparing(PERSON::getId)); + + final List selectedPersonsWithTopKeyword = + repository.findTop5ByIdBefore(person.getId()); + selectedPersonsWithTopKeyword.sort(Comparator.comparing(PERSON::getId)); + + // Because id (not fullscan) + persons.sort(Comparator.comparing(PERSON::getId, (o1, o2) -> Integer.compare(o2, o1))); + + final List expectedList = + persons.stream() + .filter(p -> p.getId().compareTo(person.getId()) < 0) + .limit(LIMIT) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()); + + assertEquals(expectedList.size(), selectedPersons.size()); + assertEquals(expectedList, selectedPersons); + + assertEquals(expectedList.size(), selectedPersonsWithTopKeyword.size()); + assertEquals(expectedList, selectedPersonsWithTopKeyword); + } + + protected static Stream dataForTestDeleteByAfter() { + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + PERSON_SPACE, + (BiFunction>) + (space, options) -> + unwrapTuples( + space.select(Collections.emptyList(), options, Person.class).join())), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + COMPLEX_PERSON_SPACE, + (BiFunction>) + (space, options) -> + unwrapTuples( + space + .select(Collections.emptyList(), options, ComplexPerson.class) + .join()))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestDeleteByAfter") + , ID> void testDeleteByAfter( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + String spaceName, + BiFunction> selectFunction) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + persons.sort(Comparator.comparing(PERSON::getName)); + + final int RANDOM_INDEX = ThreadLocalRandom.current().nextInt(0, PERSONS_COUNT); + final SelectOptions options = SelectOptions.builder().withFirst(PERSONS_COUNT).build(); + final PERSON deletingAfterPerson = persons.get(RANDOM_INDEX); + + final List deletedPersons = repository.deleteByNameAfter(deletingAfterPerson.getName()); + + List allPersonsInBaseAfterDeleted = selectFunction.apply(space, options); + assertEquals(PERSONS_COUNT - deletedPersons.size(), allPersonsInBaseAfterDeleted.size()); + + persons.removeAll(deletedPersons); + allPersonsInBaseAfterDeleted.sort(Comparator.comparing(PERSON::getId)); + persons.sort(Comparator.comparing(PERSON::getId)); + assertEquals(persons, allPersonsInBaseAfterDeleted); + } + + protected static Stream dataForTestDeleteByBefore() { + return dataForTestDeleteByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestDeleteByBefore") + , ID> void testDeleteByBefore( + BiFunction> generateAndInsertFunction, + Class> repositoryClass, + String spaceName, + BiFunction> selectFunction) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + final TarantoolCrudSpace space = client.space(spaceName); + + final SelectOptions options = SelectOptions.builder().withFirst(PERSONS_COUNT).build(); + final int RANDOM_INDEX = ThreadLocalRandom.current().nextInt(0, PERSONS_COUNT); + + final PERSON deletingBeforePerson = persons.get(RANDOM_INDEX); + final List deletedPersons = + repository.deleteByNameBefore(deletingBeforePerson.getName()); + + List allPersonsInBaseAfterDeleted = selectFunction.apply(space, options); + + assertEquals(PERSONS_COUNT - deletedPersons.size(), allPersonsInBaseAfterDeleted.size()); + + persons.removeAll(deletedPersons); + allPersonsInBaseAfterDeleted.sort(Comparator.comparing(PERSON::getId)); + persons.sort(Comparator.comparing(PERSON::getId)); + assertEquals(persons, allPersonsInBaseAfterDeleted); + } + + protected static Stream dataForTestCountByAfter() { + return dataForTestRepositoryFindByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestCountByAfter") + , ID> void testCountByAfter( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + final int INDEX = ThreadLocalRandom.current().nextInt(0, PERSONS_COUNT); + final int ID_AFTER = persons.get(INDEX).getId(); + + final long count = repository.countByIdAfter(ID_AFTER); + + final int EXPECTED_COUNT = PERSONS_COUNT - ID_AFTER - 1; + assertEquals(EXPECTED_COUNT, count); + } + + protected static Stream dataForTestCountByBefore() { + return dataForTestRepositoryFindByAfter(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestCountByBefore") + , ID> void testCountByBefore( + BiFunction> generateAndInsertFunction, + Class> repositoryClass) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + final GenericRepositoryMethods repository = context.getBean(repositoryClass); + + persons.sort(Comparator.comparing(PERSON::getName)); + + final int indexBefore = ThreadLocalRandom.current().nextInt(0, PERSONS_COUNT); + final String name = persons.get(indexBefore).getName(); + final long count = repository.countByNameBefore(name); + + assertEquals(indexBefore, count); + + final long unknownNameCount = repository.countByNameBefore(UNKNOWN_PERSON.getName()); + assertEquals(0, unknownNameCount); + } + + protected static < + REPO extends GenericPaginationMethods, PERSON extends GenericPerson, ID> + Stream dataForTestRepositorySlicePagePageableEqual() { + final int PAGE_SIZE = ThreadLocalRandom.current().nextInt(1, PERSONS_COUNT / 3 - 1); + final String NAME = "name"; + final Pageable beginPageable = new TarantoolPageRequest>(PAGE_SIZE); + + BiFunction> generatePersonAndInsertFunction = + (context, size) -> + generateAndInsertPersons( + size, context.getBean(TarantoolCrudClient.class), (person) -> person.setName(NAME)); + + BiFunction> + generateComplexPersonAndInsertFunction = + (context, size) -> + generateAndInsertComplexPersons( + size, + context.getBean(TarantoolCrudClient.class), + (person) -> person.setName(NAME)); + + Function personRepositoryFunc = + (context) -> context.getBean(PersonRepository.class); + + Function complexPersonRepositoryFunc = + (context) -> context.getBean(ComplexPersonRepository.class); + + BiFunction> executionForFindAllByName = + (repo, pageable) -> repo.findAllByName(NAME, pageable); + + Function, List> expectedListForFindAllByNameFunc = + (persons) -> + persons.stream() + .filter(person -> NAME.equals(person.getName())) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()) + .subList(0, PAGE_SIZE); + + BiFunction> executionForFindByName = + (repo, pageable) -> repo.findPersonByName(NAME, pageable); + + List> generateFunctionPairs = + Arrays.asList( + Arrays.asList(generatePersonAndInsertFunction, personRepositoryFunc), + Arrays.asList(generateComplexPersonAndInsertFunction, complexPersonRepositoryFunc)); + + List executionFunctions = Arrays.asList(executionForFindAllByName, executionForFindByName); + + // add arguments set + List arguments = new ArrayList<>(); + for (List generateFunctionPair : generateFunctionPairs) { + for (Object executionFunction : executionFunctions) { + arguments.add( + Arguments.of( + generateFunctionPair.get(0), + executionFunction, + generateFunctionPair.get(1), + expectedListForFindAllByNameFunc, + beginPageable)); + } + } + + return arguments.stream(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositorySlicePagePageableEqual") + , PERSON extends GenericPerson, ID> + void testRepositorySlicePagePageableEqual( + BiFunction> generateAndInsertFunction, + BiFunction> executingRepositoryMethod, + Function giveRepositoryFunction, + Function, List> expectedSlicePageContent, + Pageable beginPageable) { + + final List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + Slice slicePage = + executingRepositoryMethod.apply(giveRepositoryFunction.apply(context), beginPageable); + + assertEquals(beginPageable, slicePage.getPageable()); + assertEquals(expectedSlicePageContent.apply(persons), slicePage.getContent()); + } + + protected static Stream dataForTestRepositorySliceMethodWithoutPageable() { + final String NAME = "name"; + return Stream.of( + Arguments.of(PersonRepository.class, NAME), + Arguments.of(ComplexPersonRepository.class, NAME)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositorySliceMethodWithoutPageable") + , ID> void testRepositorySliceMethodWithoutPageable( + Class> repositoryClass, String name) { + GenericPaginationMethods repository = context.getBean(repositoryClass); + + assertEquals(Pageable.unpaged(), repository.findAllByName(name).getPageable()); + assertEquals(Pageable.unpaged(), repository.findAllByName(name).nextPageable()); + assertEquals(Pageable.unpaged(), repository.findAllByName(name).nextOrLastPageable()); + assertEquals(Pageable.unpaged(), repository.findAllByName(name).previousPageable()); + assertEquals(Pageable.unpaged(), repository.findAllByName(name).previousOrFirstPageable()); + } + + @SuppressWarnings("unchecked") + protected static < + PERSON extends GenericPerson, REPO extends GenericPaginationMethods> + Stream dataForTestRepositorySliceWithDifferentFields() { + final int PAGE_SIZE = 10; + + Function, Pageable> nextPageableFunc = Slice::nextPageable; + Function, Pageable> prevPageableFunc = Slice::previousPageable; + Function, Pageable> nextOrLastPageableFunc = Slice::nextOrLastPageable; + Function, Pageable> prevOrFirstPageableFunc = Slice::previousOrFirstPageable; + BiFunction predicateForNextPageableFunc = + (currentPageable, nextPageable) -> nextPageable.isPaged(); + + BiFunction predicateForNextOrPageableFunc = + (currentPageable, nextPageable) -> !currentPageable.equals(nextPageable); + + Function personRepositoryFunc = + (context) -> context.getBean(PersonRepository.class); + + Function complexPersonRepositoryFunc = + (context) -> context.getBean(ComplexPersonRepository.class); + + Pageable beginPageable = new TarantoolPageRequest<>(PAGE_SIZE); + + BiFunction, Integer, Integer> idExtractionFunction = + (args, index) -> { + List persons = (List) args.get(0); + return persons.get(index).getId(); + }; + + BiFunction, Integer, String> nameExtractionFunction = + (args, index) -> { + List persons = (List) args.get(0); + return persons.get(index).getName(); + }; + + BiFunction, Slice> executionForFindAllByIdLessThanEqualFunc = + (repo, arguments) -> { + int key = idExtractionFunction.apply(arguments, PERSONS_COUNT - 1); + return repo.findAllByIdLessThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindPersonByIdLessThanEqualFunc = + (repo, arguments) -> { + int key = idExtractionFunction.apply(arguments, PERSONS_COUNT - 1); + return repo.findPersonByIdLessThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindAllByIdGreaterThanEqualFunc = + (repo, arguments) -> { + int key = idExtractionFunction.apply(arguments, 0); + return repo.findAllByIdGreaterThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindPersonByIdGreaterThanEqualFunc = + (repo, arguments) -> { + int key = idExtractionFunction.apply(arguments, 0); + return repo.findPersonByIdGreaterThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindAllByIsMarriedLessThanEqual = + (repo, arguments) -> + repo.findAllByIsMarriedLessThanEqual(false, (Pageable) arguments.get(1)); + + BiFunction, Slice> executionForFindPersonByIsMarriedLessThanEqual = + (repo, arguments) -> + repo.findPersonByIsMarriedLessThanEqual(false, (Pageable) arguments.get(1)); + + BiFunction, Slice> executionForFindAllByIsMarriedGreaterThanEqual = + (repo, arguments) -> + repo.findAllByIsMarriedGreaterThanEqual(null, (Pageable) arguments.get(1)); + + BiFunction, Slice> + executionForFindPersonByIsMarriedGreaterThanEqual = + (repo, arguments) -> + repo.findPersonByIsMarriedGreaterThanEqual(null, (Pageable) arguments.get(1)); + + BiFunction, Slice> executionForFindAllByNameLessThanEqual = + (repo, arguments) -> { + String key = nameExtractionFunction.apply(arguments, PERSONS_COUNT / 2); + return repo.findAllByNameLessThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindPersonByNameLessThanEqual = + (repo, arguments) -> { + String key = nameExtractionFunction.apply(arguments, PERSONS_COUNT / 2); + return repo.findPersonByNameLessThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindAllByNameGreaterThanEqual = + (repo, arguments) -> { + String key = nameExtractionFunction.apply(arguments, PERSONS_COUNT / 2); + return repo.findAllByNameGreaterThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindPersonByNameGreaterThanEqual = + (repo, arguments) -> { + String key = nameExtractionFunction.apply(arguments, PERSONS_COUNT / 2); + return repo.findPersonByNameGreaterThanEqual(key, (Pageable) arguments.get(1)); + }; + + BiFunction, Slice> executionForFindAll = + (repo, arguments) -> repo.findAll((Pageable) arguments.get(1)); + + Function, List> expectedListForFindAllByIdLessThanEqualFunc = + (persons) -> + persons.stream() + .sorted(Comparator.comparing(PERSON::getId).reversed()) + .collect(Collectors.toList()); + + Function, List> expectedListForFindAllByIdGreaterThanEqualFunc = + (persons) -> persons; + + Function, List> expectedListForFindAllByIsMarriedGreaterThanEqual = + (persons) -> + persons.stream() + .sorted( + Comparator.comparing( + PERSON::getIsMarried, Comparator.nullsFirst(Comparator.naturalOrder()))) + .collect(Collectors.toList()); + + Function, List> expectedListForFindAllByIsMarriedLessThanEqual = + (persons) -> + persons.stream() + .filter(person -> person.getIsMarried() == null || !person.getIsMarried()) + .sorted( + Comparator.comparing( + PERSON::getIsMarried, Comparator.nullsLast(Comparator.naturalOrder())) + .thenComparing(Comparator.comparing(PERSON::getId).reversed())) + .collect(Collectors.toList()); + + Function, List> expectedListForFindAllByNameLessThanEqual = + (persons) -> { + String name = persons.get(persons.size() / 2).getName(); + return persons.stream() + .filter(person -> person.getName().compareTo(name) <= 0) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()); + }; + + Function, List> expectedListForFindAllByNameGreaterThanEqual = + (persons) -> { + String name = persons.get(persons.size() / 2).getName(); + return persons.stream() + .filter(person -> person.getName().compareTo(name) >= 0) + .sorted(Comparator.comparing(PERSON::getId)) + .collect(Collectors.toList()); + }; + + Function, List> expectedListForFindAll = (persons) -> persons; + + List> getNextPageableMethodFunctionPairs = + Arrays.asList( + Arrays.asList(nextPageableFunc, prevPageableFunc, predicateForNextPageableFunc), + Arrays.asList( + nextOrLastPageableFunc, prevOrFirstPageableFunc, predicateForNextOrPageableFunc)); + + List> generateFunctions = + Arrays.asList( + Arrays.asList(personGenerateAndInsertFunction, personRepositoryFunc), + Arrays.asList(complexPersonGenerateAndInsertFunction, complexPersonRepositoryFunc)); + + List> executeExpectedFunctionPairs = + Arrays.asList( + Arrays.asList( + executionForFindAllByIdLessThanEqualFunc, + expectedListForFindAllByIdLessThanEqualFunc), + Arrays.asList( + executionForFindAllByIdGreaterThanEqualFunc, + expectedListForFindAllByIdGreaterThanEqualFunc), + Arrays.asList( + executionForFindAllByIsMarriedGreaterThanEqual, + expectedListForFindAllByIsMarriedGreaterThanEqual), + Arrays.asList( + executionForFindAllByIsMarriedLessThanEqual, + expectedListForFindAllByIsMarriedLessThanEqual), + Arrays.asList( + executionForFindAllByNameLessThanEqual, expectedListForFindAllByNameLessThanEqual), + Arrays.asList( + executionForFindAllByNameGreaterThanEqual, + expectedListForFindAllByNameGreaterThanEqual), + Arrays.asList( + executionForFindPersonByIdLessThanEqualFunc, + expectedListForFindAllByIdLessThanEqualFunc), + Arrays.asList( + executionForFindPersonByIdGreaterThanEqualFunc, + expectedListForFindAllByIdGreaterThanEqualFunc), + Arrays.asList( + executionForFindPersonByIsMarriedGreaterThanEqual, + expectedListForFindAllByIsMarriedGreaterThanEqual), + Arrays.asList( + executionForFindPersonByIsMarriedLessThanEqual, + expectedListForFindAllByIsMarriedLessThanEqual), + Arrays.asList( + executionForFindPersonByNameLessThanEqual, + expectedListForFindAllByNameLessThanEqual), + Arrays.asList( + executionForFindPersonByNameGreaterThanEqual, + expectedListForFindAllByNameGreaterThanEqual), + Arrays.asList(executionForFindAll, expectedListForFindAll)); + + // add arguments set + List arguments = new ArrayList<>(); + for (List generateFunctionPair : generateFunctions) { + for (List getNextPrevPageableFuncTriple : getNextPageableMethodFunctionPairs) { + for (List executeExpectedFunctionPair : executeExpectedFunctionPairs) { + arguments.add( + Arguments.of( + generateFunctionPair.get(0), + generateFunctionPair.get(1), + executeExpectedFunctionPair.get(0), + getNextPrevPageableFuncTriple.get(0), + getNextPrevPageableFuncTriple.get(1), + getNextPrevPageableFuncTriple.get(2), + executeExpectedFunctionPair.get(1), + beginPageable)); + } + } + } + return arguments.stream(); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositorySliceWithDifferentFields") + , ID> void testRepositorySliceWithDifferentFields( + BiFunction> generateAndInsertFunction, + Function> giveRepositoryFunction, + BiFunction, List, Slice> + executingRepositoryMethod, + Function, Pageable> nextPageableMethod, + Function, Pageable> prevPageableMethod, + BiFunction stopPaginationPredicateFunction, + Function, List> expectedResultListMovingFunction, + Pageable beginPageable) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + + // moving forward + Pageable nextPageable = beginPageable; + List> forwardSlices = new ArrayList<>(); + Slice slice; + do { + slice = + executingRepositoryMethod.apply( + giveRepositoryFunction.apply(context), Arrays.asList(persons, nextPageable)); + forwardSlices.add(slice); + nextPageable = nextPageableMethod.apply(slice); + } while (stopPaginationPredicateFunction.apply(slice.getPageable(), nextPageable)); + + List forwardMovingResultList = + forwardSlices.stream().flatMap(Streamable::stream).collect(Collectors.toList()); + assertEquals(expectedResultListMovingFunction.apply(persons), forwardMovingResultList); + + // moving backward + List> reverseSlices = new ArrayList<>(); + nextPageable = forwardSlices.get(forwardSlices.size() - 1).getPageable(); + do { + slice = + executingRepositoryMethod.apply( + giveRepositoryFunction.apply(context), Arrays.asList(persons, nextPageable)); + reverseSlices.add(slice); + nextPageable = prevPageableMethod.apply(slice); + } while (stopPaginationPredicateFunction.apply(slice.getPageable(), nextPageable)); + + List backwardMovingResultList = + reverseSlices.stream() + .sorted(Comparator.comparing(Slice::getNumber)) + .flatMap(Streamable::stream) + .collect(Collectors.toList()); + + assertEquals(expectedResultListMovingFunction.apply(persons), backwardMovingResultList); + } + + protected static , ID> + List expectedForwardFuncForFindFirst5ByIsMarriedInitial(List persons) { + return persons.stream() + .filter(person -> Objects.equals(person.getIsMarried(), false)) + .sorted(Comparator.comparing(PERSON::getId)) + .toList(); + } + + protected static , ID> + List expectedBackwardFuncForFindFirst5ByIsMarriedInitial(List persons) { + List result = + new ArrayList<>(expectedForwardFuncForFindFirst5ByIsMarriedInitial(persons)); + Collections.reverse(result); + return result; + } + + protected static , ID> + List expectedForwardFuncForFindFirst10ByIsMarriedGreaterThanEqualInitial( + List persons) { + return persons.stream() + .filter( + person -> { + if (person.getIsMarried() == null) { + return false; + } + int compare = Boolean.compare(person.getIsMarried(), false); + return compare >= 0; + }) + .sorted(Comparator.comparing(PERSON::getId)) + .toList(); + } + + protected static , ID> + List expectedBackwardFuncForFindFirst10ByIsMarriedGreaterThanEqualInitial( + List persons) { + List result = + new ArrayList<>( + expectedForwardFuncForFindFirst10ByIsMarriedGreaterThanEqualInitial(persons)); + Collections.reverse(result); + return result; + } + + protected static , ID> + List expectedForwardFuncForFindFirst5ByIsMarriedIndexKey(List persons) { + return persons.stream() + .filter(person -> Objects.equals(person.getIsMarried(), false)) + .filter(person -> person.getId() >= (int) PERSON_INDEX_KEY.getSecond()) + .sorted(Comparator.comparing(PERSON::getId)) + .toList(); + } + + protected static , ID> + List expectedBackwardFuncForFindFirst5ByIsMarriedIndexKey(List persons) { + List result = + new ArrayList<>( + persons.stream() + .filter(person -> Objects.equals(person.getIsMarried(), false)) + .sorted(Comparator.comparing(PERSON::getId)) + .toList()); + Collections.reverse(result); + return result; + } + + protected static , ID> + List expectedForwardFuncForFindFirst10ByIsMarriedGreaterThanEqualIndexKey( + List persons) { + return persons.stream() + .filter( + person -> { + if (person.getIsMarried() == null) { + return false; + } + int compare = Boolean.compare(person.getIsMarried(), false); + return compare >= 0; + }) + .filter(person -> person.getId() >= (int) PERSON_INDEX_KEY.getSecond()) + .sorted(Comparator.comparing(PERSON::getId)) + .toList(); + } + + protected static , ID> + List expectedBackwardFuncForFindFirst10ByIsMarriedGreaterThanEqualIndexKey( + List persons) { + List result = + new ArrayList<>( + persons.stream() + .filter( + person -> { + if (person.getIsMarried() == null) { + return false; + } + int compare = Boolean.compare(person.getIsMarried(), false); + return compare >= 0; + }) + .sorted(Comparator.comparing(PERSON::getId)) + .toList()); + Collections.reverse(result); + return result; + } + + protected static < + PERSON extends GenericPerson, REPO extends GenericPaginationMethods> + Window executionFunctionForFindFirst5ByIsMarried(REPO repository, List args) { + ScrollPosition scrollPosition = (ScrollPosition) args.get(0); + return repository.findFirst5ByIsMarried(false, scrollPosition); + } + + protected static < + PERSON extends GenericPerson, REPO extends GenericPaginationMethods> + Window executionFunctionForFindFirst10ByIsMarriedGreaterThanEqual( + REPO repository, List args) { + ScrollPosition scrollPosition = (ScrollPosition) args.get(0); + return repository.findFirst10ByIsMarriedGreaterThanEqual(false, scrollPosition); + } + + protected static < + PERSON extends GenericPerson, REPO extends GenericPaginationMethods> + Stream dataForTestRepositoryScrollForwardAndBackwardMoving() { + + ScrollPosition beginScrollPositionInitial = + TarantoolScrollPosition.forward(PERSON_INITIAL_INDEX_KEY); + ScrollPosition beginScrollPositionIndexKey = TarantoolScrollPosition.forward(PERSON_INDEX_KEY); + + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiFunction, Window>) + GenericRepositoryTest::executionFunctionForFindFirst5ByIsMarried, + (Function, List>) + GenericRepositoryTest::expectedForwardFuncForFindFirst5ByIsMarriedInitial, + (Function, List>) + GenericRepositoryTest::expectedBackwardFuncForFindFirst5ByIsMarriedInitial, + beginScrollPositionInitial), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiFunction, Window>) + GenericRepositoryTest::executionFunctionForFindFirst5ByIsMarried, + (Function, List>) + GenericRepositoryTest::expectedForwardFuncForFindFirst5ByIsMarriedIndexKey, + (Function, List>) + GenericRepositoryTest::expectedBackwardFuncForFindFirst5ByIsMarriedIndexKey, + beginScrollPositionIndexKey), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiFunction, Window>) + GenericRepositoryTest::executionFunctionForFindFirst10ByIsMarriedGreaterThanEqual, + (Function, List>) + GenericRepositoryTest + ::expectedForwardFuncForFindFirst10ByIsMarriedGreaterThanEqualInitial, + (Function, List>) + GenericRepositoryTest + ::expectedBackwardFuncForFindFirst10ByIsMarriedGreaterThanEqualInitial, + beginScrollPositionInitial), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + (BiFunction, Window>) + GenericRepositoryTest::executionFunctionForFindFirst10ByIsMarriedGreaterThanEqual, + (Function, List>) + GenericRepositoryTest + ::expectedForwardFuncForFindFirst10ByIsMarriedGreaterThanEqualIndexKey, + (Function, List>) + GenericRepositoryTest + ::expectedBackwardFuncForFindFirst10ByIsMarriedGreaterThanEqualIndexKey, + beginScrollPositionIndexKey)); + } + + @ParameterizedTest + @MethodSource("dataForTestRepositoryScrollForwardAndBackwardMoving") + , ID, REPO extends GenericPaginationMethods> + void testRepositoryScrollForwardAndBackwardMoving( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + BiFunction, Window> executingRepositoryMethod, + Function, List> expectedForwardResultFunction, + Function, List> expectedBackwardResultFunction, + ScrollPosition beginScrollPosition) { + + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + + Window personWindow = + executingRepositoryMethod.apply(repository, Collections.singletonList(beginScrollPosition)); + List forwardResult = new ArrayList<>(); + + while (!personWindow.isEmpty() && personWindow.hasNext()) { + personWindow.forEach(forwardResult::add); + ScrollPosition nextPosition = personWindow.positionAt(personWindow.size() - 1); + personWindow = + executingRepositoryMethod.apply(repository, Collections.singletonList(nextPosition)); + } + personWindow.forEach(forwardResult::add); + + assertEquals(expectedForwardResultFunction.apply(persons), forwardResult); + + TarantoolScrollPosition backwardScrollPosition = + ((TarantoolScrollPosition) personWindow.positionAt(0)).reverse(); + List backwardResult = personWindow.getContent(); + Collections.reverse(backwardResult); + + personWindow = + executingRepositoryMethod.apply( + repository, Collections.singletonList(backwardScrollPosition)); + while (!personWindow.isEmpty() && personWindow.hasNext()) { + personWindow.forEach(backwardResult::add); + ScrollPosition nextPosition = personWindow.positionAt(personWindow.size() - 1); + personWindow = + executingRepositoryMethod.apply(repository, Collections.singletonList(nextPosition)); + } + personWindow.forEach(backwardResult::add); + + assertEquals(expectedBackwardResultFunction.apply(persons), backwardResult); + } + + protected static > + Stream dataForTestWindowIterator() { + return Stream.of( + Arguments.of( + (BiFunction>) + (context, size) -> Collections.emptyList(), + PersonRepository.class, + TarantoolScrollPosition.forward(PERSON_INITIAL_INDEX_KEY), + (Function, List>) (persons) -> Collections.emptyList(), + (Function, List>) (persons) -> Collections.emptyList()), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + TarantoolScrollPosition.forward(PERSON_INITIAL_INDEX_KEY), + (Function, List>) (persons) -> persons, + (Function, List>) + (persons) -> { + Collections.reverse(persons); + return persons; + }), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + TarantoolScrollPosition.forward(PERSON_INDEX_KEY), + (Function, List>) + GenericRepositoryTest::expectedWindowIteratorForwardFromSomeCursor, + (Function, List>) + GenericRepositoryTest::expectedWindowIteratorBackwardFromSomeCursor)); + } + + protected static , ID> + List expectedWindowIteratorForwardFromSomeCursor(List persons) { + return persons.stream() + .filter(person -> person.getId() >= (int) PERSON_INDEX_KEY.getSecond()) + .collect(Collectors.toList()); + } + + protected static , ID> + List expectedWindowIteratorBackwardFromSomeCursor(List persons) { + return persons.stream() + .filter(person -> person.getId() <= (int) PERSON_INDEX_KEY.getSecond()) + .sorted(Comparator.comparing(PERSON::getId).reversed()) + .collect(Collectors.toList()); + } + + @ParameterizedTest + @MethodSource("dataForTestWindowIterator") + , ID, REPO extends GenericPaginationMethods> + void testWithWindowIteratorMoving( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + TarantoolScrollPosition beginScrollPosition, + Function, List> expectedForwardResultFunction, + Function, List> expectedBackwardResultFunction) { + List persons = generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + + TarantoolWindowIterator personForwardIterator = + TarantoolWindowIterator.of( + scrollPosition -> + repository.findFirst10ByIsMarriedGreaterThanEqual(null, scrollPosition)) + .startingAt(beginScrollPosition); + + List forwardResult = new ArrayList<>(); + + while (personForwardIterator.hasNext()) { + forwardResult.add(personForwardIterator.next()); + } + + forwardResult.sort(Comparator.comparing(PERSON::getId)); + assertEquals(expectedForwardResultFunction.apply(persons), forwardResult); + + // go back + List backwardResult = new ArrayList<>(); + + TarantoolWindowIterator personBackwardIterator = + TarantoolWindowIterator.of( + scrollPosition -> + repository.findFirst10ByIsMarriedGreaterThanEqual(null, scrollPosition)) + .startingAt(beginScrollPosition.reverse()); + + while (personBackwardIterator.hasNext()) { + backwardResult.add(personBackwardIterator.next()); + } + + assertEquals(expectedBackwardResultFunction.apply(persons), backwardResult); + } + + protected static Stream dataForTestUnsupportedRepositoryMethods() { + return Stream.of(PersonRepository.class, ComplexPersonRepository.class); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestUnsupportedRepositoryMethods") + , ID, REPO extends GenericPaginationMethods> + void testUnsupportedRepositoryMethods(Class repositoryClass) { + final REPO repository = context.getBean(repositoryClass); + + assertThrows(UnsupportedOperationException.class, repository::findAll); + assertThrows(UnsupportedOperationException.class, () -> repository.findAll(Sort.unsorted())); + } + + protected static Stream dataForTestPageableWithTrueCursorAndPageNumber() { + final int PAGE_SIZE = 10; + final int PAGES_COUNT = PERSONS_COUNT / PAGE_SIZE; + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + new TarantoolPageRequest<>(PAGE_SIZE), + PAGES_COUNT, + PAGES_COUNT), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + new TarantoolPageRequest<>(1, PAGE_SIZE, new Person(9, null, "User-9")), + PAGES_COUNT - 1, + PAGES_COUNT), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + new TarantoolPageRequest<>(2, PAGE_SIZE, new Person(19, null, "User-19")), + PAGES_COUNT - 2, + PAGES_COUNT)); + } + + private static void doTestPageableWithCursorAndPageNumber( + Pageable pageable, + BiFunction> repositoryFunction, + BiConsumer>, List>> assertionAction) { + List> forwardPages = new ArrayList<>(); + + Slice page = repositoryFunction.apply(0, pageable); + + forwardPages.add(page); + + while (page.hasNext()) { + page = repositoryFunction.apply(0, page.nextOrLastPageable()); + forwardPages.add(page); + } + + List> backwardPages = new ArrayList<>(); + for (int i = forwardPages.size() - 1; i >= 0; i--) { + Slice currentPage = forwardPages.get(i); + if (currentPage.getPageable().isPaged()) { + page = currentPage; + break; + } + } + backwardPages.add(page); + + page = repositoryFunction.apply(0, page.previousOrFirstPageable()); + backwardPages.add(page); + + while (page.hasPrevious()) { + page = repositoryFunction.apply(0, page.previousOrFirstPageable()); + backwardPages.add(page); + } + + assertionAction.accept(forwardPages, backwardPages); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestPageableWithTrueCursorAndPageNumber") + , ID, REPO extends GenericPaginationMethods> + void testPageableWithTrueCursorAndPageNumber( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + Pageable pageable, + int expectedForwardPageCount, + int expectedBackwardPageCount) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + + BiConsumer>, List>> assertAction = + (forwardSlice, backwardSlice) -> { + assertEquals(expectedForwardPageCount, forwardSlice.size()); + assertEquals(expectedBackwardPageCount, backwardSlice.size()); + + forwardSlice.forEach(p -> assertTrue(p.getPageable().isPaged())); + backwardSlice.forEach(p -> assertTrue(p.getPageable().isPaged())); + }; + + doTestPageableWithCursorAndPageNumber( + pageable, repository::findPersonByIdGreaterThanEqual, assertAction); + doTestPageableWithCursorAndPageNumber( + pageable, repository::findAllByIdGreaterThanEqual, assertAction); + } + + protected static Stream dataForTestPageablePageNumberLessCursor() { + final int PAGE_SIZE = 10; + final int PAGES_COUNT = PERSONS_COUNT / PAGE_SIZE; + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + // fourth page + new TarantoolPageRequest<>(0, PAGE_SIZE, new Person(29, null, "User-29")), + PAGES_COUNT - 2, + PAGES_COUNT - 3, + PAGES_COUNT - 3), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + // third page + new TarantoolPageRequest<>(0, PAGE_SIZE, new Person(19, null, "User-19")), + PAGES_COUNT - 1, + PAGES_COUNT - 2, + PAGES_COUNT - 2)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestPageablePageNumberLessCursor") + , ID, REPO extends GenericPaginationMethods> + void testPageablePageNumberLessCursor( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + Pageable pageable, + int expectedForwardPageCountForPage, + int expectedForwardPageCountForSlice, + int expectedBackwardPageCount) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + + BiConsumer>, List>> assertAction = + (forwardSlice, backwardSlice) -> { + int expectedForwardPageCount = expectedForwardPageCountForSlice; + Slice firstPageable = forwardSlice.get(0); + if (firstPageable instanceof Page) { + expectedForwardPageCount = expectedForwardPageCountForPage; + } + + assertEquals(expectedForwardPageCount, forwardSlice.size()); + assertEquals(expectedBackwardPageCount, backwardSlice.size()); + + if (firstPageable instanceof Page) { + assertTrue(forwardSlice.get(forwardSlice.size() - 1).getPageable().isUnpaged()); + forwardSlice + .subList(0, forwardSlice.size() - 1) + .forEach(p -> assertTrue(p.getPageable().isPaged())); + } else { + forwardSlice.forEach(p -> assertTrue(p.getPageable().isPaged())); + } + backwardSlice.forEach(p -> assertTrue(p.getPageable().isPaged())); + }; + + doTestPageableWithCursorAndPageNumber( + pageable, repository::findPersonByIdGreaterThanEqual, assertAction); + doTestPageableWithCursorAndPageNumber( + pageable, repository::findAllByIdGreaterThanEqual, assertAction); + } + + protected static Stream dataForTestPageablePageNumberGreaterCursor() { + final int PAGE_SIZE = 10; + final int PAGES_COUNT = PERSONS_COUNT / PAGE_SIZE; + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + // second page + new TarantoolPageRequest<>(1, PAGE_SIZE, null), + PAGES_COUNT - 1, + PAGES_COUNT, + PAGES_COUNT, + PAGES_COUNT + 1), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + // fourth page + new TarantoolPageRequest<>(3, PAGE_SIZE, new Person(9, null, "User-9")), + PAGES_COUNT - 3, + PAGES_COUNT - 1, + PAGES_COUNT - 1, + PAGES_COUNT + 1)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestPageablePageNumberGreaterCursor") + , ID, REPO extends GenericPaginationMethods> + void testPageablePageNumberGreaterCursor( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + Pageable pageable, + int expectedForwardPageCountForPage, + int expectedForwardPageCountForSlice, + int expectedBackwardPageCountForPage, + int expectedBackwardPageCountForSlice) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + + BiConsumer>, List>> assertAction = + (forwardSlice, backwardSlice) -> { + int expectedForwardPageCount = expectedForwardPageCountForPage; + int expectedBackwardPageCount = expectedBackwardPageCountForPage; + + Slice firstPageable = forwardSlice.get(0); + if (!(firstPageable instanceof Page)) { + expectedForwardPageCount = expectedForwardPageCountForSlice; + expectedBackwardPageCount = expectedBackwardPageCountForSlice; + } + + assertEquals(expectedForwardPageCount, forwardSlice.size()); + assertEquals(expectedBackwardPageCount, backwardSlice.size()); + + forwardSlice.forEach(p -> assertTrue(p.getPageable().isPaged())); + assertTrue(backwardSlice.get(backwardSlice.size() - 1).getPageable().isUnpaged()); + backwardSlice + .subList(0, backwardSlice.size() - 1) + .forEach(p -> assertTrue(p.getPageable().isPaged())); + }; + + doTestPageableWithCursorAndPageNumber( + pageable, repository::findPersonByIdGreaterThanEqual, assertAction); + doTestPageableWithCursorAndPageNumber( + pageable, repository::findAllByIdGreaterThanEqual, assertAction); + } + + protected static Stream dataForTestRepositoryLimit() { + final int LIMITED_SIZE = 10; + final Limit LIMITED = Limit.of(LIMITED_SIZE); + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + null, + SelectOptions.DEFAULT_LIMIT), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + Limit.unlimited(), + SelectOptions.DEFAULT_LIMIT), + Arguments.of( + personGenerateAndInsertFunction, PersonRepository.class, LIMITED, LIMITED_SIZE), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + null, + SelectOptions.DEFAULT_LIMIT), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + Limit.unlimited(), + SelectOptions.DEFAULT_LIMIT), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + LIMITED, + LIMITED_SIZE)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryLimit") + , ID, REPO extends GenericPaginationMethods> + void testRepositoryLimit( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + Limit limit, + int expectedLimitSize) { + generateAndInsertFunction.apply(context, PERSONS_COUNT); + + REPO repository = context.getBean(repositoryClass); + + List selectedPersons = repository.findPersonByIdGreaterThanEqual(0, limit); + assertEquals(expectedLimitSize, selectedPersons.size()); + } + + protected static Stream dataForTestRepositoryLimitShouldThrow() { + final Limit BAD_LIMIT = Limit.of(-10); + return Stream.of( + Arguments.of(PersonRepository.class, BAD_LIMIT), + Arguments.of(ComplexPersonRepository.class, BAD_LIMIT)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryLimitShouldThrow") + , ID, REPO extends GenericPaginationMethods> + void testRepositoryLimitShouldThrow(Class repositoryClass, Limit limit) { + REPO repository = context.getBean(repositoryClass); + assertThrows( + IllegalArgumentException.class, () -> repository.findPersonByIdGreaterThanEqual(0, limit)); + } + + protected static Stream dataForTestRepositoryLimitWithLimitInMethodName() { + final int LIMIT_FROM_METHOD_NAME = 4; + final int LIMITED_SIZE = 10; + final Limit LIMITED = Limit.of(LIMITED_SIZE); + return Stream.of( + Arguments.of( + personGenerateAndInsertFunction, PersonRepository.class, null, LIMIT_FROM_METHOD_NAME), + Arguments.of( + personGenerateAndInsertFunction, PersonRepository.class, LIMITED, LIMITED_SIZE), + Arguments.of( + personGenerateAndInsertFunction, + PersonRepository.class, + Limit.unlimited(), + LIMIT_FROM_METHOD_NAME), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + null, + LIMIT_FROM_METHOD_NAME), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + LIMITED, + LIMITED_SIZE), + Arguments.of( + complexPersonGenerateAndInsertFunction, + ComplexPersonRepository.class, + Limit.unlimited(), + LIMIT_FROM_METHOD_NAME)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestRepositoryLimitWithLimitInMethodName") + , ID, REPO extends GenericPaginationMethods> + void testRepositoryLimitWithLimitInMethodName( + BiFunction> generateAndInsertFunction, + Class repositoryClass, + Limit limit, + int expectedLimitSize) { + + generateAndInsertFunction.apply(context, PERSONS_COUNT); + REPO repository = context.getBean(repositoryClass); + List selectedPersons = repository.findTop4ByIdGreaterThanEqual(0, limit); + + assertEquals(expectedLimitSize, selectedPersons.size()); + } + + @Test + void testConcurrentDerivedMethods() { + + final byte threadCount = 3; + final ExecutorService executor = Executors.newFixedThreadPool(threadCount); + final List>> futures = new ArrayList<>(threadCount); + final PersonRepository repository = context.getBean(PersonRepository.class); + + final List insertedPersons = + personGenerateAndInsertFunction.apply(context, (int) threadCount); + final Person firstPerson = insertedPersons.get(0); + final Person secondPerson = insertedPersons.get(1); + final Person thirdPerson = insertedPersons.get(2); + + futures.add( + CompletableFuture.supplyAsync( + () -> repository.findPersonById(firstPerson.getId()), executor)); + futures.add( + CompletableFuture.supplyAsync( + () -> repository.findByIsMarried(secondPerson.getIsMarried()), executor)); + futures.add( + CompletableFuture.supplyAsync( + () -> repository.findByName(thirdPerson.getName()), executor)); + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[] {})).join(); + + assertEquals(firstPerson, futures.get(0).join().get(0)); + assertEquals(secondPerson, futures.get(1).join().get(0)); + assertEquals(thirdPerson, futures.get(2).join().get(0)); + } + + @Test + void testFragmentsSPI() { + final int tupleCount = 100; + final List tuples = personGenerateFunction.apply(tupleCount); + + final PersonRepository repository = context.getBean(PersonRepository.class); + + final List insertedTuples = repository.replaceMany(tuples); + assertEquals(tupleCount, insertedTuples.size()); + + tuples.sort(Comparator.comparing(Person::getName)); + insertedTuples.sort(Comparator.comparing(Person::getName)); + assertEquals(tuples, insertedTuples); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericTarantoolTemplateTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericTarantoolTemplateTest.java new file mode 100644 index 0000000..49e15f3 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/GenericTarantoolTemplateTest.java @@ -0,0 +1,550 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.keyvalue.core.KeyValueTemplate; +import org.springframework.data.keyvalue.core.UncategorizedKeyValueException; + +import static io.tarantool.spring.data.ProxyTarantoolCrudKeyValueAdapter.POTENTIAL_PERFORMANCE_ISSUES_EXCEPTION_MESSAGE; +import static io.tarantool.spring.data.ProxyTarantoolQueryEngine.unwrapTuples; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.COMPLEX_PERSON_SPACE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSONS_COUNT; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSON_SPACE; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generateAndInsertComplexPersons; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generateAndInsertPersons; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generateComplexPersons; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generatePersons; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.mapping.crud.CrudException; +import io.tarantool.spring.data35.core.TarantoolTemplate; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.ComplexPersonWithIncorrectPK; +import io.tarantool.spring.data35.utils.entity.ComplexPersonWithJsonFormatVariantPK; +import io.tarantool.spring.data35.utils.entity.CompositePersonKey; +import io.tarantool.spring.data35.utils.entity.CompositePersonKeyWithJsonFormat; +import io.tarantool.spring.data35.utils.entity.Person; + +abstract class GenericTarantoolTemplateTest extends CrudConfigurations { + + @Autowired protected TarantoolTemplate tarantoolTemplate; + + @Test + void testGetAllOf() { + final Throwable throwable = + assertThrows( + UncategorizedKeyValueException.class, () -> tarantoolTemplate.findAll(Person.class)) + .getCause(); + + assertEquals(UnsupportedOperationException.class, throwable.getClass()); + assertEquals(POTENTIAL_PERFORMANCE_ISSUES_EXCEPTION_MESSAGE, throwable.getMessage()); + } + + static Stream dataForInsert() { + + Person simpleKeyPerson = generatePersons(1).get(0); + ComplexPerson compositeKeyPerson = generateComplexPersons(1).get(0); + + Function selectActionForSimpleKeyPerson = + (client) -> + unwrapTuples( + client.space(PERSON_SPACE).select(Collections.emptyList(), Person.class).join()); + + Function> selectActionForComplexKeyPerson = + (client) -> + unwrapTuples( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList(), ComplexPerson.class) + .join()); + + return Stream.of( + Arguments.of( + simpleKeyPerson, + (Function) (kv) -> kv.insert(simpleKeyPerson), + selectActionForSimpleKeyPerson), + Arguments.of( + compositeKeyPerson, + (Function) (kv) -> kv.insert(compositeKeyPerson), + selectActionForComplexKeyPerson)); + } + + @ParameterizedTest + @MethodSource("dataForInsert") + void testKVTemplateInsert( + Object result, + Function callingMethod, + Function> selectAction) { + final int SIZE = 1; + assertEquals(result, callingMethod.apply(tarantoolTemplate)); + + List selectResult = selectAction.apply(client); + assertEquals(SIZE, selectResult.size()); + assertEquals(result, selectResult.get(0)); + } + + static Stream dataForInsertById() { + + Person simpleKeyPerson = generatePersons(1).get(0); + ComplexPerson compositeKeyPerson = generateComplexPersons(1).get(0); + + Integer simpleKey = simpleKeyPerson.getId(); + CompositePersonKey compositeKey = + new CompositePersonKey(compositeKeyPerson.getId(), compositeKeyPerson.getSecondId()); + + Function selectActionForSimpleKeyPerson = + (client) -> + unwrapTuples( + client.space(PERSON_SPACE).select(Collections.emptyList(), Person.class).join()); + + Function> selectActionForComplexKeyPerson = + (client) -> + unwrapTuples( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList(), ComplexPerson.class) + .join()); + + return Stream.of( + Arguments.of( + simpleKeyPerson, + (Function) (kv) -> kv.insert(simpleKey, simpleKeyPerson), + selectActionForSimpleKeyPerson), + Arguments.of( + compositeKeyPerson, + (Function) + (kv) -> kv.insert(compositeKey, compositeKeyPerson), + selectActionForComplexKeyPerson)); + } + + @ParameterizedTest + @MethodSource("dataForInsertById") + void testKVTemplateInsertById( + Object result, + Function callingMethod, + Function> selectAction) { + final int SIZE = 1; + assertEquals(result, callingMethod.apply(tarantoolTemplate)); + + List selectResult = selectAction.apply(client); + assertEquals(SIZE, selectResult.size()); + assertEquals(result, selectResult.get(0)); + } + + static Stream dataForUpdate() { + + Person simpleKeyPerson = generatePersons(1).get(0); + ComplexPerson compositeKeyPerson = generateComplexPersons(1).get(0); + + Function selectActionForSimpleKeyPerson = + (client) -> + unwrapTuples( + client.space(PERSON_SPACE).select(Collections.emptyList(), Person.class).join()); + + Function> selectActionForComplexKeyPerson = + (client) -> + unwrapTuples( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList(), ComplexPerson.class) + .join()); + + Consumer prepareActionForSimpleKeyPerson = + (client) -> { + Person insertedPerson = + client.space(PERSON_SPACE).insert(simpleKeyPerson, Person.class).join().get(); + assertEquals(simpleKeyPerson, insertedPerson); + }; + + Consumer prepareActionForCompositeKeyPerson = + (client) -> { + ComplexPerson insertedPerson = + client + .space(COMPLEX_PERSON_SPACE) + .insert(compositeKeyPerson, ComplexPerson.class) + .join() + .get(); + assertEquals(compositeKeyPerson, insertedPerson); + }; + + return Stream.of( + Arguments.of( + prepareActionForSimpleKeyPerson, + simpleKeyPerson, + (Function) (kv) -> kv.update(simpleKeyPerson), + selectActionForSimpleKeyPerson), + Arguments.of( + prepareActionForCompositeKeyPerson, + compositeKeyPerson, + (Function) (kv) -> kv.update(compositeKeyPerson), + selectActionForComplexKeyPerson)); + } + + @ParameterizedTest + @MethodSource("dataForUpdate") + void testKVTemplateUpdate( + Consumer prepareAction, + Object result, + Function callingMethod, + Function> selectAction) { + prepareAction.accept(client); + + final int SIZE = 1; + assertEquals(result, callingMethod.apply(tarantoolTemplate)); + + List selectResult = selectAction.apply(client); + assertEquals(SIZE, selectResult.size()); + assertEquals(result, selectResult.get(0)); + } + + static Stream dataForUpdateById() { + + Person simpleKeyPerson = generatePersons(1).get(0); + ComplexPerson compositeKeyPerson = generateComplexPersons(1).get(0); + + Integer simpleKey = simpleKeyPerson.getId(); + CompositePersonKey compositeKey = + new CompositePersonKey(compositeKeyPerson.getId(), compositeKeyPerson.getSecondId()); + + Function selectActionForSimpleKeyPerson = + (client) -> + unwrapTuples( + client.space(PERSON_SPACE).select(Collections.emptyList(), Person.class).join()); + + Function> selectActionForComplexKeyPerson = + (client) -> + unwrapTuples( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList(), ComplexPerson.class) + .join()); + + Consumer prepareActionForSimpleKeyPerson = + (client) -> { + Person insertedPerson = + client.space(PERSON_SPACE).insert(simpleKeyPerson, Person.class).join().get(); + assertEquals(simpleKeyPerson, insertedPerson); + }; + + Consumer prepareActionForCompositeKeyPerson = + (client) -> { + ComplexPerson insertedPerson = + client + .space(COMPLEX_PERSON_SPACE) + .insert(compositeKeyPerson, ComplexPerson.class) + .join() + .get(); + assertEquals(compositeKeyPerson, insertedPerson); + }; + + return Stream.of( + Arguments.of( + prepareActionForSimpleKeyPerson, + simpleKeyPerson, + (Function) (kv) -> kv.update(simpleKey, simpleKeyPerson), + selectActionForSimpleKeyPerson), + Arguments.of( + prepareActionForCompositeKeyPerson, + compositeKeyPerson, + (Function) + (kv) -> kv.update(compositeKey, compositeKeyPerson), + selectActionForComplexKeyPerson)); + } + + @ParameterizedTest + @MethodSource("dataForUpdateById") + void testKVTemplateUpdateById( + Consumer prepareAction, + Object result, + Function callingMethod, + Function> selectAction) { + prepareAction.accept(client); + + final int SIZE = 1; + assertEquals(result, callingMethod.apply(tarantoolTemplate)); + + List selectResult = selectAction.apply(client); + assertEquals(SIZE, selectResult.size()); + assertEquals(result, selectResult.get(0)); + } + + static Stream dataForDelete() { + + Runnable simpleKeyPrepareAction = () -> generatePersons(PERSONS_COUNT); + Runnable compositeKeyPrepareAction = () -> generateComplexPersons(PERSONS_COUNT); + + Consumer simpleKeyFinalAction = + (client) -> + assertTrue(client.space(PERSON_SPACE).select(Collections.emptyList()).join().isEmpty()); + + Consumer compositeKeyFinalAction = + (client) -> + assertTrue( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList()) + .join() + .isEmpty()); + + return Stream.of( + Arguments.of( + simpleKeyPrepareAction, + (Consumer) (kv) -> kv.delete(Person.class), + simpleKeyFinalAction), + Arguments.of( + compositeKeyPrepareAction, + (Consumer) (kv) -> kv.delete(ComplexPerson.class), + compositeKeyFinalAction)); + } + + @ParameterizedTest + @MethodSource("dataForDelete") + void testKVTemplateDelete( + Runnable prepareAction, + Consumer callingMethod, + Consumer finalAction) { + prepareAction.run(); + callingMethod.accept(tarantoolTemplate); + finalAction.accept(client); + } + + static Stream dataForDeleteEntities() { + + Person simpleKeyPerson = new Person(0, true, "0"); + ComplexPerson compositeKeyPerson = new ComplexPerson(0, UUID.randomUUID(), true, "0"); + + Consumer simpleKeyPrepareAction = + (client) -> { + Person insertedPerson = + client.space(PERSON_SPACE).insert(simpleKeyPerson, Person.class).join().get(); + assertEquals(simpleKeyPerson, insertedPerson); + }; + + Consumer compositeKeyPrepareAction = + (client) -> { + ComplexPerson insertedPerson = + client + .space(COMPLEX_PERSON_SPACE) + .insert(compositeKeyPerson, ComplexPerson.class) + .join() + .get(); + assertEquals(compositeKeyPerson, insertedPerson); + }; + + Consumer simpleKeyFinalAction = + (client) -> + assertTrue(client.space(PERSON_SPACE).select(Collections.emptyList()).join().isEmpty()); + + Consumer compositeKeyFinalAction = + (client) -> + assertTrue( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList()) + .join() + .isEmpty()); + + return Stream.of( + Arguments.of( + simpleKeyPrepareAction, + (Consumer) (kv) -> kv.delete(simpleKeyPerson), + simpleKeyFinalAction), + Arguments.of( + compositeKeyPrepareAction, + (Consumer) (kv) -> kv.delete(compositeKeyPerson), + compositeKeyFinalAction)); + } + + @ParameterizedTest + @MethodSource("dataForDeleteEntities") + void testKVTemplateDeleteEntities( + Consumer prepareAction, + Consumer callingMethod, + Consumer finalAction) { + prepareAction.accept(client); + callingMethod.accept(tarantoolTemplate); + finalAction.accept(client); + } + + static Stream dataForDeleteEntitiesById() { + + Person simpleKeyPerson = new Person(0, true, "0"); + ComplexPerson compositeKeyPerson = new ComplexPerson(0, UUID.randomUUID(), true, "0"); + + Integer simpleKey = simpleKeyPerson.getId(); + CompositePersonKey compositeKey = + new CompositePersonKey(compositeKeyPerson.getId(), compositeKeyPerson.getSecondId()); + + Consumer simpleKeyPrepareAction = + (client) -> { + Person insertedPerson = + client.space(PERSON_SPACE).insert(simpleKeyPerson, Person.class).join().get(); + assertEquals(simpleKeyPerson, insertedPerson); + }; + + Consumer compositeKeyPrepareAction = + (client) -> { + ComplexPerson insertedPerson = + client + .space(COMPLEX_PERSON_SPACE) + .insert(compositeKeyPerson, ComplexPerson.class) + .join() + .get(); + assertEquals(compositeKeyPerson, insertedPerson); + }; + + Consumer simpleKeyFinalAction = + (client) -> + assertTrue(client.space(PERSON_SPACE).select(Collections.emptyList()).join().isEmpty()); + + Consumer compositeKeyFinalAction = + (client) -> + assertTrue( + client + .space(COMPLEX_PERSON_SPACE) + .select(Collections.emptyList()) + .join() + .isEmpty()); + + return Stream.of( + Arguments.of( + simpleKeyPrepareAction, + (Consumer) (kv) -> kv.delete(simpleKey, Person.class), + simpleKeyFinalAction), + Arguments.of( + compositeKeyPrepareAction, + (Consumer) (kv) -> kv.delete(compositeKey, ComplexPerson.class), + compositeKeyFinalAction)); + } + + @ParameterizedTest + @MethodSource("dataForDeleteEntitiesById") + void testKVTemplateDeleteEntitiesById( + Consumer prepareAction, + Consumer callingMethod, + Consumer finalAction) { + prepareAction.accept(client); + callingMethod.accept(tarantoolTemplate); + finalAction.accept(client); + } + + static Stream dataForCount() { + + Consumer simpleKeyPrepareAction = + (client) -> generateAndInsertPersons(PERSONS_COUNT, client); + + Consumer compositeKeyPrepareAction = + (client) -> generateAndInsertComplexPersons(PERSONS_COUNT, client); + + return Stream.of( + Arguments.of( + PERSONS_COUNT, + simpleKeyPrepareAction, + (Function) (kv) -> kv.count(Person.class)), + Arguments.of( + PERSONS_COUNT, + compositeKeyPrepareAction, + (Function) (kv) -> kv.count(ComplexPerson.class))); + } + + @ParameterizedTest + @MethodSource("dataForCount") + void testCount( + long expectedCount, + Consumer prepareAction, + Function callingMethod) { + prepareAction.accept(client); + assertEquals(expectedCount, callingMethod.apply(tarantoolTemplate)); + } + + @Test + void testCompositeKeyWithJsonFormat() { + ComplexPersonWithJsonFormatVariantPK complexPersonWithJsonFormatVariantPK = + new ComplexPersonWithJsonFormatVariantPK(0, UUID.randomUUID(), null, "0"); + + assertEquals( + complexPersonWithJsonFormatVariantPK, + tarantoolTemplate.insert(complexPersonWithJsonFormatVariantPK)); + + CompositePersonKeyWithJsonFormat key = + new CompositePersonKeyWithJsonFormat( + complexPersonWithJsonFormatVariantPK.getId(), + complexPersonWithJsonFormatVariantPK.getSecondId()); + Optional foundPerson = + tarantoolTemplate.findById(key, ComplexPersonWithJsonFormatVariantPK.class); + assertTrue(foundPerson.isPresent()); + assertEquals(complexPersonWithJsonFormatVariantPK, foundPerson.get()); + } + + @Test + void testCompositeKeyWithoutJsonFormat() { + ComplexPersonWithIncorrectPK complexPersonWithIncorrectPK = + new ComplexPersonWithIncorrectPK(0, UUID.randomUUID(), null, "0"); + + Throwable exception = + assertThrows( + UncategorizedKeyValueException.class, + () -> tarantoolTemplate.insert(complexPersonWithIncorrectPK)) + .getRootCause(); + assertInstanceOf(CrudException.class, exception); + } + + public static Stream dataForTestCompositeKeyWithConcurrency() { + final List threadsCount = Arrays.asList(1, 2, 3); + final List tuplesCount = Arrays.asList(20, 120, 360, 1200, 3000); + final List totalArguments = new ArrayList<>(); + + for (final Integer threadCount : threadsCount) { + for (final Integer tupleCount : tuplesCount) { + totalArguments.add(Arguments.of(threadCount, tupleCount)); + } + } + + return totalArguments.stream(); + } + + @DisplayName("Checking the insertion of an entity with a composite key") + @ParameterizedTest(autoCloseArguments = false, name = "thread count: {0}, tuples count: {1}") + @MethodSource("dataForTestCompositeKeyWithConcurrency") + void testCompositeKeyWithConcurrency(int threadsCount, int tuplesCount) { + final ExecutorService executor = Executors.newFixedThreadPool(threadsCount); + final List> futures = new ArrayList<>(); + final List tuples = generateComplexPersons(tuplesCount); + + for (final ComplexPerson tuple : tuples) { + futures.add( + CompletableFuture.supplyAsync(() -> this.tarantoolTemplate.insert(tuple), executor)); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[] {})).join(); + + for (int i = 0; i < tuplesCount; i++) { + assertEquals(tuples.get(i), futures.get(i).join()); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryConfigurationExtensionTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryConfigurationExtensionTest.java new file mode 100644 index 0000000..be7b856 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryConfigurationExtensionTest.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; + +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_CRUD_CLIENT_BEAN_REF; +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF; +import static io.tarantool.spring.data.TarantoolBeanNames.DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF; +import io.tarantool.client.ClientType; +import io.tarantool.client.factory.TarantoolBoxClientBuilder; +import io.tarantool.client.factory.TarantoolCrudClientBuilder; +import io.tarantool.client.factory.TarantoolFactory; +import io.tarantool.spring.data35.integration.BaseIntegrationTest; +import io.tarantool.spring.data35.repository.config.EnableTarantoolRepositories; +import io.tarantool.spring.data35.repository.config.TarantoolRepositoryConfigurationExtension; + +class RepositoryConfigurationExtensionTest extends BaseIntegrationTest { + + private static TarantoolRepositoryConfigurationExtension defaultExtension; + + @BeforeAll + static void setUp() throws IOException { + BaseIntegrationTest.beforeAll(); + defaultExtension = new TarantoolRepositoryConfigurationExtension(); + } + + @Test + void testGetModuleName() { + assertEquals("Tarantool", defaultExtension.getModuleName()); + } + + @Test + void testGetModulePrefix() { + assertEquals("tarantool", defaultExtension.getModulePrefix()); + } + + @Test + void testGetDefaultKeyValueTemplateRef() { + assertEquals( + DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF, defaultExtension.getDefaultKeyValueTemplateRef()); + } + + @Test + void testRegisterBeansForRootWithDefaultConfig() { + final ApplicationContext context = new AnnotationConfigApplicationContext(DefaultConfig.class); + assertTrue(context.containsBean(DEFAULT_TARANTOOL_CRUD_CLIENT_BEAN_REF)); + assertTrue(context.containsBean(DEFAULT_TARANTOOL_CRUD_KEY_VALUE_ADAPTER_REF)); + assertTrue(context.containsBean(DEFAULT_TARANTOOL_KEY_VALUE_TEMPLATE_REF)); + } + + @Test + void testRegisterBeansForRootWithCustomClientConfigShouldThrows() { + final Throwable throwable = + assertThrows( + IllegalArgumentException.class, + () -> new AnnotationConfigApplicationContext(BoxNonSupportConfig.class)); + + final String exceptionMessage = "The Box client is not yet supported."; + assertEquals(exceptionMessage, throwable.getMessage()); + } + + private static TarantoolCrudClientBuilder getCrudClientSettings() { + return TarantoolFactory.crud().withHost(getHost()).withPort(getPort()); + } + + private static TarantoolBoxClientBuilder getBoxClientSettings() { + return TarantoolFactory.box().withHost(getHost()).withPort(getPort()); + } + + @EnableTarantoolRepositories + static class DefaultConfig { + + @Bean + public TarantoolCrudClientBuilder clientSettings() { + return getCrudClientSettings(); + } + } + + @EnableTarantoolRepositories(clientType = ClientType.BOX) + static class BoxNonSupportConfig { + + @Bean + public TarantoolBoxClientBuilder clientSettings() { + return getBoxClientSettings(); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaJavaConfigTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaJavaConfigTest.java new file mode 100644 index 0000000..7274217 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaJavaConfigTest.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import org.springframework.boot.test.context.SpringBootTest; + +import io.tarantool.spring.data35.integration.crud.CrudConfigurations.JavaConfigConfiguration; + +@SpringBootTest(classes = {JavaConfigConfiguration.class}) +class RepositoryViaJavaConfigTest extends GenericRepositoryTest {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaPropertiesTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaPropertiesTest.java new file mode 100644 index 0000000..09951b2 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/RepositoryViaPropertiesTest.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import org.springframework.boot.test.context.SpringBootTest; + +import io.tarantool.spring.data35.integration.crud.CrudConfigurations.ViaPropertyFileConfiguration; + +@SpringBootTest(classes = {ViaPropertyFileConfiguration.class}) +class RepositoryViaPropertiesTest extends GenericRepositoryTest {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaJavaConfigTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaJavaConfigTest.java new file mode 100644 index 0000000..ada1d23 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaJavaConfigTest.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import org.springframework.boot.test.context.SpringBootTest; + +import io.tarantool.spring.data35.integration.crud.CrudConfigurations.JavaConfigConfiguration; + +@SpringBootTest(classes = {JavaConfigConfiguration.class}) +class TarantoolTemplateViaJavaConfigTest extends GenericTarantoolTemplateTest {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaPropertiesTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaPropertiesTest.java new file mode 100644 index 0000000..6f7ab6f --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/integration/crud/TarantoolTemplateViaPropertiesTest.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.integration.crud; + +import org.springframework.boot.test.context.SpringBootTest; + +import io.tarantool.spring.data35.integration.crud.CrudConfigurations.ViaPropertyFileConfiguration; + +@SpringBootTest(classes = {ViaPropertyFileConfiguration.class}) +class TarantoolTemplateViaPropertiesTest extends GenericTarantoolTemplateTest {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolCriteriaTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolCriteriaTest.java new file mode 100644 index 0000000..c51499e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolCriteriaTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.junit.jupiter.api.Test; + +import static io.tarantool.client.crud.ConditionOperator.EQ; +import io.tarantool.client.crud.Condition; +import io.tarantool.client.crud.options.SelectOptions; +import io.tarantool.spring.data.query.Conditions; +import io.tarantool.spring.data.query.TarantoolCriteria; + +public class TarantoolCriteriaTest { + + @Test + void testAddAndGetConditions() { + final TarantoolCriteria tarantoolCriteria = new TarantoolCriteria(); + final String identifier = "name"; + final Object value = "value"; + final String operator = "=="; + + final Condition condition = Condition.create(operator, identifier, value); + assertEquals(operator, condition.getOperator()); + assertEquals(identifier, condition.getFieldIdentifier()); + assertEquals(value, condition.getValue()); + + final Condition conditionConstructor = new Condition(operator, identifier, value); + assertEquals(operator, conditionConstructor.getOperator()); + assertEquals(identifier, conditionConstructor.getFieldIdentifier()); + assertEquals(value, conditionConstructor.getValue()); + + final Condition conditionFromBuilder = + Condition.builder() + .withOperator(operator) + .withValue(value) + .withFieldIdentifier(identifier) + .build(); + assertEquals(operator, conditionFromBuilder.getOperator()); + assertEquals(identifier, conditionFromBuilder.getFieldIdentifier()); + assertEquals(value, conditionFromBuilder.getValue()); + + final Condition conditionWithEnum = Condition.create(EQ, identifier, value); + assertEquals(operator, conditionWithEnum.getOperator()); + assertEquals(identifier, conditionWithEnum.getFieldIdentifier()); + assertEquals(value, conditionWithEnum.getValue()); + + final Condition conditionConstructorWithEnum = new Condition(EQ, identifier, value); + assertEquals(operator, conditionConstructorWithEnum.getOperator()); + assertEquals(identifier, conditionConstructorWithEnum.getFieldIdentifier()); + assertEquals(value, conditionConstructorWithEnum.getValue()); + + final Condition conditionFromBuilderWithEnum = + Condition.builder() + .withOperator(EQ) + .withValue(value) + .withFieldIdentifier(identifier) + .build(); + assertEquals(operator, conditionFromBuilderWithEnum.getOperator()); + assertEquals(identifier, conditionFromBuilderWithEnum.getFieldIdentifier()); + assertEquals(value, conditionFromBuilderWithEnum.getValue()); + + tarantoolCriteria.addCondition(condition); + tarantoolCriteria.addCondition(conditionConstructor); + tarantoolCriteria.addCondition(conditionFromBuilder); + + tarantoolCriteria.addCondition(conditionWithEnum); + tarantoolCriteria.addCondition(conditionConstructorWithEnum); + tarantoolCriteria.addCondition(conditionFromBuilderWithEnum); + + final Conditions expectedConditions = new Conditions(); + expectedConditions.addAll( + Arrays.asList( + condition, + conditionConstructor, + conditionFromBuilder, + conditionWithEnum, + conditionConstructorWithEnum, + conditionFromBuilderWithEnum)); + + assertEquals(expectedConditions, tarantoolCriteria.getConditions()); + } + + @Test + void testFirst() { + final TarantoolCriteria criteria = new TarantoolCriteria(); + criteria.withFirst(1); + + final String key = "first"; + SelectOptions options = criteria.getOptions(); + assertEquals(1, options.getOptions().get(key)); + + criteria.clear(); + criteria.withFirst(-1); + options = criteria.getOptions(); + assertEquals(-1, options.getOptions().get(key)); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPositionTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPositionTest.java new file mode 100644 index 0000000..7e57e8e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolKeysetScrollPositionTest.java @@ -0,0 +1,199 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.ScrollPosition; + +import static io.tarantool.spring.data.query.PaginationDirection.BACKWARD; +import static io.tarantool.spring.data.query.PaginationDirection.FORWARD; +import io.tarantool.spring.data.query.PaginationDirection; +import io.tarantool.spring.data.utils.Pair; + +class TarantoolKeysetScrollPositionTest { + + private static final Pair INDEX_KEY = Pair.of("pk", 123); + + private static final Pair INDEX_KEY_WITH_NULL_VALUE = Pair.of("pk", null); + + static Stream dataForTestTarantoolScrollPositionCreateDoesntThrow() { + return Stream.of( + () -> TarantoolScrollPosition.forward(INDEX_KEY), + () -> TarantoolScrollPosition.forward(INDEX_KEY_WITH_NULL_VALUE), + () -> TarantoolScrollPosition.backward(INDEX_KEY), + () -> TarantoolScrollPosition.backward(INDEX_KEY_WITH_NULL_VALUE)); + } + + @ParameterizedTest + @MethodSource("dataForTestTarantoolScrollPositionCreateDoesntThrow") + void testTarantoolScrollPositionCreateDoesntThrow(Executable scrollPositionCreateAction) { + assertDoesNotThrow(scrollPositionCreateAction); + } + + static Stream dataForTestTarantoolScrollPositionCreateThrows() { + return Stream.of( + () -> TarantoolScrollPosition.forward(null), () -> TarantoolScrollPosition.backward(null)); + } + + @ParameterizedTest + @MethodSource("dataForTestTarantoolScrollPositionCreateThrows") + void testTarantoolScrollPositionCreateThrows(Executable scrollPositionCreateAction) { + assertThrows(IllegalArgumentException.class, scrollPositionCreateAction); + } + + static Stream dataForTestEqualsAndHashCode() { + + List equalScrollPositionsWithKey = + Arrays.asList( + TarantoolScrollPosition.forward(INDEX_KEY), TarantoolScrollPosition.forward(INDEX_KEY)); + + TarantoolScrollPosition notEqualScrollPosition = + TarantoolScrollPosition.forward(INDEX_KEY_WITH_NULL_VALUE); + + final int ITERATION_COUNT = 100; + + return Stream.of( + Arguments.of(equalScrollPositionsWithKey, notEqualScrollPosition, ITERATION_COUNT), + Arguments.of(equalScrollPositionsWithKey, null, ITERATION_COUNT)); + } + + @ParameterizedTest + @MethodSource("dataForTestEqualsAndHashCode") + void testEqualsAndHashCode( + List scrollPositions, + ScrollPosition notEqualScrollPosition, + int iterationCount) { + for (int i = 0; i < iterationCount; i++) { + for (ScrollPosition scrollPosition : scrollPositions) { + for (ScrollPosition otherScrollPosition : scrollPositions) { + assertEquals(scrollPosition, otherScrollPosition); + assertEquals(scrollPosition.hashCode(), otherScrollPosition.hashCode()); + } + assertNotEquals(scrollPosition, notEqualScrollPosition); + if (notEqualScrollPosition != null) { + assertNotEquals(scrollPosition.hashCode(), notEqualScrollPosition.hashCode()); + } + } + } + } + + static Stream dataForTestIsInitial() { + var startingIndex = Pair.of("pk", Collections.emptyList()); + var complexIndex = Pair.of("pk", Arrays.asList(1, UUID.randomUUID())); + Object nonNullCursor = Arrays.asList(1, 2, 3, 4); + return Stream.of( + Arguments.of(TarantoolScrollPosition.backward(INDEX_KEY), false), + Arguments.of(TarantoolScrollPosition.forward(INDEX_KEY), false), + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, null), false), + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, BACKWARD, null), false), + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, nonNullCursor), false), + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, BACKWARD, nonNullCursor), false), + Arguments.of(TarantoolScrollPosition.backward(startingIndex), true), + Arguments.of(TarantoolScrollPosition.forward(startingIndex), true), + Arguments.of(new TarantoolKeysetScrollPosition(startingIndex, FORWARD, null), true), + Arguments.of(new TarantoolKeysetScrollPosition(startingIndex, BACKWARD, null), true), + Arguments.of( + new TarantoolKeysetScrollPosition(startingIndex, FORWARD, nonNullCursor), false), + Arguments.of( + new TarantoolKeysetScrollPosition(startingIndex, BACKWARD, nonNullCursor), false), + Arguments.of(TarantoolScrollPosition.backward(complexIndex), false), + Arguments.of(TarantoolScrollPosition.forward(complexIndex), false)); + } + + @ParameterizedTest + @MethodSource("dataForTestIsInitial") + void testIsInitial(ScrollPosition scrollPosition, boolean expectedIsInitial) { + assertEquals(scrollPosition.isInitial(), expectedIsInitial); + } + + static Stream dataForTestGetCursor() { + var cursor = String.valueOf(2); + return Stream.of( + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, null), null), + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, cursor), cursor)); + } + + @ParameterizedTest + @MethodSource("dataForTestGetCursor") + void testGetCursor(TarantoolKeysetScrollPosition scrollPosition, Object expectedCursor) { + assertEquals(expectedCursor, scrollPosition.getCursor()); + } + + static Stream dataForTestGetKeyIndex() { + return Stream.of( + Arguments.of(TarantoolScrollPosition.forward(INDEX_KEY), INDEX_KEY), + Arguments.of(TarantoolScrollPosition.backward(INDEX_KEY), INDEX_KEY)); + } + + @ParameterizedTest + @MethodSource("dataForTestGetKeyIndex") + void testGetKeyIndex( + TarantoolKeysetScrollPosition scrollPosition, Pair expectedIndexKey) { + assertEquals(expectedIndexKey, scrollPosition.getIndexKey()); + } + + static Stream dataForTestGetDirection() { + return Stream.of( + Arguments.of(new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, null), FORWARD), + Arguments.of( + new TarantoolKeysetScrollPosition(INDEX_KEY, BACKWARD, String.valueOf(2)), BACKWARD)); + } + + @ParameterizedTest + @MethodSource("dataForTestGetDirection") + void testGetDirection( + TarantoolKeysetScrollPosition scrollPosition, PaginationDirection expectedDirection) { + assertEquals(expectedDirection, scrollPosition.getDirection()); + } + + static Stream dataForTestReverse() { + Object someKey = String.valueOf(1); + var indexKeyAfterReverse = Pair.of(INDEX_KEY.getFirst(), INDEX_KEY.getSecond()); + + return Stream.of( + Arguments.of( + TarantoolScrollPosition.forward(INDEX_KEY), + TarantoolScrollPosition.backward(indexKeyAfterReverse)), + Arguments.of( + new TarantoolKeysetScrollPosition(INDEX_KEY, FORWARD, someKey), + new TarantoolKeysetScrollPosition(indexKeyAfterReverse, BACKWARD, someKey))); + } + + @ParameterizedTest + @MethodSource("dataForTestReverse") + void testGetReverse( + TarantoolScrollPosition scrollPosition, + TarantoolKeysetScrollPosition expectedReversedPosition) { + assertEquals(expectedReversedPosition, scrollPosition.reverse()); + } + + static Stream dataForTestIsScrollsBackward() { + return Stream.of( + Arguments.of( + TarantoolScrollPosition.forward(Pair.of("pk", Collections.emptyList())), false), + Arguments.of( + TarantoolScrollPosition.backward(Pair.of("pk", Collections.emptyList())), true)); + } + + @ParameterizedTest + @MethodSource("dataForTestIsScrollsBackward") + void testIsScrollsBackward(TarantoolScrollPosition position, boolean expectedIsScrollsBackward) { + assertEquals(expectedIsScrollsBackward, position.isScrollsBackward()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageImplTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageImplTest.java new file mode 100644 index 0000000..e68d0a9 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageImplTest.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.BiFunction; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSONS_COUNT; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generatePersons; +import io.tarantool.spring.data.utils.GenericPerson; +import io.tarantool.spring.data35.utils.entity.Person; + +class TarantoolPageImplTest { + + private static final List PERSONS = generatePersons(PERSONS_COUNT); + + private static final int DEFAULT_PAGE_SIZE_PER_TEST_CLASS = 10; + + private static final int DEFAULT_PAGE_NUMBER_PER_TEST_CLASS = 0; + + private static final Pageable FIRST_PAGE_PAGEABLE_PER_TEST_CLASS = + new TarantoolPageRequest<>( + DEFAULT_PAGE_NUMBER_PER_TEST_CLASS, DEFAULT_PAGE_SIZE_PER_TEST_CLASS, null); + + private static final long MULTIPLIER = 2L; + + private static final BiFunction GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE = + (pageSize, operator) -> { + switch (operator) { + // pageSize > totalPageCount + case ">": + { + return pageSize / MULTIPLIER; + } + // pageSize < totalPageCount + case "<": + { + return MULTIPLIER * pageSize; + } + // pageSize == totalPageCount + case "==": + { + return Long.valueOf(pageSize); + } + default: + throw new IllegalArgumentException("The passed option isn't supported"); + } + }; + + private static final List EMPTY_CONTENT = Collections.emptyList(); + + protected static Stream dataForTestConstructors() { + TarantoolPageable> pageable = + new TarantoolPageRequest<>(0, DEFAULT_PAGE_SIZE_PER_TEST_CLASS, null); + + long personsSize = PERSONS.size(); + + return Stream.of( + Arguments.of( + new TarantoolPageImpl<>(PERSONS, pageable, personsSize), + PERSONS.size() / DEFAULT_PAGE_SIZE_PER_TEST_CLASS, + personsSize), + Arguments.of(new TarantoolPageImpl<>(PERSONS), 1, personsSize), + Arguments.of(new TarantoolPageImpl<>(), 1, 0L)); + } + + @ParameterizedTest + @MethodSource("dataForTestConstructors") + void testConstructors(Page> page, int totalPages, long totalElements) { + assertEquals(totalPages, page.getTotalPages()); + assertEquals(totalElements, page.getTotalElements()); + } + + static Stream dataForTestConstructorsThrowsIllegalArgumentException() { + final class DummyTarantoolPageRequest implements Pageable { + + @Override + public int getPageNumber() { + return 0; + } + + @Override + public int getPageSize() { + return 0; + } + + @Override + public long getOffset() { + return 0L; + } + + @Override + public Sort getSort() { + return null; + } + + @Override + public Pageable next() { + return null; + } + + @Override + public Pageable previousOrFirst() { + return null; + } + + @Override + public Pageable first() { + return null; + } + + @Override + public Pageable withPage(int pageNumber) { + return null; + } + + @Override + public boolean hasPrevious() { + return false; + } + } + + Pageable dummyPageable = new DummyTarantoolPageRequest(); + + return Stream.of( + Arguments.of(null, null, "Content must not be null"), + Arguments.of(Collections.emptyList(), null, "Pageable must not be null"), + Arguments.of( + Collections.emptyList(), + dummyPageable, + "Pageable must be TarantoolPageable or Unpaged type")); + } + + @ParameterizedTest + @MethodSource("dataForTestConstructorsThrowsIllegalArgumentException") + void testConstructors_throws_IllegalArgumentException( + List content, Pageable pageable, String errorMsg) { + assertThrows( + IllegalArgumentException.class, + () -> new TarantoolPageImpl<>(content, pageable, 0L), + errorMsg); + } + + static Stream dataForTestGetTotalPages() { + return Stream.of( + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), ">")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "<")), + MULTIPLIER), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "==")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + Pageable.unpaged(), + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, ">")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + Pageable.unpaged(), + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, "<")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + Pageable.unpaged(), + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, "==")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), ">")), + PERSONS.size() / FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "<")), + MULTIPLIER), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "==")), + 1), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, Pageable.unpaged(), GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, ">")), + 0), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, Pageable.unpaged(), GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, "<")), + 0), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, Pageable.unpaged(), GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply(0, "==")), + 0)); + } + + @ParameterizedTest + @MethodSource("dataForTestGetTotalPages") + void testGetTotalPages(Page page, long totalPagesCount) { + assertEquals(totalPagesCount, page.getTotalPages()); + } + + static Stream dataForTestGetTotalElements() { + return Stream.of( + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), ">")), + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize() / MULTIPLIER), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "<")), + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize() * MULTIPLIER), + Arguments.of( + new TarantoolPageImpl<>( + EMPTY_CONTENT, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "==")), + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), ">")), + PERSONS.size()), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "<")), + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize() * MULTIPLIER), + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + GET_TOTAL_ELEMENTS_FOR_TEST_DATA_CASE.apply( + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize(), "==")), + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize())); + } + + @ParameterizedTest + @MethodSource("dataForTestGetTotalElements") + void testGetTotalElements(Page page, long totalElementCount) { + assertEquals(totalElementCount, page.getTotalElements()); + } + + static Stream dataForTestHasNext() { + return Stream.of( + Arguments.of( + false, + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize())), + Arguments.of( + true, + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + MULTIPLIER * FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize())), + Arguments.of( + false, + new TarantoolPageImpl<>( + PERSONS, + Pageable.unpaged(), + MULTIPLIER * FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()))); + } + + @ParameterizedTest + @MethodSource("dataForTestHasNext") + void testHasNext(boolean exceptedBool, Page page) { + assertEquals(exceptedBool, page.hasNext()); + } + + @ParameterizedTest + @MethodSource("dataForTestHasNext") + void testIsLast(boolean notEqualBool, Page page) { + assertNotEquals(notEqualBool, page.isLast()); + } + + static Stream dataForTestMap() { + List exceptedNames = PERSONS.stream().map(Person::getName).collect(Collectors.toList()); + return Stream.of( + Arguments.of( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()), + exceptedNames, + (Function) Person::getName)); + } + + @ParameterizedTest + @MethodSource("dataForTestMap") + void testMap(Page page, List exceptedNames, Function mapFunction) { + List names = page.map(mapFunction).toList(); + assertEquals(exceptedNames, names); + } + + static Stream dataForTestEqualsAndHashCode() { + List> equalPages = + Arrays.asList( + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()), + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize()), + new TarantoolPageImpl<>( + PERSONS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS, + FIRST_PAGE_PAGEABLE_PER_TEST_CLASS.getPageSize())); + + final int ITERATION_COUNT = 100; + + return Stream.of( + Arguments.of( + equalPages, + new TarantoolPageImpl<>(EMPTY_CONTENT, Pageable.unpaged(), 150), + ITERATION_COUNT), + Arguments.of(equalPages, null, ITERATION_COUNT)); + } + + @ParameterizedTest + @MethodSource("dataForTestEqualsAndHashCode") + void testEqualsAndHashCode(List> pages, Page notEqualPage, int iterationCount) { + for (int i = 0; i < iterationCount; i++) { + for (Page page : pages) { + for (Page otherPage : pages) { + assertEquals(page, otherPage); + assertEquals(page.hashCode(), otherPage.hashCode()); + } + assertNotEquals(page, notEqualPage); + if (notEqualPage != null) { + assertNotEquals(page.hashCode(), notEqualPage.hashCode()); + } + } + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageRequestTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageRequestTest.java new file mode 100644 index 0000000..2e02c97 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolPageRequestTest.java @@ -0,0 +1,284 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Arrays; +import java.util.List; +import java.util.function.Supplier; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.AbstractPageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; + +import static io.tarantool.spring.data.query.PaginationDirection.FORWARD; +import io.tarantool.spring.data.query.PaginationDirection; +import io.tarantool.spring.data.utils.GenericPerson; +import io.tarantool.spring.data35.utils.entity.Person; + +class TarantoolPageRequestTest { + + private static final int PAGE_SIZE = 10; + + private static final Person CURSOR = new Person(0, true, "0"); + + private static final int PAGE_NUMBER = 0; + + protected static Stream dataForTestConstructors() { + return Stream.of( + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_SIZE), + PAGE_NUMBER, + PAGE_SIZE, + Sort.unsorted(), + null, + FORWARD), + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), + PAGE_NUMBER, + PAGE_SIZE, + Sort.unsorted(), + CURSOR, + FORWARD), + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, null), + PAGE_NUMBER, + PAGE_SIZE, + Sort.unsorted(), + null, + FORWARD)); + } + + @ParameterizedTest + @MethodSource("dataForTestConstructors") + void testConstructors( + Supplier>> constructorGenerator, + int pageNumber, + int size, + Sort sort, + GenericPerson cursor, + PaginationDirection direction) { + TarantoolPageable> pageable = constructorGenerator.get(); + + assertEquals(size, pageable.getPageSize()); + assertEquals(pageNumber, pageable.getPageNumber()); + assertEquals(sort, pageable.getSort()); + assertEquals(cursor, pageable.getTupleCursor()); + assertEquals(direction, pageable.getPaginationDirection()); + } + + protected static Stream dataTestGetSort() { + return Stream.of( + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_SIZE), + Sort.unsorted()), + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), + Sort.unsorted())); + } + + @ParameterizedTest + @MethodSource("dataTestGetSort") + void testGetSort(Supplier>> constructorGenerator, Sort sort) { + Pageable pageable = constructorGenerator.get(); + assertEquals(sort, pageable.getSort()); + } + + protected static Stream dataForTestGetTupleCursor() { + return Stream.of( + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_SIZE), + null), + Arguments.of( + (Supplier>>) + () -> new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), + CURSOR)); + } + + @ParameterizedTest + @MethodSource("dataForTestGetTupleCursor") + void testGetTupleCursor( + Supplier>> constructorGenerator, GenericPerson person) { + TarantoolPageable> pageable = constructorGenerator.get(); + assertEquals(person, pageable.getTupleCursor()); + } + + protected static Stream dataForTestNext() { + final Person NEXT_CURSOR = + new Person(CURSOR.getId() + PAGE_SIZE, CURSOR.getIsMarried(), CURSOR.getName()); + return Stream.of( + Arguments.of( + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), CURSOR, NEXT_CURSOR)); + } + + @ParameterizedTest + @MethodSource("dataForTestNext") + void testNext( + TarantoolPageable> pageable, + GenericPerson cursor, + GenericPerson nextCursor) { + assertEquals(cursor, pageable.getTupleCursor()); + + TarantoolPageable> nextPageable = pageable.next(nextCursor); + assertEquals(nextCursor, nextPageable.getTupleCursor()); + + assertEquals(pageable.getPageSize(), nextPageable.getPageSize()); + assertEquals(pageable.getPageNumber() + 1, nextPageable.getPageNumber()); + assertEquals(pageable.getSort(), nextPageable.getSort()); + assertEquals(pageable.getOffset() + PAGE_SIZE, nextPageable.getOffset()); + } + + @Test + void testNextUnsupported() { + TarantoolPageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + assertThrows(UnsupportedOperationException.class, pageable::next); + } + + protected static Stream dataTestGetPaginationDirection() { + final Person PREV_CURSOR = new Person(CURSOR.getId(), CURSOR.getIsMarried(), CURSOR.getName()); + final Person NEXT_CURSOR = + new Person(PREV_CURSOR.getId() + PAGE_SIZE, CURSOR.getIsMarried(), CURSOR.getName()); + return Stream.of( + Arguments.of( + new TarantoolPageRequest<>(1, PAGE_SIZE, NEXT_CURSOR), NEXT_CURSOR, PREV_CURSOR)); + } + + @ParameterizedTest + @MethodSource("dataTestGetPaginationDirection") + void testGetPaginationDirection( + TarantoolPageable> pageable, + GenericPerson nextCursor, + GenericPerson prevCursor) { + + assertEquals(FORWARD, pageable.getPaginationDirection()); + + TarantoolPageable> prevPageable = pageable.previousOrFirst(prevCursor); + assertEquals(PaginationDirection.BACKWARD, prevPageable.getPaginationDirection()); + + TarantoolPageable> nextPageable = prevPageable.next(nextCursor); + assertEquals(FORWARD, nextPageable.getPaginationDirection()); + assertEquals(pageable, nextPageable); + } + + protected static Stream dataTestFirst() { + final Person CURSOR = new Person(2 * PAGE_SIZE, true, "name"); + return Stream.of( + Arguments.of( + new TarantoolPageRequest<>(1, PAGE_SIZE, CURSOR), + new TarantoolPageRequest<>(0, PAGE_SIZE, null))); + } + + @ParameterizedTest + @MethodSource("dataTestFirst") + void testFirst( + TarantoolPageable> pageable, + TarantoolPageable> exceptedFirstPageable) { + assertEquals(exceptedFirstPageable, pageable.first()); + } + + @Test + void testWithPage() { + TarantoolPageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + assertThrows(UnsupportedOperationException.class, () -> pageable.withPage(100)); + } + + protected static Stream dataTestPreviousOrFirst() { + final Person CURSOR = new Person(PAGE_SIZE, true, "name"); + return Stream.of( + Arguments.of( + new TarantoolPageRequest<>(1, PAGE_SIZE, CURSOR), + new Person(CURSOR.getId() - PAGE_SIZE, CURSOR.getIsMarried(), CURSOR.getName()))); + } + + @ParameterizedTest + @MethodSource("dataTestPreviousOrFirst") + void testPreviousOrFirst( + TarantoolPageable> pageable, GenericPerson prevCursor) { + + TarantoolPageable> prevPageable = pageable.previousOrFirst(prevCursor); + + assertNotEquals(pageable, prevPageable); + assertEquals(pageable.getPageSize(), prevPageable.getPageSize()); + assertEquals(pageable.getOffset() - PAGE_SIZE, prevPageable.getOffset()); + assertEquals(pageable.getSort(), prevPageable.getSort()); + assertEquals(pageable.getPageNumber() - 1, prevPageable.getPageNumber()); + + // here will be first with null cursor + TarantoolPageable> firstPageable = prevPageable.previousOrFirst(prevCursor); + assertNull(firstPageable.getTupleCursor()); + } + + @Test + void testPreviousOrFirstUnsupported() { + TarantoolPageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + assertThrows(UnsupportedOperationException.class, pageable::previousOrFirst); + } + + @Test + void testPreviousUnsupported() { + AbstractPageRequest pageable = new TarantoolPageRequest<>(PAGE_SIZE); + assertThrows(UnsupportedOperationException.class, pageable::previous); + } + + protected static Stream dataForTestEquals() { + return Stream.of( + Arguments.of( + Arrays.asList( + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR), + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, CURSOR)), + new TarantoolPageRequest<>(PAGE_SIZE))); + } + + @ParameterizedTest + @MethodSource("dataForTestEquals") + void testEquals( + List>> equalPageableList, + TarantoolPageable> notEqualPageable) { + + assertTrue(equalPageableList.size() > 0); + final int COUNT = 10; + + for (int i = 0; i < COUNT; i++) { + for (TarantoolPageable> pageable : equalPageableList) { + for (TarantoolPageable> secondPageable : equalPageableList) { + assertEquals(pageable, secondPageable); + } + assertNotEquals(pageable, null); + assertNotEquals(pageable, notEqualPageable); + } + } + } + + @ParameterizedTest + @MethodSource("dataForTestEquals") + void testHashCode( + List>> equalPageableList, + TarantoolPageable> notEqualPageable) { + + for (TarantoolPageable> pageable : equalPageableList) { + for (TarantoolPageable> secondPageable : equalPageableList) { + assertEquals(pageable.hashCode(), secondPageable.hashCode()); + } + assertNotEquals(pageable.hashCode(), notEqualPageable.hashCode()); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolSliceTest.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolSliceTest.java new file mode 100644 index 0000000..46072aa --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/query/TarantoolSliceTest.java @@ -0,0 +1,539 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.query; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.function.Executable; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; + +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.PERSONS_COUNT; +import static io.tarantool.spring.data35.utils.TarantoolTestSupport.generatePersons; +import io.tarantool.spring.data35.utils.entity.Person; + +public class TarantoolSliceTest { + + private static final int PAGE_SIZE = 10; + + private static final int PAGE_NUMBER = 0; + + private static final Person SOME_CURSOR = new Person(0, true, "0"); + + private static List PERSONS; + + private static final Pageable SOME_PAGEABLE = + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR); + + @BeforeAll + static void setUp() { + PERSONS = generatePersons(PERSONS_COUNT); + } + + static Stream dataForTestConstructorsShouldThrow() { + Pageable tarantoolPageable = new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR); + Pageable notTarantoolPageable = Pageable.ofSize(PAGE_SIZE); + return Stream.of( + Arguments.of(null, tarantoolPageable, false), + Arguments.of(null, tarantoolPageable, true), + Arguments.of(PERSONS, null, false), + Arguments.of(PERSONS, null, true), + Arguments.of(PERSONS, notTarantoolPageable, false), + Arguments.of(PERSONS, notTarantoolPageable, true)); + } + + @ParameterizedTest + @MethodSource("dataForTestConstructorsShouldThrow") + public void testConstructorsShouldThrow(List content, Pageable pageable, boolean hasNext) { + assertThrows( + IllegalArgumentException.class, () -> new TarantoolSliceImpl<>(content, pageable, hasNext)); + } + + static Stream dataForTestConstructorsDoesntThrow() { + return Stream.of( + () -> new TarantoolSliceImpl<>(PERSONS), + () -> new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), + () -> new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, false)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestConstructorsDoesntThrow") + public void testConstructorsDoesntThrow(Executable runTestCase) { + assertDoesNotThrow(runTestCase); + } + + static Stream dataForTestGetNumber() { + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), SOME_PAGEABLE.getPageNumber()), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), true), 0)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetNumber") + public void testGetNumber(Slice slice, int expectedPageNumber) { + assertEquals(expectedPageNumber, slice.getNumber()); + } + + static Stream dataForTestGetSize() { + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), SOME_PAGEABLE.getPageSize()), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), PERSONS.size()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), + SOME_PAGEABLE.getPageSize()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), 0)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetSize") + public void testGetSize(Slice slice, int expectedSize) { + assertEquals(expectedSize, slice.getSize()); + } + + static Stream dataForTestGetNumberOfElements() { + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), PERSONS.size()), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), PERSONS.size()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), 0), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), 0)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetNumberOfElements") + public void testGetNumberOfElements(Slice slice, int expectedNumberOfElements) { + assertEquals(expectedNumberOfElements, slice.getNumberOfElements()); + } + + static Stream dataForTestGetContent() { + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), PERSONS), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), PERSONS), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), + Collections.emptyList()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Collections.emptyList())); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetContent") + public void testGetContent(Slice slice, List expectedContent) { + assertEquals(expectedContent, slice.getContent()); + } + + static Stream dataForTestHasContent() { + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), true), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), true), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), false), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), false)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestHasContent") + void testHasContent(Slice slice, boolean expectedHasContent) { + assertEquals(expectedHasContent, slice.hasContent()); + } + + static Stream dataForTestGetSort() { + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), SOME_PAGEABLE.getSort()), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Sort.unsorted())); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetSort") + void testGetSort(Slice slice, Sort expectedSort) { + assertEquals(expectedSort, slice.getSort()); + } + + static Stream dataForTestIsFirst() { + final int MIDDLE_PAGE_NUMBER = 2; + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS, new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), true), + true), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS, + new TarantoolPageRequest<>(MIDDLE_PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), + true), + false), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), true), true), + Arguments.of( + new TarantoolSliceImpl<>( + Collections.emptyList(), + new TarantoolPageRequest<>(PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), + true), + true), + Arguments.of( + new TarantoolSliceImpl<>( + Collections.emptyList(), + new TarantoolPageRequest<>(MIDDLE_PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), + true), + true), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), true), true)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestIsFirst") + void testIsFirst(Slice slice, boolean expectedIsFirst) { + assertEquals(expectedIsFirst, slice.isFirst()); + } + + static Stream dataForTestIsLast() { + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), false), + // last imitation + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, false), true), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), true), false), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), false), true), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), true), + // last imitation + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, false), true), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), true), true), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), false), true)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestIsLast") + void testIsLast(Slice slice, boolean expectedIsLast) { + assertEquals(expectedIsLast, slice.isLast()); + } + + static Stream dataForTestHasNext() { + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, new TarantoolPageRequest<>(PAGE_SIZE), true), true), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, new TarantoolPageRequest<>(PAGE_SIZE), false), false), + Arguments.of( + new TarantoolSliceImpl<>( + Collections.emptyList(), new TarantoolPageRequest<>(PAGE_SIZE), true), + false), + Arguments.of( + new TarantoolSliceImpl<>( + Collections.emptyList(), new TarantoolPageRequest<>(PAGE_SIZE), false), + false)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestHasNext") + void testHasNext(Slice slice, boolean expectedHasNext) { + assertEquals(expectedHasNext, slice.hasNext()); + } + + static Stream dataForTestHasPrevious() { + final int MIDDLE_PAGE_NUMBER = 2; + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), false), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS, + new TarantoolPageRequest<>(MIDDLE_PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), + false), + true), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), true), false), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), false), false), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true), false), + Arguments.of( + new TarantoolSliceImpl<>( + Collections.emptyList(), + new TarantoolPageRequest<>(MIDDLE_PAGE_NUMBER, PAGE_SIZE, SOME_CURSOR), + false), + false), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), true), false), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), false), false)); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestHasPrevious") + void testHasPrevious(Slice slice, boolean expectedHasPrevious) { + assertEquals(expectedHasPrevious, slice.hasPrevious()); + } + + static Stream dataForTestGetPageable() { + Pageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS, pageable, true), pageable), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), pageable, true), pageable), + Arguments.of(new TarantoolSliceImpl<>(PERSONS, pageable, false), pageable), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), pageable, false), pageable), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), true), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), true), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, Pageable.unpaged(), false), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), Pageable.unpaged(), false), + Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Pageable.unpaged())); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestGetPageable") + void testGetPageable(Slice slice, Pageable expectedPageable) { + assertEquals(expectedPageable, slice.getPageable()); + } + + static Stream dataForTestNextPageable() { + Pageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), pageable, true), + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1))), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), pageable, true), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), pageable, false), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), pageable, false), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(0, PAGE_SIZE), + new TarantoolPageRequest<>(4, PAGE_SIZE, null), + true), + new TarantoolPageRequest<>(5, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1)))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestNextPageable") + void testNextPageable(Slice slice, Pageable expectedNextPageable) { + assertEquals(expectedNextPageable, slice.nextPageable()); + } + + static Stream dataForTestPreviousPageable() { + Pageable firstPagePageable = new TarantoolPageRequest<>(PAGE_SIZE); + Pageable secondPagePageable = + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1)); + + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), firstPagePageable, true), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), firstPagePageable, true), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), firstPagePageable, false), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), firstPagePageable, false), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(PAGE_SIZE, 2 * PAGE_SIZE), secondPagePageable, true), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(PAGE_SIZE))), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), secondPagePageable, true), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(PAGE_SIZE, 2 * PAGE_SIZE), secondPagePageable, false), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(PAGE_SIZE))), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), secondPagePageable, false), + Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(0, PAGE_SIZE), + new TarantoolPageRequest<>(4, PAGE_SIZE, null), + true), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(4, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(0)))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestPreviousPageable") + void testPreviousPageable(Slice slice, Pageable expectedPrevPageable) { + assertEquals(expectedPrevPageable, slice.previousPageable()); + } + + static Stream dataForTestNextOrLastPageable() { + Pageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), pageable, true), + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1))), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), pageable, true), pageable), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), pageable, false), pageable), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList(), pageable, false), pageable), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(0, PAGE_SIZE), + new TarantoolPageRequest<>(4, PAGE_SIZE, null), + true), + new TarantoolPageRequest<>(5, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1))), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(0, PAGE_SIZE), + new TarantoolPageRequest<>(4, PAGE_SIZE, null), + false), + new TarantoolPageRequest<>(4, PAGE_SIZE, null))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestNextOrLastPageable") + void testNextOrLastPageable(Slice slice, Pageable expectedNextOrLastPageable) { + assertEquals(expectedNextOrLastPageable, slice.nextOrLastPageable()); + } + + static Stream dataForTestPreviousOrFirstPageable() { + Pageable firstPagePageable = new TarantoolPageRequest<>(PAGE_SIZE); + Pageable secondPagePageable = + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE - 1)); + + return Stream.of( + Arguments.of(new TarantoolSliceImpl<>(PERSONS), Pageable.unpaged()), + Arguments.of(new TarantoolSliceImpl<>(Collections.emptyList()), Pageable.unpaged()), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), firstPagePageable, true), + firstPagePageable), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), firstPagePageable, true), + firstPagePageable), + Arguments.of( + new TarantoolSliceImpl<>(PERSONS.subList(0, PAGE_SIZE), firstPagePageable, false), + firstPagePageable), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), firstPagePageable, false), + firstPagePageable), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(PAGE_SIZE, 2 * PAGE_SIZE), secondPagePageable, true), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(PAGE_SIZE))), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), secondPagePageable, true), + secondPagePageable), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(PAGE_SIZE, 2 * PAGE_SIZE), secondPagePageable, false), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(1, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(PAGE_SIZE))), + Arguments.of( + new TarantoolSliceImpl<>(Collections.emptyList(), secondPagePageable, false), + secondPagePageable), + Arguments.of( + new TarantoolSliceImpl<>( + PERSONS.subList(0, PAGE_SIZE), + new TarantoolPageRequest<>(4, PAGE_SIZE, null), + true), + // to take into account the direction of pagination use previousOrFirst method + new TarantoolPageRequest<>(4, PAGE_SIZE, PERSONS.get(PAGE_SIZE)) + .previousOrFirst(PERSONS.get(0)))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestPreviousOrFirstPageable") + void testPreviousOrFirstPageable(Slice slice, Pageable expectedPrevOrFirstPageable) { + assertEquals(expectedPrevOrFirstPageable, slice.previousOrFirstPageable()); + } + + static Stream dataForTestMap() { + + Pageable pageable = new TarantoolPageRequest<>(PAGE_SIZE); + List expectedNames = PERSONS.stream().map(Person::getName).collect(Collectors.toList()); + return Stream.of( + Arguments.of( + new TarantoolSliceImpl<>(PERSONS, pageable, true), + (Function) Person::getName, + expectedNames, + new TarantoolSliceImpl<>(expectedNames, pageable, true))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestMap") + void testMap( + Slice slice, + Function mapper, + List expectedMappedList, + Slice expectedMappedSlice) { + assertEquals(expectedMappedSlice, slice.map(mapper)); + assertEquals(expectedMappedList, slice.map(mapper).getContent()); + } + + protected static Stream dataForTestEquals() { + + return Stream.of( + Arguments.of( + Arrays.asList( + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true), + new TarantoolSliceImpl<>(PERSONS, SOME_PAGEABLE, true)), + new TarantoolSliceImpl<>(Collections.emptyList(), SOME_PAGEABLE, true))); + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestEquals") + void testEquals(List> equalPageableList, Slice notEqualPageable) { + + assertFalse(equalPageableList.isEmpty()); + final int COUNT = 10; + + for (int i = 0; i < COUNT; i++) { + for (Slice pageable : equalPageableList) { + for (Slice secondPageable : equalPageableList) { + assertEquals(pageable, secondPageable); + } + assertNotEquals(pageable, null); + assertNotEquals(pageable, notEqualPageable); + } + } + } + + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("dataForTestEquals") + void testHashCode(List> equalPageableList, Slice notEqualPageable) { + for (Slice pageable : equalPageableList) { + for (Slice secondPageable : equalPageableList) { + assertEquals(pageable.hashCode(), secondPageable.hashCode()); + } + assertNotEquals(pageable.hashCode(), notEqualPageable.hashCode()); + } + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/TarantoolTestSupport.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/TarantoolTestSupport.java new file mode 100644 index 0000000..de90d9e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/TarantoolTestSupport.java @@ -0,0 +1,433 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils; + +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Consumer; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import org.springframework.lang.NonNull; +import org.springframework.lang.Nullable; +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.introspector.Property; +import org.yaml.snakeyaml.nodes.NodeTuple; +import org.yaml.snakeyaml.nodes.Tag; +import org.yaml.snakeyaml.representer.Representer; + +import static io.tarantool.balancer.BalancerMode.DEFAULT_BALANCER_MODE; +import static io.tarantool.client.TarantoolClient.DEFAULT_TAG; +import static io.tarantool.client.crud.TarantoolCrudClient.DEFAULT_CRUD_PASSWORD; +import static io.tarantool.client.crud.TarantoolCrudClient.DEFAULT_CRUD_USERNAME; +import static io.tarantool.core.protocol.requests.IProtoAuth.DEFAULT_AUTH_TYPE; +import static io.tarantool.pool.InstanceConnectionGroup.DEFAULT_HOST; +import static io.tarantool.spring.data.ProxyTarantoolQueryEngine.unwrapTuples; +import io.tarantool.balancer.BalancerMode; +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.client.crud.TarantoolCrudSpace; +import io.tarantool.core.protocol.requests.IProtoAuth; +import io.tarantool.mapping.Tuple; +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties.PropertyFlushConsolidationHandler; +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties.PropertyHeartbeatOpts; +import io.tarantool.spring.data.config.properties.BaseTarantoolProperties.PropertyInstanceConnectionGroup; +import io.tarantool.spring.data35.config.properties.TarantoolProperties; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.Person; + +public class TarantoolTestSupport { + + private static final ThreadLocalRandom random = ThreadLocalRandom.current(); + private static final Map firstInternalMap = new HashMap<>(); + private static final Map secondInternalMap = new HashMap<>(); + private static final Map externalMap = new HashMap<>(); + private static final Representer representer; + public static final String PERSON_SPACE = "person"; + public static final String COMPLEX_PERSON_SPACE = "complex_person"; + public static final int PERSONS_COUNT = 100; + public static final Random RANDOMIZER = new Random(); + public static final Person UNKNOWN_PERSON = new Person(-1, true, "Alien"); + public static final ComplexPerson UNKNOWN_COMPLEX_PERSON = + new ComplexPerson(-1, new UUID(0, 0), true, "Alien"); + + public static final Path DEFAULT_TEST_PROPERTY_DIR = Paths.get("target", "test-classes"); + private static final Yaml yaml; + + static { + representer = + new Representer(new DumperOptions()) { + @Override + @Nullable + protected NodeTuple representJavaBeanProperty( + Object javaBean, Property property, @Nullable Object propertyValue, Tag customTag) { + if (propertyValue == null) { + return null; + } else { + return super.representJavaBeanProperty(javaBean, property, propertyValue, customTag); + } + } + }; + representer.addClassTag(TarantoolProperties.class, Tag.MAP); + yaml = new Yaml(representer, new DumperOptions()); + } + + public static TarantoolProperties writeTestPropertiesYaml(@NonNull final String fileName) + throws IOException { + return writeTestPropertiesYaml(fileName, createRandomProperty()); + } + + public static TarantoolProperties writeTestPropertiesYaml( + final String fileName, final TarantoolProperties properties) throws IOException { + final File FILE = DEFAULT_TEST_PROPERTY_DIR.resolve(fileName).toFile().getAbsoluteFile(); + + try (Writer writer = new FileWriter(FILE)) { + firstInternalMap.put("tarantool", properties); + secondInternalMap.put("data", firstInternalMap); + externalMap.put("spring", secondInternalMap); + + yaml.dump(externalMap, writer); + replaceNullToDefault(properties); + return properties; + } + } + + public static void writeTestEmptyPropertiesYaml(@NonNull final String fileName) + throws IOException { + final File FILE = DEFAULT_TEST_PROPERTY_DIR.resolve(fileName).toFile().getAbsoluteFile(); + try (Writer writer = new FileWriter(FILE)) { + yaml.dump(Collections.emptyMap(), writer); + } + } + + private static void replaceNullToDefault(final TarantoolProperties result) { + + if (result.getHost() == null) { + result.setHost(DEFAULT_HOST); + } + + if (result.getUserName() == null) { + result.setUserName(DEFAULT_CRUD_USERNAME); + } + + if (result.getBalancerMode() == null) { + result.setBalancerMode(DEFAULT_BALANCER_MODE); + } + + if (result.getPassword() == null) { + result.setPassword(DEFAULT_CRUD_PASSWORD); + } + + if (result.getConnectionGroups() != null) { + for (PropertyInstanceConnectionGroup propertyInstanceConnectionGroup : + result.getConnectionGroups()) { + if (propertyInstanceConnectionGroup.getAuthType() == null) { + propertyInstanceConnectionGroup.setAuthType(DEFAULT_AUTH_TYPE); + } + + if (propertyInstanceConnectionGroup.getHost() == null) { + propertyInstanceConnectionGroup.setHost(DEFAULT_HOST); + } + + if (propertyInstanceConnectionGroup.getTag() == null) { + propertyInstanceConnectionGroup.setTag(DEFAULT_TAG); + } + + if (propertyInstanceConnectionGroup.getUserName() == null) { + propertyInstanceConnectionGroup.setUserName(DEFAULT_CRUD_USERNAME); + } + + if (propertyInstanceConnectionGroup.getUserName() == null) { + propertyInstanceConnectionGroup.setPassword(DEFAULT_CRUD_PASSWORD); + } + } + } + } + + private static TarantoolProperties createRandomProperty() { + final int intVar = 999; + final long longVar = 9999L; + final List booleanVar = Arrays.asList(true, false); + + PropertyHeartbeatOpts propertyHeartbeatOpts = + heartbeat( + choiceOrNull(longVar), + choiceOrNull(intVar), + choiceOrNull(intVar), + choiceOrNull(intVar)); + + PropertyFlushConsolidationHandler propertyFlushConsolidationHandler = + choiceOrNull(flushConsolidation(choiceOrNull(intVar), choiceOrNull(booleanVar))); + + PropertyInstanceConnectionGroup propertyInstanceConnectionGroup = + choiceOrNull( + connectionGroup( + choiceOrNull("host"), + "password", + choiceOrNull(intVar), + choiceOrNull(intVar), + choiceOrNull("tag"), + "user", + choiceOrNull(Arrays.asList(IProtoAuth.AuthType.values())), + propertyFlushConsolidationHandler)); + List propertyInstanceConnectionGroups = null; + if (propertyInstanceConnectionGroup != null) { + propertyInstanceConnectionGroups = new ArrayList<>(); + propertyInstanceConnectionGroups.add(propertyInstanceConnectionGroup); + } + + return tarantoolProperties( + choiceOrNull("host"), + choiceOrNull("password"), + choiceOrNull(intVar), + choiceOrNull("user"), + propertyInstanceConnectionGroups, + choiceOrNull(intVar), + choiceOrNull(booleanVar), + choiceOrNull(propertyHeartbeatOpts), + choiceOrNull(longVar), + choiceOrNull(longVar), + choiceOrNull(booleanVar), + choiceOrNull(booleanVar), + choiceOrNull(Arrays.asList(BalancerMode.values()))); + } + + @Nullable + public static T choiceOrNull(@Nullable T element) { + int rnd = random.nextInt(2); + if (rnd == 0 || element == null) { + return null; + } + return element; + } + + public static PropertyHeartbeatOpts heartbeat( + @Nullable Long pingInterval, + @Nullable Integer invalidationThreshold, + @Nullable Integer windowSize, + @Nullable Integer deathThreshold) { + + PropertyHeartbeatOpts propertyHeartbeatOpts = new PropertyHeartbeatOpts(); + if (invalidationThreshold != null) { + propertyHeartbeatOpts.setInvalidationThreshold(invalidationThreshold); + } + if (pingInterval != null) { + propertyHeartbeatOpts.setPingInterval(pingInterval); + } + if (deathThreshold != null) { + propertyHeartbeatOpts.setDeathThreshold(deathThreshold); + } + if (windowSize != null) { + propertyHeartbeatOpts.setWindowSize(windowSize); + } + + return propertyHeartbeatOpts; + } + + public static PropertyFlushConsolidationHandler flushConsolidation( + @Nullable Integer explicitFlushAfterFlushes, + @Nullable Boolean consolidateWhenNoReadInProgress) { + + PropertyFlushConsolidationHandler propertyFlushConsolidationHandler = + new PropertyFlushConsolidationHandler(); + if (explicitFlushAfterFlushes != null) { + propertyFlushConsolidationHandler.setExplicitFlushAfterFlushes(explicitFlushAfterFlushes); + } + + if (consolidateWhenNoReadInProgress != null) { + propertyFlushConsolidationHandler.setConsolidateWhenNoReadInProgress( + consolidateWhenNoReadInProgress); + } + return propertyFlushConsolidationHandler; + } + + @Nullable + public static T choiceOrNull(@Nullable List list) { + if (list == null || list.isEmpty()) { + return null; + } + int rnd = random.nextInt(list.size() + 1); + + if (rnd == list.size()) { + return null; + } + return list.get(rnd); + } + + @NonNull + public static PropertyInstanceConnectionGroup connectionGroup( + @Nullable String host, + @Nullable String password, + @Nullable Integer port, + @Nullable Integer connectionNumber, + @Nullable String tag, + @Nullable String user, + @Nullable IProtoAuth.AuthType type, + @Nullable PropertyFlushConsolidationHandler propertyFlushConsolidationHandler) { + + PropertyInstanceConnectionGroup propertyInstanceConnectionGroup = + new PropertyInstanceConnectionGroup(); + propertyInstanceConnectionGroup.setHost(host); + if (connectionNumber != null) { + propertyInstanceConnectionGroup.setConnectionGroupSize(connectionNumber); + } + propertyInstanceConnectionGroup.setAuthType(type); + propertyInstanceConnectionGroup.setPassword(password); + if (port != null) { + propertyInstanceConnectionGroup.setPort(port); + } + propertyInstanceConnectionGroup.setTag(tag); + propertyInstanceConnectionGroup.setUserName(user); + + propertyInstanceConnectionGroup.setFlushConsolidationHandler(propertyFlushConsolidationHandler); + return propertyInstanceConnectionGroup; + } + + @NonNull + public static TarantoolProperties tarantoolProperties( + @Nullable String host, + @Nullable String password, + @Nullable Integer port, + @Nullable String user, + @Nullable List propertyInstanceConnectionGroups, + @Nullable Integer eventLoopThreadsCount, + @Nullable Boolean enableGracefulShutdown, + @Nullable PropertyHeartbeatOpts propertyHeartbeatOptsOpts, + @Nullable Long connectTimeout, + @Nullable Long reconnectAfter, + @Nullable Boolean fetchSchema, + @Nullable Boolean ignoreOldSchemaVersion, + @Nullable BalancerMode balancerClass) { + + TarantoolProperties prop = new TarantoolProperties(); + prop.setHost(host); + prop.setPassword(password); + if (port != null) { + prop.setPort(port); + } + prop.setUserName(user); + prop.setConnectionGroups(propertyInstanceConnectionGroups); + if (eventLoopThreadsCount != null) { + prop.setEventLoopThreadsCount(eventLoopThreadsCount); + } + + if (enableGracefulShutdown != null) { + prop.setGracefulShutdownEnabled(enableGracefulShutdown); + } + prop.setHeartbeat(propertyHeartbeatOptsOpts); + if (connectTimeout != null) { + prop.setConnectTimeout(connectTimeout); + } + if (reconnectAfter != null) { + prop.setReconnectAfter(reconnectAfter); + } + if (fetchSchema != null) { + prop.setFetchSchema(fetchSchema); + } + if (ignoreOldSchemaVersion != null) { + prop.setIgnoreOldSchemaVersion(ignoreOldSchemaVersion); + } + prop.setBalancerMode(balancerClass); + return prop; + } + + public static List generatePersons(int count) { + List persons = new ArrayList<>(); + for (int i = 0; i < count; i++) { + persons.add(new Person(i, i % 3 == 0 ? null : i % 2 == 0, "User-" + i)); + } + return persons; + } + + public static List generatePersons(int count, Consumer actionAtPerson) { + List persons = generatePersons(count); + persons.forEach(actionAtPerson); + return persons; + } + + public static List generateComplexPersons(int count) { + List persons = new ArrayList<>(); + for (int i = 0; i < count; i++) { + persons.add( + new ComplexPerson(i, UUID.randomUUID(), i % 3 == 0 ? null : i % 2 == 0, "User-" + i)); + } + return persons; + } + + public static List generateComplexPersons( + int count, Consumer actionAtPerson) { + List persons = generateComplexPersons(count); + persons.forEach(actionAtPerson); + return persons; + } + + public static List generateAndInsertPersons(int count, TarantoolCrudClient client) { + final List persons = generatePersons(count); + final TarantoolCrudSpace space = client.space(PERSON_SPACE); + + List> insertedPersons = space.insertMany(persons, Person.class).join().getRows(); + insertedPersons.sort(Comparator.comparing((p) -> p.get().getId())); + + assertEquals(persons, unwrapTuples(insertedPersons)); + + return persons; + } + + public static List generateAndInsertPersons( + int count, TarantoolCrudClient client, Consumer action) { + final List persons = generatePersons(count, action); + final TarantoolCrudSpace space = client.space(PERSON_SPACE); + + List> insertedPersons = space.insertMany(persons, Person.class).join().getRows(); + insertedPersons.sort(Comparator.comparing((p) -> p.get().getId())); + + assertEquals(persons, unwrapTuples(insertedPersons)); + + return persons; + } + + public static List generateAndInsertComplexPersons( + int count, TarantoolCrudClient client) { + final List persons = generateComplexPersons(count); + final TarantoolCrudSpace space = client.space(COMPLEX_PERSON_SPACE); + + List> insertedPersons = + space.insertMany(persons, ComplexPerson.class).join().getRows(); + insertedPersons.sort(Comparator.comparing((p) -> p.get().getId())); + + assertEquals(persons, unwrapTuples(insertedPersons)); + + return persons; + } + + public static List generateAndInsertComplexPersons( + int count, TarantoolCrudClient client, Consumer action) { + + final List persons = generateComplexPersons(count, action); + final TarantoolCrudSpace space = client.space(COMPLEX_PERSON_SPACE); + + List> insertedPersons = + space.insertMany(persons, ComplexPerson.class).join().getRows(); + insertedPersons.sort(Comparator.comparing((p) -> p.get().getId())); + + assertEquals(persons, unwrapTuples(insertedPersons)); + + return persons; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonListRepository.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonListRepository.java new file mode 100644 index 0000000..5c80d13 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonListRepository.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.core; + +import org.springframework.data.repository.ListCrudRepository; + +import io.tarantool.spring.data.utils.GenericListRepositoryMethods; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.CompositePersonKey; + +public interface ComplexPersonListRepository + extends ListCrudRepository, + GenericListRepositoryMethods {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonRepository.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonRepository.java new file mode 100644 index 0000000..224a77a --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/ComplexPersonRepository.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.core; + +import org.springframework.data.repository.PagingAndSortingRepository; + +import io.tarantool.spring.data.utils.GenericRepositoryMethods; +import io.tarantool.spring.data35.utils.entity.ComplexPerson; +import io.tarantool.spring.data35.utils.entity.CompositePersonKey; + +public interface ComplexPersonRepository + extends PagingAndSortingRepository, + GenericRepositoryMethods, + GenericPaginationMethods {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/GenericPaginationMethods.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/GenericPaginationMethods.java new file mode 100644 index 0000000..d3162d9 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/GenericPaginationMethods.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.core; + +import java.util.List; + +import org.springframework.data.domain.Limit; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.ScrollPosition; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.Sort; +import org.springframework.data.domain.Window; + +public interface GenericPaginationMethods { + + Slice findAllByName(String name); + + Slice findAllByName(String name, Pageable pageable); + + Slice findAllByNameGreaterThanEqual(String name, Pageable pageable); + + Slice findAllByNameLessThanEqual(String name, Pageable pageable); + + Slice findAllByIdLessThanEqual(int id, Pageable pageable); + + Slice findAllByIdGreaterThanEqual(int id, Pageable pageable); + + Slice findAllByIsMarriedLessThanEqual(Boolean id, Pageable pageable); + + Slice findAllByIsMarriedGreaterThanEqual(Boolean id, Pageable pageable); + + Page findPersonByName(String name, Pageable pageable); + + Page findPersonByNameGreaterThanEqual(String name, Pageable pageable); + + Page findPersonByNameLessThanEqual(String name, Pageable pageable); + + Page findPersonByIdLessThanEqual(int id, Pageable pageable); + + Page findPersonByIdGreaterThanEqual(int id, Pageable pageable); + + Page findPersonByIsMarriedLessThanEqual(Boolean id, Pageable pageable); + + Page findPersonByIsMarriedGreaterThanEqual(Boolean id, Pageable pageable); + + Window findFirst5ByIsMarried(Boolean isMarried, ScrollPosition scrollPosition); + + Window findFirst10ByIsMarriedGreaterThanEqual( + Boolean isMarried, ScrollPosition scrollPosition); + + List findAll(Sort sort); + + Page findAll(Pageable pageable); + + List findAll(); + + List findPersonByIdGreaterThanEqual(int id, Limit limit); + + List findTop4ByIdGreaterThanEqual(int id, Limit limit); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonListRepository.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonListRepository.java new file mode 100644 index 0000000..390dca3 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonListRepository.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.core; + +import org.springframework.data.repository.ListCrudRepository; + +import io.tarantool.spring.data.utils.GenericListRepositoryMethods; +import io.tarantool.spring.data35.utils.entity.Person; + +public interface PersonListRepository + extends ListCrudRepository, GenericListRepositoryMethods {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonRepository.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonRepository.java new file mode 100644 index 0000000..3d8ce32 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/core/PersonRepository.java @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.core; + +import org.springframework.data.repository.PagingAndSortingRepository; + +import io.tarantool.spring.data.utils.GenericRepositoryMethods; +import io.tarantool.spring.data35.utils.entity.Person; +import io.tarantool.spring.data35.utils.fragments.core.ReplaceManyFragment; + +public interface PersonRepository + extends PagingAndSortingRepository, + GenericRepositoryMethods, + GenericPaginationMethods, + ReplaceManyFragment {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPerson.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPerson.java new file mode 100644 index 0000000..7481d8b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPerson.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.annotation.KeySpace; + +import io.tarantool.spring.data.core.annotation.IdClass; +import io.tarantool.spring.data.utils.GenericPerson; +import io.tarantool.spring.data35.query.Field; + +@NoArgsConstructor +@AllArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonIgnoreProperties(ignoreUnknown = true) // for example bucket_id +@Data +@KeySpace("complex_person") +@IdClass(CompositePersonKey.class) +public class ComplexPerson implements GenericPerson { + + @Id + @Field("id") + @JsonProperty("id") + private Integer id; + + @Id + @Field("second_id") + @JsonProperty("secondId") + private UUID secondId; + + @Field("is_married") + @JsonProperty("isMarried") + protected Boolean isMarried; + + @Field("name") + @JsonProperty("name") + String name; + + @Override + public CompositePersonKey generateFullKey() { + return new CompositePersonKey(id, secondId); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithIncorrectPK.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithIncorrectPK.java new file mode 100644 index 0000000..dff02c3 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithIncorrectPK.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.annotation.KeySpace; + +import io.tarantool.spring.data.core.annotation.IdClass; +import io.tarantool.spring.data35.query.Field; + +@NoArgsConstructor +@AllArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonIgnoreProperties(ignoreUnknown = true) // for example bucket_id +@Data +@KeySpace("complex_person") +@IdClass(CompositePersonKeyWithoutJsonFormat.class) +public class ComplexPersonWithIncorrectPK { + + @Id + @JsonProperty("id") + private Integer id; + + @Id + @Field("second_id") + @JsonProperty("secondId") + private UUID secondId; + + @Field("is_married") + @JsonProperty("isMarried") + private Boolean isMarried; + + @JsonProperty("name") + private String name; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithJsonFormatVariantPK.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithJsonFormatVariantPK.java new file mode 100644 index 0000000..995f50f --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/ComplexPersonWithJsonFormatVariantPK.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.annotation.KeySpace; + +import io.tarantool.spring.data.core.annotation.IdClass; +import io.tarantool.spring.data35.query.Field; + +@NoArgsConstructor +@AllArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonIgnoreProperties(ignoreUnknown = true) // for example bucket_id +@Data +@KeySpace("complex_person") +@IdClass(CompositePersonKeyWithJsonFormat.class) +public class ComplexPersonWithJsonFormatVariantPK { + + @Id + @JsonProperty("id") + private Integer id; + + @Id + @Field("second_id") + @JsonProperty("secondId") + private UUID secondId; + + @Field("is_married") + @JsonProperty("isMarried") + private Boolean isMarried; + + @JsonProperty("name") + private String name; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldCount.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldCount.java new file mode 100644 index 0000000..5b5016a --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldCount.java @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import io.tarantool.spring.data.mapping.model.CompositeKey; + +public class CompositeKeyWithWrongFieldCount implements CompositeKey { + + @JsonProperty("id") + private long id; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldTypes.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldTypes.java new file mode 100644 index 0000000..f7306d4 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositeKeyWithWrongFieldTypes.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import io.tarantool.spring.data.mapping.model.CompositeKey; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CompositeKeyWithWrongFieldTypes implements CompositeKey { + + @JsonProperty("id") + private long id; + + @JsonProperty("secondId") + private String secondId; + + @JsonProperty("thirdId") + private boolean thirdId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKey.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKey.java new file mode 100644 index 0000000..bf02604 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKey.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import io.tarantool.spring.data.mapping.model.CompositeKey; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CompositePersonKey implements CompositeKey { + + @JsonProperty("id") + private Integer id; + + @JsonProperty("secondId") + private UUID secondId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithJsonFormat.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithJsonFormat.java new file mode 100644 index 0000000..511dc11 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithJsonFormat.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +public class CompositePersonKeyWithJsonFormat { + + @JsonProperty("id") + private Integer id; + + @JsonProperty("secondId") + private UUID secondId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithoutJsonFormat.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithoutJsonFormat.java new file mode 100644 index 0000000..57a685a --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/CompositePersonKeyWithoutJsonFormat.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import java.util.UUID; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CompositePersonKeyWithoutJsonFormat { + + @JsonProperty("id") + private Integer id; + + @JsonProperty("secondId") + private UUID secondId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithAnnotationAsIdClass.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithAnnotationAsIdClass.java new file mode 100644 index 0000000..1ea45fb --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithAnnotationAsIdClass.java @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import io.tarantool.spring.data.core.annotation.IdClass; + +@IdClass(IdClass.class) +public class EntityWithAnnotationAsIdClass {} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongCompositeKeyPartsCount.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongCompositeKeyPartsCount.java new file mode 100644 index 0000000..678de57 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongCompositeKeyPartsCount.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import io.tarantool.spring.data.core.annotation.IdClass; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@IdClass(CompositeKeyWithWrongFieldCount.class) +public class EntityWithWrongCompositeKeyPartsCount { + + @Id + @JsonProperty("id") + private long id; + + @Id + @JsonProperty("secondId") + private long secondId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongFieldTypes.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongFieldTypes.java new file mode 100644 index 0000000..dd03d0c --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/EntityWithWrongFieldTypes.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; + +import io.tarantool.spring.data.core.annotation.IdClass; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@IdClass(CompositeKeyWithWrongFieldTypes.class) +public class EntityWithWrongFieldTypes { + + @Id + @JsonProperty("id") + private long id; + + @Id + @JsonProperty("secondId") + private String secondId; + + @Id + @JsonProperty("thirdId") + private int thirdId; +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/Person.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/Person.java new file mode 100644 index 0000000..f056a95 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/entity/Person.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.keyvalue.annotation.KeySpace; + +import io.tarantool.spring.data.utils.GenericPerson; +import io.tarantool.spring.data35.query.Field; + +/** + * @author Artyom Dubinin + */ +@NoArgsConstructor +@AllArgsConstructor +@JsonFormat(shape = JsonFormat.Shape.ARRAY) +@JsonIgnoreProperties(ignoreUnknown = true) // for example bucket_id +@Data +@KeySpace("person") +public class Person implements GenericPerson { + + @Id + @Field("id") + @JsonProperty("id") + public Integer id; + + @Field("is_married") + @JsonProperty("isMarried") + private Boolean isMarried; + + @JsonProperty("name") + private String name; + + @Override + public Integer generateFullKey() { + return id; + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/core/ReplaceManyFragment.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/core/ReplaceManyFragment.java new file mode 100644 index 0000000..5f08f7e --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/core/ReplaceManyFragment.java @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.fragments.core; + +import java.util.List; + +public interface ReplaceManyFragment { + + List replaceMany(List tuples); +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/implementations/PersonReplaceManyFragment.java b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/implementations/PersonReplaceManyFragment.java new file mode 100644 index 0000000..67cf159 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/java/io/tarantool/spring/data35/utils/fragments/implementations/PersonReplaceManyFragment.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 VK DIGITAL TECHNOLOGIES LIMITED LIABILITY COMPANY + * All Rights Reserved. + */ + +package io.tarantool.spring.data35.utils.fragments.implementations; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; + +import io.tarantool.client.crud.TarantoolCrudClient; +import io.tarantool.mapping.Tuple; +import io.tarantool.mapping.crud.CrudBatchResponse; +import io.tarantool.spring.data35.utils.entity.Person; +import io.tarantool.spring.data35.utils.fragments.core.ReplaceManyFragment; + +public class PersonReplaceManyFragment implements ReplaceManyFragment { + + private final TarantoolCrudClient client; + + public PersonReplaceManyFragment(TarantoolCrudClient client) { + this.client = client; + } + + @Override + public List replaceMany(List tuples) { + return getResults(this.client.space("person").replaceMany(tuples, Person.class)); + } + + private static List getResults( + CompletableFuture>>> tuples) { + return new ArrayList<>(tuples.join().getRows().stream().map(Tuple::get).toList()); + } +} diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/META-INF/spring.factories b/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/META-INF/spring.factories new file mode 100644 index 0000000..8579575 --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/META-INF/spring.factories @@ -0,0 +1 @@ +io.tarantool.spring.data35.utils.fragments.core.ReplaceManyFragment=io.tarantool.spring.data35.utils.fragments.implementations.PersonReplaceManyFragment \ No newline at end of file diff --git a/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/application-crud-test.yaml b/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/application-crud-test.yaml new file mode 100644 index 0000000..9bc8f9b --- /dev/null +++ b/tarantool-spring-data/tarantool-spring-data-35/src/test/resources/application-crud-test.yaml @@ -0,0 +1,5 @@ +spring: + data: + tarantool: + host: localhost + port: 3301