From 9c52cea80b76522fcad738b3c8d18439610619cb Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 14:48:56 +0200 Subject: [PATCH 01/14] feat: add morphium-jakarta-data as optional module --- morphium-jakarta-data/CHANGELOG.md | 29 + morphium-jakarta-data/README.md | 141 +++ morphium-jakarta-data/pom.xml | 76 ++ .../data/AbstractMorphiumRepository.java | 537 +++++++++++ .../de/caluga/morphium/data/CursorHelper.java | 208 +++++ .../morphium/data/FindMethodBridge.java | 324 +++++++ .../morphium/data/JdqlMethodBridge.java | 839 ++++++++++++++++++ .../de/caluga/morphium/data/JdqlParser.java | 666 ++++++++++++++ .../de/caluga/morphium/data/JdqlQuery.java | 104 +++ .../morphium/data/MethodNameParser.java | 307 +++++++ .../de/caluga/morphium/data/MorphiumPage.java | 156 ++++ .../morphium/data/MorphiumRepository.java | 82 ++ .../caluga/morphium/data/QueryDescriptor.java | 78 ++ .../caluga/morphium/data/QueryExecutor.java | 282 ++++++ .../morphium/data/QueryMethodBridge.java | 213 +++++ .../morphium/data/QueryResultHelper.java | 68 ++ .../morphium/data/RepositoryMetadata.java | 20 + .../de/caluga/morphium/data/SortMapper.java | 54 ++ .../caluga/morphium/data/JdqlParserTest.java | 482 ++++++++++ .../morphium/data/MethodNameParserTest.java | 119 +++ .../morphium/data/QueryExecutorAliasTest.java | 400 +++++++++ 21 files changed, 5185 insertions(+) create mode 100644 morphium-jakarta-data/CHANGELOG.md create mode 100644 morphium-jakarta-data/README.md create mode 100644 morphium-jakarta-data/pom.xml create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlQuery.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumPage.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumRepository.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryDescriptor.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryResultHelper.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/RepositoryMetadata.java create mode 100644 morphium-jakarta-data/src/main/java/de/caluga/morphium/data/SortMapper.java create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java diff --git a/morphium-jakarta-data/CHANGELOG.md b/morphium-jakarta-data/CHANGELOG.md new file mode 100644 index 000000000..a64a69c34 --- /dev/null +++ b/morphium-jakarta-data/CHANGELOG.md @@ -0,0 +1,29 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Changed + +#### Integrated as a module of the Morphium multi-module project +`morphium-jakarta-data` is no longer a standalone Maven project with its own release cycle. It is now built as a module of the Morphium multi-module reactor (`morphium-parent`), lives in the `morphium-jakarta-data/` directory of the [sboesebeck/morphium](https://github.com/sboesebeck/morphium) repository, and is versioned in lockstep with Morphium core. The artifact coordinates changed from `de.caluga:morphium-jakarta-data:1.1.0` (standalone) to `de.caluga:morphium-jakarta-data:` (currently `6.2.6-SNAPSHOT`). The groupId is unchanged. Existing users pinning `1.1.0`/`1.1.0-SNAPSHOT` (or the earlier `1.0.0-SNAPSHOT` line) need to bump the dependency version to match the Morphium core version they use, and should expect the artifact to be built from the Morphium reactor going forward — this repository is archived once the migration completes. No source-level API changes are part of this move; only the build/versioning model changed. + +## [1.1.0-SNAPSHOT] (superseded — see [Unreleased]) + +This heading previously read `[Unreleased] - 1.0.0-SNAPSHOT`, which no longer reflected reality: the module had already moved past `1.0.0-SNAPSHOT` to `1.1.0-SNAPSHOT` as a standalone project before the integration into Morphium made a fixed pre-1.0 standalone version number moot altogether. The entries below are kept for history; going forward, changes are tracked under `[Unreleased]` above and, once released, under the Morphium version they ship with. + +### Added +- Framework-agnostic Jakarta Data 1.0 runtime for Morphium ODM +- `AbstractMorphiumRepository` base class with full CRUD implementation +- `MorphiumRepository` extended interface (distinct, direct Morphium/Query access) +- Query derivation from method names: `findBy*`, `countBy*`, `existsBy*`, `deleteBy*` + - Supported operators: equals, greaterThan, lessThan, like, in, between, not, and, or +- JDQL parsing via `@Query` annotation +- `@Find` / `@Delete` with `@By` parameter binding +- Pagination support: `Page`, `CursoredPage`, `PageRequest` +- Sorting: `Sort`, `Order`, `@OrderBy` +- Stream and async return types: `Stream`, `CompletionStage` +- `RepositoryMetadata` for entity type, ID type, and collection name resolution diff --git a/morphium-jakarta-data/README.md b/morphium-jakarta-data/README.md new file mode 100644 index 000000000..7830a2044 --- /dev/null +++ b/morphium-jakarta-data/README.md @@ -0,0 +1,141 @@ +# Morphium Jakarta Data + +An optional module of [Morphium](https://github.com/sboesebeck/morphium), the MongoDB ODM and messaging framework for Java 21+. This module provides a framework-agnostic [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) runtime — repository implementation, query derivation, JDQL parsing, pagination, and sorting — on top of Morphium. + +## What this module is and is not + +`morphium-jakarta-data` is the shared implementation layer that turns Jakarta Data repository interfaces into Morphium queries. It has **zero framework dependencies**: only Morphium core and the Jakarta Data API. + +Application code typically does **not** depend on this module directly. Instead, it goes through a framework integration: + +| Framework | Module | Repository generation | +|-----------|--------|------------------------| +| Quarkus | `quarkus-morphium` | Gizmo bytecode generation (build-time) | +| Spring Boot | `spring-boot-morphium` | JDK dynamic proxies (runtime) | + +This module exists as a separate artifact so the ~2400 lines of query derivation, JDQL parsing, pagination, and result-type handling are implemented once and shared, instead of being duplicated between the Quarkus and Spring Boot adapters. + +The direct target audience for this module is anyone building their **own** framework integration — Micronaut, Helidon, plain Jakarta EE, or a hand-rolled repository wiring in plain Java. If that is not your situation, use `quarkus-morphium` or `spring-boot-morphium` instead and treat this module as an implementation detail. + +## Optionality + +Morphium core (`de.caluga:morphium`) does **not** depend on this module. Projects that only pull in `de.caluga:morphium` get the ODM, driver, caching, and messaging — but no `jakarta.data-api` dependency and no repository support. Jakarta Data support is opt-in by adding `morphium-jakarta-data` (directly, or transitively via one of the framework integrations). + +## Features + +- `CrudRepository` and `MorphiumRepository` base interfaces +- Query derivation from method names: `findBy*`, `countBy*`, `existsBy*`, `deleteBy*` + - Supported operators: equals, greaterThan, lessThan, like, in, between, not, and, or +- JDQL (Jakarta Data Query Language) support via `@Query` annotation +- `@Find` / `@Delete` with `@By` parameter binding +- Pagination: `Page`, `CursoredPage`, `PageRequest` +- Sorting: `Sort`, `Order`, `@OrderBy` +- Stream and async return types: `Stream`, `CompletionStage` +- `RepositoryMetadata` for entity type, ID type, and collection name resolution + +## Maven Dependency + +```xml + + de.caluga + morphium-jakarta-data + ${project.version} + +``` + +The version tracks Morphium's version lockstep — `morphium-jakarta-data` is released alongside `morphium` core with the same version number, not independently. + +## Architecture + +``` +morphium-jakarta-data + de.caluga.morphium.data + AbstractMorphiumRepository Core CRUD implementation (protected setMorphium) + MorphiumRepository Extended repository interface (distinct, query access) + RepositoryMetadata Entity type, ID type, collection name metadata + QueryDescriptor Parsed query representation (field, operator, value) + MethodNameParser Parses findByXxx method names into QueryDescriptors + JdqlParser / JdqlQuery JDQL (Jakarta Data Query Language) parsing + QueryMethodBridge Executes derived queries (findBy*, countBy*, deleteBy*) + JdqlMethodBridge Executes @Query JDQL methods + FindMethodBridge Executes @Find / @Delete annotated methods + QueryExecutor Low-level Morphium query execution + QueryResultHelper Result type adaptation (List, Stream, Page, Optional) + CursorHelper Cursor-based pagination support + SortMapper Maps Jakarta Data Sort/Order to Morphium sort + MorphiumPage Page/CursoredPage implementation +``` + +### Processing chain + +A repository method call is resolved through a fixed pipeline, regardless of which bridge parses it: + +``` +Repository method call + -> MethodNameParser (findBy*/countBy*/...) or JdqlParser (@Query / JDQL) + -> QueryDescriptor (parsed field/operator/value/sort representation) + -> QueryExecutor (builds and runs the Morphium Query) + -> QueryResultHelper (adapts the raw result to the declared return type) + -> return type (T, Optional, List, Stream, Page, CursoredPage, CompletionStage, ...) +``` + +`@Find` / `@Delete` methods go through `FindMethodBridge` instead of `MethodNameParser`, but join the same `QueryDescriptor` → `QueryExecutor` → `QueryResultHelper` chain from that point on. + +The key design point is `AbstractMorphiumRepository.setMorphium(Morphium)` being `protected` — framework subclasses override it to bridge their injection mechanism: +- Quarkus: `@Inject` + `@PostConstruct` +- Spring Boot: public setter called by `FactoryBean` + +## Building your own framework integration + +To wire a new framework to this module, extend `AbstractMorphiumRepository` for each repository interface and call `setMorphium(Morphium)` once a `Morphium` instance is available from your framework's dependency injection (or from plain code). The repository interface methods delegate to the `doXxx()` methods already implemented on `AbstractMorphiumRepository`; for query-derivation and JDQL methods not covered by the base class, dispatch through `QueryMethodBridge` / `JdqlMethodBridge` / `FindMethodBridge` as needed. + +Minimal example without any framework, wiring a repository by hand: + +```java +import de.caluga.morphium.Morphium; +import de.caluga.morphium.data.AbstractMorphiumRepository; +import de.caluga.morphium.data.RepositoryMetadata; + +public class PersonRepositoryImpl extends AbstractMorphiumRepository + implements PersonRepository { + + public PersonRepositoryImpl(Morphium morphium) { + super(new RepositoryMetadata(Person.class, String.class, "id")); + setMorphium(morphium); + } + + @Override + public Optional findById(String id) { + return doFindById(id); + } + + @Override + public List findAll() { + return doFindAll().toList(); + } +} +``` + +`setMorphium(Morphium)` is `protected`, so it can only be called from within the class hierarchy — subclasses either widen its visibility (as Spring Boot's public setter does) or call it internally from a constructor/lifecycle callback (as the example above and the Quarkus `@PostConstruct` integration do). + +## Building + +This module is part of the Morphium multi-module Maven build. Build it from the root of the `morphium` repository: + +```bash +mvn -pl morphium-jakarta-data -am verify +``` + +`-am` (also-make) ensures `morphium-core` is built first if it is not already up to date in the reactor. + +## Requirements + +| Requirement | Version | +|-------------|---------| +| Java | 21+ | +| Morphium | same version (lockstep) | +| Jakarta Data API | 1.0 | + +## License + +This module is licensed under the same terms as the Morphium project (Apache License 2.0). There is no separate license file for this module — the license is defined at the repository root of the Morphium project. diff --git a/morphium-jakarta-data/pom.xml b/morphium-jakarta-data/pom.xml new file mode 100644 index 000000000..c559ee1fd --- /dev/null +++ b/morphium-jakarta-data/pom.xml @@ -0,0 +1,76 @@ + + + 4.0.0 + + + de.caluga + morphium-parent + 6.3.0-SNAPSHOT + + morphium-jakarta-data + jar + Morphium Jakarta Data + Framework-agnostic Jakarta Data runtime for Morphium ODM + + + de.caluga + morphium + ${project.version} + + + + jakarta.data + jakarta.data-api + + + org.slf4j + slf4j-api + + + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.assertj + assertj-core + test + + + ch.qos.logback + logback-classic + test + + + + src/main/java + src/test/java + + + org.apache.maven.plugins + maven-compiler-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + + org.apache.maven.plugins + maven-source-plugin + + + org.apache.maven.plugins + maven-javadoc-plugin + + + org.apache.maven.plugins + maven-jar-plugin + + + + diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java new file mode 100644 index 000000000..f9c8177bc --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java @@ -0,0 +1,537 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.page.CursoredPage; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.page.impl.CursoredPageRecord; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.Executor; +import java.util.stream.Stream; + +/** + * Framework-agnostic base class for repository implementations. + *

+ * Contains all CRUD, pagination and query logic as regular Java methods ({@code doXxx()}). + * A repository interface annotated with Jakarta Data's {@code @Repository} is not implemented + * by hand; instead, a generated or proxied implementation extends this class and forwards each + * interface method to the matching {@code doXxx()} method here. This keeps the actual business + * logic in plain, testable Java code and out of generated bytecode or dynamic proxies. + *

+ * Role in the processing chain: interface method calls arrive here (or at {@link QueryMethodBridge}, + * {@link FindMethodBridge}, {@link JdqlMethodBridge} for derived, {@code @Find}, and {@code @Query} + * methods respectively), which use {@link #getMorphium()} and {@link #getMetadata()} to build and + * run a {@link Query} against MongoDB. The {@code doXxx()} methods declared directly on this class + * cover the plain {@code CrudRepository} operations that do not require parsing (find by id, + * find all, save, insert, update, delete, paging, cursoring). + *

+ * The single most important design decision of this module is how the {@link Morphium} instance + * reaches the repository: {@link #setMorphium(Morphium)} is the sole extension point for this. + * Framework adapters bridge their own dependency injection to it: + *

    + *
  • A Quarkus CDI adapter injects the {@code Morphium} bean via {@code @Inject} and calls + * {@code setMorphium(morphium)} from a {@code @PostConstruct} method (or overrides + * {@code setMorphium} itself to react to the injection).
  • + *
  • A Spring adapter exposes a public setter that delegates to {@code setMorphium}, so Spring's + * dependency injection (constructor or setter injection) can populate the field.
  • + *
+ * No other part of this module depends on a specific DI framework; only this one method needs to + * be overridden or called by each framework-specific integration. + *

+ * Example of a generated subclass (simplified): + *

{@code
+ * public class ProductRepositoryImpl extends AbstractMorphiumRepository
+ *         implements ProductRepository {
+ *
+ *     public ProductRepositoryImpl(RepositoryMetadata metadata) {
+ *         super(metadata);
+ *     }
+ *
+ *     // Quarkus-style bridging of CDI injection to the extension point:
+ *     @Inject
+ *     Morphium morphium;
+ *
+ *     @PostConstruct
+ *     void init() {
+ *         setMorphium(morphium);
+ *     }
+ *
+ *     @Override
+ *     public Optional findById(String id) {
+ *         return doFindById(id);
+ *     }
+ * }
+ * }
+ * + * @param the entity type + * @param the primary-key type + */ +public abstract class AbstractMorphiumRepository { + + private Morphium morphium; + + private final RepositoryMetadata metadata; + + protected AbstractMorphiumRepository(RepositoryMetadata metadata) { + this.metadata = metadata; + } + + // -- accessors for subclasses and QueryExecutor -------------------------- + + /** + * Returns the {@link Morphium} instance used to run queries and CRUD operations. + * Populated by {@link #setMorphium(Morphium)}. + * + * @return the Morphium instance, or {@code null} if it has not been injected yet + */ + public Morphium getMorphium() { + return morphium; + } + + /** + * Central extension point for wiring dependency injection into this repository. + *

+ * This class is deliberately framework-agnostic and has no dependency on CDI, Spring, or any + * other injection mechanism. Framework-specific integrations bridge their own injection to this + * method: a Quarkus adapter typically injects {@code Morphium} via {@code @Inject} and calls + * this method from a {@code @PostConstruct} lifecycle callback (or overrides this method to be + * notified directly), while a Spring adapter exposes a public setter that delegates here so that + * Spring's constructor or setter injection can populate the instance. Overriding or calling this + * method is the only integration point framework adapters need. + * + * @param morphium the Morphium instance to use for all subsequent operations + */ + protected void setMorphium(Morphium morphium) { + this.morphium = morphium; + } + + /** + * Returns the build-time metadata (entity class, id class, id field name) for this repository. + * + * @return the repository metadata + */ + public RepositoryMetadata getMetadata() { + return metadata; + } + + /** + * Returns the entity type managed by this repository, as declared in {@link #getMetadata()}. + * + * @return the entity class + */ + @SuppressWarnings("unchecked") + public Class entityClass() { + return (Class) metadata.entityClass(); + } + + // -- BasicRepository CRUD operations ------------------------------------- + + /** + * Finds a single entity by its primary key. + * + * @param id the primary key value + * @return an {@link Optional} containing the entity, or {@link Optional#empty()} if not found + */ + @SuppressWarnings("unchecked") + public Optional doFindById(K id) { + T result = (T) morphium.findById(entityClass(), id, null); + return Optional.ofNullable(result); + } + + /** + * Streams all entities of this repository's type. + * + * @return a stream over all entities + */ + public Stream doFindAll() { + return morphium.createQueryFor(entityClass()).stream(); + } + + /** + * Finds all entities as an offset-based {@link Page}, applying the given sort order. + * + * @param pageRequest the requested page (offset, size, whether to compute the total count) + * @param sortBy the sort order to apply, may be {@code null} or empty for no explicit sort + * @return the requested page of entities + */ + @SuppressWarnings("unchecked") + public Page doFindAllPaged(PageRequest pageRequest, Order sortBy) { + Query query = morphium.createQueryFor(entityClass()); + + // Apply sorting from Order + if (sortBy != null && !sortBy.sorts().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (Sort sort : sortBy.sorts()) { + String mongoField = resolveMongoField(sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + } + query.sort(sortMap); + } + + // Apply pagination + int size = pageRequest.size(); + long page = pageRequest.page(); + int skip = (int) ((page - 1) * size); + query.skip(skip).limit(size); + + List content = query.asList(); + + // Total count (only if requested) + long totalElements = -1; + if (pageRequest.requestTotal()) { + totalElements = morphium.createQueryFor(entityClass()).countAll(); + } + + return new MorphiumPage<>(content, totalElements, pageRequest); + } + + /** + * Finds all entities as a keyset (cursor-based) {@link CursoredPage}, applying the given sort + * order. Delegates cursor-condition and cursor-extraction logic to {@link CursorHelper}. + * + * @param pageRequest the requested page (cursor/offset mode, size, whether to compute the total count) + * @param sortBy the sort order defining the keyset fields, may be {@code null} or empty + * @return the requested cursored page of entities + * @throws IllegalArgumentException if {@code pageRequest} requires a cursor but none is present + */ + @SuppressWarnings("unchecked") + public CursoredPage doFindAllCursored(PageRequest pageRequest, Order sortBy) { + Query query = morphium.createQueryFor(entityClass()); + + // Build sort specs from Order + List sortSpecs = new ArrayList<>(); + if (sortBy != null && !sortBy.sorts().isEmpty()) { + for (Sort sort : sortBy.sorts()) { + sortSpecs.add(new CursorHelper.SortSpec(sort.property(), sort.isAscending())); + } + } + + boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + + if (pageRequest.mode() != PageRequest.Mode.OFFSET) { + PageRequest.Cursor cursor = pageRequest.cursor() + .orElseThrow(() -> new IllegalArgumentException( + "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); + CursorHelper.applyCursorCondition(query, cursor, sortSpecs, morphium, entityClass(), isForward); + } + + CursorHelper.applySort(query, sortSpecs, morphium, entityClass(), isForward); + int requestedSize = pageRequest.size(); + query.limit(requestedSize + 1); + + List content = query.asList(); + boolean hasMore = content.size() > requestedSize; + if (hasMore) { + content = new ArrayList<>(content.subList(0, requestedSize)); + } + if (!isForward) { + Collections.reverse(content); + } + + List sortFields = sortSpecs.stream().map(CursorHelper.SortSpec::javaField).toList(); + List cursors = CursorHelper.extractCursors(content, sortFields, morphium, entityClass()); + + long totalElements = -1; + if (pageRequest.requestTotal()) { + totalElements = morphium.createQueryFor(entityClass()).countAll(); + } + + boolean isFirstPage = pageRequest.mode() == PageRequest.Mode.OFFSET; + boolean isLastPage = !hasMore; + + if (content.isEmpty()) { + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + (PageRequest) null, (PageRequest) null); + } + + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + isFirstPage, isLastPage); + } + + /** + * Saves (upserts) a single entity via {@code Morphium.store()}. + * + * @param entity the entity to save + * @return the same entity instance + */ + public Object doSave(Object entity) { + morphium.store(entity); + return entity; + } + + /** + * Saves (upserts) a list of entities via {@code Morphium.storeList()}. + * + * @param entities the entities to save + * @return the same list of entities + */ + @SuppressWarnings("unchecked") + public List doSaveAll(List entities) { + morphium.storeList((List) entities); + return (List) entities; + } + + /** + * Inserts a single new entity via {@code Morphium.insert()}. + * + * @param entity the entity to insert + * @return the same entity instance + */ + public Object doInsert(Object entity) { + morphium.insert(entity); + return entity; + } + + /** + * Inserts a list of new entities via {@code Morphium.insertList()}. + * + * @param entities the entities to insert + * @return the same list of entities + */ + @SuppressWarnings("unchecked") + public List doInsertAll(List entities) { + morphium.insertList((List) entities); + return (List) entities; + } + + /** + * Updates a single entity via {@code Morphium.store()}. + * + * @param entity the entity to update + * @return the same entity instance + */ + public Object doUpdate(Object entity) { + morphium.store(entity); + return entity; + } + + /** + * Updates a list of entities via {@code Morphium.storeList()}. + * + * @param entities the entities to update + * @return the same list of entities + */ + @SuppressWarnings("unchecked") + public List doUpdateAll(List entities) { + morphium.storeList((List) entities); + return (List) entities; + } + + /** + * Deletes a single entity via {@code Morphium.delete()}. + * + * @param entity the entity to delete + */ + public void doDelete(Object entity) { + morphium.delete(entity); + } + + /** + * Deletes a single entity by its primary key, loading it first. Does nothing if no + * entity with the given id exists. + * + * @param id the primary key of the entity to delete + */ + @SuppressWarnings("unchecked") + public void doDeleteById(K id) { + T entity = (T) morphium.findById(entityClass(), id, null); + if (entity != null) { + morphium.delete(entity); + } + } + + /** + * Deletes each entity in the given list via {@code Morphium.delete()}. + * + * @param entities the entities to delete + */ + @SuppressWarnings("unchecked") + public void doDeleteAll(List entities) { + for (Object entity : entities) { + morphium.delete(entity); + } + } + + /** + * Deletes all entities of this repository's type by clearing the entire collection. + */ + public void doDeleteAllNoArg() { + morphium.clearCollection(entityClass()); + } + + /** + * Creates a new, unrestricted Morphium {@link Query} for this repository's entity type. + * + * @return a new query instance + */ + public Query createQuery() { + return morphium.createQueryFor(entityClass()); + } + + // -- MorphiumRepository operations ---------------------------------------- + + /** + * Returns distinct values for the given Java field name across all entities. + * + * @param fieldName the Java field name (resolved to the MongoDB field name) + * @return the distinct values found for the field + */ + @SuppressWarnings("unchecked") + public List doDistinct(String fieldName) { + String mongoField = resolveMongoField(fieldName); + return (List) (List) morphium.createQueryFor(entityClass()).distinct(mongoField); + } + + /** + * Returns the underlying {@link Morphium} instance, for escape-hatch operations that have + * no Jakarta Data equivalent (aggregations, atomic updates, etc.). + * + * @return the Morphium instance + */ + public Morphium doMorphium() { + return morphium; + } + + /** + * Creates a new, unrestricted Morphium {@link Query} for this repository's entity type. + * Equivalent to {@link #createQuery()}, exposed under the name used by + * {@link MorphiumRepository#query()}. + * + * @return a new query instance + */ + public Query doQuery() { + return morphium.createQueryFor(entityClass()); + } + + /** + * Resolves a Java field name to its MongoDB field name via the Morphium annotation/reflection + * helper, falling back to the Java field name itself if resolution fails. + * + * @param javaFieldName the Java field name + * @return the MongoDB field name, or {@code javaFieldName} if it cannot be resolved + */ + @SuppressWarnings("unchecked") + private String resolveMongoField(String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass(), javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } + + // -- Async support -------------------------------------------------------- + + /** + * Returns the executor used for the {@code doXxxAsync()} methods, backed by Morphium's + * async operations thread pool. + * + * @return the async executor + */ + public Executor getAsyncExecutor() { + return morphium.getAsyncOperationsThreadPool(); + } + + /** + * Asynchronous variant of {@link #doFindById(Object)}. + * + * @param id the primary key value + * @return a completion stage yielding the result of {@link #doFindById(Object)} + */ + public CompletionStage> doFindByIdAsync(K id) { + return CompletableFuture.supplyAsync(() -> doFindById(id), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doFindAll()}. + * + * @return a completion stage yielding the result of {@link #doFindAll()} + */ + public CompletionStage> doFindAllAsync() { + return CompletableFuture.supplyAsync(this::doFindAll, getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doSave(Object)}. + * + * @param entity the entity to save + * @return a completion stage yielding the result of {@link #doSave(Object)} + */ + public CompletionStage doSaveAsync(Object entity) { + return CompletableFuture.supplyAsync(() -> doSave(entity), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doSaveAll(List)}. + * + * @param entities the entities to save + * @return a completion stage yielding the result of {@link #doSaveAll(List)} + */ + public CompletionStage> doSaveAllAsync(List entities) { + return CompletableFuture.supplyAsync(() -> doSaveAll(entities), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doInsert(Object)}. + * + * @param entity the entity to insert + * @return a completion stage yielding the result of {@link #doInsert(Object)} + */ + public CompletionStage doInsertAsync(Object entity) { + return CompletableFuture.supplyAsync(() -> doInsert(entity), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doInsertAll(List)}. + * + * @param entities the entities to insert + * @return a completion stage yielding the result of {@link #doInsertAll(List)} + */ + public CompletionStage> doInsertAllAsync(List entities) { + return CompletableFuture.supplyAsync(() -> doInsertAll(entities), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doUpdate(Object)}. + * + * @param entity the entity to update + * @return a completion stage yielding the result of {@link #doUpdate(Object)} + */ + public CompletionStage doUpdateAsync(Object entity) { + return CompletableFuture.supplyAsync(() -> doUpdate(entity), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doUpdateAll(List)}. + * + * @param entities the entities to update + * @return a completion stage yielding the result of {@link #doUpdateAll(List)} + */ + public CompletionStage> doUpdateAllAsync(List entities) { + return CompletableFuture.supplyAsync(() -> doUpdateAll(entities), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doDelete(Object)}. + * + * @param entity the entity to delete + * @return a completion stage that completes when the deletion finishes + */ + public CompletionStage doDeleteAsync(Object entity) { + return CompletableFuture.runAsync(() -> doDelete(entity), getAsyncExecutor()); + } + + /** + * Asynchronous variant of {@link #doDeleteById(Object)}. + * + * @param id the primary key of the entity to delete + * @return a completion stage that completes when the deletion finishes + */ + public CompletionStage doDeleteByIdAsync(K id) { + return CompletableFuture.runAsync(() -> doDeleteById(id), getAsyncExecutor()); + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java new file mode 100644 index 000000000..062459d0f --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java @@ -0,0 +1,208 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.page.PageRequest; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Utility for cursor-based (keyset) pagination. + *

+ * Called from {@link AbstractMorphiumRepository#doFindAllCursored}, {@link FindMethodBridge}, and + * {@link JdqlMethodBridge} whenever a {@code @Find} or {@code @Query} method returns a + * {@code CursoredPage}. It has two responsibilities: turning a sort order into a MongoDB + * {@code $or}/comparison condition that continues a result set after (or before) a given cursor + * ({@link #applyCursorCondition}), and extracting new cursors from the returned entities for the + * next/previous page ({@link #extractCursor}, {@link #extractCursors}). It does not parse queries + * itself; the caller has already built the base {@link Query} and just needs cursor support added. + */ +public final class CursorHelper { + + private CursorHelper() {} + + public record SortSpec(String javaField, boolean ascending) {} + + /** + * Parses an orderBySpec string ("field1:ASC,field2:DESC") into SortSpec list. + * + * @param orderBySpec the encoded sort spec, e.g. {@code "field1:ASC,field2:DESC"}; may be + * {@code null} or empty + * @return the parsed sort specs, empty if {@code orderBySpec} was {@code null} or empty + */ + public static List parseSortSpecs(String orderBySpec) { + List specs = new ArrayList<>(); + if (orderBySpec == null || orderBySpec.isEmpty()) return specs; + for (String part : orderBySpec.split(",")) { + String[] fieldAndDir = part.split(":"); + specs.add(new SortSpec(fieldAndDir[0], !"DESC".equals(fieldAndDir[1]))); + } + return specs; + } + + /** + * Extracts a cursor from an entity based on the sort fields. + * The cursor contains the values of the sort fields in order. + * + * @param entity the entity to extract the cursor values from + * @param sortFields the Java field names, in sort order, that make up the cursor key + * @param morphium the Morphium instance (for field resolution/reflection) + * @param entityClass the entity class + * @return a cursor holding the values of {@code sortFields} for {@code entity}, in order + * @throws IllegalStateException if a field value cannot be extracted from the entity + */ + @SuppressWarnings("unchecked") + public static PageRequest.Cursor extractCursor(Object entity, List sortFields, + Morphium morphium, Class entityClass) { + Object[] values = new Object[sortFields.size()]; + for (int i = 0; i < sortFields.size(); i++) { + values[i] = getFieldValue(entity, sortFields.get(i), morphium, entityClass); + } + return PageRequest.Cursor.forKey(values); + } + + /** + * Extracts cursors for all entities in a content list. + * + * @param content the entities to extract cursors from + * @param sortFields the Java field names, in sort order, that make up the cursor key + * @param morphium the Morphium instance (for field resolution/reflection) + * @param entityClass the entity class + * @return one cursor per entity in {@code content}, in the same order + * @throws IllegalStateException if a field value cannot be extracted from an entity + */ + public static List extractCursors(List content, List sortFields, + Morphium morphium, Class entityClass) { + List cursors = new ArrayList<>(content.size()); + for (Object entity : content) { + cursors.add(extractCursor(entity, sortFields, morphium, entityClass)); + } + return cursors; + } + + /** + * Applies a cursor condition to the query for keyset pagination. + *

+ * For CURSOR_NEXT with sort [amount ASC, id ASC] and cursor [200, "abc"]: + *

+     * $or: [
+     *   { amount: { $gt: 200 } },
+     *   { amount: 200, _id: { $gt: "abc" } }
+     * ]
+     * 
+ * For CURSOR_PREVIOUS, comparison operators are inverted and sort direction is flipped. + * + * @param query the query to add the cursor condition to (modified in place) + * @param cursor the cursor to continue from + * @param sortSpecs the sort fields defining the keyset, in sort order + * @param morphium the Morphium instance (for field resolution) + * @param entityClass the entity class + * @param isForward true for {@code CURSOR_NEXT}, false for {@code CURSOR_PREVIOUS} + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void applyCursorCondition(Query query, PageRequest.Cursor cursor, + List sortSpecs, + Morphium morphium, Class entityClass, + boolean isForward) { + List orQueries = new ArrayList(); + + for (int i = 0; i < sortSpecs.size(); i++) { + Query sub = morphium.createQueryFor(entityClass); + + // All preceding fields must be equal + for (int j = 0; j < i; j++) { + String mongoField = resolveMongoField(morphium, entityClass, sortSpecs.get(j).javaField()); + sub.f(mongoField).eq(cursor.get(j)); + } + + // The i-th field uses a comparison operator + SortSpec spec = sortSpecs.get(i); + String mongoField = resolveMongoField(morphium, entityClass, spec.javaField()); + Object cursorValue = cursor.get(i); + + // Determine comparison direction: + // CURSOR_NEXT + ASC → $gt, CURSOR_NEXT + DESC → $lt + // CURSOR_PREVIOUS + ASC → $lt, CURSOR_PREVIOUS + DESC → $gt + boolean useGt = isForward == spec.ascending(); + + if (useGt) { + sub.f(mongoField).gt(cursorValue); + } else { + sub.f(mongoField).lt(cursorValue); + } + + orQueries.add(sub); + } + + query.or(orQueries); + } + + /** + * Applies sort to a query, inverting direction for CURSOR_PREVIOUS. + * + * @param query the query to sort (modified in place) + * @param sortSpecs the sort fields to apply, in sort order + * @param morphium the Morphium instance (for field resolution) + * @param entityClass the entity class + * @param isForward true for {@code CURSOR_NEXT}/offset paging, false for {@code CURSOR_PREVIOUS} + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void applySort(Query query, List sortSpecs, + Morphium morphium, Class entityClass, + boolean isForward) { + Map sortMap = new LinkedHashMap<>(); + for (SortSpec spec : sortSpecs) { + String mongoField = resolveMongoField(morphium, entityClass, spec.javaField()); + boolean ascending = isForward ? spec.ascending() : !spec.ascending(); + sortMap.put(mongoField, ascending ? 1 : -1); + } + query.sort(sortMap); + } + + /** + * Reads the value of the given Java field from an entity via reflection. + * + * @param entity the entity instance + * @param javaFieldName the Java field name + * @param morphium the Morphium instance (for field resolution) + * @param entityClass the entity class + * @return the field value + * @throws IllegalStateException if the field cannot be found or read + */ + @SuppressWarnings("unchecked") + private static Object getFieldValue(Object entity, String javaFieldName, + Morphium morphium, Class entityClass) { + try { + Field field = morphium.getARHelper().getField(entityClass, javaFieldName); + if (field != null) { + field.setAccessible(true); + return field.get(entity); + } + } catch (Exception e) { + // fallback below + } + throw new IllegalStateException( + "Cannot extract cursor value for field '" + javaFieldName + "' on " + entityClass.getName()); + } + + /** + * Resolves a Java field name to its MongoDB field name, falling back to the Java name. + * + * @param morphium the Morphium instance + * @param entityClass the entity class + * @param javaFieldName the Java field name + * @return the MongoDB field name, or {@code javaFieldName} if it cannot be resolved + */ + @SuppressWarnings("unchecked") + static String resolveMongoField(Morphium morphium, Class entityClass, String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass, javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java new file mode 100644 index 000000000..b2bdb7e61 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java @@ -0,0 +1,324 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.Limit; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.exceptions.EmptyResultException; +import jakarta.data.exceptions.NonUniqueResultException; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; +import jakarta.data.page.impl.CursoredPageRecord; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.stream.Stream; + +/** + * Runtime bridge called by Gizmo-generated repository methods for + * {@code @Find}, {@code @Delete} annotated methods with {@code @By} parameters. + *

+ * The build-time annotation processor does not generate query-building bytecode itself; instead + * it encodes the query specification as simple strings (field:paramIndex pairs for {@code @By} + * conditions, {@code field:ASC/DESC} pairs for {@code @OrderBy}) and emits a call into this class. + * At runtime, {@link #executeFind} decodes those strings, builds a Morphium {@link Query}, applies + * any dynamic {@code Sort}/{@code Order}/{@code Limit}/{@code PageRequest} parameters, delegates + * offset paging to {@link MorphiumPage} and cursor paging to {@link CursorHelper}, and finally + * turns the query into the shape the repository method declares (single entity via + * {@link QueryResultHelper}, {@code Optional}, {@code Stream}, {@code List}, {@code Page}, or + * {@code CursoredPage}). This mirrors what {@link QueryExecutor} does for {@code findBy*}-style + * derived methods, but for methods explicitly annotated with {@code @Find}. + */ +public final class FindMethodBridge { + + private FindMethodBridge() {} + + /** + * Executes a {@code @Find} annotated method. + * + * @param repo the repository instance + * @param conditionsSpec encoded conditions: "field1:0,field2:1" (fieldName:paramIndex, all EQ) + * @param orderBySpec encoded ordering: "field1:ASC,field2:DESC" or "" for none + * @param sortParamIndex index of Sort parameter, -1 if absent + * @param orderParamIndex index of Order parameter, -1 if absent + * @param pageRequestParamIndex index of PageRequest parameter, -1 if absent + * @param limitParamIndex index of Limit parameter, -1 if absent + * @param args the method arguments + * @param returnsSingle true if method returns a single entity T (not List/Stream/Page/Optional) + * @param returnsOptional true if method returns Optional<T> + * @param returnsCursoredPage true if method returns CursoredPage<T> + * @param returnsStream true if method returns Stream<T> + * @return the query result + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object executeFind(AbstractMorphiumRepository repo, + String conditionsSpec, + String orderBySpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsCursoredPage, + boolean returnsStream) { + Morphium morphium = repo.getMorphium(); + Class entityClass = repo.getMetadata().entityClass(); + Query query = morphium.createQueryFor(entityClass); + + // Apply equality conditions from @By parameters + if (!conditionsSpec.isEmpty()) { + for (String part : conditionsSpec.split(",")) { + String[] fieldAndIdx = part.split(":"); + String javaField = fieldAndIdx[0]; + int paramIdx = Integer.parseInt(fieldAndIdx[1]); + String mongoField = resolveMongoField(morphium, entityClass, javaField); + query.f(mongoField).eq(args[paramIdx]); + } + } + + // Apply static @OrderBy sorting + if (!orderBySpec.isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (String part : orderBySpec.split(",")) { + String[] fieldAndDir = part.split(":"); + String javaField = fieldAndDir[0]; + String dir = fieldAndDir[1]; + String mongoField = resolveMongoField(morphium, entityClass, javaField); + sortMap.put(mongoField, "DESC".equals(dir) ? -1 : 1); + } + query.sort(sortMap); + } + + // Apply dynamic Sort parameter + if (sortParamIndex >= 0 && args[sortParamIndex] != null) { + Sort sort = (Sort) args[sortParamIndex]; + Map sortMap = new LinkedHashMap<>(); + String mongoField = resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + query.sort(sortMap); + } + + // Apply dynamic Order parameter (contains multiple Sort entries) + if (orderParamIndex >= 0 && args[orderParamIndex] != null) { + Order order = (Order) args[orderParamIndex]; + if (!order.sorts().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (Object s : order.sorts()) { + Sort sort = (Sort) s; + String mongoField = resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + } + query.sort(sortMap); + } + } + + // Apply Limit parameter + if (limitParamIndex >= 0 && args[limitParamIndex] != null) { + Limit limit = (Limit) args[limitParamIndex]; + query.skip((int) (limit.startAt() - 1)); + query.limit(limit.maxResults()); + } + + // Apply PageRequest parameter → return Page or CursoredPage + if (pageRequestParamIndex >= 0 && args[pageRequestParamIndex] != null) { + PageRequest pageRequest = (PageRequest) args[pageRequestParamIndex]; + + if (returnsCursoredPage) { + return executeCursoredFind(query, pageRequest, conditionsSpec, orderBySpec, + morphium, entityClass, args); + } + + int size = pageRequest.size(); + long page = pageRequest.page(); + int skip = (int) ((page - 1) * size); + query.skip(skip).limit(size); + + List content = query.asList(); + long totalElements = -1; + if (pageRequest.requestTotal()) { + // Re-create query for total count (without skip/limit) + Query countQuery = morphium.createQueryFor(entityClass); + if (!conditionsSpec.isEmpty()) { + for (String p : conditionsSpec.split(",")) { + String[] fieldAndIdx = p.split(":"); + String mongoField = resolveMongoField(morphium, entityClass, fieldAndIdx[0]); + countQuery.f(mongoField).eq(args[Integer.parseInt(fieldAndIdx[1])]); + } + } + totalElements = countQuery.countAll(); + } + return new MorphiumPage<>(content, totalElements, pageRequest); + } + + // Execute + if (returnsOptional) { + return QueryResultHelper.optionalSingle(query); + } + if (returnsSingle) { + return QueryResultHelper.requireSingle(query); + } + if (returnsStream) { + return query.stream(); + } + return query.asList(); + } + + /** + * Executes the cursor-paged branch of {@link #executeFind} for {@code @Find} methods + * returning {@code CursoredPage}. + * + * @param query the base query with conditions and static ordering already applied + * @param pageRequest the requested page (cursor/offset mode, size, whether to compute totals) + * @param conditionsSpec encoded conditions, used to rebuild an unrestricted count query + * @param orderBySpec encoded ordering, used to derive the keyset fields for cursor support + * @param morphium the Morphium instance + * @param entityClass the entity class + * @param args the method arguments (for re-applying conditions to the count query) + * @return the cursored page of results + * @throws IllegalArgumentException if {@code pageRequest} requires a cursor but none is present + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object executeCursoredFind(Query query, PageRequest pageRequest, + String conditionsSpec, String orderBySpec, + Morphium morphium, Class entityClass, + Object[] args) { + List sortSpecs = CursorHelper.parseSortSpecs(orderBySpec); + boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + + if (pageRequest.mode() != PageRequest.Mode.OFFSET) { + // Cursor-based: apply cursor condition and adjusted sort + PageRequest.Cursor cursor = pageRequest.cursor() + .orElseThrow(() -> new IllegalArgumentException( + "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); + CursorHelper.applyCursorCondition(query, cursor, sortSpecs, morphium, entityClass, isForward); + } + + // Apply sort (inverted for CURSOR_PREVIOUS) + CursorHelper.applySort(query, sortSpecs, morphium, entityClass, isForward); + // Fetch one extra to determine hasNext precisely + int requestedSize = pageRequest.size(); + query.limit(requestedSize + 1); + + List content = query.asList(); + boolean hasMore = content.size() > requestedSize; + if (hasMore) { + content = new ArrayList(content.subList(0, requestedSize)); + } + if (!isForward) { + Collections.reverse(content); + } + + // Extract cursors for each row + List sortFields = sortSpecs.stream().map(CursorHelper.SortSpec::javaField).toList(); + List cursors = CursorHelper.extractCursors(content, sortFields, morphium, entityClass); + + long totalElements = -1; + if (pageRequest.requestTotal()) { + Query countQuery = morphium.createQueryFor(entityClass); + if (!conditionsSpec.isEmpty()) { + for (String p : conditionsSpec.split(",")) { + String[] fieldAndIdx = p.split(":"); + String mongoField = resolveMongoField(morphium, entityClass, fieldAndIdx[0]); + countQuery.f(mongoField).eq(args[Integer.parseInt(fieldAndIdx[1])]); + } + } + totalElements = countQuery.countAll(); + } + + boolean isFirstPage = pageRequest.mode() == PageRequest.Mode.OFFSET; + boolean isLastPage = !hasMore; + + if (content.isEmpty()) { + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + (PageRequest) null, (PageRequest) null); + } + + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + isFirstPage, isLastPage); + } + + /** + * Executes a {@code @Delete} annotated method with {@code @By} parameters. + * + * @param repo the repository instance + * @param conditionsSpec encoded conditions (same format as executeFind) + * @param args the method arguments + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static void executeAnnotatedDelete(AbstractMorphiumRepository repo, + String conditionsSpec, + Object[] args) { + Morphium morphium = repo.getMorphium(); + Class entityClass = repo.getMetadata().entityClass(); + Query query = morphium.createQueryFor(entityClass); + + if (!conditionsSpec.isEmpty()) { + for (String part : conditionsSpec.split(",")) { + String[] fieldAndIdx = part.split(":"); + String javaField = fieldAndIdx[0]; + int paramIdx = Integer.parseInt(fieldAndIdx[1]); + String mongoField = resolveMongoField(morphium, entityClass, javaField); + query.f(mongoField).eq(args[paramIdx]); + } + } + + List toDelete = query.asList(); + for (Object entity : toDelete) { + morphium.delete(entity); + } + } + + /** + * Asynchronous variant of {@link #executeFind}, running the query on the repository's + * async executor. + * + * @param repo the repository instance + * @param conditionsSpec encoded conditions, see {@link #executeFind} + * @param orderBySpec encoded ordering, see {@link #executeFind} + * @param sortParamIndex index of Sort parameter, -1 if absent + * @param orderParamIndex index of Order parameter, -1 if absent + * @param pageRequestParamIndex index of PageRequest parameter, -1 if absent + * @param limitParamIndex index of Limit parameter, -1 if absent + * @param args the method arguments + * @param returnsSingle true if method returns a single entity T + * @param returnsOptional true if method returns Optional<T> + * @param returnsCursoredPage true if method returns CursoredPage<T> + * @param returnsStream true if method returns Stream<T> + * @return a completion stage yielding the result of {@link #executeFind} + */ + public static CompletionStage executeFindAsync(AbstractMorphiumRepository repo, + String conditionsSpec, + String orderBySpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsCursoredPage, + boolean returnsStream) { + return CompletableFuture.supplyAsync( + () -> executeFind(repo, conditionsSpec, orderBySpec, sortParamIndex, orderParamIndex, + pageRequestParamIndex, limitParamIndex, args, returnsSingle, returnsOptional, + returnsCursoredPage, returnsStream), + repo.getAsyncExecutor()); + } + + @SuppressWarnings("unchecked") + private static String resolveMongoField(Morphium morphium, Class entityClass, String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass, javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java new file mode 100644 index 000000000..220b83e42 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java @@ -0,0 +1,839 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.aggregation.Aggregator; +import de.caluga.morphium.aggregation.Group; +import de.caluga.morphium.query.Query; +import jakarta.data.Limit; +import jakarta.data.Order; +import jakarta.data.Sort; +import jakarta.data.page.PageRequest; +import jakarta.data.page.impl.CursoredPageRecord; + +import java.lang.reflect.Constructor; +import java.lang.reflect.RecordComponent; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * Runtime bridge for {@code @Query} annotated repository methods. + *

+ * This is the runtime counterpart of {@link JdqlParser}: the build-time processor leaves the JDQL + * string from {@code @Query} untouched and emits a call into this class together with a + * {@code @Param} name-to-index mapping encoded as a string. At runtime, {@link #executeJdql} parses + * the JDQL (cached in {@link #CACHE}, keyed by the raw string) into a {@link JdqlQuery} via + * {@link JdqlParser#parse}, resolves named parameters against the actual method arguments, and then + * either builds a Morphium {@link Query} (conditions, sorting, projection, paging — with cursor + * paging delegated to {@link CursorHelper} and single-result semantics to {@link QueryResultHelper}) + * or, if the JDQL contains aggregate functions, builds an {@link de.caluga.morphium.aggregation.Aggregator} + * pipeline and maps grouped results onto a caller-supplied Java record. This mirrors what + * {@link QueryExecutor} does for method-name-derived queries and what {@link FindMethodBridge} does + * for {@code @Find} methods, but for explicit JDQL query strings. + */ +public final class JdqlMethodBridge { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private JdqlMethodBridge() {} + + /** + * Executes a {@code @Query} annotated method. + * + * @param repo the repository instance + * @param jdql the JDQL query string + * @param paramMapSpec encoded param name-to-index mapping: "cat:0,minPrice:1" + * @param sortParamIndex index of Sort parameter, -1 if absent + * @param orderParamIndex index of Order parameter, -1 if absent + * @param pageRequestParamIndex index of PageRequest parameter, -1 if absent + * @param limitParamIndex index of Limit parameter, -1 if absent + * @param args the method arguments + * @param returnsSingle true if method returns a single entity T + * @param returnsCount true if method returns long (count) + * @param returnsBoolean true if method returns boolean (exists) + * @param returnsOptional true if method returns Optional<T> + * @param returnsCursoredPage true if method returns CursoredPage<T> + * @param orderBySpec encoded ordering from {@code @OrderBy}: "field1:ASC,field2:DESC" + * @param returnsStream true if method returns Stream<T> + * @param resultRecordClass FQCN of a Java Record for GROUP BY result mapping, null otherwise + * @return the query result + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object executeJdql(AbstractMorphiumRepository repo, + String jdql, + String paramMapSpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex, + Object[] args, + boolean returnsSingle, + boolean returnsCount, + boolean returnsBoolean, + boolean returnsOptional, + boolean returnsCursoredPage, + String orderBySpec, + boolean returnsStream, + String resultRecordClass) { + Morphium morphium = repo.getMorphium(); + Class entityClass = repo.getMetadata().entityClass(); + + // Parse JDQL (cached) + JdqlQuery query = CACHE.computeIfAbsent(jdql, JdqlParser::parse); + + // Build param name → value map + Map paramValues = buildParamMap(paramMapSpec, args); + + // Aggregate functions → Aggregation Pipeline (early return) + if (query.aggregateFunctions() != null && !query.aggregateFunctions().isEmpty()) { + PageRequest aggPageRequest = (pageRequestParamIndex >= 0 && args[pageRequestParamIndex] != null) + ? (PageRequest) args[pageRequestParamIndex] : null; + return executeAggregate(repo, query, paramValues, morphium, entityClass, + resultRecordClass, aggPageRequest); + } + + // Build Morphium query + Query mQuery = morphium.createQueryFor(entityClass); + + // Apply conditions + applyConditions(mQuery, query, paramValues, morphium, entityClass); + + // Apply JDQL ORDER BY + if (!query.orderBy().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (JdqlQuery.OrderSpec spec : query.orderBy()) { + String mongoField = resolveMongoField(morphium, entityClass, spec.field()); + sortMap.put(mongoField, spec.ascending() ? 1 : -1); + } + mQuery.sort(sortMap); + } + + // Apply dynamic Sort parameter + if (sortParamIndex >= 0 && args[sortParamIndex] != null) { + Sort sort = (Sort) args[sortParamIndex]; + Map sortMap = new LinkedHashMap<>(); + String mongoField = resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + mQuery.sort(sortMap); + } + + // Apply dynamic Order parameter + if (orderParamIndex >= 0 && args[orderParamIndex] != null) { + Order order = (Order) args[orderParamIndex]; + if (!order.sorts().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (Object s : order.sorts()) { + Sort sort = (Sort) s; + String mongoField = resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + } + mQuery.sort(sortMap); + } + } + + // Apply Limit + if (limitParamIndex >= 0 && args[limitParamIndex] != null) { + Limit limit = (Limit) args[limitParamIndex]; + mQuery.skip((int) (limit.startAt() - 1)); + mQuery.limit(limit.maxResults()); + } + + // Apply SELECT projection (skip for COUNT/EXISTS — they don't need it) + if (query.selectFields() != null && !query.selectFields().isEmpty() + && !returnsCount && !returnsBoolean) { + for (String field : query.selectFields()) { + String mongoField = resolveMongoField(morphium, entityClass, field); + mQuery.addProjection(mongoField); + } + } + + // Apply PageRequest → return Page or CursoredPage + if (pageRequestParamIndex >= 0 && args[pageRequestParamIndex] != null) { + PageRequest pageRequest = (PageRequest) args[pageRequestParamIndex]; + + if (returnsCursoredPage) { + return executeCursoredJdql(mQuery, pageRequest, orderBySpec, query, paramValues, + morphium, entityClass); + } + + int size = pageRequest.size(); + long page = pageRequest.page(); + int skip = (int) ((page - 1) * size); + mQuery.skip(skip).limit(size); + + List content = mQuery.asList(); + long totalElements = -1; + if (pageRequest.requestTotal()) { + Query countQuery = morphium.createQueryFor(entityClass); + applyConditions(countQuery, query, paramValues, morphium, entityClass); + totalElements = countQuery.countAll(); + } + return new MorphiumPage<>(content, totalElements, pageRequest); + } + + // Execute + if (returnsCount) { + return mQuery.countAll(); + } + if (returnsBoolean) { + return mQuery.countAll() > 0; + } + if (returnsOptional) { + return QueryResultHelper.optionalSingle(mQuery); + } + if (returnsSingle) { + return QueryResultHelper.requireSingle(mQuery); + } + if (returnsStream) { + return mQuery.stream(); + } + return mQuery.asList(); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object executeCursoredJdql(Query mQuery, PageRequest pageRequest, + String orderBySpec, JdqlQuery jdqlQuery, + Map paramValues, + Morphium morphium, Class entityClass) { + List sortSpecs = CursorHelper.parseSortSpecs(orderBySpec); + boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + + if (pageRequest.mode() != PageRequest.Mode.OFFSET) { + PageRequest.Cursor cursor = pageRequest.cursor() + .orElseThrow(() -> new IllegalArgumentException( + "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); + CursorHelper.applyCursorCondition(mQuery, cursor, sortSpecs, morphium, entityClass, isForward); + } + + CursorHelper.applySort(mQuery, sortSpecs, morphium, entityClass, isForward); + int requestedSize = pageRequest.size(); + mQuery.limit(requestedSize + 1); + + List content = mQuery.asList(); + boolean hasMore = content.size() > requestedSize; + if (hasMore) { + content = new ArrayList(content.subList(0, requestedSize)); + } + if (!isForward) { + Collections.reverse(content); + } + + List sortFields = sortSpecs.stream().map(CursorHelper.SortSpec::javaField).toList(); + List cursors = CursorHelper.extractCursors(content, sortFields, morphium, entityClass); + + long totalElements = -1; + if (pageRequest.requestTotal()) { + Query countQuery = morphium.createQueryFor(entityClass); + applyConditions(countQuery, jdqlQuery, paramValues, morphium, entityClass); + totalElements = countQuery.countAll(); + } + + boolean isFirstPage = pageRequest.mode() == PageRequest.Mode.OFFSET; + boolean isLastPage = !hasMore; + + if (content.isEmpty()) { + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + (PageRequest) null, (PageRequest) null); + } + + return new CursoredPageRecord<>(content, cursors, totalElements, pageRequest, + isFirstPage, isLastPage); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void applyConditions(Query mQuery, + JdqlQuery query, + Map paramValues, + Morphium morphium, + Class entityClass) { + boolean isOr = query.combinator() == JdqlQuery.Combinator.OR; + + if (isOr && query.conditions().size() > 1) { + List orQueries = new ArrayList<>(); + for (JdqlQuery.JdqlCondition cond : query.conditions()) { + Query sub = morphium.createQueryFor(entityClass); + applyCondition(sub, cond, paramValues, morphium, entityClass); + orQueries.add(sub); + } + mQuery.or(orQueries); + } else { + for (JdqlQuery.JdqlCondition cond : query.conditions()) { + applyCondition(mQuery, cond, paramValues, morphium, entityClass); + } + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static void applyCondition(Query mQuery, + JdqlQuery.JdqlCondition cond, + Map paramValues, + Morphium morphium, + Class entityClass) { + // Handle parenthesized group conditions (e.g. "(a IS NULL OR a = '')") + if (cond.isGroup()) { + if (cond.negated()) { + // NOT (...) → De Morgan: NOT (A OR B) = NOT A AND NOT B + // NOT (A AND B) = NOT A OR NOT B + JdqlQuery.Combinator flipped = cond.groupCombinator() == JdqlQuery.Combinator.OR + ? JdqlQuery.Combinator.AND : JdqlQuery.Combinator.OR; + List negatedSubs = new ArrayList<>(); + for (JdqlQuery.JdqlCondition sub : cond.groupConditions()) { + negatedSubs.add(negateCondition(sub)); + } + applyCondition(mQuery, JdqlQuery.JdqlCondition.group(negatedSubs, flipped), + paramValues, morphium, entityClass); + return; + } + boolean isGroupOr = cond.groupCombinator() == JdqlQuery.Combinator.OR; + if (isGroupOr && cond.groupConditions().size() > 1) { + List orQueries = new ArrayList<>(); + for (JdqlQuery.JdqlCondition sub : cond.groupConditions()) { + Query subQuery = morphium.createQueryFor(entityClass); + applyCondition(subQuery, sub, paramValues, morphium, entityClass); + orQueries.add(subQuery); + } + mQuery.or(orQueries); + } else { + for (JdqlQuery.JdqlCondition sub : cond.groupConditions()) { + applyCondition(mQuery, sub, paramValues, morphium, entityClass); + } + } + return; + } + + String mongoField = resolveMongoField(morphium, entityClass, cond.fieldName()); + var field = mQuery.f(mongoField); + + Object value = resolveValue(cond.valueRef(), paramValues); + + // Resolve the effective operator (invert if negated) + JdqlQuery.Operator op = cond.negated() ? invertOperator(cond.operator()) : cond.operator(); + + switch (op) { + case EQ -> { + if (cond.literal() != null) { + field.eq(cond.literal()); + } else { + field.eq(value); + } + } + case NE -> { + if (cond.literal() != null) { + field.ne(cond.literal()); + } else { + field.ne(value); + } + } + case GT -> field.gt(value); + case GTE -> field.gte(value); + case LT -> field.lt(value); + case LTE -> field.lte(value); + case BETWEEN -> { + Object value2 = resolveValue(cond.valueRef2(), paramValues); + if (cond.negated()) { + // NOT BETWEEN a AND b → field < a OR field > b + List orQueries = new ArrayList<>(); + Query ltQuery = morphium.createQueryFor(entityClass); + ltQuery.f(mongoField).lt(value); + orQueries.add(ltQuery); + Query gtQuery = morphium.createQueryFor(entityClass); + gtQuery.f(mongoField).gt(value2); + orQueries.add(gtQuery); + mQuery.or(orQueries); + } else { + field.gte(value); + mQuery.f(mongoField).lte(value2); + } + } + case IN -> field.in((Collection) value); + case NOT_IN -> field.nin((Collection) value); + case LIKE -> { + String pattern = value.toString() + .replace("%", ".*") + .replace("_", "."); + if (cond.negated()) { + // NOT LIKE → $not with $regex + field.not(); + field.matches(Pattern.compile(pattern)); + } else { + field.matches(Pattern.compile(pattern)); + } + } + case IS_NULL -> field.eq(null); + case IS_NOT_NULL -> field.ne(null); + } + } + + /** + * Negates a condition for De Morgan transformation of NOT (...) groups. + * Simple conditions get their negated flag flipped; groups are recursively De Morgan'd. + */ + private static JdqlQuery.JdqlCondition negateCondition(JdqlQuery.JdqlCondition cond) { + if (cond.isGroup()) { + if (cond.negated()) { + // Double negation: NOT applied to already-negated group → cancel out + return JdqlQuery.JdqlCondition.group(cond.groupConditions(), cond.groupCombinator()); + } + // De Morgan: NOT (A OR B) = NOT A AND NOT B + JdqlQuery.Combinator flipped = cond.groupCombinator() == JdqlQuery.Combinator.OR + ? JdqlQuery.Combinator.AND : JdqlQuery.Combinator.OR; + List negatedChildren = new ArrayList<>(); + for (JdqlQuery.JdqlCondition child : cond.groupConditions()) { + negatedChildren.add(negateCondition(child)); + } + return JdqlQuery.JdqlCondition.group(negatedChildren, flipped); + } + return new JdqlQuery.JdqlCondition(cond.fieldName(), cond.operator(), cond.valueRef(), + cond.valueRef2(), cond.literal(), !cond.negated(), null, null); + } + + /** + * Inverts an operator for NOT negation. + */ + private static JdqlQuery.Operator invertOperator(JdqlQuery.Operator op) { + return switch (op) { + case EQ -> JdqlQuery.Operator.NE; + case NE -> JdqlQuery.Operator.EQ; + case GT -> JdqlQuery.Operator.LTE; + case GTE -> JdqlQuery.Operator.LT; + case LT -> JdqlQuery.Operator.GTE; + case LTE -> JdqlQuery.Operator.GT; + case IN -> JdqlQuery.Operator.NOT_IN; + case NOT_IN -> JdqlQuery.Operator.IN; + case IS_NULL -> JdqlQuery.Operator.IS_NOT_NULL; + case IS_NOT_NULL -> JdqlQuery.Operator.IS_NULL; + // LIKE and BETWEEN: keep as-is, handle negation in applyCondition directly + case LIKE, BETWEEN -> op; + }; + } + + private static Object resolveValue(String valueRef, Map paramValues) { + if (valueRef == null) return null; + if (valueRef.startsWith(":")) { + String paramName = valueRef.substring(1); + if (!paramValues.containsKey(paramName)) { + throw new IllegalArgumentException( + "JDQL parameter :" + paramName + " not found. Available: " + paramValues.keySet()); + } + return paramValues.get(paramName); + } + // Try numeric literal + try { + if (valueRef.contains(".")) { + return Double.parseDouble(valueRef); + } + return Long.parseLong(valueRef); + } catch (NumberFormatException e) { + // Return as string literal (strip quotes if present) + if ((valueRef.startsWith("'") && valueRef.endsWith("'")) + || (valueRef.startsWith("\"") && valueRef.endsWith("\""))) { + return valueRef.substring(1, valueRef.length() - 1); + } + return valueRef; + } + } + + private static Map buildParamMap(String paramMapSpec, Object[] args) { + Map map = new HashMap<>(); + if (paramMapSpec == null || paramMapSpec.isEmpty()) return map; + for (String entry : paramMapSpec.split(",")) { + String[] parts = entry.split(":"); + String name = parts[0]; + int idx = Integer.parseInt(parts[1]); + map.put(name, args[idx]); + } + return map; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object executeAggregate(AbstractMorphiumRepository repo, + JdqlQuery query, + Map paramValues, + Morphium morphium, Class entityClass, + String resultRecordClass, + PageRequest pageRequest) { + Aggregator agg = morphium.createAggregator(entityClass, Map.class); + + // $match stage: WHERE conditions + if (!query.conditions().isEmpty()) { + Query matchQuery = morphium.createQueryFor(entityClass); + applyConditions(matchQuery, query, paramValues, morphium, entityClass); + agg.match(matchQuery); + } + + boolean isGrouped = query.groupByFields() != null && !query.groupByFields().isEmpty(); + boolean isCompoundGroup = isGrouped && query.groupByFields().size() > 1; + + // $addFields for COUNT(field) NULL filtering — must come before $group + Map addFieldsMap = new LinkedHashMap<>(); + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + JdqlQuery.AggregateFunction func = query.aggregateFunctions().get(i); + if (func.type() == JdqlQuery.AggregateType.COUNT && !"this".equals(func.field())) { + String mongoField = resolveMongoField(morphium, entityClass, func.field()); + String helperField = "_cnt_notnull_" + i; + addFieldsMap.put(helperField, Map.of( + "$cond", Arrays.asList( + Map.of("$ne", Arrays.asList("$" + mongoField, null)), + 1, 0 + ) + )); + } + } + if (!addFieldsMap.isEmpty()) { + agg.addFields(addFieldsMap); + } + + // $group stage + Group group; + if (isCompoundGroup) { + Map compoundId = new LinkedHashMap<>(); + for (String field : query.groupByFields()) { + compoundId.put(field, "$" + resolveMongoField(morphium, entityClass, field)); + } + group = agg.group(compoundId); + } else if (isGrouped) { + String groupField = query.groupByFields().get(0); + String mongoGroupField = "$" + resolveMongoField(morphium, entityClass, groupField); + group = agg.group(mongoGroupField); + } else { + group = agg.group((String) null); + } + + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + JdqlQuery.AggregateFunction func = query.aggregateFunctions().get(i); + String resultField = "agg_" + i; + String mongoField = "this".equals(func.field()) ? null + : "$" + resolveMongoField(morphium, entityClass, func.field()); + + switch (func.type()) { + case COUNT -> { + if ("this".equals(func.field())) { + group.sum(resultField, 1); + } else { + group.sum(resultField, "$_cnt_notnull_" + i); + } + } + case SUM -> group.sum(resultField, mongoField); + case AVG -> group.avg(resultField, mongoField); + case MIN -> group.min(resultField, mongoField); + case MAX -> group.max(resultField, mongoField); + } + } + group.end(); + + // $match stages for HAVING (post-group filter) + if (query.havingConditions() != null && !query.havingConditions().isEmpty()) { + if (query.havingCombinator() == JdqlQuery.Combinator.OR) { + // OR: single $match with $or array + List> orConditions = new ArrayList<>(); + for (JdqlQuery.HavingCondition hc : query.havingConditions()) { + String aggField = resolveAggFieldForHaving(hc.aggregateFunction(), query); + Object value = resolveValue(hc.valueRef(), paramValues); + orConditions.add(Map.of(aggField, Map.of(toMongoOperator(hc.operator()), value))); + } + agg.addOperator(Map.of("$match", Map.of("$or", orConditions))); + } else { + // AND: separate $match stages (InMemory multi-field workaround) + for (JdqlQuery.HavingCondition hc : query.havingConditions()) { + String aggField = resolveAggFieldForHaving(hc.aggregateFunction(), query); + Object value = resolveValue(hc.valueRef(), paramValues); + agg.addOperator(Map.of("$match", + Map.of(aggField, Map.of(toMongoOperator(hc.operator()), value)))); + } + } + } + + // For compound GROUP BY: $project to promote _id sub-fields to top level + // (InMemAggregator's $sort doesn't handle dotted paths like _id.fieldName) + if (isCompoundGroup) { + Map projectFields = new LinkedHashMap<>(); + for (String field : query.groupByFields()) { + projectFields.put(field, "$_id." + field); + } + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + projectFields.put("agg_" + i, 1); + } + projectFields.put("_id", 0); + agg.addOperator(Map.of("$project", projectFields)); + } + + // $sort stage (only for GROUP BY with ORDER BY) + if (isGrouped && !query.orderBy().isEmpty()) { + Map sortMap = new LinkedHashMap<>(); + for (JdqlQuery.OrderSpec spec : query.orderBy()) { + String sortKey = resolveAggSortKey(spec.field(), query, morphium, entityClass); + sortMap.put(sortKey, spec.ascending() ? 1 : -1); + } + agg.sort(sortMap); + } + + List> results = agg.aggregateMap(); + + // Grouped → List or Page + if (isGrouped) { + List allMapped = mapGroupedResults(results, query, resultRecordClass); + + if (pageRequest != null) { + int size = pageRequest.size(); + long page = pageRequest.page(); + int skip = (int) ((page - 1) * size); + long totalElements = allMapped.size(); + + List pageContent; + if (skip >= allMapped.size()) { + pageContent = List.of(); + } else { + int end = Math.min(skip + size, allMapped.size()); + pageContent = allMapped.subList(skip, end); + } + + long effectiveTotal = pageRequest.requestTotal() ? totalElements : -1; + return new MorphiumPage<>(pageContent, effectiveTotal, pageRequest); + } + + return allMapped; + } + + // --- Global aggregation (existing v1 logic) --- + if (results.isEmpty()) { + if (query.aggregateFunctions().size() == 1) { + return defaultAggregateValue(query.aggregateFunctions().get(0).type()); + } + Object[] arr = new Object[query.aggregateFunctions().size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = defaultAggregateValue(query.aggregateFunctions().get(i).type()); + } + return arr; + } + + Map result = results.get(0); + + if (query.aggregateFunctions().size() == 1) { + return toNumber(result.get("agg_0"), query.aggregateFunctions().get(0).type()); + } + + // Multiple aggregates → Object[] + Object[] arr = new Object[query.aggregateFunctions().size()]; + for (int i = 0; i < arr.length; i++) { + arr[i] = toNumber(result.get("agg_" + i), query.aggregateFunctions().get(i).type()); + } + return arr; + } + + private static String toMongoOperator(JdqlQuery.Operator op) { + return switch (op) { + case EQ -> "$eq"; + case NE -> "$ne"; + case GT -> "$gt"; + case GTE -> "$gte"; + case LT -> "$lt"; + case LTE -> "$lte"; + default -> throw new IllegalArgumentException("Unsupported HAVING operator: " + op); + }; + } + + private static String resolveAggFieldForHaving(String aggFuncStr, JdqlQuery query) { + Matcher aggMatcher = Pattern.compile( + "(?i)(COUNT|SUM|AVG|MIN|MAX)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_.]*|this)\\s*\\)") + .matcher(aggFuncStr); + if (!aggMatcher.matches()) { + throw new IllegalArgumentException("HAVING references invalid aggregate: " + aggFuncStr); + } + String funcName = aggMatcher.group(1).toUpperCase(Locale.ROOT); + String argName = aggMatcher.group(2); + JdqlQuery.AggregateType type = JdqlQuery.AggregateType.valueOf(funcName); + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + JdqlQuery.AggregateFunction f = query.aggregateFunctions().get(i); + if (f.type() == type && f.field().equals(argName)) { + return "agg_" + i; + } + } + throw new IllegalArgumentException("HAVING references unknown aggregate: " + aggFuncStr); + } + + private static String resolveAggSortKey(String orderField, JdqlQuery query, + Morphium morphium, Class entityClass) { + // Check if it's an aggregate function reference like "COUNT(this)" + Matcher aggMatcher = Pattern.compile( + "(?i)(COUNT|SUM|AVG|MIN|MAX)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_.]*|this)\\s*\\)") + .matcher(orderField); + if (aggMatcher.matches()) { + String funcName = aggMatcher.group(1).toUpperCase(Locale.ROOT); + String argName = aggMatcher.group(2); + JdqlQuery.AggregateType type = JdqlQuery.AggregateType.valueOf(funcName); + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + JdqlQuery.AggregateFunction f = query.aggregateFunctions().get(i); + if (f.type() == type && f.field().equals(argName)) { + return "agg_" + i; + } + } + throw new IllegalArgumentException("ORDER BY references unknown aggregate: " + orderField); + } + // Check if it's a group field → _id (scalar) or plain field name (compound, after $project) + if (query.groupByFields() != null && query.groupByFields().contains(orderField)) { + return query.groupByFields().size() == 1 ? "_id" : orderField; + } + return resolveMongoField(morphium, entityClass, orderField); + } + + @SuppressWarnings("unchecked") + private static List mapGroupedResults(List> results, + JdqlQuery query, + String resultRecordClass) { + if (resultRecordClass == null || resultRecordClass.isEmpty()) { + throw new IllegalArgumentException( + "GROUP BY queries must return List. " + + "Declare a Java record matching the SELECT clause."); + } + + Class recordClass; + try { + recordClass = Thread.currentThread().getContextClassLoader().loadClass(resultRecordClass); + } catch (ClassNotFoundException e) { + throw new IllegalArgumentException("Record class not found: " + resultRecordClass, e); + } + + RecordComponent[] components = recordClass.getRecordComponents(); + int groupFieldCount = query.groupByFields() != null ? query.groupByFields().size() : 0; + int expectedCount = groupFieldCount + query.aggregateFunctions().size(); + if (components.length != expectedCount) { + throw new IllegalArgumentException( + "Record " + recordClass.getSimpleName() + " has " + components.length + + " components but SELECT has " + expectedCount + " fields"); + } + + Class[] paramTypes = new Class[components.length]; + for (int i = 0; i < components.length; i++) { + paramTypes[i] = components[i].getType(); + } + Constructor ctor; + try { + ctor = recordClass.getDeclaredConstructor(paramTypes); + } catch (NoSuchMethodException e) { + throw new IllegalArgumentException("No canonical constructor for " + recordClass.getName(), e); + } + + List mapped = new ArrayList<>(); + for (Map row : results) { + Object[] ctorArgs = new Object[components.length]; + // Group fields from _id (scalar) or top-level (compound, after $project) + if (groupFieldCount == 1) { + ctorArgs[0] = convertValue(row.get("_id"), paramTypes[0]); + } else { + for (int i = 0; i < groupFieldCount; i++) { + String fieldName = query.groupByFields().get(i); + ctorArgs[i] = convertValue(row.get(fieldName), paramTypes[i]); + } + } + // Aggregates from agg_0, agg_1, ... + for (int i = 0; i < query.aggregateFunctions().size(); i++) { + Object val = row.get("agg_" + i); + ctorArgs[groupFieldCount + i] = convertAggValue(val, paramTypes[groupFieldCount + i]); + } + try { + mapped.add(ctor.newInstance(ctorArgs)); + } catch (Exception e) { + throw new RuntimeException("Failed to instantiate " + recordClass.getSimpleName(), e); + } + } + return mapped; + } + + private static Object convertValue(Object value, Class targetType) { + if (value == null) return null; + if (targetType.isInstance(value)) return value; + if (targetType == String.class) return value.toString(); + if (targetType == long.class || targetType == Long.class) return ((Number) value).longValue(); + if (targetType == int.class || targetType == Integer.class) return ((Number) value).intValue(); + if (targetType == double.class || targetType == Double.class) return ((Number) value).doubleValue(); + if (targetType == boolean.class || targetType == Boolean.class) return Boolean.valueOf(value.toString()); + return value; + } + + private static Object convertAggValue(Object value, Class targetType) { + if (value == null) { + if (targetType == long.class || targetType == Long.class) return 0L; + if (targetType == double.class || targetType == Double.class) return 0.0; + if (targetType == int.class || targetType == Integer.class) return 0; + return null; + } + return convertValue(value, targetType); + } + + private static Object defaultAggregateValue(JdqlQuery.AggregateType type) { + return switch (type) { + case COUNT -> 0L; + case SUM, AVG, MIN, MAX -> 0.0; + }; + } + + private static Object toNumber(Object value, JdqlQuery.AggregateType type) { + if (value == null) return defaultAggregateValue(type); + if (type == JdqlQuery.AggregateType.COUNT) { + return ((Number) value).longValue(); + } + if (value instanceof Number n) { + return (n instanceof Integer || n instanceof Long) ? n.longValue() : n.doubleValue(); + } + return value; + } + + /** + * Asynchronous variant of {@link #executeJdql}, running the query on the repository's + * async executor. + * + * @param repo the repository instance + * @param jdql the JDQL query string + * @param paramMapSpec encoded param name-to-index mapping, see {@link #executeJdql} + * @param sortParamIndex index of Sort parameter, -1 if absent + * @param orderParamIndex index of Order parameter, -1 if absent + * @param pageRequestParamIndex index of PageRequest parameter, -1 if absent + * @param limitParamIndex index of Limit parameter, -1 if absent + * @param args the method arguments + * @param returnsSingle true if method returns a single entity T + * @param returnsCount true if method returns long (count) + * @param returnsBoolean true if method returns boolean (exists) + * @param returnsOptional true if method returns Optional<T> + * @param returnsCursoredPage true if method returns CursoredPage<T> + * @param orderBySpec encoded ordering from {@code @OrderBy} + * @param returnsStream true if method returns Stream<T> + * @param resultRecordClass FQCN of a Java Record for GROUP BY result mapping, null otherwise + * @return a completion stage yielding the result of {@link #executeJdql} + */ + public static CompletionStage executeJdqlAsync(AbstractMorphiumRepository repo, + String jdql, + String paramMapSpec, + int sortParamIndex, + int orderParamIndex, + int pageRequestParamIndex, + int limitParamIndex, + Object[] args, + boolean returnsSingle, + boolean returnsCount, + boolean returnsBoolean, + boolean returnsOptional, + boolean returnsCursoredPage, + String orderBySpec, + boolean returnsStream, + String resultRecordClass) { + return CompletableFuture.supplyAsync( + () -> executeJdql(repo, jdql, paramMapSpec, sortParamIndex, orderParamIndex, + pageRequestParamIndex, limitParamIndex, args, returnsSingle, returnsCount, + returnsBoolean, returnsOptional, returnsCursoredPage, orderBySpec, + returnsStream, resultRecordClass), + repo.getAsyncExecutor()); + } + + @SuppressWarnings("unchecked") + private static String resolveMongoField(Morphium morphium, Class entityClass, String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass, javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java new file mode 100644 index 000000000..7501dfddd --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java @@ -0,0 +1,666 @@ +package de.caluga.morphium.data; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses a JDQL (Jakarta Data Query Language) string into a {@link JdqlQuery}. + *

+ * This is the entry point of the {@code @Query} processing chain: {@link JdqlMethodBridge} calls + * {@link #parse} once per distinct JDQL string (results are cached there) and gets back a + * {@link JdqlQuery} descriptor, analogous to how {@link MethodNameParser} turns a method name into + * a {@link QueryDescriptor} for derived queries. The descriptor is then translated into a Morphium + * {@link de.caluga.morphium.query.Query} (or an aggregation pipeline for GROUP BY) by + * {@code JdqlMethodBridge}, with cursor pagination handled by {@link CursorHelper} and single-result + * semantics by {@link QueryResultHelper}. + *

+ * Supported JDQL subset (MongoDB-compatible): + *

{@code
+ * [SELECT field1, field2 [FROM EntityName]]
+ * [WHERE condition [AND|OR condition ...]]
+ * [GROUP BY field1, field2 [HAVING aggCondition [AND|OR ...]]]
+ * [ORDER BY field [ASC|DESC] [, ...]]
+ * }
+ * Conditions: + *
    + *
  • {@code field = :param} / {@code field <> :param} / {@code field != :param}
  • + *
  • {@code field > :param} / {@code field >= :param} / {@code field < :param} / {@code field <= :param}
  • + *
  • {@code field BETWEEN :min AND :max}
  • + *
  • {@code field IN :param}
  • + *
  • {@code field NOT IN :param}
  • + *
  • {@code field LIKE :param}
  • + *
  • {@code field IS NULL} / {@code field IS NOT NULL}
  • + *
  • Boolean literals: {@code field = true} / {@code field = false}
  • + *
  • Numeric literals: {@code field > 100}
  • + *
  • String literals: {@code field = 'value'}
  • + *
  • NOT prefix: {@code NOT field = :param} / {@code NOT field LIKE :pattern}
  • + *
+ * Parenthesized groups: {@code field1 = :a AND (field2 IS NULL OR field2 = '')} + * NOT prefix: {@code NOT field BETWEEN :min AND :max}, {@code NOT (cond1 OR cond2)} + *

+ * Aggregate functions ({@code COUNT}, {@code SUM}, {@code AVG}, {@code MIN}, {@code MAX}) are + * recognized in the SELECT clause and turn the query into an aggregation pipeline, for example: + *

{@code
+ * SELECT category, COUNT(this), SUM(price)
+ * FROM Product
+ * WHERE active = true
+ * GROUP BY category
+ * HAVING COUNT(this) > 1
+ * ORDER BY category
+ * }
+ * Not supported: JOINs, subqueries. + */ +public final class JdqlParser { + + private JdqlParser() {} + + // Split ORDER BY from WHERE (case-insensitive) + private static final Pattern ORDER_BY_SPLIT = Pattern.compile( + "^(.+?)\\s+ORDER\\s+BY\\s+(.+)$", Pattern.CASE_INSENSITIVE); + + // Match aggregate function: COUNT(this), SUM(amount), AVG(field), MIN(field), MAX(field) + private static final Pattern AGGREGATE_PATTERN = Pattern.compile( + "(?i)(COUNT|SUM|AVG|MIN|MAX)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_.]*|this)\\s*\\)"); + + // Match a named parameter :paramName + private static final Pattern PARAM_REF = Pattern.compile(":([a-zA-Z_][a-zA-Z0-9_]*)"); + + // Match numeric literal (int or double) + private static final Pattern NUMERIC_LITERAL = Pattern.compile("-?\\d+(\\.\\d+)?"); + + // BETWEEN :min AND :max + private static final Pattern BETWEEN_PATTERN = Pattern.compile( + "(.+?)\\s+BETWEEN\\s+(.+?)\\s+AND\\s+(.+)", Pattern.CASE_INSENSITIVE); + + // NOT IN :param + private static final Pattern NOT_IN_PATTERN = Pattern.compile( + "(.+?)\\s+NOT\\s+IN\\s+(.+)", Pattern.CASE_INSENSITIVE); + + // IN :param + private static final Pattern IN_PATTERN = Pattern.compile( + "(.+?)\\s+IN\\s+(.+)", Pattern.CASE_INSENSITIVE); + + // LIKE :param + private static final Pattern LIKE_PATTERN = Pattern.compile( + "(.+?)\\s+LIKE\\s+(.+)", Pattern.CASE_INSENSITIVE); + + // Comparison operators: >=, <=, <>, !=, >, <, = + private static final Pattern COMP_PATTERN = Pattern.compile("(.+?)\\s*(>=|<=|<>|!=|>|<|=)\\s*(.+)"); + + // HAVING condition: AGGREGATE(field) operator value + private static final Pattern HAVING_PATTERN = Pattern.compile( + "(?i)(COUNT|SUM|AVG|MIN|MAX)\\s*\\(\\s*([a-zA-Z_][a-zA-Z0-9_.]*|this)\\s*\\)" + + "\\s*(>=|<=|<>|!=|>|<|=)\\s*(.+)"); + + /** + * Parses a JDQL query string. + * + * @param jdql the JDQL string (may or may not start with "SELECT" or "WHERE") + * @return the parsed query descriptor + * @throws IllegalArgumentException if the JDQL cannot be parsed + */ + public static JdqlQuery parse(String jdql) { + if (jdql == null || jdql.isBlank()) { + return new JdqlQuery(null, null, List.of(), JdqlQuery.Combinator.AND, List.of(), null, null, JdqlQuery.Combinator.AND); + } + + String trimmed = jdql.trim(); + String upper = trimmed.toUpperCase(Locale.ROOT); + + // --- Parse SELECT clause --- + List selectFields = null; + List aggregateFunctions = null; + List selectPlainFields = null; // non-null when SELECT mixes aggs + plain fields + if (upper.startsWith("SELECT ")) { + int selectEnd = findSelectEnd(upper); + String selectPart = trimmed.substring("SELECT ".length(), selectEnd).trim(); + List rawFields = Arrays.stream(selectPart.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + + // Classify each field: aggregate function or plain field + List plainFields = new ArrayList<>(); + List aggFuncs = new ArrayList<>(); + for (String field : rawFields) { + Matcher aggMatcher = AGGREGATE_PATTERN.matcher(field); + if (aggMatcher.matches()) { + String funcName = aggMatcher.group(1).toUpperCase(Locale.ROOT); + String argName = aggMatcher.group(2); + JdqlQuery.AggregateType type = JdqlQuery.AggregateType.valueOf(funcName); + aggFuncs.add(new JdqlQuery.AggregateFunction(type, argName)); + } else { + plainFields.add(field); + } + } + + // Defer validation of mixed agg+fields — GROUP BY may legitimize it + if (!aggFuncs.isEmpty() && !plainFields.isEmpty()) { + aggregateFunctions = aggFuncs; + selectPlainFields = plainFields; + } else if (!aggFuncs.isEmpty()) { + aggregateFunctions = aggFuncs; + } else { + selectFields = plainFields; + } + + // Advance past SELECT fields + trimmed = trimmed.substring(selectEnd).trim(); + upper = trimmed.toUpperCase(Locale.ROOT); + + // Skip optional FROM clause + if (upper.startsWith("FROM ")) { + int fromEnd = findFromEnd(upper); + trimmed = trimmed.substring(fromEnd).trim(); + upper = trimmed.toUpperCase(Locale.ROOT); + } + } + + // --- From here, the remainder is [WHERE ...] [GROUP BY ...] [ORDER BY ...] --- + if (trimmed.isEmpty()) { + if (selectPlainFields != null) { + throw new IllegalArgumentException( + "Mixing aggregate functions and field projections requires GROUP BY: " + jdql); + } + return new JdqlQuery(selectFields, aggregateFunctions, List.of(), JdqlQuery.Combinator.AND, List.of(), null, null, JdqlQuery.Combinator.AND); + } + + // Split off ORDER BY + List orderBy = new ArrayList<>(); + String wherePart = trimmed; + + Matcher orderMatcher = ORDER_BY_SPLIT.matcher(trimmed); + if (orderMatcher.matches()) { + wherePart = orderMatcher.group(1).trim(); + String orderPart = orderMatcher.group(2).trim(); + orderBy = parseOrderBy(orderPart); + } + + // Split off GROUP BY from the remainder (ORDER BY already removed) + List groupByFields = null; + String whereUpper = wherePart.toUpperCase(Locale.ROOT); + int groupByIdx = whereUpper.indexOf(" GROUP BY "); + if (groupByIdx < 0 && whereUpper.startsWith("GROUP BY ")) { + groupByIdx = 0; + } + List havingConditions = null; + JdqlQuery.Combinator havingCombinator = JdqlQuery.Combinator.AND; + if (groupByIdx >= 0) { + String groupByPart; + if (groupByIdx == 0) { + groupByPart = wherePart.substring("GROUP BY ".length()).trim(); + wherePart = ""; + } else { + groupByPart = wherePart.substring(groupByIdx + " GROUP BY ".length()).trim(); + wherePart = wherePart.substring(0, groupByIdx).trim(); + } + + // Split HAVING from GROUP BY part (ORDER BY already removed) + String groupByUpper2 = groupByPart.toUpperCase(Locale.ROOT); + int havingIdx = groupByUpper2.indexOf(" HAVING "); + if (havingIdx >= 0) { + String havingPart = groupByPart.substring(havingIdx + " HAVING ".length()).trim(); + groupByPart = groupByPart.substring(0, havingIdx).trim(); + try { + HavingParseResult havingResult = parseHavingClause(havingPart); + havingConditions = havingResult.conditions(); + havingCombinator = havingResult.combinator(); + } catch (IllegalArgumentException e) { + int havingStart = jdql.toUpperCase(Locale.ROOT).indexOf("HAVING "); + throw new IllegalArgumentException( + formatParseError(jdql, havingPart, Math.max(0, havingStart), e.getMessage()), e); + } + } + + groupByFields = Arrays.stream(groupByPart.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .toList(); + } + + // Validate GROUP BY constraints + if (groupByFields != null) { + if (aggregateFunctions == null || aggregateFunctions.isEmpty()) { + throw new IllegalArgumentException("GROUP BY without aggregate functions: " + jdql); + } + if (selectPlainFields != null) { + for (String f : selectPlainFields) { + if (!groupByFields.contains(f)) { + throw new IllegalArgumentException( + "SELECT field '" + f + "' must appear in GROUP BY clause: " + jdql); + } + } + } + if (havingConditions != null && !havingConditions.isEmpty() + && (groupByFields == null || groupByFields.isEmpty())) { + throw new IllegalArgumentException("HAVING without GROUP BY: " + jdql); + } + } else if (selectPlainFields != null) { + throw new IllegalArgumentException( + "Mixing aggregate functions and field projections requires GROUP BY: " + jdql); + } + + // Strip leading WHERE keyword + if (wherePart.toUpperCase(Locale.ROOT).startsWith("WHERE ")) { + wherePart = wherePart.substring(6).trim(); + } + + // If empty after stripping WHERE, no conditions + if (wherePart.isEmpty()) { + return new JdqlQuery(selectFields, aggregateFunctions, List.of(), JdqlQuery.Combinator.AND, orderBy, groupByFields, havingConditions, havingCombinator); + } + + // Determine combinator and split conditions + JdqlQuery.Combinator combinator = JdqlQuery.Combinator.AND; + List conditionStrings; + + // Check for OR (case-insensitive, not inside BETWEEN...AND or ORDER BY) + if (containsTopLevelOr(wherePart)) { + combinator = JdqlQuery.Combinator.OR; + conditionStrings = splitTopLevel(wherePart, "OR"); + } else { + conditionStrings = splitTopLevel(wherePart, "AND"); + } + + List conditions = new ArrayList<>(); + int searchFrom = 0; + for (String condStr : conditionStrings) { + String trimmedCond = condStr.trim(); + try { + conditions.add(parseConditionOrGroup(trimmedCond)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException(formatParseError(jdql, trimmedCond, searchFrom, e.getMessage()), e); + } + int found = jdql.indexOf(trimmedCond, searchFrom); + if (found >= 0) { + searchFrom = found + trimmedCond.length(); + } + } + + return new JdqlQuery(selectFields, aggregateFunctions, conditions, combinator, orderBy, groupByFields, havingConditions, havingCombinator); + } + + // -- SELECT clause helpers -- + + /** + * Finds the end index of the SELECT field list. + * The SELECT clause ends at the first FROM, WHERE, or ORDER BY keyword. + */ + private static int findSelectEnd(String upper) { + int fromIdx = indexOfKeyword(upper, " FROM "); + int whereIdx = indexOfKeyword(upper, " WHERE "); + int orderByIdx = indexOfKeyword(upper, " ORDER BY "); + int groupByIdx = indexOfKeyword(upper, " GROUP BY "); + int havingIdx = indexOfKeyword(upper, " HAVING "); + int end = smallestPositive(fromIdx, whereIdx, orderByIdx, groupByIdx, havingIdx); + return end > 0 ? end : upper.length(); + } + + /** + * Finds the end of the FROM clause (the entity name after FROM). + * FROM clause ends at WHERE, ORDER BY, GROUP BY, HAVING, or end of string. + */ + private static int findFromEnd(String upper) { + int whereIdx = indexOfKeyword(upper, " WHERE "); + int orderByIdx = indexOfKeyword(upper, " ORDER BY "); + int groupByIdx = indexOfKeyword(upper, " GROUP BY "); + int havingIdx = indexOfKeyword(upper, " HAVING "); + int end = smallestPositive(whereIdx, orderByIdx, groupByIdx, havingIdx); + return end > 0 ? end : upper.length(); + } + + private static int indexOfKeyword(String upper, String keyword) { + return upper.indexOf(keyword); + } + + private static int smallestPositive(int... values) { + int min = Integer.MAX_VALUE; + for (int v : values) { + if (v > 0) min = Math.min(min, v); + } + return min == Integer.MAX_VALUE ? -1 : min; + } + + // -- Condition parsing -- + + /** + * Parses a condition string that may be a parenthesized group or a simple condition. + * E.g. {@code (otaUpdateError IS NULL OR otaUpdateError = '')} is parsed as a group condition. + */ + private static JdqlQuery.JdqlCondition parseConditionOrGroup(String cond) { + String trimmed = cond.trim(); + + // NOT (...) group negation + if (trimmed.toUpperCase(Locale.ROOT).startsWith("NOT ")) { + String afterNot = trimmed.substring(4).trim(); + if (afterNot.startsWith("(") && afterNot.endsWith(")") && isBalancedGroup(afterNot)) { + JdqlQuery.JdqlCondition innerGroup = parseConditionOrGroup(afterNot); + if (innerGroup.isGroup()) { + // XOR negation: NOT on an already-negated group cancels out + return new JdqlQuery.JdqlCondition(null, null, null, null, null, !innerGroup.negated(), + innerGroup.groupConditions(), innerGroup.groupCombinator()); + } + // Single condition wrapped in parens after NOT → just negate it + return new JdqlQuery.JdqlCondition(innerGroup.fieldName(), innerGroup.operator(), + innerGroup.valueRef(), innerGroup.valueRef2(), innerGroup.literal(), + !innerGroup.negated(), null, null); + } + } + + if (trimmed.startsWith("(") && trimmed.endsWith(")") && isBalancedGroup(trimmed)) { + String inner = trimmed.substring(1, trimmed.length() - 1).trim(); + JdqlQuery.Combinator groupCombinator = JdqlQuery.Combinator.AND; + List subConditions; + if (containsTopLevelOr(inner)) { + groupCombinator = JdqlQuery.Combinator.OR; + subConditions = splitTopLevel(inner, "OR"); + } else { + subConditions = splitTopLevel(inner, "AND"); + } + // Single condition inside parens — no group needed, just parse directly + if (subConditions.size() == 1) { + return parseConditionOrGroup(subConditions.get(0).trim()); + } + List groupConds = new ArrayList<>(); + for (String sub : subConditions) { + groupConds.add(parseConditionOrGroup(sub.trim())); + } + return JdqlQuery.JdqlCondition.group(groupConds, groupCombinator); + } + return parseCondition(trimmed); + } + + /** + * Checks if a string starting with '(' has the closing ')' at the very end, + * meaning the outer parentheses wrap the entire expression. + */ + private static boolean isBalancedGroup(String s) { + int depth = 0; + for (int i = 0; i < s.length(); i++) { + if (s.charAt(i) == '(') depth++; + else if (s.charAt(i) == ')') depth--; + if (depth == 0 && i < s.length() - 1) return false; + } + return depth == 0; + } + + private static JdqlQuery.JdqlCondition parseCondition(String cond) { + String trimmed = cond.trim(); + String upper = trimmed.toUpperCase(Locale.ROOT); + + // Detect and strip NOT prefix + boolean negated = false; + if (upper.startsWith("NOT ")) { + negated = true; + trimmed = trimmed.substring(4).trim(); + upper = trimmed.toUpperCase(Locale.ROOT); + } + + // IS NOT NULL + if (upper.endsWith(" IS NOT NULL")) { + String field = trimmed.substring(0, trimmed.length() - " IS NOT NULL".length()).trim(); + JdqlQuery.Operator op = negated ? JdqlQuery.Operator.IS_NULL : JdqlQuery.Operator.IS_NOT_NULL; + return new JdqlQuery.JdqlCondition(field, op, null, null, null, false); + } + + // IS NULL + if (upper.endsWith(" IS NULL")) { + String field = trimmed.substring(0, trimmed.length() - " IS NULL".length()).trim(); + JdqlQuery.Operator op = negated ? JdqlQuery.Operator.IS_NOT_NULL : JdqlQuery.Operator.IS_NULL; + return new JdqlQuery.JdqlCondition(field, op, null, null, null, false); + } + + // BETWEEN :min AND :max + Matcher betweenMatcher = BETWEEN_PATTERN.matcher(trimmed); + if (betweenMatcher.matches()) { + String field = betweenMatcher.group(1).trim(); + String minRef = betweenMatcher.group(2).trim(); + String maxRef = betweenMatcher.group(3).trim(); + return new JdqlQuery.JdqlCondition(field, JdqlQuery.Operator.BETWEEN, + extractParamOrLiteral(minRef), extractParamOrLiteral(maxRef), null, negated); + } + + // NOT IN :param (within condition, NOT as infix operator on field) + Matcher notInMatcher = NOT_IN_PATTERN.matcher(trimmed); + if (notInMatcher.matches()) { + String field = notInMatcher.group(1).trim(); + String paramRef = notInMatcher.group(2).trim(); + // "NOT field NOT IN x" (double negation) → field IN x + JdqlQuery.Operator op = negated ? JdqlQuery.Operator.IN : JdqlQuery.Operator.NOT_IN; + return new JdqlQuery.JdqlCondition(field, op, + extractParamOrLiteral(paramRef), null, null, false); + } + + // IN :param + Matcher inMatcher = IN_PATTERN.matcher(trimmed); + if (inMatcher.matches()) { + String field = inMatcher.group(1).trim(); + String paramRef = inMatcher.group(2).trim(); + return new JdqlQuery.JdqlCondition(field, JdqlQuery.Operator.IN, + extractParamOrLiteral(paramRef), null, null, negated); + } + + // LIKE :param + Matcher likeMatcher = LIKE_PATTERN.matcher(trimmed); + if (likeMatcher.matches()) { + String field = likeMatcher.group(1).trim(); + String paramRef = likeMatcher.group(2).trim(); + return new JdqlQuery.JdqlCondition(field, JdqlQuery.Operator.LIKE, + extractParamOrLiteral(paramRef), null, null, negated); + } + + // Comparison operators: >=, <=, <>, !=, >, <, = + Matcher compMatcher = COMP_PATTERN.matcher(trimmed); + if (compMatcher.matches()) { + String field = compMatcher.group(1).trim(); + String op = compMatcher.group(2); + String valueRef = compMatcher.group(3).trim(); + + JdqlQuery.Operator operator = switch (op) { + case "=" -> JdqlQuery.Operator.EQ; + case "<>", "!=" -> JdqlQuery.Operator.NE; + case ">" -> JdqlQuery.Operator.GT; + case ">=" -> JdqlQuery.Operator.GTE; + case "<" -> JdqlQuery.Operator.LT; + case "<=" -> JdqlQuery.Operator.LTE; + default -> throw new IllegalArgumentException("Unknown operator: " + op); + }; + + // Check for boolean/null literals + String upperVal = valueRef.toUpperCase(Locale.ROOT); + if ("TRUE".equals(upperVal)) { + return new JdqlQuery.JdqlCondition(field, operator, null, null, Boolean.TRUE, negated); + } + if ("FALSE".equals(upperVal)) { + return new JdqlQuery.JdqlCondition(field, operator, null, null, Boolean.FALSE, negated); + } + if ("NULL".equals(upperVal)) { + JdqlQuery.Operator nullOp = operator == JdqlQuery.Operator.EQ + ? JdqlQuery.Operator.IS_NULL : JdqlQuery.Operator.IS_NOT_NULL; + if (negated) { + nullOp = nullOp == JdqlQuery.Operator.IS_NULL + ? JdqlQuery.Operator.IS_NOT_NULL : JdqlQuery.Operator.IS_NULL; + } + return new JdqlQuery.JdqlCondition(field, nullOp, null, null, null, false); + } + + return new JdqlQuery.JdqlCondition(field, operator, + extractParamOrLiteral(valueRef), null, null, negated); + } + + throw new IllegalArgumentException("Cannot parse JDQL condition: " + cond); + } + + /** + * Extracts a parameter reference or literal value from a token. + * Parameters start with ':', literals are numbers or quoted strings. + */ + private static String extractParamOrLiteral(String token) { + token = token.trim(); + if (token.startsWith(":")) { + return token; // keep the colon prefix to identify as param ref + } + // Numeric literal or other literal — return as-is + return token; + } + + // -- ORDER BY parsing -- + + private static List parseOrderBy(String orderPart) { + List specs = new ArrayList<>(); + String[] parts = orderPart.split(","); + for (String part : parts) { + String p = part.trim(); + if (p.isEmpty()) continue; + String[] tokens = p.split("\\s+"); + String field = tokens[0]; + boolean ascending = true; + if (tokens.length > 1) { + ascending = !"DESC".equalsIgnoreCase(tokens[1]); + } + specs.add(new JdqlQuery.OrderSpec(field, ascending)); + } + return specs; + } + + // -- Top-level AND/OR splitting (avoids splitting inside BETWEEN...AND) -- + + private static boolean containsTopLevelOr(String wherePart) { + String upper = wherePart.toUpperCase(Locale.ROOT); + int depth = 0; + for (int i = 0; i < upper.length(); i++) { + char c = upper.charAt(i); + if (c == '(') depth++; + else if (c == ')') depth--; + else if (depth == 0 && i + 4 <= upper.length() + && upper.startsWith(" OR ", i)) { + return true; + } + } + return false; + } + + /** + * Splits a WHERE clause on a top-level combinator (AND or OR). + * Handles BETWEEN...AND by not splitting inside it. + * Respects parenthesis depth — never splits inside parenthesized groups. + */ + private static List splitTopLevel(String wherePart, String combinator) { + List result = new ArrayList<>(); + String upper = wherePart.toUpperCase(Locale.ROOT); + String sep = " " + combinator + " "; + int sepLen = sep.length(); + + int start = 0; + int depth = 0; + + for (int i = 0; i < upper.length(); i++) { + char c = upper.charAt(i); + if (c == '(') { + depth++; + } else if (c == ')') { + depth--; + } else if (depth == 0 && i + sepLen <= upper.length() + && upper.startsWith(sep, i)) { + // Check if this AND is part of BETWEEN...AND + if ("AND".equals(combinator) && isBetweenAnd(upper, i)) { + continue; + } + result.add(wherePart.substring(start, i)); + start = i + sepLen; + i += sepLen - 1; // skip past separator (loop will increment) + } + } + result.add(wherePart.substring(start)); + + return result; + } + + /** + * Checks if the AND at the given position is part of a BETWEEN...AND construct. + */ + private static boolean isBetweenAnd(String upper, int andIdx) { + // Look backwards from AND position for "BETWEEN" + String before = upper.substring(0, andIdx); + int betweenIdx = before.lastIndexOf("BETWEEN"); + if (betweenIdx < 0) return false; + + // Check that there's no other AND between BETWEEN and this AND + String betweenToAnd = before.substring(betweenIdx + "BETWEEN".length()); + return !betweenToAnd.contains(" AND "); + } + + // -- HAVING parsing -- + + private record HavingParseResult(List conditions, + JdqlQuery.Combinator combinator) {} + + private static HavingParseResult parseHavingClause(String havingPart) { + List conditions = new ArrayList<>(); + JdqlQuery.Combinator combinator = JdqlQuery.Combinator.AND; + List parts; + + if (containsTopLevelOr(havingPart)) { + combinator = JdqlQuery.Combinator.OR; + parts = splitTopLevel(havingPart, "OR"); + } else { + parts = splitTopLevel(havingPart, "AND"); + } + + for (String part : parts) { + conditions.add(parseHavingCondition(part.trim())); + } + return new HavingParseResult(conditions, combinator); + } + + private static JdqlQuery.HavingCondition parseHavingCondition(String cond) { + Matcher m = HAVING_PATTERN.matcher(cond.trim()); + if (!m.matches()) { + throw new IllegalArgumentException("Cannot parse HAVING condition: " + cond + + ". Expected: AGGREGATE(field) operator value"); + } + + String funcName = m.group(1).toUpperCase(Locale.ROOT); + String argName = m.group(2); + String opStr = m.group(3); + String valueRef = m.group(4).trim(); + + String aggFuncStr = funcName + "(" + argName + ")"; + + JdqlQuery.Operator operator = switch (opStr) { + case "=" -> JdqlQuery.Operator.EQ; + case "<>", "!=" -> JdqlQuery.Operator.NE; + case ">" -> JdqlQuery.Operator.GT; + case ">=" -> JdqlQuery.Operator.GTE; + case "<" -> JdqlQuery.Operator.LT; + case "<=" -> JdqlQuery.Operator.LTE; + default -> throw new IllegalArgumentException("Unknown HAVING operator: " + opStr); + }; + + return new JdqlQuery.HavingCondition(aggFuncStr, operator, extractParamOrLiteral(valueRef)); + } + + /** + * Formats a parse error with position information and a caret pointer. + * + * @param searchFrom index in originalJdql to start searching from, avoids + * pointing at a duplicate earlier occurrence + */ + private static String formatParseError(String originalJdql, String failedFragment, + int searchFrom, String detail) { + int pos = originalJdql.indexOf(failedFragment, searchFrom); + if (pos < 0) { + pos = originalJdql.indexOf(failedFragment); + } + if (pos < 0) { + return detail + "\n JDQL: " + originalJdql; + } + return "JDQL parse error at position " + pos + ": " + detail + + "\n " + originalJdql + + "\n " + " ".repeat(pos) + "^"; + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlQuery.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlQuery.java new file mode 100644 index 000000000..e72e44769 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlQuery.java @@ -0,0 +1,104 @@ +package de.caluga.morphium.data; + +import java.util.List; + +/** + * Parsed representation of a JDQL (Jakarta Data Query Language) query. + * Created by {@link JdqlParser} from a {@code @Query} annotation value. + * + * @param selectFields projected field names from SELECT clause, null or empty = all fields + * @param aggregateFunctions aggregate functions (COUNT/SUM/AVG/MIN/MAX) from the SELECT clause, + * null or empty when the query has no aggregation + * @param conditions the WHERE conditions (simple or grouped), empty if there is no WHERE clause + * @param combinator how the top-level {@code conditions} are combined ({@code AND} or {@code OR}) + * @param orderBy the ORDER BY fields and directions, empty if there is no ORDER BY clause + * @param groupByFields fields from GROUP BY clause, null when no GROUP BY + * @param havingConditions the HAVING conditions filtering aggregated results, null or empty when + * there is no HAVING clause + * @param havingCombinator how the top-level {@code havingConditions} are combined ({@code AND} or {@code OR}) + */ +public record JdqlQuery( + List selectFields, + List aggregateFunctions, + List conditions, + Combinator combinator, + List orderBy, + List groupByFields, + List havingConditions, + Combinator havingCombinator +) { + + public enum Combinator { AND, OR } + + public enum AggregateType { COUNT, SUM, AVG, MIN, MAX } + + public record AggregateFunction(AggregateType type, String field) {} + + public enum Operator { + EQ, NE, GT, GTE, LT, LTE, + BETWEEN, IN, NOT_IN, + LIKE, IS_NULL, IS_NOT_NULL + } + + /** + * A single JDQL condition or a parenthesized group of conditions. + *

+ * Simple condition: {@code fieldName} and {@code operator} are set, {@code groupConditions} is null. + * Group condition: {@code groupConditions} and {@code groupCombinator} are set, {@code fieldName} is null. + * + * @param fieldName the entity field name (null for group conditions) + * @param operator the comparison operator (null for group conditions) + * @param valueRef parameter reference (":name") or literal value, null for IS NULL/IS NOT NULL + * @param valueRef2 second param/literal for BETWEEN, null otherwise + * @param literal literal value (Boolean, etc.) when not using a parameter reference + * @param negated true if the condition is prefixed with NOT + * @param groupConditions nested conditions for parenthesized groups, null for simple conditions + * @param groupCombinator combinator (AND/OR) for the group, null for simple conditions + */ + public record JdqlCondition( + String fieldName, + Operator operator, + String valueRef, + String valueRef2, + Object literal, + boolean negated, + List groupConditions, + Combinator groupCombinator + ) { + /** Convenience constructor without negation (backwards-compatible). */ + public JdqlCondition(String fieldName, Operator operator, String valueRef, + String valueRef2, Object literal) { + this(fieldName, operator, valueRef, valueRef2, literal, false, null, null); + } + + /** Convenience constructor with negation but no group. */ + public JdqlCondition(String fieldName, Operator operator, String valueRef, + String valueRef2, Object literal, boolean negated) { + this(fieldName, operator, valueRef, valueRef2, literal, negated, null, null); + } + + /** Creates a group condition from nested conditions. */ + public static JdqlCondition group(List conditions, Combinator combinator) { + return new JdqlCondition(null, null, null, null, null, false, conditions, combinator); + } + + public boolean isGroup() { + return groupConditions != null; + } + } + + public record OrderSpec(String field, boolean ascending) {} + + /** + * A single HAVING condition referencing an aggregate result. + * + * @param aggregateFunction canonical form, e.g. "COUNT(this)" or "SUM(amount)" + * @param operator comparison operator + * @param valueRef parameter reference (":name") or numeric literal string + */ + public record HavingCondition( + String aggregateFunction, + Operator operator, + String valueRef + ) {} +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java new file mode 100644 index 000000000..eb76d1719 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java @@ -0,0 +1,307 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.data.QueryDescriptor.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses a Jakarta Data repository method name into a {@link QueryDescriptor}. + *

+ * This is the entry point of the query-derivation processing chain: {@link QueryMethodBridge} + * calls {@link #parse} once per distinct method name (results are cached there) and gets back a + * {@link QueryDescriptor}, which {@link QueryExecutor} then translates into a Morphium + * {@link de.caluga.morphium.query.Query} and executes, with single-result semantics enforced by + * {@link QueryResultHelper}. This mirrors what {@link JdqlParser} does for {@code @Query}-annotated + * methods, but derives the query purely from the method name instead of an explicit query string. + *

+ * Supports prefixes: {@code findBy}, {@code countBy}, {@code existsBy}, {@code deleteBy}. + * Supports operators: Equals/Is, Not, GreaterThan, GreaterThanEqual, LessThan, LessThanEqual, + * Between, In, NotIn, Like, StartsWith, EndsWith, Null/IsNull, NotNull/IsNotNull, True, False. + * Supports combinators: And, Or. + * Supports OrderBy suffix: {@code OrderByFieldAsc}, {@code OrderByFieldDesc}. + *

+ * Example: + *

{@code
+ * // Method name:
+ * List findByCategoryAndPriceGreaterThanOrderByPriceDesc(String category, double minPrice);
+ *
+ * // Parses to a QueryDescriptor equivalent to:
+ * //   prefix     = FIND
+ * //   conditions = [category EQ arg0, price GT arg1]
+ * //   combinator = AND
+ * //   orderBy    = [price DESC]
+ * }
+ */ +public final class MethodNameParser { + + private MethodNameParser() {} + + private static final Pattern PREFIX_PATTERN = Pattern.compile( + "^(find|count|exists|delete)By(.*)$"); + + private static final Pattern ORDER_BY_SPLIT = Pattern.compile( + "^(.+?)OrderBy(.+)$"); + + /** + * Parses the given method name into a {@link QueryDescriptor}. + * + * @param methodName the repository method name + * @param entityFields set of known Java field names on the entity (for validation) + * @return the parsed descriptor + * @throws IllegalArgumentException if the method name cannot be parsed + */ + public static QueryDescriptor parse(String methodName, java.util.Set entityFields) { + Matcher m = PREFIX_PATTERN.matcher(methodName); + if (!m.matches()) { + throw new IllegalArgumentException( + "Cannot parse repository method name: " + methodName + + ". Expected pattern: findBy.../countBy.../existsBy.../deleteBy..."); + } + + String prefixStr = m.group(1); + String rest = m.group(2); + + Prefix prefix = switch (prefixStr) { + case "find" -> Prefix.FIND; + case "count" -> Prefix.COUNT; + case "exists" -> Prefix.EXISTS; + case "delete" -> Prefix.DELETE; + default -> throw new IllegalArgumentException("Unknown prefix: " + prefixStr); + }; + + ReturnType returnType = switch (prefix) { + case FIND -> ReturnType.LIST; // may be overridden to SINGLE by caller + case COUNT -> ReturnType.COUNT; + case EXISTS -> ReturnType.BOOLEAN; + case DELETE -> ReturnType.COUNT; + }; + + // No conditions after "By" → match all entities (e.g. countBy(), findBy(), deleteBy()) + if (rest.isEmpty()) { + return new QueryDescriptor(prefix, List.of(), Combinator.AND, List.of(), returnType); + } + + // Split off OrderBy clause + List orderSpecs = new ArrayList<>(); + Matcher orderMatcher = ORDER_BY_SPLIT.matcher(rest); + if (orderMatcher.matches()) { + rest = orderMatcher.group(1); + String orderPart = orderMatcher.group(2); + orderSpecs = parseOrderBy(orderPart); + } + + // Determine combinator: check for "Or" or "And" + // We need to split on And/Or but only at word boundaries between conditions + Combinator combinator = Combinator.AND; + String[] parts; + if (containsCombinator(rest, "Or")) { + combinator = Combinator.OR; + parts = splitOnCombinator(rest, "Or"); + } else { + parts = splitOnCombinator(rest, "And"); + } + + // Parse each condition part + List conditions = new ArrayList<>(); + int paramIndex = 0; + for (String part : parts) { + ParsedCondition pc = parseCondition(part, paramIndex, entityFields); + conditions.add(pc.condition); + paramIndex = pc.nextParamIndex; + } + + return new QueryDescriptor(prefix, conditions, combinator, orderSpecs, returnType); + } + + // -- OrderBy parsing -- + + private static List parseOrderBy(String orderPart) { + List specs = new ArrayList<>(); + // Split on Asc/Desc boundaries while keeping the direction + // e.g. "PriceDescNameAsc" -> [Price,Desc], [Name,Asc] + // Pattern: field name followed by optional Asc/Desc + Pattern p = Pattern.compile("([A-Z][a-z0-9]*(?:[A-Z][a-z0-9]*)*?)(Asc|Desc)?(?=(?:[A-Z])|$)"); + + // Simpler approach: split tokens + List tokens = splitCamelCase(orderPart); + int i = 0; + while (i < tokens.size()) { + StringBuilder fieldBuilder = new StringBuilder(); + fieldBuilder.append(decapitalize(tokens.get(i))); + i++; + // Consume tokens until we hit Asc/Desc or end + while (i < tokens.size() && !tokens.get(i).equals("Asc") && !tokens.get(i).equals("Desc")) { + fieldBuilder.append(capitalize(tokens.get(i))); + i++; + } + Direction dir = Direction.ASC; + if (i < tokens.size()) { + if (tokens.get(i).equals("Desc")) { + dir = Direction.DESC; + } + i++; + } + specs.add(new OrderSpec(fieldBuilder.toString(), dir)); + } + return specs; + } + + // -- Condition parsing -- + + private record ParsedCondition(Condition condition, int nextParamIndex) {} + + private static ParsedCondition parseCondition(String part, int paramIndex, + java.util.Set entityFields) { + // Try to match operators from longest to shortest + for (OperatorMatch om : OPERATOR_MATCHES) { + if (part.endsWith(om.suffix)) { + String fieldPart = part.substring(0, part.length() - om.suffix.length()); + String field = resolveFieldName(fieldPart, entityFields); + if (om.operator == Operator.BETWEEN) { + return new ParsedCondition( + new Condition(field, om.operator, paramIndex, paramIndex + 1), + paramIndex + 2); + } + if (om.paramCount == 0) { + return new ParsedCondition( + new Condition(field, om.operator, -1), + paramIndex); + } + return new ParsedCondition( + new Condition(field, om.operator, paramIndex), + paramIndex + om.paramCount); + } + } + + // No operator suffix found → implicit Equals + String field = resolveFieldName(part, entityFields); + return new ParsedCondition( + new Condition(field, Operator.EQ, paramIndex), + paramIndex + 1); + } + + private record OperatorMatch(String suffix, Operator operator, int paramCount) {} + + // Ordered longest-first to avoid prefix ambiguity + private static final List OPERATOR_MATCHES = List.of( + new OperatorMatch("GreaterThanEqual", Operator.GTE, 1), + new OperatorMatch("LessThanEqual", Operator.LTE, 1), + new OperatorMatch("GreaterThan", Operator.GT, 1), + new OperatorMatch("LessThan", Operator.LT, 1), + new OperatorMatch("NotContains", Operator.NOT_CONTAINS, 1), + new OperatorMatch("IsNotEmpty", Operator.IS_NOT_EMPTY, 0), + new OperatorMatch("IsNotNull", Operator.IS_NOT_NULL, 0), + new OperatorMatch("IgnoreCase", Operator.IGNORE_CASE, 1), + new OperatorMatch("NotNull", Operator.IS_NOT_NULL, 0), + new OperatorMatch("NotEmpty", Operator.IS_NOT_EMPTY, 0), + new OperatorMatch("IsEmpty", Operator.IS_EMPTY, 0), + new OperatorMatch("IsNull", Operator.IS_NULL, 0), + new OperatorMatch("IsTrue", Operator.IS_TRUE, 0), + new OperatorMatch("IsFalse", Operator.IS_FALSE, 0), + new OperatorMatch("StartsWith", Operator.STARTS_WITH, 1), + new OperatorMatch("EndsWith", Operator.ENDS_WITH, 1), + new OperatorMatch("Contains", Operator.CONTAINS, 1), + new OperatorMatch("Matches", Operator.MATCHES, 1), + new OperatorMatch("Between", Operator.BETWEEN, 2), + new OperatorMatch("NotIn", Operator.NIN, 1), + new OperatorMatch("Equals", Operator.EQ, 1), + new OperatorMatch("Regex", Operator.MATCHES, 1), + new OperatorMatch("Empty", Operator.IS_EMPTY, 0), + new OperatorMatch("Not", Operator.NE, 1), + new OperatorMatch("Null", Operator.IS_NULL, 0), + new OperatorMatch("True", Operator.IS_TRUE, 0), + new OperatorMatch("False", Operator.IS_FALSE, 0), + new OperatorMatch("Size", Operator.SIZE, 1), + new OperatorMatch("Like", Operator.LIKE, 1), + new OperatorMatch("In", Operator.IN, 1), + new OperatorMatch("Is", Operator.EQ, 1) + ); + + // -- Field name resolution -- + + /** + * Converts a PascalCase field segment from a method name to a Java field name. + * E.g. "Status" → "status", "CustomerName" → "customerName". + */ + private static String resolveFieldName(String part, java.util.Set entityFields) { + String camelCase = decapitalize(part); + if (entityFields != null && !entityFields.isEmpty()) { + // Try exact match first + if (entityFields.contains(camelCase)) { + return camelCase; + } + // Try case-insensitive match + for (String f : entityFields) { + if (f.equalsIgnoreCase(camelCase)) { + return f; + } + } + } + return camelCase; + } + + // -- Combinator detection and splitting -- + + private static boolean containsCombinator(String text, String combinator) { + // Must appear between two uppercase-starting segments + int idx = text.indexOf(combinator); + while (idx > 0 && idx + combinator.length() < text.length()) { + char before = text.charAt(idx - 1); + char after = text.charAt(idx + combinator.length()); + if (Character.isLowerCase(before) && Character.isUpperCase(after)) { + return true; + } + idx = text.indexOf(combinator, idx + 1); + } + return false; + } + + private static String[] splitOnCombinator(String text, String combinator) { + List result = new ArrayList<>(); + int start = 0; + int idx = text.indexOf(combinator, start); + while (idx > 0 && idx + combinator.length() < text.length()) { + char before = text.charAt(idx - 1); + char after = text.charAt(idx + combinator.length()); + if (Character.isLowerCase(before) && Character.isUpperCase(after)) { + result.add(text.substring(start, idx)); + start = idx + combinator.length(); + } + idx = text.indexOf(combinator, idx + 1); + } + result.add(text.substring(start)); + return result.toArray(new String[0]); + } + + // -- Utility -- + + private static List splitCamelCase(String s) { + List tokens = new ArrayList<>(); + int start = 0; + for (int i = 1; i < s.length(); i++) { + if (Character.isUpperCase(s.charAt(i))) { + tokens.add(s.substring(start, i)); + start = i; + } + } + tokens.add(s.substring(start)); + return tokens; + } + + private static String decapitalize(String s) { + if (s == null || s.isEmpty()) return s; + if (s.length() > 1 && Character.isUpperCase(s.charAt(1))) { + return s; // e.g. "URL" stays "URL" + } + return Character.toLowerCase(s.charAt(0)) + s.substring(1); + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumPage.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumPage.java new file mode 100644 index 000000000..b54e4f646 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumPage.java @@ -0,0 +1,156 @@ +package de.caluga.morphium.data; + +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; + +import java.util.Iterator; +import java.util.List; + +/** + * Morphium-backed implementation of Jakarta Data's {@link Page}. + *

+ * Returned as the result of offset-based pagination: {@link AbstractMorphiumRepository#doFindAllPaged}, + * {@link FindMethodBridge}, and {@link JdqlMethodBridge} construct instances of this class once the + * page content and (optionally) the total element count have been fetched via a Morphium + * {@link de.caluga.morphium.query.Query}. Keyset (cursor-based) pagination uses + * {@code jakarta.data.page.impl.CursoredPageRecord} directly instead, with cursor extraction + * handled by {@link CursorHelper}. + * + * @param the entity type + */ +public class MorphiumPage implements Page { + + private final List content; + private final long totalElements; + private final PageRequest pageRequest; + + /** + * Creates a new page. + * + * @param content the entities on this page + * @param totalElements the total number of matching entities across all pages, or a negative + * value if the total was not requested (see {@link #hasTotals()}) + * @param pageRequest the page request this page was created for + */ + public MorphiumPage(List content, long totalElements, PageRequest pageRequest) { + this.content = content; + this.totalElements = totalElements; + this.pageRequest = pageRequest; + } + + /** + * @return the entities on this page + */ + @Override + public List content() { + return content; + } + + /** + * @return true if the total element/page count was requested and is available + */ + @Override + public boolean hasTotals() { + return totalElements >= 0; + } + + /** + * @return the total number of matching entities across all pages + * @throws IllegalStateException if the total was not requested ({@link #hasTotals()} is false) + */ + @Override + public long totalElements() { + if (!hasTotals()) { + throw new IllegalStateException("Total not requested. Use PageRequest.withTotal()."); + } + return totalElements; + } + + /** + * @return the total number of pages, given the page size of {@link #pageRequest()} + * @throws IllegalStateException if the total was not requested ({@link #hasTotals()} is false) + */ + @Override + public long totalPages() { + if (!hasTotals()) { + throw new IllegalStateException("Total not requested. Use PageRequest.withTotal()."); + } + if (pageRequest.size() <= 0) return 1; + return (totalElements + pageRequest.size() - 1) / pageRequest.size(); + } + + /** + * @return the page request this page was created for + */ + @Override + public PageRequest pageRequest() { + return pageRequest; + } + + /** + * @return the page request for the next page, or {@code null} if there is no next page + */ + @Override + public PageRequest nextPageRequest() { + if (!hasNext()) { + return null; + } + return PageRequest.ofPage(pageRequest.page() + 1, pageRequest.size(), pageRequest.requestTotal()); + } + + /** + * @return the page request for the previous page, or {@code null} if this is the first page + */ + @Override + public PageRequest previousPageRequest() { + if (pageRequest.page() <= 1) { + return null; + } + return PageRequest.ofPage(pageRequest.page() - 1, pageRequest.size(), pageRequest.requestTotal()); + } + + /** + * @return true if this page has at least one entity + */ + @Override + public boolean hasContent() { + return !content.isEmpty(); + } + + /** + * @return the number of entities on this page + */ + @Override + public int numberOfElements() { + return content.size(); + } + + /** + * @return true if a next page is likely to exist. When {@link #hasTotals()} is false this is a + * heuristic based on whether this page is full, since the total page count is unknown + */ + @Override + public boolean hasNext() { + if (!hasTotals()) { + // If no totals, check if we got a full page (heuristic) + return content.size() >= pageRequest.size(); + } + return pageRequest.page() < totalPages(); + } + + /** + * @return true if this is not the first page + */ + @Override + public boolean hasPrevious() { + return pageRequest.page() > 1; + } + + /** + * @return an iterator over the entities on this page + */ + @Override + public Iterator iterator() { + return content.iterator(); + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumRepository.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumRepository.java new file mode 100644 index 000000000..7342afb87 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MorphiumRepository.java @@ -0,0 +1,82 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.repository.CrudRepository; + +import java.util.List; + +/** + * Morphium-specific extension of Jakarta Data's {@link CrudRepository}. + * + *

Provides access to Morphium features that have no equivalent in the Jakarta Data 1.0 + * specification, such as {@code distinct()} queries and direct access to the {@link Morphium} + * instance for aggregation pipelines, atomic field operations, and other advanced features.

+ * + *

All standard Jakarta Data features (query derivation, {@code @Find}, {@code @Query}/JDQL, + * pagination, sorting) work exactly as with {@code CrudRepository}. Additionally, all Morphium + * ORM annotations ({@code @Version}, {@code @CreationTime}, {@code @PreStore}, {@code @Cache}, + * {@code @Reference}) work transparently because the generated implementation delegates to the + * Morphium API.

+ * + *

An interface extending {@code MorphiumRepository} is implemented at build time by a + * generated subclass of {@link AbstractMorphiumRepository}. Each interface method is routed to + * one of the runtime bridges depending on how it is declared: method-name-derived queries go + * through {@link MethodNameParser} / {@link QueryExecutor} (via {@link QueryMethodBridge}), + * {@code @Find} methods through {@link FindMethodBridge}, and {@code @Query}/JDQL methods through + * {@link JdqlParser} / {@link JdqlMethodBridge}. {@link #distinct(String)}, {@link #morphium()}, + * and {@link #query()} below bypass all of that and call directly into + * {@link AbstractMorphiumRepository}.

+ * + *

Usage

+ *
+ * {@code @Repository}
+ * public interface ProductRepository extends MorphiumRepository<Product, MorphiumId> {
+ *
+ *     List<Product> findByCategory(String category);
+ * }
+ *
+ * // In your service:
+ * List<Object> categories = productRepository.distinct("category");
+ *
+ * // Escape hatch for aggregations, inc/push/pull etc.:
+ * Morphium m = productRepository.morphium();
+ * m.createAggregator(Product.class, Map.class)
+ *     .group("$category").sum("total", "$price").end()
+ *     .aggregate();
+ * 
+ * + * @param the entity type + * @param the primary-key type + */ +public interface MorphiumRepository extends CrudRepository { + + /** + * Returns distinct values for the given field across all documents of this entity type. + * + *

This has no equivalent in Jakarta Data 1.0. It maps to + * {@code morphium.createQueryFor(entityClass).distinct(fieldName)}.

+ * + * @param fieldName the Java field name (resolved to MongoDB field name via {@code @Property}) + * @return distinct values for the field + */ + List distinct(String fieldName); + + /** + * Returns the underlying {@link Morphium} instance for operations that have no + * Jakarta Data equivalent: aggregation pipelines, atomic updates ({@code inc}, + * {@code push}, {@code pull}, {@code set}), change streams, messaging, etc. + * + * @return the Morphium instance + */ + Morphium morphium(); + + /** + * Creates a Morphium {@link Query} for the entity type of this repository. + * + *

Convenience shortcut for {@code morphium().createQueryFor(entityClass)}.

+ * + * @return a new query instance + */ + Query query(); +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryDescriptor.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryDescriptor.java new file mode 100644 index 000000000..6c5849fa1 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryDescriptor.java @@ -0,0 +1,78 @@ +package de.caluga.morphium.data; + +import java.util.List; + +/** + * Describes a parsed query derived from a repository method name. + *

+ * Built at deploy time by {@link MethodNameParser#parse}, executed at runtime by + * {@link QueryExecutor#execute}. This is the derived-query counterpart of {@link JdqlQuery} + * (parsed by {@link JdqlParser} for {@code @Query} methods): both descriptors are plain data that + * an executor turns into a Morphium {@link de.caluga.morphium.query.Query}. + * + * @param prefix the repository method prefix ({@code findBy}, {@code countBy}, + * {@code existsBy}, {@code deleteBy}) + * @param conditions the field conditions parsed from the method name, in order + * @param combinator how the conditions are combined ({@code AND} or {@code OR}) + * @param orderBy the sort order parsed from an {@code OrderBy...} suffix, empty if none + * @param returnType the shape of the result the caller expects + */ +public record QueryDescriptor( + Prefix prefix, + List conditions, + Combinator combinator, + List orderBy, + ReturnType returnType +) { + + public enum Prefix { FIND, COUNT, EXISTS, DELETE } + + public enum Combinator { AND, OR } + + public enum ReturnType { SINGLE, OPTIONAL, LIST, STREAM, COUNT, BOOLEAN } + + /** + * A single field condition parsed from a method name. + * + * @param field the Java field name + * @param operator the comparison operator + * @param paramIndex the index of the method argument supplying the value, or -1 if the + * operator takes no parameter (e.g. {@code IS_NULL}) + * @param paramIndex2 the index of the second method argument, used only by {@code BETWEEN} + */ + public record Condition( + String field, + Operator operator, + int paramIndex, + int paramIndex2 // only used by BETWEEN (second param) + ) { + /** + * Convenience constructor for conditions with at most one parameter. + * + * @param field the Java field name + * @param operator the comparison operator + * @param paramIndex the index of the method argument supplying the value, or -1 if none + */ + public Condition(String field, Operator operator, int paramIndex) { + this(field, operator, paramIndex, -1); + } + } + + public enum Operator { + EQ, NE, GT, GTE, LT, LTE, BETWEEN, + IN, NIN, + LIKE, STARTS_WITH, ENDS_WITH, CONTAINS, NOT_CONTAINS, + IS_NULL, IS_NOT_NULL, IS_TRUE, IS_FALSE, + IS_EMPTY, IS_NOT_EMPTY, SIZE, MATCHES, IGNORE_CASE + } + + /** + * A single sort field parsed from an {@code OrderBy...} method-name suffix. + * + * @param field the Java field name + * @param direction the sort direction + */ + public record OrderSpec(String field, Direction direction) {} + + public enum Direction { ASC, DESC } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java new file mode 100644 index 000000000..207f04a91 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java @@ -0,0 +1,282 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.FilterExpression; +import de.caluga.morphium.Morphium; +import de.caluga.morphium.annotations.Aliases; +import de.caluga.morphium.query.Query; +import de.caluga.morphium.data.QueryDescriptor.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.util.*; +import java.util.regex.Pattern; + +/** + * Executes a {@link QueryDescriptor} against a Morphium instance at runtime. + *

+ * This is the runtime counterpart of {@link MethodNameParser}: {@link QueryMethodBridge} parses + * the method name into a {@link QueryDescriptor} once (cached there) and calls {@link #execute} + * for every invocation. {@link #execute} builds a Morphium {@link Query} from the descriptor's + * conditions and sort order, resolving Java field names to MongoDB field names via + * {@code morphium.getARHelper().getMongoFieldName()}, and — for single-result return types — + * delegates result-cardinality checks to {@link QueryResultHelper}. This mirrors what + * {@link JdqlMethodBridge} does for {@code @Query} methods and what {@link FindMethodBridge} + * does for {@code @Find} methods. + */ +public final class QueryExecutor { + + private static final Logger log = LoggerFactory.getLogger(QueryExecutor.class); + + private QueryExecutor() {} + + /** + * Executes the given query descriptor and returns the result. + * + * @param descriptor the parsed query + * @param args the method arguments + * @param repo the repository instance (provides Morphium + metadata) + * @return the query result (List, single entity, long, boolean, or Stream) + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + public static Object execute(QueryDescriptor descriptor, + Object[] args, + AbstractMorphiumRepository repo) { + Morphium morphium = repo.getMorphium(); + Class entityClass = repo.getMetadata().entityClass(); + Query query = morphium.createQueryFor(entityClass); + + // Apply conditions + applyConditions(query, descriptor, args, morphium, entityClass); + + // Apply sorting + if (descriptor.orderBy() != null && !descriptor.orderBy().isEmpty()) { + applySorting(query, descriptor.orderBy(), morphium, entityClass); + } + + // Execute based on prefix + return switch (descriptor.prefix()) { + case FIND -> switch (descriptor.returnType()) { + case SINGLE -> QueryResultHelper.requireSingle(query); + case OPTIONAL -> QueryResultHelper.optionalSingle(query); + case STREAM -> query.stream(); + default -> query.asList(); + }; + case COUNT -> query.countAll(); + case EXISTS -> query.countAll() > 0; + case DELETE -> { + long count = query.countAll(); + // Uses bulk deleteMany — does NOT fire @PreRemove/@PostRemove lifecycle + // callbacks. This is intentional for performance (avoids loading all entities + // into memory). Entities requiring lifecycle hooks should use Morphium.delete() + // directly instead of derived deleteBy* methods. + query.delete(); + yield count; + } + }; + } + + // Visible for testing — called directly by QueryExecutorAliasTest + @SuppressWarnings({"unchecked", "rawtypes"}) + static void applyConditions(Query query, + QueryDescriptor descriptor, + Object[] args, + Morphium morphium, + Class entityClass) { + boolean isOr = descriptor.combinator() == Combinator.OR; + + if (isOr && descriptor.conditions().size() > 1) { + // Build OR query using Morphium's or() mechanism + List orQueries = new ArrayList<>(); + for (Condition cond : descriptor.conditions()) { + Query sub = morphium.createQueryFor(entityClass); + applyCondition(sub, cond, args, morphium, entityClass); + orQueries.add(sub); + } + query.or(orQueries); + } else { + for (Condition cond : descriptor.conditions()) { + applyCondition(query, cond, args, morphium, entityClass); + } + } + } + + // Visible for testing — called indirectly via applyConditions + @SuppressWarnings({"unchecked", "rawtypes"}) + static void applyCondition(Query query, + Condition cond, + Object[] args, + Morphium morphium, + Class entityClass) { + String mongoField = resolveMongoField(morphium, entityClass, cond.field()); + List aliases = resolveAliases(morphium, entityClass, cond.field()); + + if (!aliases.isEmpty()) { + // Field has @Aliases — build $or to match current name + all aliases. + // LinkedHashSet deduplicates in case an alias equals the current mongo name. + List> orBranches = new ArrayList<>(); + Set uniqueFields = new LinkedHashSet<>(); + uniqueFields.add(mongoField); + uniqueFields.addAll(aliases); + for (String f : uniqueFields) { + orBranches.add(buildRawCondition(f, cond, args)); + } + FilterExpression fe = new FilterExpression(); + fe.setField("$or"); + fe.setValue(orBranches); + query.addChild(fe); + } else { + // No aliases — build raw condition and add as FilterExpression. + // Uses the same buildRawCondition() as the alias path, keeping + // operator handling in a single place. + addRawConditionToQuery(query, buildRawCondition(mongoField, cond, args)); + } + } + + /** + * Converts a raw condition map (from {@link #buildRawCondition}) to + * {@link FilterExpression}s and adds them to the query. + */ + @SuppressWarnings("rawtypes") + private static void addRawConditionToQuery(Query query, Map rawCondition) { + for (Map.Entry entry : rawCondition.entrySet()) { + FilterExpression fe = new FilterExpression(); + fe.setField(entry.getKey()); + fe.setValue(entry.getValue()); + query.addChild(fe); + } + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + static void applySorting(Query query, + List orderSpecs, + Morphium morphium, + Class entityClass) { + Map sortMap = new LinkedHashMap<>(); + for (OrderSpec spec : orderSpecs) { + String mongoField = resolveMongoField(morphium, entityClass, spec.field()); + sortMap.put(mongoField, spec.direction() == Direction.ASC ? 1 : -1); + } + query.sort(sortMap); + } + + @SuppressWarnings("unchecked") + private static String resolveMongoField(Morphium morphium, + Class entityClass, + String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass, javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } + + /** + * Builds a raw MongoDB query condition map for the given field name and operator. + * Intended for internal use by alias handling, where this map is wrapped in a + * {@link FilterExpression} and attached to the main query via {@code query.addChild()}. + *

+ * Comparison operators (EQ, NE, GT, GTE, LT, LTE, IN, NIN, IS_NULL, IS_NOT_NULL, + * IS_TRUE, IS_FALSE) are null-safe. String-based operators (LIKE, STARTS_WITH, + * ENDS_WITH, MATCHES, IGNORE_CASE) call {@code toString()} on the argument and + * will throw {@link NullPointerException} if the argument is null. + */ + private static Map buildRawCondition(String fieldName, + Condition cond, + Object[] args) { + Map result = new LinkedHashMap<>(); + switch (cond.operator()) { + case EQ -> result.put(fieldName, args[cond.paramIndex()]); + case NE -> result.put(fieldName, nullSafeOp("$ne", args[cond.paramIndex()])); + case GT -> result.put(fieldName, nullSafeOp("$gt", args[cond.paramIndex()])); + case GTE -> result.put(fieldName, nullSafeOp("$gte", args[cond.paramIndex()])); + case LT -> result.put(fieldName, nullSafeOp("$lt", args[cond.paramIndex()])); + case LTE -> result.put(fieldName, nullSafeOp("$lte", args[cond.paramIndex()])); + case BETWEEN -> { + Map range = new LinkedHashMap<>(); + range.put("$gte", args[cond.paramIndex()]); + range.put("$lte", args[cond.paramIndex2()]); + result.put(fieldName, range); + } + case IN -> result.put(fieldName, nullSafeOp("$in", args[cond.paramIndex()])); + case NIN -> result.put(fieldName, nullSafeOp("$nin", args[cond.paramIndex()])); + case LIKE -> { + String regex = likeToRegex(args[cond.paramIndex()].toString()); + result.put(fieldName, Map.of("$regex", regex)); + } + case STARTS_WITH -> result.put(fieldName, Map.of("$regex", "^" + Pattern.quote(args[cond.paramIndex()].toString()))); + case ENDS_WITH -> result.put(fieldName, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()) + "$")); + case CONTAINS -> result.put(fieldName, args[cond.paramIndex()]); + case NOT_CONTAINS -> result.put(fieldName, nullSafeOp("$ne", args[cond.paramIndex()])); + case MATCHES -> result.put(fieldName, Map.of("$regex", args[cond.paramIndex()].toString())); + case IGNORE_CASE -> { + Map regex = new LinkedHashMap<>(); + regex.put("$regex", "^" + Pattern.quote(args[cond.paramIndex()].toString()) + "$"); + regex.put("$options", "i"); + result.put(fieldName, regex); + } + case IS_NULL -> result.put(fieldName, null); + case IS_NOT_NULL -> result.put(fieldName, nullSafeOp("$ne", null)); + case IS_TRUE -> result.put(fieldName, true); + case IS_FALSE -> result.put(fieldName, false); + case IS_EMPTY -> result.put(fieldName, Map.of("$size", 0)); + case IS_NOT_EMPTY -> result.put("$nor", List.of(Map.of(fieldName, Map.of("$size", 0)))); + case SIZE -> result.put(fieldName, Map.of("$size", ((Number) args[cond.paramIndex()]).intValue())); + } + return result; + } + + /** + * Creates a single-entry operator map that tolerates null values. + * {@code Map.of()} throws NPE for null values; this does not. + */ + private static Map nullSafeOp(String op, Object value) { + Map map = new LinkedHashMap<>(1); + map.put(op, value); + return map; + } + + /** + * Returns the @Aliases values for the given Java field, or an empty list if none. + */ + private static List resolveAliases(Morphium morphium, + Class entityClass, + String javaFieldName) { + try { + Field javaField = morphium.getARHelper().getField(entityClass, javaFieldName); + if (javaField != null && javaField.isAnnotationPresent(Aliases.class)) { + return List.of(javaField.getAnnotation(Aliases.class).value()); + } + } catch (Exception e) { + log.trace("Could not resolve aliases for field '{}' on {}", + javaFieldName, entityClass.getSimpleName(), e); + } + return List.of(); + } + + /** + * Converts a SQL LIKE pattern to a regex, escaping regex metacharacters + * while converting {@code %} to {@code .*} and {@code _} to {@code .}. + */ + static String likeToRegex(String likePattern) { + StringBuilder regex = new StringBuilder(); + StringBuilder literal = new StringBuilder(); + for (int i = 0; i < likePattern.length(); i++) { + char c = likePattern.charAt(i); + if (c == '%' || c == '_') { + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + literal.setLength(0); + } + regex.append(c == '%' ? ".*" : "."); + } else { + literal.append(c); + } + } + if (literal.length() > 0) { + regex.append(Pattern.quote(literal.toString())); + } + return "^" + regex + "$"; + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java new file mode 100644 index 000000000..b0dab1dc3 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryMethodBridge.java @@ -0,0 +1,213 @@ +package de.caluga.morphium.data; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Runtime bridge called by Gizmo-generated repository methods for query derivation. + *

+ * The build-time annotation processor generates a call from each repository interface method + * (e.g. {@code findByStatus(String status)}) into {@link #executeQuery}, passing the method name + * and arguments as plain runtime values instead of generating query-building bytecode. This class + * parses the method name via {@link MethodNameParser#parse} (caching the resulting + * {@link QueryDescriptor} per entity type and method), adjusts the descriptor's return type to + * match the caller's declared return shape, merges any {@code @OrderBy} annotation spec, and + * finally delegates execution to {@link QueryExecutor#execute}. This is the derived-query + * counterpart of {@link FindMethodBridge} ({@code @Find} methods) and {@link JdqlMethodBridge} + * ({@code @Query}/JDQL methods). + */ +public final class QueryMethodBridge { + + private static final ConcurrentHashMap CACHE = new ConcurrentHashMap<>(); + + private QueryMethodBridge() {} + + /** + * Called from generated bytecode for each derived query method invocation. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @return the query result + */ + public static Object executeQuery(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream) { + return executeQuery(repo, methodName, args, returnsSingle, returnsOptional, returnsBoolean, returnsStream, ""); + } + + /** + * Called from generated bytecode for each derived query method invocation. + * Overload that accepts an {@code @OrderBy} annotation spec to merge with + * any method-name-derived ordering. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @param orderBySpec the {@code @OrderBy} annotation spec (e.g. "createdAt:DESC") + * @return the query result + */ + public static Object executeQuery(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream, + String orderBySpec) { + String cacheKey = repo.getMetadata().entityClass().getName() + "#" + methodName + + (orderBySpec.isEmpty() ? "" : "#" + orderBySpec); + + QueryDescriptor descriptor = CACHE.computeIfAbsent(cacheKey, k -> { + QueryDescriptor parsed = MethodNameParser.parse(methodName, null); + + // Merge method-name-derived OrderBy with @OrderBy annotation specs + if (!orderBySpec.isEmpty()) { + var mergedOrderBy = new ArrayList<>(parsed.orderBy()); + mergedOrderBy.addAll(parseOrderBySpec(orderBySpec)); + return new QueryDescriptor( + parsed.prefix(), + parsed.conditions(), + parsed.combinator(), + mergedOrderBy, + parsed.returnType()); + } + return parsed; + }); + + // Override return type if caller expects single, optional, or stream result + if (descriptor.prefix() == QueryDescriptor.Prefix.FIND) { + if (returnsOptional) { + descriptor = new QueryDescriptor( + descriptor.prefix(), + descriptor.conditions(), + descriptor.combinator(), + descriptor.orderBy(), + QueryDescriptor.ReturnType.OPTIONAL); + } else if (returnsSingle) { + descriptor = new QueryDescriptor( + descriptor.prefix(), + descriptor.conditions(), + descriptor.combinator(), + descriptor.orderBy(), + QueryDescriptor.ReturnType.SINGLE); + } else if (returnsStream) { + descriptor = new QueryDescriptor( + descriptor.prefix(), + descriptor.conditions(), + descriptor.combinator(), + descriptor.orderBy(), + QueryDescriptor.ReturnType.STREAM); + } + } + + Object result = QueryExecutor.execute(descriptor, args, repo); + + // For deleteBy* with boolean return: convert count > 0 + if (returnsBoolean && result instanceof Long count) { + return count > 0; + } + + return result; + } + + /** + * Asynchronous variant of {@link #executeQuery(AbstractMorphiumRepository, String, Object[], + * boolean, boolean, boolean, boolean)}, running the query on the repository's async executor. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @return a completion stage yielding the query result + */ + public static CompletionStage executeQueryAsync(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream) { + return executeQueryAsync(repo, methodName, args, returnsSingle, returnsOptional, returnsBoolean, returnsStream, ""); + } + + /** + * Asynchronous variant of {@link #executeQuery(AbstractMorphiumRepository, String, Object[], + * boolean, boolean, boolean, boolean, String)}, running the query on the repository's async + * executor. + * + * @param repo the repository instance (provides Morphium + metadata) + * @param methodName the repository method name (e.g. "findByStatus") + * @param args the method arguments + * @param returnsSingle whether the caller expects a single result (T) + * @param returnsOptional whether the caller expects an Optional result + * @param returnsBoolean whether the caller expects a boolean result (for deleteBy*) + * @param returnsStream whether the caller expects a Stream result + * @param orderBySpec the {@code @OrderBy} annotation spec (e.g. "createdAt:DESC") + * @return a completion stage yielding the query result + */ + public static CompletionStage executeQueryAsync(AbstractMorphiumRepository repo, + String methodName, + Object[] args, + boolean returnsSingle, + boolean returnsOptional, + boolean returnsBoolean, + boolean returnsStream, + String orderBySpec) { + return CompletableFuture.supplyAsync( + () -> executeQuery(repo, methodName, args, returnsSingle, returnsOptional, returnsBoolean, returnsStream, orderBySpec), + repo.getAsyncExecutor()); + } + + /** + * Parses the build-time orderBy spec string (e.g. "createdAt:DESC,name:ASC") + * into a list of {@link QueryDescriptor.OrderSpec}. + */ + private static List parseOrderBySpec(String spec) { + var result = new ArrayList(); + for (String part : spec.split(",")) { + String trimmed = part.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("Empty fragment in @OrderBy spec: '" + spec + "'"); + } + String[] fieldAndDir = trimmed.split(":", -1); + if (fieldAndDir.length > 2) { + throw new IllegalArgumentException("Invalid @OrderBy fragment: '" + trimmed + "'"); + } + String field = fieldAndDir[0].trim(); + if (field.isEmpty()) { + throw new IllegalArgumentException("Empty field name in @OrderBy spec: '" + spec + "'"); + } + if (fieldAndDir.length > 1) { + String dirStr = fieldAndDir[1].trim(); + if (!"ASC".equals(dirStr) && !"DESC".equals(dirStr)) { + throw new IllegalArgumentException( + "Invalid direction '" + dirStr + "' in @OrderBy spec: '" + spec + + "' — expected ASC or DESC"); + } + } + QueryDescriptor.Direction dir = fieldAndDir.length > 1 && "DESC".equals(fieldAndDir[1].trim()) + ? QueryDescriptor.Direction.DESC : QueryDescriptor.Direction.ASC; + result.add(new QueryDescriptor.OrderSpec(field, dir)); + } + return result; + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryResultHelper.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryResultHelper.java new file mode 100644 index 000000000..1d7beb974 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryResultHelper.java @@ -0,0 +1,68 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.query.Query; +import jakarta.data.exceptions.EmptyResultException; +import jakarta.data.exceptions.NonUniqueResultException; + +import java.util.List; +import java.util.Optional; + +/** + * Shared helper for enforcing Jakarta Data single-result semantics. + *

+ * When a repository method declares a single-entity return type ({@code T}, not + * {@code List} or {@code Stream}), the spec requires: + *

    + *
  • {@link EmptyResultException} if the query returns no results
  • + *
  • {@link NonUniqueResultException} if the query returns more than one result
  • + *
+ * For {@code Optional} return types, no result returns {@code Optional.empty()} + * but multiple results still throw {@code NonUniqueResultException}. + *

+ * Used by {@link QueryExecutor}, {@link FindMethodBridge}, and {@link JdqlMethodBridge} as the + * final step before returning a single-entity or {@code Optional} result to the caller — the + * query itself (conditions, sorting, etc.) has already been fully built by that point. + */ +final class QueryResultHelper { + + private QueryResultHelper() {} + + /** + * Executes the query expecting exactly one result. + * + * @param query the Morphium query to execute + * @return the single result entity (never null) + * @throws EmptyResultException if no result is found + * @throws NonUniqueResultException if more than one result is found + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + static Object requireSingle(Query query) { + List results = query.limit(2).asList(); + if (results.isEmpty()) { + throw new EmptyResultException("Query returned no result"); + } + if (results.size() > 1) { + throw new NonUniqueResultException("Query returned more than one result"); + } + return results.get(0); + } + + /** + * Executes the query expecting zero or one result, returning an Optional. + * + * @param query the Morphium query to execute + * @return {@code Optional.of(entity)} or {@code Optional.empty()} + * @throws NonUniqueResultException if more than one result is found + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + static Optional optionalSingle(Query query) { + List results = query.limit(2).asList(); + if (results.isEmpty()) { + return Optional.empty(); + } + if (results.size() > 1) { + throw new NonUniqueResultException("Query returned more than one result"); + } + return Optional.of(results.get(0)); + } +} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/RepositoryMetadata.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/RepositoryMetadata.java new file mode 100644 index 000000000..14d530a06 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/RepositoryMetadata.java @@ -0,0 +1,20 @@ +package de.caluga.morphium.data; + +/** + * Holds the metadata extracted at build time for a single {@code @Repository} interface. + *

+ * Every {@link AbstractMorphiumRepository} subclass carries exactly one instance of this record, + * obtained from the build-time annotation processor and passed to the constructor. It is the + * shared source of truth for the entity type used by {@link QueryExecutor}, + * {@link FindMethodBridge}, and {@link JdqlMethodBridge} when resolving field names and building + * queries — none of them need to know how the repository interface itself is declared. + * + * @param entityClass the entity type {@code T} + * @param idClass the primary-key type {@code K} + * @param idFieldName the Java field name annotated with {@code @Id} + */ +public record RepositoryMetadata( + Class entityClass, + Class idClass, + String idFieldName +) {} diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/SortMapper.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/SortMapper.java new file mode 100644 index 000000000..0074a89e1 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/SortMapper.java @@ -0,0 +1,54 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.query.Query; +import jakarta.data.Order; +import jakarta.data.Sort; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Maps Jakarta Data {@link Order} / {@link Sort} to Morphium query sorting. + *

+ * A small, self-contained mapping utility used wherever a repository method accepts a dynamic + * {@code Order} or {@code Sort} parameter (as opposed to a static, method-name- or + * annotation-derived sort order). {@link FindMethodBridge}, {@link JdqlMethodBridge}, and + * {@link AbstractMorphiumRepository#doFindAllPaged}/{@link AbstractMorphiumRepository#doFindAllCursored} + * inline the same field-resolution logic rather than calling this class directly in every case; + * {@link #apply} exists as the shared entry point for callers that only need to apply Jakarta + * Data's ordering type to a query without any other processing. + */ +public final class SortMapper { + + private SortMapper() {} + + /** + * Applies the given Jakarta Data Order to the Morphium query. + * + * @param query the Morphium query + * @param order the Jakarta Data order specification + * @param morphium the Morphium instance (for field name resolution) + * @param entityClass the entity class + */ + @SuppressWarnings("unchecked") + public static void apply(Query query, Order order, Morphium morphium, Class entityClass) { + if (order == null || order.sorts().isEmpty()) return; + + Map sortMap = new LinkedHashMap<>(); + for (Sort sort : order.sorts()) { + String mongoField = resolveMongoField(morphium, entityClass, sort.property()); + sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + } + query.sort(sortMap); + } + + @SuppressWarnings("unchecked") + private static String resolveMongoField(Morphium morphium, Class entityClass, String javaFieldName) { + try { + return morphium.getARHelper().getMongoFieldName(entityClass, javaFieldName); + } catch (Exception e) { + return javaFieldName; + } + } +} diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java new file mode 100644 index 000000000..a84c6109f --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java @@ -0,0 +1,482 @@ +package de.caluga.morphium.data; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link JdqlParser}, covering empty/null input handling, + * parenthesized group support, and parenthesis-aware top-level splitting. + */ +class JdqlParserTest { + + @Nested + @DisplayName("Empty and null input — find all contract") + class EmptyInputTests { + + @Test + @DisplayName("parse(\"\") returns empty conditions (find all)") + void emptyStringReturnsEmptyConditions() { + JdqlQuery result = JdqlParser.parse(""); + assertThat(result.conditions()).isEmpty(); + assertThat(result.orderBy()).isEmpty(); + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + } + + @Test + @DisplayName("parse(null) returns empty conditions (find all)") + void nullReturnsEmptyConditions() { + JdqlQuery result = JdqlParser.parse(null); + assertThat(result.conditions()).isEmpty(); + assertThat(result.orderBy()).isEmpty(); + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + } + + @Test + @DisplayName("parse(\" \") (blank) returns empty conditions (find all)") + void blankStringReturnsEmptyConditions() { + JdqlQuery result = JdqlParser.parse(" "); + assertThat(result.conditions()).isEmpty(); + assertThat(result.orderBy()).isEmpty(); + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + } + } + + @Nested + @DisplayName("Parenthesized group parsing") + class ParenthesizedGroupTests { + + @Test + @DisplayName("AND with parenthesized OR group: a = :a AND (b IS NULL OR b = '')") + void andWithParenthesizedOrGroup() { + String jdql = "WHERE campaignNumber = :campaignNumber AND (otaUpdateError IS NULL OR otaUpdateError = '')"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + // First condition: simple campaignNumber = :campaignNumber + JdqlQuery.JdqlCondition first = result.conditions().get(0); + assertThat(first.isGroup()).isFalse(); + assertThat(first.fieldName()).isEqualTo("campaignNumber"); + assertThat(first.operator()).isEqualTo(JdqlQuery.Operator.EQ); + assertThat(first.valueRef()).isEqualTo(":campaignNumber"); + + // Second condition: group (otaUpdateError IS NULL OR otaUpdateError = '') + JdqlQuery.JdqlCondition second = result.conditions().get(1); + assertThat(second.isGroup()).isTrue(); + assertThat(second.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(second.groupConditions()).hasSize(2); + + JdqlQuery.JdqlCondition groupCond1 = second.groupConditions().get(0); + assertThat(groupCond1.fieldName()).isEqualTo("otaUpdateError"); + assertThat(groupCond1.operator()).isEqualTo(JdqlQuery.Operator.IS_NULL); + + JdqlQuery.JdqlCondition groupCond2 = second.groupConditions().get(1); + assertThat(groupCond2.fieldName()).isEqualTo("otaUpdateError"); + assertThat(groupCond2.operator()).isEqualTo(JdqlQuery.Operator.EQ); + assertThat(groupCond2.valueRef()).isEqualTo("''"); + } + + @Test + @DisplayName("Multiple AND conditions with parenthesized OR: a = :a AND b = :b AND (c IS NULL OR c = '')") + void multipleAndWithParenthesizedOr() { + String jdql = "WHERE a = :a AND b = :b AND (c IS NULL OR c = '')"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(3); + + assertThat(result.conditions().get(0).isGroup()).isFalse(); + assertThat(result.conditions().get(0).fieldName()).isEqualTo("a"); + + assertThat(result.conditions().get(1).isGroup()).isFalse(); + assertThat(result.conditions().get(1).fieldName()).isEqualTo("b"); + + JdqlQuery.JdqlCondition group = result.conditions().get(2); + assertThat(group.isGroup()).isTrue(); + assertThat(group.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(group.groupConditions()).hasSize(2); + } + + @Test + @DisplayName("Parenthesized AND group inside OR: (a = :a AND b = :b) OR c = :c") + void parenthesizedAndGroupInsideOr() { + String jdql = "WHERE (a = :a AND b = :b) OR c = :c"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(result.conditions()).hasSize(2); + + JdqlQuery.JdqlCondition group = result.conditions().get(0); + assertThat(group.isGroup()).isTrue(); + assertThat(group.groupCombinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(group.groupConditions()).hasSize(2); + + assertThat(result.conditions().get(1).isGroup()).isFalse(); + assertThat(result.conditions().get(1).fieldName()).isEqualTo("c"); + } + + @Test + @DisplayName("Single condition in parentheses is unwrapped: (a = :a)") + void singleConditionInParentheses() { + String jdql = "WHERE (a = :a)"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.isGroup()).isFalse(); + assertThat(cond.fieldName()).isEqualTo("a"); + assertThat(cond.operator()).isEqualTo(JdqlQuery.Operator.EQ); + } + + @Test + @DisplayName("Nested groups: (a = :a OR (b = :b AND c = :c))") + void nestedGroups() { + String jdql = "WHERE x = :x AND (a = :a OR (b = :b AND c = :c))"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + assertThat(result.conditions().get(0).fieldName()).isEqualTo("x"); + + JdqlQuery.JdqlCondition outerGroup = result.conditions().get(1); + assertThat(outerGroup.isGroup()).isTrue(); + assertThat(outerGroup.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(outerGroup.groupConditions()).hasSize(2); + + // First inner: a = :a + assertThat(outerGroup.groupConditions().get(0).fieldName()).isEqualTo("a"); + + // Second inner: (b = :b AND c = :c) + JdqlQuery.JdqlCondition innerGroup = outerGroup.groupConditions().get(1); + assertThat(innerGroup.isGroup()).isTrue(); + assertThat(innerGroup.groupCombinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(innerGroup.groupConditions()).hasSize(2); + } + } + + @Nested + @DisplayName("Top-level OR (no parentheses) — existing behavior preserved") + class TopLevelOrTests { + + @Test + @DisplayName("Simple OR: a = :a OR b = :b") + void simpleOr() { + String jdql = "WHERE a = :a OR b = :b"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(result.conditions()).hasSize(2); + assertThat(result.conditions().get(0).fieldName()).isEqualTo("a"); + assertThat(result.conditions().get(1).fieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("Simple AND: a = :a AND b = :b") + void simpleAnd() { + String jdql = "WHERE a = :a AND b = :b"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + } + } + + @Nested + @DisplayName("BETWEEN...AND inside parenthesized groups") + class BetweenTests { + + @Test + @DisplayName("BETWEEN...AND is not split: a BETWEEN :min AND :max AND b = :b") + void betweenNotSplit() { + String jdql = "WHERE a BETWEEN :min AND :max AND b = :b"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + JdqlQuery.JdqlCondition between = result.conditions().get(0); + assertThat(between.operator()).isEqualTo(JdqlQuery.Operator.BETWEEN); + assertThat(between.valueRef()).isEqualTo(":min"); + assertThat(between.valueRef2()).isEqualTo(":max"); + } + } + + @Nested + @DisplayName("containsTopLevelOr must not see OR inside parentheses") + class ContainsTopLevelOrTests { + + @Test + @DisplayName("OR only inside parentheses → no top-level OR → combinator is AND") + void orInsideParenthesesIsNotTopLevel() { + String jdql = "WHERE a = :a AND (b = :b OR c = :c)"; + JdqlQuery result = JdqlParser.parse(jdql); + + // The top-level combinator must be AND, not OR + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + } + + @Test + @DisplayName("OR at top level → combinator is OR") + void orAtTopLevel() { + String jdql = "WHERE a = :a OR b = :b"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.OR); + } + } + + @Nested + @DisplayName("Real-world OTA Authority queries") + class RealWorldTests { + + @Test + @DisplayName("UpdateMorphiumRepository: campaignNumber = :cn AND (otaUpdateError IS NULL OR otaUpdateError = '')") + void updateRepositoryQuery() { + String jdql = "WHERE campaignNumber = :campaignNumber AND (otaUpdateError IS NULL OR otaUpdateError = '')"; + JdqlQuery result = JdqlParser.parse(jdql); + + // Must be AND at top level with 2 conditions + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + // First: campaignNumber = :campaignNumber + JdqlQuery.JdqlCondition campaignCond = result.conditions().get(0); + assertThat(campaignCond.isGroup()).isFalse(); + assertThat(campaignCond.fieldName()).isEqualTo("campaignNumber"); + + // Second: OR group + JdqlQuery.JdqlCondition orGroup = result.conditions().get(1); + assertThat(orGroup.isGroup()).isTrue(); + assertThat(orGroup.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(orGroup.groupConditions()).hasSize(2); + + // Group condition 1: otaUpdateError IS NULL + assertThat(orGroup.groupConditions().get(0).fieldName()).isEqualTo("otaUpdateError"); + assertThat(orGroup.groupConditions().get(0).operator()).isEqualTo(JdqlQuery.Operator.IS_NULL); + + // Group condition 2: otaUpdateError = '' + assertThat(orGroup.groupConditions().get(1).fieldName()).isEqualTo("otaUpdateError"); + assertThat(orGroup.groupConditions().get(1).operator()).isEqualTo(JdqlQuery.Operator.EQ); + } + } + + @Nested + @DisplayName("NOT BETWEEN support") + class NotBetweenTests { + + @Test + @DisplayName("NOT field BETWEEN :min AND :max") + void notBetween() { + String jdql = "WHERE NOT price BETWEEN :min AND :max"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.operator()).isEqualTo(JdqlQuery.Operator.BETWEEN); + assertThat(cond.fieldName()).isEqualTo("price"); + assertThat(cond.valueRef()).isEqualTo(":min"); + assertThat(cond.valueRef2()).isEqualTo(":max"); + assertThat(cond.negated()).isTrue(); + } + + @Test + @DisplayName("NOT BETWEEN combined with other AND conditions") + void notBetweenWithAnd() { + String jdql = "WHERE status = :status AND NOT price BETWEEN :min AND :max"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + JdqlQuery.JdqlCondition first = result.conditions().get(0); + assertThat(first.fieldName()).isEqualTo("status"); + assertThat(first.operator()).isEqualTo(JdqlQuery.Operator.EQ); + + JdqlQuery.JdqlCondition second = result.conditions().get(1); + assertThat(second.operator()).isEqualTo(JdqlQuery.Operator.BETWEEN); + assertThat(second.negated()).isTrue(); + } + } + + @Nested + @DisplayName("NOT (...) group negation") + class NotGroupTests { + + @Test + @DisplayName("NOT (a = :a OR b = :b) creates negated group") + void notOrGroup() { + String jdql = "WHERE NOT (a = :a OR b = :b)"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.isGroup()).isTrue(); + assertThat(cond.negated()).isTrue(); + assertThat(cond.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(cond.groupConditions()).hasSize(2); + assertThat(cond.groupConditions().get(0).fieldName()).isEqualTo("a"); + assertThat(cond.groupConditions().get(1).fieldName()).isEqualTo("b"); + } + + @Test + @DisplayName("NOT (a = :a AND b = :b) creates negated group") + void notAndGroup() { + String jdql = "WHERE NOT (a = :a AND b = :b)"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.isGroup()).isTrue(); + assertThat(cond.negated()).isTrue(); + assertThat(cond.groupCombinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(cond.groupConditions()).hasSize(2); + } + + @Test + @DisplayName("x = :x AND NOT (a = :a OR b = :b)") + void notGroupCombinedWithAnd() { + String jdql = "WHERE x = :x AND NOT (a = :a OR b = :b)"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + assertThat(result.conditions().get(0).isGroup()).isFalse(); + assertThat(result.conditions().get(0).fieldName()).isEqualTo("x"); + + JdqlQuery.JdqlCondition group = result.conditions().get(1); + assertThat(group.isGroup()).isTrue(); + assertThat(group.negated()).isTrue(); + assertThat(group.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + } + + @Test + @DisplayName("NOT (single condition) just negates it") + void notSingleConditionInParens() { + String jdql = "WHERE NOT (a = :a)"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.isGroup()).isFalse(); + assertThat(cond.fieldName()).isEqualTo("a"); + assertThat(cond.operator()).isEqualTo(JdqlQuery.Operator.EQ); + assertThat(cond.negated()).isTrue(); + } + + @Test + @DisplayName("NOT (NOT (...)) double negation cancels out") + void doubleNegationCancels() { + String jdql = "WHERE NOT (NOT (a = :a OR b = :b))"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.isGroup()).isTrue(); + assertThat(cond.negated()).isFalse(); // double NOT cancels + assertThat(cond.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(cond.groupConditions()).hasSize(2); + } + + @Test + @DisplayName("NOT (a AND NOT (b OR c)) — nested negated group preserved") + void nestedNegatedGroup() { + String jdql = "WHERE NOT (a = :a AND NOT (b = :b OR c = :c))"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition outer = result.conditions().get(0); + assertThat(outer.isGroup()).isTrue(); + assertThat(outer.negated()).isTrue(); + assertThat(outer.groupCombinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(outer.groupConditions()).hasSize(2); + + // First child: a = :a (simple condition) + JdqlQuery.JdqlCondition first = outer.groupConditions().get(0); + assertThat(first.isGroup()).isFalse(); + assertThat(first.fieldName()).isEqualTo("a"); + + // Second child: NOT (b = :b OR c = :c) — inner negated group + JdqlQuery.JdqlCondition inner = outer.groupConditions().get(1); + assertThat(inner.isGroup()).isTrue(); + assertThat(inner.negated()).isTrue(); + assertThat(inner.groupCombinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(inner.groupConditions()).hasSize(2); + } + } + + @Nested + @DisplayName("Error messages with position info") + class ErrorMessageTests { + + @Test + @DisplayName("Parse error includes position and caret") + void parseErrorIncludesPosition() { + String jdql = "WHERE name = :name AND status > "; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains("JDQL parse error at position"); + assertThat(e.getMessage()).contains("^"); + assertThat(e.getMessage()).contains(jdql); + } + } + + @Test + @DisplayName("Duplicate fragment points at correct (second) occurrence") + void duplicateFragmentPointsAtCorrectOccurrence() { + // "a = :a" appears twice; the error is in the second occurrence (incomplete) + String jdql = "WHERE a = :a AND a > "; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + // Position must point past the first "a = :a AND ", i.e. at the second "a" + assertThat(e.getMessage()).contains("JDQL parse error at position"); + // "a > " starts at position 17 in "WHERE a = :a AND a > " + assertThat(e.getMessage()).contains("position 17"); + } + } + + @Test + @DisplayName("Parse error for invalid HAVING includes position") + void havingParseErrorIncludesPosition() { + String jdql = "SELECT COUNT(this) FROM Entity GROUP BY status HAVING badexpr"; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains("JDQL parse error at position"); + assertThat(e.getMessage()).contains("^"); + } + } + } + + @Nested + @DisplayName("ORDER BY with parenthesized groups") + class OrderByWithGroupTests { + + @Test + @DisplayName("Parenthesized group with ORDER BY") + void groupWithOrderBy() { + String jdql = "WHERE a = :a AND (b IS NULL OR b = '') ORDER BY a ASC"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + assertThat(result.conditions().get(1).isGroup()).isTrue(); + assertThat(result.orderBy()).hasSize(1); + assertThat(result.orderBy().get(0).field()).isEqualTo("a"); + assertThat(result.orderBy().get(0).ascending()).isTrue(); + } + } +} diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java new file mode 100644 index 000000000..eb74ee586 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java @@ -0,0 +1,119 @@ +package de.caluga.morphium.data; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link MethodNameParser}, covering the "match all" contract + * (empty suffix after prefix) and basic method-name derivation. + */ +class MethodNameParserTest { + + private static final Set ENTITY_FIELDS = Set.of("id", "status", "name", "campaignNumber", "createdAt"); + + @Nested + @DisplayName("Empty suffix — match all contract") + class MatchAllTests { + + @Test + @DisplayName("countBy() with empty suffix returns count-all descriptor") + void countByEmptySuffix() { + QueryDescriptor result = MethodNameParser.parse("countBy", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.COUNT); + assertThat(result.conditions()).isEmpty(); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.AND); + assertThat(result.returnType()).isEqualTo(QueryDescriptor.ReturnType.COUNT); + } + + @Test + @DisplayName("findBy() with empty suffix returns find-all descriptor") + void findByEmptySuffix() { + QueryDescriptor result = MethodNameParser.parse("findBy", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.FIND); + assertThat(result.conditions()).isEmpty(); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.AND); + assertThat(result.returnType()).isEqualTo(QueryDescriptor.ReturnType.LIST); + } + + @Test + @DisplayName("existsBy() with empty suffix returns exists-all descriptor") + void existsByEmptySuffix() { + QueryDescriptor result = MethodNameParser.parse("existsBy", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.EXISTS); + assertThat(result.conditions()).isEmpty(); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.AND); + assertThat(result.returnType()).isEqualTo(QueryDescriptor.ReturnType.BOOLEAN); + } + + @Test + @DisplayName("deleteBy() with empty suffix returns delete-all descriptor") + void deleteByEmptySuffix() { + QueryDescriptor result = MethodNameParser.parse("deleteBy", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.DELETE); + assertThat(result.conditions()).isEmpty(); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.AND); + assertThat(result.returnType()).isEqualTo(QueryDescriptor.ReturnType.COUNT); + } + } + + @Nested + @DisplayName("Single condition parsing") + class SingleConditionTests { + + @Test + @DisplayName("findByStatus parses as FIND with EQ on status") + void findByStatus() { + QueryDescriptor result = MethodNameParser.parse("findByStatus", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.FIND); + assertThat(result.conditions()).hasSize(1); + assertThat(result.conditions().get(0).field()).isEqualTo("status"); + assertThat(result.conditions().get(0).operator()).isEqualTo(QueryDescriptor.Operator.EQ); + } + + @Test + @DisplayName("existsById parses as EXISTS with EQ on id") + void existsById() { + QueryDescriptor result = MethodNameParser.parse("existsById", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.EXISTS); + assertThat(result.conditions()).hasSize(1); + assertThat(result.conditions().get(0).field()).isEqualTo("id"); + assertThat(result.conditions().get(0).operator()).isEqualTo(QueryDescriptor.Operator.EQ); + } + + @Test + @DisplayName("deleteByStatus parses as DELETE with EQ on status") + void deleteByStatus() { + QueryDescriptor result = MethodNameParser.parse("deleteByStatus", ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.DELETE); + assertThat(result.conditions()).hasSize(1); + assertThat(result.conditions().get(0).field()).isEqualTo("status"); + assertThat(result.conditions().get(0).operator()).isEqualTo(QueryDescriptor.Operator.EQ); + } + } + + @Nested + @DisplayName("Method name validation") + class ValidationTests { + + @Test + @DisplayName("Invalid prefix throws IllegalArgumentException") + void invalidPrefix() { + assertThatThrownBy(() -> MethodNameParser.parse("getByStatus", ENTITY_FIELDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot parse repository method name"); + } + } +} diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java new file mode 100644 index 000000000..069204d97 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java @@ -0,0 +1,400 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.annotations.Aliases; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.QueryDescriptor.Combinator; +import de.caluga.morphium.data.QueryDescriptor.Condition; +import de.caluga.morphium.data.QueryDescriptor.Operator; +import de.caluga.morphium.data.QueryDescriptor.Prefix; +import de.caluga.morphium.data.QueryDescriptor.ReturnType; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.morphium.query.Query; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies that derived queries correctly use $or to match both the current + * MongoDB field name and any @Aliases, so old documents stored under a + * previous field name are found. + */ +class QueryExecutorAliasTest { + + private static Morphium morphium; + + @Entity + static class OtaUpdate { + @Id + private String id; + + private String campaignNumber; + + private String vin; + + @Aliases({"updateId"}) + private String otaUpdateId; + + @Aliases({"uploadDate"}) + private String otaUploadDate; + + private String status; + } + + @BeforeAll + static void setUp() { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.setDatabase("test"); + cfg.addHostToSeed("localhost"); + cfg.setDriverName(InMemoryDriver.driverName); + morphium = new Morphium(cfg); + } + + @AfterAll + static void tearDown() { + if (morphium != null) { + morphium.close(); + } + } + + @Test + @DisplayName("Query on aliased field generates $or with current name + aliases") + void queryOnAliasedFieldGeneratesOr() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("otaUpdateId", Operator.EQ, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"update-123"}); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).containsKey("$or"); + @SuppressWarnings("unchecked") + List> orList = (List>) queryObj.get("$or"); + assertThat(orList).hasSize(2); + + Set queriedFields = new HashSet<>(); + for (Map sub : orList) { + queriedFields.addAll(sub.keySet()); + } + assertThat(queriedFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("Query on non-aliased field generates simple condition (no $or)") + void queryOnNonAliasedFieldIsSimple() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("campaignNumber", Operator.EQ, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"CN-001"}); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).doesNotContainKey("$or"); + assertThat(queryObj).containsKey("campaign_number"); + } + + @Test + @DisplayName("Combined AND query with aliased + non-aliased fields") + void combinedAndQueryWithAliasedField() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of( + new Condition("campaignNumber", Operator.EQ, 0), + new Condition("otaUpdateId", Operator.EQ, 1) + ), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"CN-001", "update-123"}); + Map queryObj = query.toQueryObject(); + + // Should have $and containing the simple condition + the $or for the alias + assertThat(queryObj).containsKey("$and"); + @SuppressWarnings("unchecked") + List> andList = (List>) queryObj.get("$and"); + assertThat(andList).hasSizeGreaterThanOrEqualTo(2); + + // Find the campaign_number condition in $and + boolean hasCampaignNumber = andList.stream() + .anyMatch(entry -> entry.containsKey("campaign_number")); + assertThat(hasCampaignNumber).as("$and should contain campaign_number condition").isTrue(); + + // Find the $or sub-condition for otaUpdateId aliases + @SuppressWarnings("unchecked") + Optional> orEntry = andList.stream() + .filter(entry -> entry.containsKey("$or")) + .findFirst(); + assertThat(orEntry).as("$and should contain an $or entry for aliased field").isPresent(); + + @SuppressWarnings("unchecked") + List> orList = (List>) orEntry.get().get("$or"); + Set orFields = new HashSet<>(); + for (Map sub : orList) { + orFields.addAll(sub.keySet()); + } + assertThat(orFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("Multiple aliased fields each get their own $or") + void multipleAliasedFields() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of( + new Condition("otaUpdateId", Operator.EQ, 0), + new Condition("otaUploadDate", Operator.EQ, 1) + ), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"update-123", "2025-01-01"}); + Map queryObj = query.toQueryObject(); + + // Should have $and with two $or groups + assertThat(queryObj).containsKey("$and"); + @SuppressWarnings("unchecked") + List> andList = (List>) queryObj.get("$and"); + + // Collect all $or groups from the $and list + List> orFieldSets = new ArrayList<>(); + for (Map entry : andList) { + if (entry.containsKey("$or")) { + @SuppressWarnings("unchecked") + List> orList = (List>) entry.get("$or"); + Set fields = new HashSet<>(); + for (Map sub : orList) { + fields.addAll(sub.keySet()); + } + orFieldSets.add(fields); + } + } + assertThat(orFieldSets).as("should have two separate $or groups").hasSize(2); + + // One $or for otaUpdateId aliases, one for otaUploadDate aliases + assertThat(orFieldSets).anySatisfy(fields -> + assertThat(fields).containsExactlyInAnyOrder("ota_update_id", "updateId")); + assertThat(orFieldSets).anySatisfy(fields -> + assertThat(fields).containsExactlyInAnyOrder("ota_upload_date", "uploadDate")); + } + + @Test + @DisplayName("IN operator on aliased field generates $or with $in payload") + void inOperatorOnAliasedField() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("otaUpdateId", Operator.IN, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + List searchValues = List.of("u1", "u2"); + Query query = buildQuery(descriptor, new Object[]{searchValues}); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).containsKey("$or"); + @SuppressWarnings("unchecked") + List> orList = (List>) queryObj.get("$or"); + assertThat(orList).hasSize(2); + + // Verify each $or branch contains $in with the correct values + Set queriedFields = new HashSet<>(); + for (Map sub : orList) { + for (Map.Entry e : sub.entrySet()) { + queriedFields.add(e.getKey()); + @SuppressWarnings("unchecked") + Map opMap = (Map) e.getValue(); + assertThat(opMap).containsKey("$in"); + assertThat(opMap.get("$in")).isEqualTo(searchValues); + } + } + assertThat(queriedFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("OR combinator with aliased field generates nested $or") + void orCombinatorWithAliasedField() { + // findByCampaignNumberOrOtaUpdateId — OR combinator, one aliased field + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of( + new Condition("campaignNumber", Operator.EQ, 0), + new Condition("otaUpdateId", Operator.EQ, 1) + ), + Combinator.OR, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"CN-001", "update-123"}); + Map queryObj = query.toQueryObject(); + + // The top-level OR should contain: one branch for campaignNumber, + // and one branch that itself contains $or for the aliased otaUpdateId + assertThat(queryObj).containsKey("$or"); + @SuppressWarnings("unchecked") + List> orList = (List>) queryObj.get("$or"); + assertThat(orList).hasSize(2); + + // One branch should have campaign_number + boolean hasCampaignNumber = orList.stream() + .anyMatch(entry -> entry.containsKey("campaign_number")); + assertThat(hasCampaignNumber).as("top-level $or should contain campaign_number branch").isTrue(); + + // The other branch should have a nested $or for the aliased field + @SuppressWarnings("unchecked") + Optional> aliasedBranch = orList.stream() + .filter(entry -> entry.containsKey("$or")) + .findFirst(); + assertThat(aliasedBranch).as("top-level $or should contain a nested $or for aliased field").isPresent(); + + @SuppressWarnings("unchecked") + List> nestedOr = (List>) aliasedBranch.get().get("$or"); + Set nestedFields = new HashSet<>(); + for (Map sub : nestedOr) { + nestedFields.addAll(sub.keySet()); + } + assertThat(nestedFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("LIKE operator generates anchored $regex with escaped metacharacters") + void likeOperatorGeneratesRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("campaignNumber", Operator.LIKE, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"%test[1]%"}); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).containsKey("campaign_number"); + @SuppressWarnings("unchecked") + Map regexMap = (Map) queryObj.get("campaign_number"); + String regex = (String) regexMap.get("$regex"); + assertThat(regex).startsWith("^"); + assertThat(regex).endsWith("$"); + // Pattern.quote escapes the whole literal block including [1] + assertThat(regex).contains("\\Qtest[1]\\E"); + } + + @Test + @DisplayName("STARTS_WITH generates anchored $regex with ^ prefix") + void startsWithGeneratesRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("campaignNumber", Operator.STARTS_WITH, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"CN-"}); + Map queryObj = query.toQueryObject(); + + @SuppressWarnings("unchecked") + Map regexMap = (Map) queryObj.get("campaign_number"); + String regex = (String) regexMap.get("$regex"); + assertThat(regex).startsWith("^"); + assertThat(regex).contains("\\QCN-\\E"); + } + + @Test + @DisplayName("ENDS_WITH generates $regex with $ suffix") + void endsWithGeneratesRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("status", Operator.ENDS_WITH, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"_done"}); + Map queryObj = query.toQueryObject(); + + @SuppressWarnings("unchecked") + Map regexMap = (Map) queryObj.get("status"); + String regex = (String) regexMap.get("$regex"); + assertThat(regex).endsWith("$"); + assertThat(regex).contains("\\Q_done\\E"); + } + + @Test + @DisplayName("MATCHES generates $regex with raw pattern") + void matchesGeneratesRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("vin", Operator.MATCHES, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"^WBA[0-9]+"}); + Map queryObj = query.toQueryObject(); + + @SuppressWarnings("unchecked") + Map regexMap = (Map) queryObj.get("vin"); + assertThat(regexMap).containsEntry("$regex", "^WBA[0-9]+"); + } + + @Test + @DisplayName("IGNORE_CASE generates $regex with case-insensitive option") + void ignoreCaseGeneratesRegexWithOption() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("status", Operator.IGNORE_CASE, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"Active"}); + Map queryObj = query.toQueryObject(); + + @SuppressWarnings("unchecked") + Map regexMap = (Map) queryObj.get("status"); + assertThat(regexMap).containsKey("$regex"); + assertThat(regexMap).containsEntry("$options", "i"); + String regex = (String) regexMap.get("$regex"); + assertThat(regex).startsWith("^"); + assertThat(regex).endsWith("$"); + assertThat(regex).contains("\\QActive\\E"); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private Query buildQuery(QueryDescriptor descriptor, Object[] args) { + Class entityClass = OtaUpdate.class; + Query query = morphium.createQueryFor(entityClass); + QueryExecutor.applyConditions(query, descriptor, args, morphium, entityClass); + return query; + } +} From 59405119b739a5ce78d424c84b97bc93b09eec48 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 14:49:00 +0200 Subject: [PATCH 02/14] build: register morphium-jakarta-data in extensions profile --- pom.xml | 41 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/pom.xml b/pom.xml index 4ee521870..27dd9ec93 100644 --- a/pom.xml +++ b/pom.xml @@ -30,6 +30,22 @@ sb@caluga.de + morphium-core poppydb @@ -51,6 +67,9 @@ manual = process-killing / hardcoded-local tests, NEVER in CI --> external,manual + + 1.0.0 @@ -313,6 +332,14 @@ + + + jakarta.data + jakarta.data-api + ${jakarta.data.version} + @@ -373,5 +400,19 @@ single + + + extensions + + + !skipExtensions + + + + morphium-jakarta-data + + From 4398414de4ad31caf2c1c5713f2b0b46d0d005c1 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 14:57:00 +0200 Subject: [PATCH 03/14] docs: add jakarta data module documentation --- docs/index.md | 10 + docs/jakarta-data.md | 530 +++++++++++++++++++++++++++++++++++++++++++ mkdocs.yml | 3 + 3 files changed, 543 insertions(+) create mode 100644 docs/jakarta-data.md diff --git a/docs/index.md b/docs/index.md index 032d898d2..dcefb9a7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,6 +48,16 @@ Morphium includes a complete in-memory MongoDB-compatible implementation for tes ## Reference - **[API Reference](./api-reference.md)** - Complete API documentation with examples +## Extensions (Optional Modules) +Morphium's core module (`de.caluga:morphium`) is fully self-contained and does not need +any of the following. These are additional, opt-in modules built on top of the core: +- **[Jakarta Data](./jakarta-data.md)** - Optional module implementing the Jakarta Data + 1.0 specification on top of Morphium's query engine (repository pattern, `@Repository`) + - Query derivation from method names, JDQL (`@Query`), `@Find`/`@Delete` with `@By` + - Offset and cursor pagination (`Page`, `CursoredPage`), dynamic and static sorting + - Zero dependency from the core: build with `-DskipExtensions` for a core-only artifact; + framework integrations for Quarkus and Spring Boot build on top of this module + Minimum requirements - Java 21+ - MongoDB 5.0+ diff --git a/docs/jakarta-data.md b/docs/jakarta-data.md new file mode 100644 index 000000000..de247f953 --- /dev/null +++ b/docs/jakarta-data.md @@ -0,0 +1,530 @@ +# Jakarta Data: Framework-Agnostic Repository Runtime + +`morphium-jakarta-data` is an **optional Morphium module** that implements the +[Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) specification on top of +Morphium's core query engine. It gives Morphium a standard, `@Repository`-based data +access layer — query derivation from method names, JDQL, `@Find`/`@Delete` methods, +pagination, and sorting — without depending on any particular application framework. + +## What is Jakarta Data? + +Jakarta Data is a Jakarta EE specification that standardizes the repository pattern for +Java persistence: you declare an interface such as `CrudRepository`, +add derived-query methods like `findByCategory(String category)`, and a runtime +generates the implementation for you — comparable in spirit to Spring Data repositories, +but as a vendor-neutral Jakarta specification. It defines the core annotations +(`@Repository`, `@Find`, `@Query`, `@OrderBy`, `@By`), pagination types (`Page`, +`CursoredPage`, `PageRequest`), and sorting types (`Sort`, `Order`) that any compliant +provider implements against its own data store. `morphium-jakarta-data` is Morphium's +provider for this specification, translating Jakarta Data semantics into Morphium +`Query` calls against MongoDB (or PoppyDB/InMemoryDriver). + +## Purpose and Scope + +This module is **not** something most application code depends on directly. It contains +only the framework-agnostic runtime: parsing, query building, and result-type adaptation +as plain Java classes with zero dependencies on Quarkus, Spring, or any DI container. + +!!! note "Applications typically don't add this module directly" + Applications normally consume Jakarta Data through a full framework integration: + **quarkus-morphium** (Gizmo bytecode generation at build time) or + **spring-boot-morphium** (JDK dynamic proxies at runtime). Those modules pull in + `morphium-jakarta-data` transitively and wire the generated/proxied repositories + into their respective dependency-injection containers. + + `morphium-jakarta-data` is directly relevant to you if you are **building your own + framework integration** — for a DI container or framework not already covered by + the two integrations above. See [Building your own framework integration](#building-your-own-framework-integration) + below. + +## Dependency Direction + +`morphium-jakarta-data` depends on Morphium core (`de.caluga:morphium`) and on the +Jakarta Data API (`jakarta.data:jakarta.data-api`). The dependency direction is strictly +one-way: **module → core, never core → module.** Morphium core has no knowledge of +Jakarta Data and no compile- or runtime dependency on this module. + +!!! note "The core does not pull in Jakarta Data" + If your application only declares a dependency on `de.caluga:morphium`, you do + **not** get `jakarta.data-api` on your classpath, and none of the Jakarta Data + annotations or types described on this page are available. You must add + `de.caluga:morphium-jakarta-data` (or one of the framework integrations) explicitly + to use any of this. + +## Maven Coordinates + +```xml + + de.caluga + morphium-jakarta-data + ${project.version} + +``` + +In the Morphium reactor, `${project.version}` currently resolves to `6.3.0-SNAPSHOT`. +This module follows Morphium's regular release versioning; there is no separate version +line to track. + +## Repository Interfaces + +Two base interfaces are available: + +- `jakarta.data.repository.CrudRepository` — the standard Jakarta Data interface: + `insert`, `insertAll`, `update`, `updateAll` (from `CrudRepository`), plus + `save`, `saveAll`, `findById`, `existsById`, `findAll`, `delete`, `deleteAll`, + `deleteById` (inherited from `BasicRepository`). +- `de.caluga.morphium.data.MorphiumRepository` — extends `CrudRepository` + with Morphium-specific escape hatches that have no equivalent in the Jakarta Data 1.0 + specification: `distinct(String fieldName)` and `morphium()` (direct access to the + underlying `Morphium` instance for aggregation pipelines, atomic field operations, + change streams, and messaging), plus `query()` as a shortcut for + `morphium().createQueryFor(entityClass)`. + +All standard Jakarta Data features — query derivation, `@Find`, `@Query`/JDQL, +pagination, sorting — work identically on both interfaces. Morphium ORM annotations +(`@Version`, `@CreationTime`, `@PreStore`, `@Cache`, `@Reference`, `@Aliases`, ...) +work transparently on the entity because the generated implementation delegates to the +regular Morphium API underneath. + +### Example + +```java +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.MorphiumRepository; +import de.caluga.morphium.driver.MorphiumId; +import jakarta.data.repository.Repository; + +import java.util.List; +import java.util.Optional; + +@Entity +public class Product { + @Id + private MorphiumId id; + private String category; + private String name; + private double price; + private boolean active; + + // getters/setters omitted +} + +@Repository +public interface ProductRepository extends MorphiumRepository { + + List findByCategory(String category); + + Optional findByName(String name); + + long countByCategory(String category); +} +``` + +```java +// Using it via a framework integration (Quarkus/Spring inject the implementation): +List active = productRepository.findByCategory("electronics"); + +// Morphium-specific escape hatches from MorphiumRepository: +List categories = productRepository.distinct("category"); +Morphium m = productRepository.morphium(); +``` + +## Query Derivation + +Repository method names are parsed by `MethodNameParser` into a `QueryDescriptor`, +which `QueryExecutor` then translates into a Morphium `Query`. The parser recognizes the +prefixes `find`, `count`, `exists`, `delete` followed by `By`, e.g. `findByStatus`, +`countByCategory`, `existsById`, `deleteByStatus`. A `By` with nothing after it +(`findBy()`, `countBy()`, ...) matches all entities. + +The following table lists every keyword the parser supports, each verified directly +against `MethodNameParser.java`. + +| Keyword | Example method | Resulting Morphium condition | +|---|---|---| +| `findBy` | `findByStatus(String status)` | `query.f("status").eq(status)` — implicit `Equals` when no operator suffix matches (`MethodNameParser.java:161-165`) | +| `countBy` | `countByCategory(String category)` | Prefix maps to `Prefix.COUNT`, executed as `query.countAll()` (`MethodNameParser.java:51`, `QueryExecutor.java:59`) | +| `existsBy` | `existsById(String id)` | Prefix maps to `Prefix.EXISTS`, executed as `query.countAll() > 0` (`MethodNameParser.java:52`, `QueryExecutor.java:60`) | +| `deleteBy` | `deleteByStatus(String status)` | Prefix maps to `Prefix.DELETE`, executed via `query.delete()` after counting matches (`MethodNameParser.java:53`, `QueryExecutor.java:61-69`) | +| `And` | `findByStatusAndCategory(String s, String c)` | Combinator `AND`: both conditions applied to the same query (`MethodNameParser.java:78-87`, `QueryExecutor.java:91-95`) | +| `Or` | `findByStatusOrCategory(String s, String c)` | Combinator `OR`: `query.or(...)` combining sub-queries (`MethodNameParser.java:82-84`, `QueryExecutor.java:82-90`) | +| `Between` | `findByPriceBetween(double min, double max)` | `{ price: { $gte: min, $lte: max } }` (`MethodNameParser.java:145-148,190`, `QueryExecutor.java:189-194`) | +| `In` | `findByStatusIn(List statuses)` | `{ status: { $in: statuses } }` (`MethodNameParser.java:201`, `QueryExecutor.java:195`) | +| `Like` | `findByNameLike(String pattern)` | SQL-style `%`/`_` pattern converted to anchored `$regex` (`MethodNameParser.java:200`, `QueryExecutor.java:197-199`, `likeToRegex` at `QueryExecutor.java:255-274`) | +| `GreaterThan` | `findByPriceGreaterThan(double price)` | `{ price: { $gt: price } }` (`MethodNameParser.java:174`, `QueryExecutor.java:185`) | +| `LessThan` | `findByPriceLessThan(double price)` | `{ price: { $lt: price } }` (`MethodNameParser.java:175`, `QueryExecutor.java:187`) | +| `Not` | `findByStatusNot(String status)` | `{ status: { $ne: status } }` — matched last among suffixes to avoid shadowing `NotIn`/`NotNull`/etc. (`MethodNameParser.java:195`, `QueryExecutor.java:184`) | +| `OrderBy` | `findByStatusOrderByCreatedAtDesc(String status)` | `query.sort({ createdAt: -1 })` after applying conditions (`MethodNameParser.java:69-76,103-132`, `QueryExecutor.java:47-49,145-155`) | + +Beyond the keywords requested for this table, the parser also supports (verified at the +same source locations, `MethodNameParser.java:172-202`): `GreaterThanEqual`, +`LessThanEqual`, `NotIn`, `StartsWith`, `EndsWith`, `Contains`, `NotContains`, `Matches`/ +`Regex`, `IgnoreCase`, `IsNull`/`Null`, `IsNotNull`/`NotNull`, `IsEmpty`/`Empty`, +`IsNotEmpty`/`NotEmpty`, `IsTrue`/`True`, `IsFalse`/`False`, `Size`, and `Is`/`Equals` as +explicit equality suffixes. + +Return type overrides — a single-entity return type (`T`), `Optional`, or +`Stream` — are detected by the build-time code generator and passed to the runtime +bridge (`QueryMethodBridge.executeQuery`), which adjusts the effective `ReturnType` +accordingly (`QueryMethodBridge.java:84-108`). + +## JDQL via `@Query` + +For queries that don't fit the method-name convention, annotate a method with +`@jakarta.data.repository.Query("...")` using JDQL (Jakarta Data Query Language). +`JdqlParser` parses the string into a `JdqlQuery`; `JdqlMethodBridge` executes it. + +The supported grammar, verified against `JdqlParser.java`: + +``` +[SELECT field1, field2 [FROM EntityName]] +[WHERE condition [AND|OR condition ...]] +[GROUP BY field1, field2 [HAVING aggregateCondition [AND|OR ...]]] +[ORDER BY field [ASC|DESC] [, field [ASC|DESC] ...]] +``` + +Condition grammar (`JdqlParser.java:14-33`): + +- `field = :param`, `field <> :param`, `field != :param` +- `field > :param` / `>=` / `<` / `<=` +- `field BETWEEN :min AND :max` +- `field IN :param` +- `field NOT IN :param` +- `field LIKE :param` +- `field IS NULL` / `field IS NOT NULL` +- Boolean literals: `field = true` / `field = false` +- Numeric literals: `field > 100` +- String literals: `field = 'value'` +- `NOT` prefix on any condition or parenthesized group: `NOT field = :param`, + `NOT (cond1 OR cond2)` +- Parenthesized groups with `AND`/`OR` nesting: `field1 = :a AND (field2 IS NULL OR field2 = '')` + +Not supported: JOINs, subqueries (documented explicitly in `JdqlParser.java:33`). + +Aggregate/grouping support (`JdqlQuery.java:25-33`, `JdqlMethodBridge.java:443-620`): +`SELECT COUNT(this)`, `SUM(field)`, `AVG(field)`, `MIN(field)`, `MAX(field)` are +compiled into a Morphium aggregation pipeline (`$match` → `$group` → optional `$match` +for `HAVING` → optional `$sort`). `GROUP BY` results must be mapped into a Java `record` +whose canonical constructor matches the `SELECT` field order. + +### Examples + +```java +@Repository +public interface ProductRepository extends MorphiumRepository { + + @Query("WHERE category = :cat AND price BETWEEN :min AND :max ORDER BY price") + List searchInPriceRange(@Param("cat") String category, + @Param("min") double min, + @Param("max") double max); + + @Query("WHERE active = true AND (category = :cat OR category IS NULL)") + List findActiveInCategoryOrUncategorized(@Param("cat") String category); + + record CategorySummary(String category, long count, double avgPrice) {} + + @Query("SELECT category, COUNT(this), AVG(price) FROM Product GROUP BY category HAVING COUNT(this) > :minCount") + List summarizeByCategory(@Param("minCount") long minCount); +} +``` + +## `@Find` / `@Delete` with `@By` Parameter Binding + +As an alternative to method-name derivation, annotate a method with +`@jakarta.data.repository.Find` or `@jakarta.data.repository.Delete` and bind each +parameter explicitly with `@By("fieldName")`. `FindMethodBridge` applies each `@By` +parameter as an equality condition (`FindMethodBridge.java:68-77`), then layers on +dynamic `Sort`/`Order`/`Limit`/`PageRequest` parameters if present. + +```java +@Repository +public interface ProductRepository extends MorphiumRepository { + + @Find + List byCategoryAndActive(@By("category") String category, + @By("active") boolean active); + + @Delete + void removeByCategory(@By("category") String category); +} +``` + +`@Delete` with `@By` parameters loads matching entities and deletes them one by one via +`morphium.delete(entity)` (`FindMethodBridge.java:251-254`) — unlike derived +`deleteBy*` methods, which use a bulk `query.delete()`. + +## Pagination + +Three types cover pagination: `jakarta.data.page.Page`, +`jakarta.data.page.CursoredPage`, and `jakarta.data.page.PageRequest`. + +- **Offset pagination** (`Page`): pass a `PageRequest` (e.g. + `PageRequest.ofPage(1, 20, true)`) to a repository method; the runtime computes + `skip`/`limit` from `page()`/`size()` and, if `requestTotal()` is true, issues a + separate `countAll()` query for the total (`AbstractMorphiumRepository.java:71-99`, + `MorphiumPage.java`). +- **Cursor (keyset) pagination** (`CursoredPage`): pass a `PageRequest` in one of the + cursor modes; the runtime builds a keyset condition from the previous page's last + sort-key values and fetches one extra row to determine `hasNext`/`hasPrevious` + (`AbstractMorphiumRepository.java:102-153`, `CursorHelper.java`). + +```java +// Offset pagination +PageRequest request = PageRequest.ofPage(1, 20, true); +Page page = productRepository.findAll(request, Order.by(Sort.asc("name"))); +long total = page.totalElements(); +Page next = productRepository.findAll(page.nextPageRequest(), Order.by(Sort.asc("name"))); + +// Cursor pagination via @Find + PageRequest parameter +@Find +@OrderBy("createdAt") +CursoredPage allOrderedByCreation(PageRequest pageRequest); +``` + +!!! note "When to prefer cursor pagination over offset pagination" + Offset pagination (`Page`, `skip`/`limit`) re-evaluates `skip` on every request, + so results can shift or duplicate if documents are inserted or deleted between page + requests, and `skip` on large offsets becomes expensive as MongoDB still has to walk + past the skipped documents. Cursor pagination (`CursoredPage`) anchors each page + request to the sort-key values of the last row seen, so it stays stable and + efficient under concurrent writes and for deep pagination. Prefer `CursoredPage` + whenever the underlying data can change between page fetches or the result set is + large; keep `Page` for small, mostly-static datasets or when you need + `totalPages()`/direct page-number jumps. + +## Sorting + +Sorting is available through three complementary mechanisms: + +- `jakarta.data.Sort` / `jakarta.data.Order` — pass a dynamic `Sort` or `Order` + parameter to a `@Find`/`@Query` method; `SortMapper.apply(...)` (and the equivalent + inline logic in `FindMethodBridge`/`JdqlMethodBridge`) resolves each `Sort.property()` + to its MongoDB field name and applies ascending/descending order + (`SortMapper.java:26-36`). +- `@jakarta.data.repository.OrderBy("field")` — a static, compile-time ordering + annotation on the repository method, merged with any method-name-derived `OrderBy` + clause (`QueryMethodBridge.java:70-80,143-175`). +- `OrderBy[Asc|Desc]` suffix on derived query method names, e.g. + `findByStatusOrderByCreatedAtDesc` (`MethodNameParser.java:103-132`). + +```java +@Find +@OrderBy(value = "price", descending = true) +List allSortedByPriceDesc(); + +// Dynamic sort parameter +List found = productRepository.query() + .f("category").eq("electronics") + .sort(Map.of("price", 1)) + .asList(); +``` + +## Return Types + +`QueryResultHelper` enforces Jakarta Data's single-result semantics for the two +single-entity helper methods it provides; the broader set of return types is handled by +the calling bridges (`QueryExecutor`, `FindMethodBridge`, `JdqlMethodBridge`), which +route to the right result shape. + +| Return type | Behavior | Source | +|---|---|---| +| `T` (single entity) | `requireSingle`: throws `EmptyResultException` on zero results, `NonUniqueResultException` on more than one | `QueryResultHelper.java:34-44` | +| `Optional` | `optionalSingle`: `Optional.empty()` on zero results, `Optional.of(entity)` on exactly one, `NonUniqueResultException` on more than one | `QueryResultHelper.java:53-63` | +| `List` | `query.asList()` | `QueryExecutor.java:57`, `FindMethodBridge.java:163`, `JdqlMethodBridge.java:184` | +| `Stream` | `query.stream()` | `QueryExecutor.java:56`, `FindMethodBridge.java:161`, `JdqlMethodBridge.java:182` | +| `Page` | `MorphiumPage` built from `skip`/`limit` results plus optional total count | `AbstractMorphiumRepository.java:71-99`, `FindMethodBridge.java:150`, `JdqlMethodBridge.java:165` | +| `CursoredPage` | `CursoredPageRecord` built via keyset lookup | `AbstractMorphiumRepository.java:102-153`, `FindMethodBridge.java:126-129`, `JdqlMethodBridge.java:148-151` | +| `long` (count) | `query.countAll()` | `QueryExecutor.java:59`, `JdqlMethodBridge.java:169-171` | +| `boolean` (exists) | `query.countAll() > 0` | `QueryExecutor.java:60`, `JdqlMethodBridge.java:172-174` | +| `CompletionStage` (async) | Wraps any of the above in `CompletableFuture.supplyAsync(...)` on the Morphium async operations thread pool | `QueryMethodBridge.java:120-141`, `FindMethodBridge.java:257-274`, `JdqlMethodBridge.java:775-797`, `AbstractMorphiumRepository.java:246-284` | +| Scalar aggregate (`long`/`double`/boxed) | Single `COUNT`/`SUM`/`AVG`/`MIN`/`MAX` from JDQL, converted via `toNumber(...)` | `JdqlMethodBridge.java:605-615,764-773` | +| `Object[]` | Multiple aggregate functions in one `SELECT` (no `GROUP BY`) return one array slot per aggregate | `JdqlMethodBridge.java:596-614` | +| `List` | JDQL `GROUP BY` queries mapped into a caller-supplied Java `record` matching the `SELECT` clause | `JdqlMethodBridge.java:566-589,674-734` | + +## Building Your Own Framework Integration + +If neither `quarkus-morphium` nor `spring-boot-morphium` fits your target environment, +you can build your own thin adapter on top of `morphium-jakarta-data`. The key +extension point is `AbstractMorphiumRepository`: it implements all CRUD logic as +plain `doXxx()` methods (`doFindById`, `doFindAll`, `doSave`, `doDelete`, ...) and +exposes a `protected void setMorphium(Morphium morphium)` setter that your framework +subclass or generated proxy must call to wire in a live `Morphium` instance before any +`doXxx()` method is used. + +A minimal, framework-free example — implementing `MorphiumRepository` +by hand, without any bytecode generation or dynamic proxy. `MorphiumRepository` extends +`CrudRepository` which extends `BasicRepository`, so a full implementation covers all +three interfaces' methods; every one of them delegates directly to a `doXxx()` method +already provided by `AbstractMorphiumRepository`: + +```java +import de.caluga.morphium.Morphium; +import de.caluga.morphium.data.AbstractMorphiumRepository; +import de.caluga.morphium.data.MorphiumRepository; +import de.caluga.morphium.data.RepositoryMetadata; +import de.caluga.morphium.driver.MorphiumId; +import de.caluga.morphium.query.Query; +import jakarta.data.Order; +import jakarta.data.page.Page; +import jakarta.data.page.PageRequest; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +public class ProductRepositoryImpl + extends AbstractMorphiumRepository + implements MorphiumRepository { + + public ProductRepositoryImpl(Morphium morphium) { + super(new RepositoryMetadata(Product.class, MorphiumId.class, "id")); + setMorphium(morphium); // wires the Morphium instance for all doXxx() calls + } + + // -- BasicRepository -- + + @Override + public S save(S entity) { + return (S) doSave(entity); + } + + @Override + @SuppressWarnings("unchecked") + public List saveAll(List entities) { + return (List) (List) doSaveAll(entities); + } + + @Override + public Optional findById(MorphiumId id) { + return doFindById(id); + } + + @Override + public Stream findAll() { + return doFindAll(); + } + + @Override + public Page findAll(PageRequest pageRequest, Order sortBy) { + return doFindAllPaged(pageRequest, sortBy); + } + + @Override + public void deleteById(MorphiumId id) { + doDeleteById(id); + } + + @Override + public void delete(Product entity) { + doDelete(entity); + } + + @Override + public void deleteAll(List entities) { + doDeleteAll(entities); + } + + // -- CrudRepository -- + + @Override + public S insert(S entity) { + return (S) doInsert(entity); + } + + @Override + @SuppressWarnings("unchecked") + public List insertAll(List entities) { + return (List) (List) doInsertAll(entities); + } + + @Override + public S update(S entity) { + return (S) doUpdate(entity); + } + + @Override + @SuppressWarnings("unchecked") + public List updateAll(List entities) { + return (List) (List) doUpdateAll(entities); + } + + // -- MorphiumRepository extensions -- + + @Override + public List distinct(String fieldName) { + return doDistinct(fieldName); + } + + @Override + public Morphium morphium() { + return doMorphium(); + } + + @Override + public Query query() { + return doQuery(); + } + + // -- A hand-written derived query, without any code generation -- + + public List findByCategory(String category) { + return morphium().createQueryFor(Product.class) + .f("category").eq(category) + .asList(); + } +} +``` + +Quarkus and Spring Boot differ only in *how* they call `setMorphium(...)` and how they +generate the repository interface implementation: + +- **Quarkus**: build-time Gizmo bytecode generation produces a concrete subclass of + `AbstractMorphiumRepository`; the Quarkus extension injects the `Morphium` instance + via `@Inject` and a `@PostConstruct` callback that calls `setMorphium(...)`. +- **Spring Boot**: a JDK dynamic proxy backed by an `AbstractMorphiumRepository` + instance is created by a `FactoryBean`; `setMorphium(...)` is invoked from the + factory once the `Morphium` bean is available. + +Any integration you write follows the same shape: construct or generate a repository +implementation that extends `AbstractMorphiumRepository`, call `setMorphium(...)` once a +`Morphium` instance is available, and either hand-implement the derived-query methods +(as above) or reuse `MethodNameParser`/`QueryMethodBridge`, +`JdqlParser`/`JdqlMethodBridge`, and `FindMethodBridge` to interpret method names, +`@Query` strings, and `@Find`/`@Delete`/`@By` annotations at runtime instead of +generating bytecode. + +## Limitations + +Jakarta Data 1.0, as implemented by this module, does **not** cover every Morphium +capability. Fall back to `MorphiumRepository.query()` (or `MorphiumRepository.morphium()` +for the full Morphium API) when you need: + +- **Joins / references across collections.** JDQL explicitly excludes subqueries and + joins (`JdqlParser.java:33`). Cross-collection lookups need Morphium's `@Reference` + resolution or manual queries. +- **Aggregation pipeline stages beyond `COUNT`/`SUM`/`AVG`/`MIN`/`MAX` with `GROUP BY`.** + JDQL's aggregate support compiles to a fixed `$match → $group → $match(HAVING) → $sort` + pipeline shape. Anything requiring `$unwind`, `$lookup`, `$facet`, or custom pipeline + stages needs `morphium().createAggregator(...)` directly. + `MorphiumRepository.distinct(fieldName)` is provided as a targeted escape hatch for + distinct-value queries, since Jakarta Data has no equivalent. +- **Atomic field operations** (`$inc`, `$push`, `$pull`, `$set` on individual fields) — + use `Morphium`'s update methods via `morphium()` directly. +- **Change streams, messaging, and other Morphium-specific runtime features** — none of + these have a Jakarta Data equivalent; access them through `morphium()`. +- **Lifecycle callbacks on bulk `deleteBy*` methods.** Derived `deleteBy*` methods use a + bulk `query.delete()` for performance and therefore do **not** fire `@PreRemove`/ + `@PostRemove` (documented explicitly at `QueryExecutor.java:63-66`). If lifecycle hooks + must run, load and delete entities individually via `Morphium.delete(entity)` — this + is exactly what `@Delete`-with-`@By` methods do (`FindMethodBridge.java:251-254`), + so prefer that annotation style over derived `deleteBy*` when lifecycle callbacks + matter. +- **Complex boolean nesting beyond one level of parenthesized grouping in method-name + derivation.** `MethodNameParser` only understands a single flat `And`/`Or` chain per + method name (with `OrderBy` split off). Nested boolean logic needs JDQL's + parenthesized groups (`@Query`) or a hand-written Morphium `Query`. + +For anything not covered by `findBy*`/`@Find`/`@Query`, `MorphiumRepository.query()` +returns a plain Morphium `Query` you can compose with the full fluent API — no +Jakarta Data restrictions apply beyond that point. diff --git a/mkdocs.yml b/mkdocs.yml index 469a0b7c7..7bf1c8047 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,9 @@ nav: - Messaging Implementations: howtos/messaging-implementations.md - SSL/TLS Connections: ssl-tls.md - Developer Guide: developer-guide.md + - Extensions: + # Placeholder: Quarkus- und Spring-Boot-Integrationsseiten folgen in späteren Wellen (M4, M5). + - Jakarta Data: jakarta-data.md - Reference: - API Reference: api-reference.md - Configuration: configuration-reference.md From 09d6c1c7492bb925a2b2b158c5cf567872faff42 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 14:57:05 +0200 Subject: [PATCH 04/14] docs: add changelog entry for morphium-jakarta-data module --- CHANGELOG.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b1a2e9cb..cf2954267 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,30 @@ Each of `--auth` and `--ssl`, independently, made a multi-node PoppyDB replica s ### Added +#### `morphium-jakarta-data` — optional Jakarta Data 1.0 runtime module +A new optional module, `morphium-jakarta-data`, brings a [Jakarta Data 1.0](https://jakarta.ee/specifications/data/1.0/) +provider implementation on top of Morphium's existing query engine: `@Repository`-based +`CrudRepository`/`MorphiumRepository` interfaces with query derivation from method names +(`findByCategory`, `countByStatus`, `deleteByX`, `And`/`Or`/`Between`/`In`/`Like`/`OrderBy` +and the rest of the standard keyword set), JDQL via `@Query` (including `GROUP BY`/`HAVING` +aggregates compiled into a Morphium aggregation pipeline), `@Find`/`@Delete` with explicit +`@By` parameter binding, offset pagination (`Page`) and cursor/keyset pagination +(`CursoredPage`), and both static (`@OrderBy`) and dynamic (`Sort`/`Order`) sorting. The +module depends on Morphium core and on `jakarta.data:jakarta.data-api`; the dependency +direction is strictly one-way — core has no knowledge of Jakarta Data and no dependency on +this module, so an application declaring only `de.caluga:morphium` does not get +`jakarta.data-api` on its classpath and none of these annotations or types become available. +Building the reactor with `-DskipExtensions` produces a core-only build (core + PoppyDB, no +extension modules) exactly as before this change. `morphium-jakarta-data` is deliberately +framework-agnostic — plain Java classes with zero dependencies on Quarkus, Spring, or any DI +container — because it is meant to be consumed transitively by framework integrations, not +added directly by most applications: `quarkus-morphium` (build-time Gizmo bytecode +generation) and `spring-boot-morphium` (JDK dynamic proxies) build on top of this module and +will follow in subsequent PRs. The code originates from +[Bardioc1977/morphium-jakarta-data](https://github.com/Bardioc1977/morphium-jakarta-data), +which is being archived now that its content has moved into the main Morphium repository. +See [Jakarta Data](docs/jakarta-data.md). + #### PoppyDB: `--users-file` — declarative user provisioning (bootstrap, upsert, version-gated) Builds on user replication: `--rootUser`/`--rootPassword` only ever provisioned one admin user, so any real application user set still had to be created by hand (a shell script running From cdc468ce6b84ec681221338acb990fc06529c811 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 15:05:44 +0200 Subject: [PATCH 05/14] build: include morphium-jakarta-data in release bundle Extend release.sh to handle morphium-jakarta-data alongside morphium-core and poppydb in the Sonatype Central bundle. Replaces the previous per-module copy-paste blocks with a small module registry (MODULE_DIRS/MODULE_ARTIFACT_IDS/MODULE_EXTRA_CLASSIFIERS parallel arrays, bash 3.2 compatible) and a shared add_module_to_bundle() helper, since the blocks were structurally identical and copy-paste would not scale to the further modules coming in M4/M5. Version-sync checks, backup cleanup, rollback, structure verification, bundle assembly and the artifact verification loop all now iterate over the registry instead of naming morphium-core/poppydb explicitly. Verified: bash -n passes, shellcheck shows no new findings (all 3 remaining findings are on pre-existing lines), and 'mvn -pl morphium-jakarta-data package source:jar javadoc:jar' produces the expected jar/sources.jar/javadoc.jar triple. --- release.sh | 265 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 183 insertions(+), 82 deletions(-) diff --git a/release.sh b/release.sh index 1bdd8df9b..b95bff4ea 100755 --- a/release.sh +++ b/release.sh @@ -10,7 +10,8 @@ set -eo pipefail # 3. Aligns POM versions if necessary # 4. Prepares release (creates tag, bumps next SNAPSHOT via maven-release-plugin) # 5. Builds release artifacts for all modules -# 6. Creates combined bundle (parent + morphium + poppydb) +# 6. Creates combined bundle (parent + all modules in MODULE_DIRS, see the +# "Module registry" section below) # 7. Signs & generates checksums for all artifacts # 8. Uploads bundle to Sonatype Central Portal # 9. Merges tag to master and pushes changes @@ -116,6 +117,36 @@ while [[ $# -gt 0 ]]; do esac done +# ----------------------------------------------------------------------------- +# Module registry +# ----------------------------------------------------------------------------- +# Extend this registry when new modules join the release bundle (e.g. future +# M4: quarkus-morphium, M5: spring-boot-morphium). Parallel indexed arrays are +# used on purpose (not associative arrays / mapfile) so this script keeps +# working under the plain bash 3.2 shipped as /bin/bash on macOS. +# +# MODULE_DIRS[i] - module directory relative to repo root +# MODULE_ARTIFACT_IDS[i] - Maven artifactId (may differ from the dir, +# e.g. morphium-core -> morphium) +# MODULE_EXTRA_CLASSIFIERS[i] - comma-separated extra classifiers beyond +# jar/sources/javadoc (e.g. "cli"), empty if +# none +# +# A later wave that adds a module directory with a nested test-only submodule +# (e.g. quarkus-morphium/integration-tests) should still list modules +# explicitly here rather than glob-discovering directories, so such submodules +# are simply never added to the arrays. +MODULE_DIRS=(morphium-core poppydb morphium-jakarta-data) +MODULE_ARTIFACT_IDS=(morphium poppydb morphium-jakarta-data) +MODULE_EXTRA_CLASSIFIERS=("" "cli" "") + +# All module pom.xml paths plus the root pom.xml, for git add/commit calls. +ALL_POM_FILES=(pom.xml) +for _module_dir in "${MODULE_DIRS[@]}"; do + ALL_POM_FILES+=("${_module_dir}/pom.xml") +done +unset _module_dir + # ----------------------------------------------------------------------------- # Helper functions # ----------------------------------------------------------------------------- @@ -197,6 +228,72 @@ checksum_file() { fi } +# Copy, sign and checksum one module's artifacts into the bundle staging area. +# Usage: add_module_to_bundle [allow_snapshot_fallback] +# +# extra_classifiers_csv is a comma-separated list of additional classifiers +# beyond the standard jar/sources/javadoc set (e.g. "cli" for poppydb), or +# empty if the module has none. Extra classifiers are always optional (copied +# only if present), matching the historic poppydb -cli.jar handling. +# +# allow_snapshot_fallback (default: false) is for the --dry-run path, where +# release:prepare has NOT run yet and the built jars still carry the +# "-SNAPSHOT" suffix in their filename while $version is already the release +# version. When true, missing artifacts are tolerated (best-effort, dry-run +# only). When false (the real release path), a missing mandatory artifact +# makes `cp` fail and — via `set -e` — aborts the script, which is the +# desired strict behavior for an actual release. +add_module_to_bundle() { + local module_dir="$1" + local artifact_id="$2" + local version="$3" + local bundle_dir="$4" + local extra_classifiers_csv="$5" + local allow_snapshot_fallback="${6:-false}" + + log_info "Adding ${artifact_id}..." + local module_repo="${bundle_dir}/de/caluga/${artifact_id}/${version}" + mkdir -p "$module_repo" + + cp "${module_dir}/pom.xml" "${module_repo}/${artifact_id}-${version}.pom" + + local mandatory_classifiers=("" "-sources" "-javadoc") + local classifier target_file source_file snapshot_source + for classifier in "${mandatory_classifiers[@]}"; do + target_file="${module_repo}/${artifact_id}-${version}${classifier}.jar" + source_file="${module_dir}/target/${artifact_id}-${version}${classifier}.jar" + if [ "$allow_snapshot_fallback" = true ]; then + snapshot_source="${module_dir}/target/${artifact_id}-${version}-SNAPSHOT${classifier}.jar" + cp "$snapshot_source" "$target_file" 2>/dev/null || + cp "$source_file" "$target_file" 2>/dev/null || true + else + cp "$source_file" "${module_repo}/" + fi + done + + if [ -n "$extra_classifiers_csv" ]; then + local extra_classifiers extra_classifier extra_source extra_target + IFS=',' read -r -a extra_classifiers <<<"$extra_classifiers_csv" + for extra_classifier in "${extra_classifiers[@]}"; do + extra_target="${module_repo}/${artifact_id}-${version}-${extra_classifier}.jar" + extra_source="${module_dir}/target/${artifact_id}-${version}-${extra_classifier}.jar" + if [ "$allow_snapshot_fallback" = true ]; then + cp "${module_dir}/target/${artifact_id}-${version}-SNAPSHOT-${extra_classifier}.jar" "$extra_target" 2>/dev/null || + cp "$extra_source" "$extra_target" 2>/dev/null || true + elif [ -f "$extra_source" ]; then + cp "$extra_source" "$extra_target" + fi + done + fi + + local file + for file in "${module_repo}"/"${artifact_id}"-"${version}"*; do + [ -f "$file" ] || continue + sign_file "$file" + checksum_file "$file" + done +} + # Upload a bundle to Sonatype Central Portal # Usage: upload_bundle upload_bundle() { @@ -254,7 +351,9 @@ cleanup() { fi # Clean up release leftovers rm -f release.properties pom.xml.releaseBackup 2>/dev/null || true - rm -f morphium-core/pom.xml.releaseBackup poppydb/pom.xml.releaseBackup 2>/dev/null || true + for _module_dir in "${MODULE_DIRS[@]}"; do + rm -f "${_module_dir}/pom.xml.releaseBackup" 2>/dev/null || true + done exit $exit_code } @@ -346,7 +445,7 @@ do_rollback() { git pull origin develop --no-edit || true mvn versions:set -DnewVersion="${tag_version}-SNAPSHOT" -DgenerateBackupPoms=false -q - git add pom.xml morphium-core/pom.xml poppydb/pom.xml + git add "${ALL_POM_FILES[@]}" git commit -m "Rollback: reset version to ${tag_version}-SNAPSHOT (rolled back ${last_tag})" git push origin develop log_success "Develop version reset to ${tag_version}-SNAPSHOT" @@ -388,7 +487,9 @@ do_reset() { # 1. Clean up release leftovers log_info "Removing release leftovers..." rm -f release.properties pom.xml.releaseBackup 2>/dev/null || true - rm -f morphium-core/pom.xml.releaseBackup poppydb/pom.xml.releaseBackup 2>/dev/null || true + for module_dir in "${MODULE_DIRS[@]}"; do + rm -f "${module_dir}/pom.xml.releaseBackup" 2>/dev/null || true + done mvn release:clean -q 2>/dev/null || true log_success "Release leftovers cleaned" @@ -404,17 +505,29 @@ do_reset() { log_info "Develop branch version: $develop_version" # 3. Check current module versions - local parent_ver core_ver poppy_ver + local parent_ver parent_ver=$(grep '' pom.xml | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') - core_ver=$(grep '' morphium-core/pom.xml | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') - poppy_ver=$(grep '' poppydb/pom.xml | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') - log_info "Current versions: parent=$parent_ver core=$core_ver poppydb=$poppy_ver" + local versions_in_sync=true + local module_dir module_ver + local version_summary="parent=$parent_ver" + for module_dir in "${MODULE_DIRS[@]}"; do + module_ver=$(grep '' "${module_dir}/pom.xml" | head -1 | sed 's/.*\(.*\)<\/version>.*/\1/') + version_summary="${version_summary} ${module_dir}=${module_ver}" + if [ "$module_ver" != "$develop_version" ]; then + versions_in_sync=false + fi + done + + log_info "Current versions: $version_summary" - if [ "$parent_ver" != "$develop_version" ] || [ "$core_ver" != "$develop_version" ] || [ "$poppy_ver" != "$develop_version" ]; then + if [ "$parent_ver" != "$develop_version" ] || [ "$versions_in_sync" != true ]; then log_warn "Versions are out of sync — resetting all to $develop_version" mvn versions:set -DnewVersion="$develop_version" -DgenerateBackupPoms=false -q - rm -f pom.xml.versionsBackup morphium-core/pom.xml.versionsBackup poppydb/pom.xml.versionsBackup 2>/dev/null || true + rm -f pom.xml.versionsBackup 2>/dev/null || true + for module_dir in "${MODULE_DIRS[@]}"; do + rm -f "${module_dir}/pom.xml.versionsBackup" 2>/dev/null || true + done log_success "All modules set to $develop_version" else log_success "All module versions already aligned at $develop_version" @@ -451,7 +564,7 @@ do_reset() { git diff --stat -- '*/pom.xml' pom.xml echo "" if confirm "Stage and commit the version fixes?"; then - git add pom.xml morphium-core/pom.xml poppydb/pom.xml + git add "${ALL_POM_FILES[@]}" git commit -m "Reset: align all module versions to $develop_version" log_success "Version fix committed" fi @@ -618,7 +731,7 @@ if [ "$current_version" != "${release_version}-SNAPSHOT" ]; then else log_info "POM version is $current_version, setting to ${release_version}-SNAPSHOT..." mvn versions:set -DnewVersion="${release_version}-SNAPSHOT" -DgenerateBackupPoms=false -q - git add pom.xml morphium-core/pom.xml poppydb/pom.xml + git add "${ALL_POM_FILES[@]}" git commit -m "Set version to ${release_version}-SNAPSHOT for release" -q log_success "POM versions aligned to ${release_version}-SNAPSHOT" fi @@ -627,13 +740,17 @@ else fi # Verify multi-module structure -for module_dir in morphium-core poppydb; do +for module_dir in "${MODULE_DIRS[@]}"; do if [ ! -f "$module_dir/pom.xml" ]; then log_error "Module directory $module_dir/pom.xml not found" exit 1 fi done -log_success "Multi-module structure: morphium-parent, morphium-core (morphium), poppydb" +module_list="" +for module_dir in "${MODULE_DIRS[@]}"; do + module_list="${module_list:+$module_list, }$module_dir" +done +log_success "Multi-module structure: morphium-parent, ${module_list}" fi # ----------------------------------------------------------------------------- @@ -685,29 +802,15 @@ if [ "$DRY_RUN" = true ]; then sign_file "${parent_repo}/morphium-parent-${version}.pom" checksum_file "${parent_repo}/morphium-parent-${version}.pom" - log_info "Adding morphium..." - morphium_repo="${BUNDLE_DIR}/de/caluga/morphium/${version}" - mkdir -p "$morphium_repo" - cp morphium-core/pom.xml "${morphium_repo}/morphium-${version}.pom" - cp morphium-core/target/morphium-${version}-SNAPSHOT.jar "${morphium_repo}/morphium-${version}.jar" 2>/dev/null || - cp morphium-core/target/morphium-${version}.jar "${morphium_repo}/" 2>/dev/null || true - cp morphium-core/target/morphium-${version}-SNAPSHOT-sources.jar "${morphium_repo}/morphium-${version}-sources.jar" 2>/dev/null || - cp morphium-core/target/morphium-${version}-sources.jar "${morphium_repo}/" 2>/dev/null || true - cp morphium-core/target/morphium-${version}-SNAPSHOT-javadoc.jar "${morphium_repo}/morphium-${version}-javadoc.jar" 2>/dev/null || - cp morphium-core/target/morphium-${version}-javadoc.jar "${morphium_repo}/" 2>/dev/null || true - - log_info "Adding poppydb..." - poppydb_repo="${BUNDLE_DIR}/de/caluga/poppydb/${version}" - mkdir -p "$poppydb_repo" - cp poppydb/pom.xml "${poppydb_repo}/poppydb-${version}.pom" - cp poppydb/target/poppydb-${version}-SNAPSHOT.jar "${poppydb_repo}/poppydb-${version}.jar" 2>/dev/null || - cp poppydb/target/poppydb-${version}.jar "${poppydb_repo}/" 2>/dev/null || true - cp poppydb/target/poppydb-${version}-SNAPSHOT-sources.jar "${poppydb_repo}/poppydb-${version}-sources.jar" 2>/dev/null || - cp poppydb/target/poppydb-${version}-sources.jar "${poppydb_repo}/" 2>/dev/null || true - cp poppydb/target/poppydb-${version}-SNAPSHOT-javadoc.jar "${poppydb_repo}/poppydb-${version}-javadoc.jar" 2>/dev/null || - cp poppydb/target/poppydb-${version}-javadoc.jar "${poppydb_repo}/" 2>/dev/null || true - cp poppydb/target/poppydb-${version}-SNAPSHOT-cli.jar "${poppydb_repo}/poppydb-${version}-cli.jar" 2>/dev/null || - cp poppydb/target/poppydb-${version}-cli.jar "${poppydb_repo}/" 2>/dev/null || true + for i in "${!MODULE_DIRS[@]}"; do + add_module_to_bundle \ + "${MODULE_DIRS[$i]}" \ + "${MODULE_ARTIFACT_IDS[$i]}" \ + "$version" \ + "$BUNDLE_DIR" \ + "${MODULE_EXTRA_CLASSIFIERS[$i]}" \ + true + done bundle_file="target/bundle-${version}.jar" (cd "$BUNDLE_DIR" && zip -q -r "$(pwd)/../bundle-${version}.jar" de/) @@ -715,7 +818,7 @@ if [ "$DRY_RUN" = true ]; then log_step "Dry run complete" echo "" echo "Would release version: $release_version" - echo " Modules: morphium-parent, morphium, poppydb" + echo " Modules: morphium-parent, ${MODULE_ARTIFACT_IDS[*]}" echo " From branch: $branch" echo "" echo "Bundle contents:" @@ -738,7 +841,7 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then echo " Last release: $last_tag" echo " Release version: $release_version (--${BUMP_TYPE})" echo " Next development: $next_snapshot" - echo " Modules: morphium-parent, morphium, poppydb" + echo " Modules: morphium-parent, ${MODULE_ARTIFACT_IDS[*]}" echo " Branch: $branch" echo " Auto-publish: $AUTO_PUBLISH" echo "" @@ -821,7 +924,7 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then fi # ----------------------------------------------------------------------------- -# Step 7: Create combined bundle (parent + morphium + poppydb) +# Step 7: Create combined bundle (parent + all registered modules) # ----------------------------------------------------------------------------- if [ "$SKIP_TO_UPLOAD" != true ]; then @@ -839,45 +942,25 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then sign_file "${parent_repo}/morphium-parent-${version}.pom" checksum_file "${parent_repo}/morphium-parent-${version}.pom" - # --- morphium (morphium-core module, artifactId=morphium) --- - log_info "Adding morphium..." - morphium_repo="${BUNDLE_DIR}/de/caluga/morphium/${version}" - mkdir -p "$morphium_repo" - - cp morphium-core/pom.xml "${morphium_repo}/morphium-${version}.pom" - cp morphium-core/target/morphium-${version}.jar "${morphium_repo}/" - cp morphium-core/target/morphium-${version}-sources.jar "${morphium_repo}/" - cp morphium-core/target/morphium-${version}-javadoc.jar "${morphium_repo}/" - - for file in "${morphium_repo}"/morphium-${version}*; do - [ -f "$file" ] || continue - sign_file "$file" - checksum_file "$file" - done - - # --- poppydb --- - log_info "Adding poppydb..." - poppydb_repo="${BUNDLE_DIR}/de/caluga/poppydb/${version}" - mkdir -p "$poppydb_repo" - - cp poppydb/pom.xml "${poppydb_repo}/poppydb-${version}.pom" - cp poppydb/target/poppydb-${version}.jar "${poppydb_repo}/" - cp poppydb/target/poppydb-${version}-sources.jar "${poppydb_repo}/" - cp poppydb/target/poppydb-${version}-javadoc.jar "${poppydb_repo}/" - if [ -f "poppydb/target/poppydb-${version}-cli.jar" ]; then - cp "poppydb/target/poppydb-${version}-cli.jar" "${poppydb_repo}/" - fi - - for file in "${poppydb_repo}"/poppydb-${version}*; do - [ -f "$file" ] || continue - sign_file "$file" - checksum_file "$file" + # --- one block per registered module (see MODULE_DIRS/MODULE_ARTIFACT_IDS + # /MODULE_EXTRA_CLASSIFIERS above); analogous to the former morphium/poppydb + # copy-paste blocks, now driven by add_module_to_bundle() so a future module + # (M4: quarkus-morphium, M5: spring-boot-morphium) only needs a registry + # entry, not a new block --- + for i in "${!MODULE_DIRS[@]}"; do + add_module_to_bundle \ + "${MODULE_DIRS[$i]}" \ + "${MODULE_ARTIFACT_IDS[$i]}" \ + "$version" \ + "$BUNDLE_DIR" \ + "${MODULE_EXTRA_CLASSIFIERS[$i]}" done # Verify all required files log_info "Verifying artifacts..." for suffix in .pom .pom.asc .jar .jar.asc -sources.jar -sources.jar.asc -javadoc.jar -javadoc.jar.asc; do - for artifact_repo in "$morphium_repo/morphium" "$poppydb_repo/poppydb"; do + for artifact_id in "${MODULE_ARTIFACT_IDS[@]}"; do + artifact_repo="${BUNDLE_DIR}/de/caluga/${artifact_id}/${version}/${artifact_id}" if [ ! -f "${artifact_repo}-${version}${suffix}" ]; then log_error "Missing: $(basename "${artifact_repo}-${version}${suffix}")" exit 1 @@ -891,7 +974,7 @@ if [ "$SKIP_TO_UPLOAD" != true ]; then (cd "$BUNDLE_DIR" && zip -q -r "$(pwd)/../bundle-${version}.jar" de/) log_success "Combined bundle: $bundle_file ($(du -h "$bundle_file" | cut -f1))" - log_info " Contents: morphium-parent (pom), morphium (jar+sources+javadoc), poppydb (jar+sources+javadoc+cli)" + log_info " Contents: morphium-parent (pom), ${MODULE_ARTIFACT_IDS[*]} (jar+sources+javadoc, plus extra classifiers where applicable)" fi # ----------------------------------------------------------------------------- @@ -911,7 +994,14 @@ fi # Create base64 encoded credentials auth_token=$(echo -n "${SONATYPE_USERNAME}:${SONATYPE_PASSWORD}" | base64) -upload_bundle "$bundle_file" "morphium+poppydb" || exit 1 +upload_display_name=$( + module_list="" + for artifact_id in "${MODULE_ARTIFACT_IDS[@]}"; do + module_list="${module_list:+$module_list+}$artifact_id" + done + echo "$module_list" +) +upload_bundle "$bundle_file" "$upload_display_name" || exit 1 log_success "Bundle uploaded" log_info "Monitor at: https://central.sonatype.com/publishing/deployments" @@ -956,7 +1046,9 @@ log_success "Back on $branch branch" # Clean up release leftovers (also in trap, but be thorough) rm -f release.properties pom.xml.releaseBackup 2>/dev/null || true -rm -f morphium-core/pom.xml.releaseBackup poppydb/pom.xml.releaseBackup 2>/dev/null || true +for _module_dir in "${MODULE_DIRS[@]}"; do + rm -f "${_module_dir}/pom.xml.releaseBackup" 2>/dev/null || true +done # ----------------------------------------------------------------------------- # Step 10: Deploy documentation (optional) @@ -983,7 +1075,10 @@ log_step "Release complete!" echo "" echo "==============================================" -echo " Morphium + PoppyDB $version released!" +echo " Morphium + $( + IFS='+' + echo "${MODULE_ARTIFACT_IDS[*]:1}" +) $version released!" echo "==============================================" echo "" echo " Git tag: $tag" @@ -991,8 +1086,13 @@ echo " Release log: $RELEASE_LOG" echo "" echo " Bundle: $bundle_file" echo " morphium-parent (POM)" -echo " morphium (jar, sources, javadoc)" -echo " poppydb (jar, sources, javadoc, cli)" +for i in "${!MODULE_ARTIFACT_IDS[@]}"; do + extra_desc="" + if [ -n "${MODULE_EXTRA_CLASSIFIERS[$i]}" ]; then + extra_desc=", ${MODULE_EXTRA_CLASSIFIERS[$i]}" + fi + echo " ${MODULE_ARTIFACT_IDS[$i]} (jar, sources, javadoc${extra_desc})" +done echo "" if [ "$AUTO_PUBLISH" = true ]; then @@ -1005,6 +1105,7 @@ fi echo "" echo " After publish, artifacts will be available at:" -echo " https://repo1.maven.org/maven2/de/caluga/morphium/$version/" -echo " https://repo1.maven.org/maven2/de/caluga/poppydb/$version/" +for artifact_id in "${MODULE_ARTIFACT_IDS[@]}"; do + echo " https://repo1.maven.org/maven2/de/caluga/${artifact_id}/$version/" +done echo "" From 9dc86b0fe1142c0236a1e9d4962f9f8ff71ac6dd Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 16:57:27 +0200 Subject: [PATCH 06/14] fix(jakarta-data): reject mixed And/Or method names and non-upserting update() --- .../data/AbstractMorphiumRepository.java | 43 +++++- .../morphium/data/MethodNameParser.java | 17 +- .../AbstractMorphiumRepositoryUpdateTest.java | 146 ++++++++++++++++++ .../morphium/data/MethodNameParserTest.java | 10 ++ 4 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/AbstractMorphiumRepositoryUpdateTest.java diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java index f9c8177bc..27f7a0148 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java @@ -300,28 +300,67 @@ public List doInsertAll(List entities) { } /** - * Updates a single entity via {@code Morphium.store()}. + * Updates a single entity via {@code Morphium.store()}, but only if an entity with the same + * id already exists. + *

+ * Unlike {@link #doSave(Object)}, this method implements the {@code CrudRepository.update()} + * semantics defined by Jakarta Data: updating a non-existent entity must fail rather than + * silently insert a new document (which is what a bare {@code Morphium.store()} call would do, + * since it performs an upsert). To enforce this, the entity's id is extracted via + * {@code morphium.getARHelper().getId(Object)} and an existence check is performed with + * {@link Morphium#findById(Class, Object, String)} before storing. This costs one extra + * {@code findById} roundtrip per update call, which is an accepted trade-off for correct CRUD + * semantics. * * @param entity the entity to update * @return the same entity instance + * @throws IllegalStateException if no entity with the same id currently exists */ public Object doUpdate(Object entity) { + requireExists(entity); morphium.store(entity); return entity; } /** - * Updates a list of entities via {@code Morphium.storeList()}. + * Updates a list of entities via {@code Morphium.storeList()}, but only if every entity in the + * list already exists. + *

+ * Like {@link #doUpdate(Object)}, this enforces {@code CrudRepository.updateAll()} semantics: + * updating a non-existent entity must fail rather than silently insert it. Every entity in the + * list is checked for existence (one extra {@code findById} roundtrip per entity, accepted as a + * trade-off for correctness) before any entity is stored, so that a violation for one entity + * does not leave the list partially updated. * * @param entities the entities to update * @return the same list of entities + * @throws IllegalStateException if any entity in the list does not currently exist */ @SuppressWarnings("unchecked") public List doUpdateAll(List entities) { + for (Object entity : entities) { + requireExists(entity); + } morphium.storeList((List) entities); return (List) entities; } + /** + * Verifies that an entity with the same id as the given entity currently exists, throwing if not. + * Used by {@link #doUpdate(Object)} and {@link #doUpdateAll(List)} to reject updates of + * non-existent entities instead of silently upserting them. + * + * @param entity the entity whose id is checked for existence + * @throws IllegalStateException if no entity with that id currently exists + */ + private void requireExists(Object entity) { + Object id = morphium.getARHelper().getId(entity); + Object existing = morphium.findById(entityClass(), id, null); + if (existing == null) { + throw new IllegalStateException("Cannot update: no entity with id '" + id + "' exists"); + } + } + /** * Deletes a single entity via {@code Morphium.delete()}. * diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java index eb76d1719..a6acbe1df 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java @@ -96,9 +96,24 @@ public static QueryDescriptor parse(String methodName, java.util.Set ent // Determine combinator: check for "Or" or "And" // We need to split on And/Or but only at word boundaries between conditions + // + // Note: this module deliberately supports only a single combinator per query + // (see QueryDescriptor's class Javadoc) rather than a full boolean-expression tree. + // A method name that mixes both "And" and "Or" (e.g. "findByStatusAndCategoryOrPriority") + // cannot be represented faithfully by that single-combinator model — silently picking one + // combinator and ignoring the other would produce a query that looks plausible but is + // wrong. We fail fast instead of guessing. + boolean hasAnd = containsCombinator(rest, "And"); + boolean hasOr = containsCombinator(rest, "Or"); + if (hasAnd && hasOr) { + throw new IllegalArgumentException( + "Mixed And/Or combinators in a single derived query method are not supported: " + + methodName + ". Use @Query with JDQL for complex boolean expressions."); + } + Combinator combinator = Combinator.AND; String[] parts; - if (containsCombinator(rest, "Or")) { + if (hasOr) { combinator = Combinator.OR; parts = splitOnCombinator(rest, "Or"); } else { diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/AbstractMorphiumRepositoryUpdateTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/AbstractMorphiumRepositoryUpdateTest.java new file mode 100644 index 000000000..1ddcbba12 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/AbstractMorphiumRepositoryUpdateTest.java @@ -0,0 +1,146 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Verifies that {@link AbstractMorphiumRepository#doUpdate(Object)} and + * {@link AbstractMorphiumRepository#doUpdateAll(java.util.List)} implement genuine + * {@code CrudRepository.update()} semantics — updating an entity whose id does not yet exist + * must fail with {@link IllegalStateException} rather than silently upserting it (which is what a + * bare {@code Morphium.store()} call, used by {@link AbstractMorphiumRepository#doSave(Object)}, + * would do). Uses a real {@link Morphium} instance backed by {@link InMemoryDriver}, following the + * same setup pattern as {@link QueryExecutorAliasTest}, since a fake/mocked Morphium would not + * exercise the actual existence-check roundtrip via {@code findById()}. + */ +class AbstractMorphiumRepositoryUpdateTest { + + private static Morphium morphium; + + @Entity + static class Product { + @Id + private String id; + private String name; + + Product() {} + + Product(String id, String name) { + this.id = id; + this.name = name; + } + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + } + + static class ProductRepositoryImpl extends AbstractMorphiumRepository { + ProductRepositoryImpl(Morphium morphium) { + super(new RepositoryMetadata(Product.class, String.class, "id")); + setMorphium(morphium); + } + } + + private ProductRepositoryImpl repo; + + @BeforeAll + static void setUp() { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.setDatabase("test"); + cfg.addHostToSeed("localhost"); + cfg.setDriverName(InMemoryDriver.driverName); + morphium = new Morphium(cfg); + } + + @AfterAll + static void tearDown() { + if (morphium != null) { + morphium.close(); + } + } + + @BeforeEach + void initRepo() { + morphium.clearCollection(Product.class); + repo = new ProductRepositoryImpl(morphium); + } + + @Test + @DisplayName("doUpdate() on an existing entity succeeds and persists the change") + void doUpdateOnExistingEntitySucceeds() { + Product product = new Product("p1", "Widget"); + morphium.store(product); + + product.setName("Widget v2"); + Object result = repo.doUpdate(product); + + assertThat(result).isSameAs(product); + Product reloaded = morphium.findById(Product.class, "p1", null); + assertThat(reloaded).isNotNull(); + assertThat(reloaded.getName()).isEqualTo("Widget v2"); + } + + @Test + @DisplayName("doUpdate() on a non-existent id throws IllegalStateException instead of upserting") + void doUpdateOnNonExistentEntityThrows() { + Product ghost = new Product("does-not-exist", "Ghost"); + + assertThatThrownBy(() -> repo.doUpdate(ghost)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("does-not-exist"); + + // Crucially: no document must have been created as a side effect of the failed update. + Product reloaded = morphium.findById(Product.class, "does-not-exist", null); + assertThat(reloaded).isNull(); + } + + @Test + @DisplayName("doUpdateAll() with all-existing entities succeeds") + void doUpdateAllOnExistingEntitiesSucceeds() { + Product p1 = new Product("p1", "One"); + Product p2 = new Product("p2", "Two"); + morphium.store(p1); + morphium.store(p2); + + p1.setName("One-updated"); + p2.setName("Two-updated"); + repo.doUpdateAll(List.of(p1, p2)); + + assertThat(morphium.findById(Product.class, "p1", null).getName()).isEqualTo("One-updated"); + assertThat(morphium.findById(Product.class, "p2", null).getName()).isEqualTo("Two-updated"); + } + + @Test + @DisplayName("doUpdateAll() rejects the whole batch if any entity does not exist (no partial update)") + void doUpdateAllRejectsPartialBatchOnMissingEntity() { + Product existing = new Product("p1", "One"); + morphium.store(existing); + + existing.setName("One-should-not-be-applied"); + Product ghost = new Product("missing", "Ghost"); + + assertThatThrownBy(() -> repo.doUpdateAll(List.of(existing, ghost))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing"); + + // The existing entity must remain unchanged since the batch was rejected before storing. + Product reloaded = morphium.findById(Product.class, "p1", null); + assertThat(reloaded.getName()).isEqualTo("One"); + assertThat(morphium.findById(Product.class, "missing", null)).isNull(); + } +} diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java index eb74ee586..a195a62d1 100644 --- a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java @@ -115,5 +115,15 @@ void invalidPrefix() { .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("Cannot parse repository method name"); } + + @Test + @DisplayName("Mixed And/Or combinators throw IllegalArgumentException instead of silently mis-parsing") + void mixedAndOrCombinatorsRejected() { + assertThatThrownBy(() -> + MethodNameParser.parse("findByStatusAndCategoryOrPriority", ENTITY_FIELDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Mixed And/Or combinators") + .hasMessageContaining("findByStatusAndCategoryOrPriority"); + } } } From cf4f72e0331f5d883f0f876d011975c2a6a3eb4b Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:05:00 +0200 Subject: [PATCH 07/14] fix(jakarta-data): correct CONTAINS substring match, delete() count, and negated alias conditions CONTAINS previously built an exact-equality condition instead of a substring match; it now builds an unanchored, literal-escaped regex. Query.delete() for derived deleteBy* methods returned the pre-delete countAll() instead of the actual number of deleted documents; it now reads the "n" field from the driver's delete-result map, falling back to the pre-count only if that field is absent or non-numeric. Negating operators (NE, NIN, NOT_CONTAINS, IS_NOT_NULL, IS_NOT_EMPTY) on a field with @Aliases combined alias branches with $or, which is almost always trivially true for a negation (a document lacking the alias field entirely would match "alias != X"). Alias branches for negating operators are now combined with $and instead. Reported by CodeRabbit/Codex review on PR #16. --- .../caluga/morphium/data/QueryExecutor.java | 64 ++++-- .../morphium/data/QueryExecutorAliasTest.java | 108 ++++++++++ .../morphium/data/QueryExecutorTest.java | 190 ++++++++++++++++++ 3 files changed, 348 insertions(+), 14 deletions(-) create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java index 207f04a91..0f3127882 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java @@ -65,14 +65,24 @@ public static Object execute(QueryDescriptor descriptor, }; case COUNT -> query.countAll(); case EXISTS -> query.countAll() > 0; + // Uses bulk deleteMany — does NOT fire @PreRemove/@PostRemove lifecycle + // callbacks. This is intentional for performance (avoids loading all entities + // into memory). Entities requiring lifecycle hooks should use Morphium.delete() + // directly instead of derived deleteBy* methods. + // + // The returned count is read from the "n" key of the MongoDB delete-command + // result map (matches wire-protocol convention; see InMemoryDriver.delete() and + // AliasesTest, which read the analogous store-result the same way). If for any + // reason the driver's result map does not contain a numeric "n" entry, we fall + // back to a pre-delete countAll(); in that fallback path there is a narrow + // concurrency window between the count and the actual delete where concurrent + // inserts/deletes on the same query could make the returned number slightly + // inaccurate. case DELETE -> { - long count = query.countAll(); - // Uses bulk deleteMany — does NOT fire @PreRemove/@PostRemove lifecycle - // callbacks. This is intentional for performance (avoids loading all entities - // into memory). Entities requiring lifecycle hooks should use Morphium.delete() - // directly instead of derived deleteBy* methods. - query.delete(); - yield count; + long preCount = query.countAll(); + Map deleteResult = query.delete(); + Object n = deleteResult == null ? null : deleteResult.get("n"); + yield (n instanceof Number) ? ((Number) n).longValue() : preCount; } }; } @@ -113,18 +123,29 @@ static void applyCondition(Query query, List aliases = resolveAliases(morphium, entityClass, cond.field()); if (!aliases.isEmpty()) { - // Field has @Aliases — build $or to match current name + all aliases. - // LinkedHashSet deduplicates in case an alias equals the current mongo name. - List> orBranches = new ArrayList<>(); + // Field has @Aliases — build a boolean combination over the current mongo + // name + all aliases. LinkedHashSet deduplicates in case an alias equals the + // current mongo name. + // + // For POSITIVE operators (EQ, LIKE, CONTAINS, ...) we need "matches under + // ANY of the names", i.e. $or. + // + // For NEGATING operators (NE, NIN, NOT_CONTAINS, IS_NOT_NULL, IS_NOT_EMPTY — + // anything expressing "not X") we need "does NOT match under ANY of the + // names", i.e. the condition must hold for EVERY alias branch ($and). Using + // $or here would be wrong: "field != X OR alias != X" is true for almost any + // document (e.g. one that has X under the current name but no value at all + // under the alias name), defeating the negation entirely. + List> aliasBranches = new ArrayList<>(); Set uniqueFields = new LinkedHashSet<>(); uniqueFields.add(mongoField); uniqueFields.addAll(aliases); for (String f : uniqueFields) { - orBranches.add(buildRawCondition(f, cond, args)); + aliasBranches.add(buildRawCondition(f, cond, args)); } FilterExpression fe = new FilterExpression(); - fe.setField("$or"); - fe.setValue(orBranches); + fe.setField(isNegatingOperator(cond.operator()) ? "$and" : "$or"); + fe.setValue(aliasBranches); query.addChild(fe); } else { // No aliases — build raw condition and add as FilterExpression. @@ -134,6 +155,21 @@ static void applyCondition(Query query, } } + /** + * Returns {@code true} if the given operator expresses a negation (i.e. its raw + * condition relies on a {@code $}-prefixed MongoDB negation operator such as + * {@code $ne}, {@code $nin}, or an equivalent {@code $nor}/exclusion semantic). + * Used by {@link #applyCondition} to decide whether alias branches must be + * combined with {@code $and} (all names must satisfy the negation) instead of + * {@code $or} (which would be trivially true for negated conditions). + */ + private static boolean isNegatingOperator(Operator operator) { + return switch (operator) { + case NE, NIN, NOT_CONTAINS, IS_NOT_NULL, IS_NOT_EMPTY -> true; + default -> false; + }; + } + /** * Converts a raw condition map (from {@link #buildRawCondition}) to * {@link FilterExpression}s and adds them to the query. @@ -207,7 +243,7 @@ private static Map buildRawCondition(String fieldName, } case STARTS_WITH -> result.put(fieldName, Map.of("$regex", "^" + Pattern.quote(args[cond.paramIndex()].toString()))); case ENDS_WITH -> result.put(fieldName, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()) + "$")); - case CONTAINS -> result.put(fieldName, args[cond.paramIndex()]); + case CONTAINS -> result.put(fieldName, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()))); case NOT_CONTAINS -> result.put(fieldName, nullSafeOp("$ne", args[cond.paramIndex()])); case MATCHES -> result.put(fieldName, Map.of("$regex", args[cond.paramIndex()].toString())); case IGNORE_CASE -> { diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java index 069204d97..38f4ade41 100644 --- a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java @@ -397,4 +397,112 @@ private Query buildQuery(QueryDescriptor descriptor, Object[] args) { QueryExecutor.applyConditions(query, descriptor, args, morphium, entityClass); return query; } + + @Test + @DisplayName("NE on aliased field generates $and (not $or) over negated branches for each alias") + void neOperatorOnAliasedFieldGeneratesAnd() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("otaUpdateId", Operator.NE, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"update-123"}); + Map queryObj = query.toQueryObject(); + + // Must NOT be $or — "field != X OR alias != X" would be trivially true for + // almost any document. + assertThat(queryObj).doesNotContainKey("$or"); + assertThat(queryObj).containsKey("$and"); + + @SuppressWarnings("unchecked") + List> andList = (List>) queryObj.get("$and"); + assertThat(andList).hasSize(2); + + Set queriedFields = new HashSet<>(); + for (Map sub : andList) { + for (Map.Entry e : sub.entrySet()) { + queriedFields.add(e.getKey()); + @SuppressWarnings("unchecked") + Map opMap = (Map) e.getValue(); + assertThat(opMap).containsKey("$ne"); + assertThat(opMap.get("$ne")).isEqualTo("update-123"); + } + } + assertThat(queriedFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("IS_NOT_NULL on aliased field generates $and over negated branches for each alias") + void isNotNullOperatorOnAliasedFieldGeneratesAnd() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("otaUpdateId", Operator.IS_NOT_NULL, -1)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{}); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).doesNotContainKey("$or"); + assertThat(queryObj).containsKey("$and"); + + @SuppressWarnings("unchecked") + List> andList = (List>) queryObj.get("$and"); + assertThat(andList).hasSize(2); + + Set queriedFields = new HashSet<>(); + for (Map sub : andList) { + for (Map.Entry e : sub.entrySet()) { + queriedFields.add(e.getKey()); + @SuppressWarnings("unchecked") + Map opMap = (Map) e.getValue(); + assertThat(opMap).containsEntry("$ne", null); + } + } + assertThat(queriedFields).containsExactlyInAnyOrder("ota_update_id", "updateId"); + } + + @Test + @DisplayName("NE on aliased field correctly excludes documents matching under either name") + void neOnAliasedFieldExcludesMatchesUnderEitherName() { + // Document stored under the CURRENT mongo field name with the excluded value. + morphium.storeMap(OtaUpdate.class, new java.util.LinkedHashMap<>(java.util.Map.of( + "_id", "doc-current-name", + "ota_update_id", "update-123"))); + // Document stored under the ALIAS name with the excluded value (legacy document). + morphium.storeMap(OtaUpdate.class, new java.util.LinkedHashMap<>(java.util.Map.of( + "_id", "doc-alias-name", + "updateId", "update-123"))); + // Document that genuinely does not have the excluded value anywhere. + morphium.storeMap(OtaUpdate.class, new java.util.LinkedHashMap<>(java.util.Map.of( + "_id", "doc-other-value", + "ota_update_id", "some-other-id"))); + + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("otaUpdateId", Operator.NE, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = buildQuery(descriptor, new Object[]{"update-123"}); + List results = query.asList(); + + Set ids = new HashSet<>(); + for (Object doc : results) { + ids.add(morphium.getARHelper().getId(doc)); + } + + // Both the current-name and alias-name matches must be EXCLUDED — this is exactly + // what the buggy $or implementation got wrong (it would incorrectly include both). + assertThat(ids).doesNotContain("doc-current-name", "doc-alias-name"); + assertThat(ids).contains("doc-other-value"); + } } + diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java new file mode 100644 index 000000000..d17f18353 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java @@ -0,0 +1,190 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.QueryDescriptor.Combinator; +import de.caluga.morphium.data.QueryDescriptor.Condition; +import de.caluga.morphium.data.QueryDescriptor.Operator; +import de.caluga.morphium.data.QueryDescriptor.Prefix; +import de.caluga.morphium.data.QueryDescriptor.ReturnType; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.morphium.query.Query; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies {@link QueryExecutor#execute} behavior that is not specific to alias handling: + * CONTAINS substring matching and the {@code DELETE} prefix's returned count. Uses a real + * {@link Morphium} instance backed by {@link InMemoryDriver}, following the same setup pattern + * as {@link QueryExecutorAliasTest} and {@link AbstractMorphiumRepositoryUpdateTest}. + */ +class QueryExecutorTest { + + private static Morphium morphium; + + @Entity + static class Product { + @Id + private String id; + private String name; + private String status; + + Product() {} + + Product(String id, String name, String status) { + this.id = id; + this.name = name; + this.status = status; + } + + public String getId() { return id; } + public String getName() { return name; } + public String getStatus() { return status; } + } + + static class ProductRepositoryImpl extends AbstractMorphiumRepository { + ProductRepositoryImpl(Morphium morphium) { + super(new RepositoryMetadata(Product.class, String.class, "id")); + setMorphium(morphium); + } + } + + private ProductRepositoryImpl repo; + + @BeforeAll + static void setUp() { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.setDatabase("test"); + cfg.addHostToSeed("localhost"); + cfg.setDriverName(InMemoryDriver.driverName); + morphium = new Morphium(cfg); + } + + @AfterAll + static void tearDown() { + if (morphium != null) { + morphium.close(); + } + } + + @BeforeEach + void initRepo() { + morphium.clearCollection(Product.class); + repo = new ProductRepositoryImpl(morphium); + } + + // -- BUG 1: CONTAINS must be a substring match, not an exact match ----------------- + + @Test + @DisplayName("CONTAINS generates a non-anchored $regex (substring match), not an exact-match equality") + void containsGeneratesUnanchoredRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("name", Operator.CONTAINS, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = morphium.createQueryFor(Product.class); + QueryExecutor.applyConditions(query, descriptor, new Object[]{"idg"}, morphium, Product.class); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).containsKey("name"); + Object nameCondition = queryObj.get("name"); + // Must NOT be the plain raw argument (that would be an exact-match equality). + assertThat(nameCondition).isNotEqualTo("idg"); + assertThat(nameCondition).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map regexMap = (Map) nameCondition; + assertThat(regexMap).containsKey("$regex"); + String regex = (String) regexMap.get("$regex"); + // Unanchored: no leading ^ / trailing $ around the literal. + assertThat(regex).doesNotStartWith("^"); + assertThat(regex).doesNotEndWith("$"); + assertThat(regex).contains("\\Qidg\\E"); + } + + @Test + @DisplayName("CONTAINS matches documents where the argument occurs anywhere in the field value") + void containsMatchesSubstringAgainstRealData() { + morphium.store(new Product("p1", "Widget", "ACTIVE")); + morphium.store(new Product("p2", "Gadget", "ACTIVE")); + morphium.store(new Product("p3", "Gizmo", "ACTIVE")); + + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("name", Operator.CONTAINS, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + @SuppressWarnings("unchecked") + Object result = QueryExecutor.execute(descriptor, new Object[]{"dget"}, repo); + @SuppressWarnings("unchecked") + List found = (List) result; + + // "dget" is a substring of both "Widget" and "Gadget" but not "Gizmo" — + // an exact-match CONTAINS (the bug) would find nothing at all. + assertThat(found).extracting(Product::getName).containsExactlyInAnyOrder("Widget", "Gadget"); + } + + // -- BUG 2: DELETE must return the actually-deleted count, not a pre-delete count --- + + @Test + @DisplayName("DELETE prefix returns the actually deleted document count") + void deletePrefixReturnsActualDeletedCount() { + morphium.store(new Product("p1", "Widget", "INACTIVE")); + morphium.store(new Product("p2", "Gadget", "INACTIVE")); + morphium.store(new Product("p3", "Gizmo", "ACTIVE")); + + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.DELETE, + List.of(new Condition("status", Operator.EQ, 0)), + Combinator.AND, + List.of(), + ReturnType.COUNT + ); + + Object result = QueryExecutor.execute(descriptor, new Object[]{"INACTIVE"}, repo); + + assertThat(result).isInstanceOf(Long.class); + assertThat((Long) result).isEqualTo(2L); + + // Verify the documents are indeed gone and the untouched one remains. + assertThat(morphium.createQueryFor(Product.class).countAll()).isEqualTo(1); + assertThat(morphium.createQueryFor(Product.class).asList()) + .extracting(Product::getName) + .containsExactly("Gizmo"); + } + + @Test + @DisplayName("DELETE prefix returns 0 when no documents match") + void deletePrefixReturnsZeroWhenNoMatch() { + morphium.store(new Product("p1", "Widget", "ACTIVE")); + + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.DELETE, + List.of(new Condition("status", Operator.EQ, 0)), + Combinator.AND, + List.of(), + ReturnType.COUNT + ); + + Object result = QueryExecutor.execute(descriptor, new Object[]{"DOES-NOT-EXIST"}, repo); + + assertThat(result).isEqualTo(0L); + assertThat(morphium.createQueryFor(Product.class).countAll()).isEqualTo(1); + } +} From 420725a3124f506403aceaa5d66d09322d777b04 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:05:16 +0200 Subject: [PATCH 08/14] fix(jakarta-data): correct cursor pagination sort sources and OFFSET-mode skip Three related bugs across the cursor-pagination paths in AbstractMorphiumRepository.doFindAllCursored, JdqlMethodBridge.executeCursoredJdql, and FindMethodBridge.executeCursoredFind: - A @Query method's own JDQL ORDER BY clause was silently dropped for CursoredPage results: the cursor keyset was built only from the separate @OrderBy annotation spec, which CursorHelper.applySort then used to overwrite the sort already applied for the JDQL ORDER BY, leaving the cursor without any sort key. The JDQL ORDER BY now takes precedence when present. - A dynamic Sort/Order method parameter on a @Find method returning CursoredPage was applied to the query but then overwritten by CursorHelper.applySort with the (empty) static @OrderBy keyset. The dynamic sort is now threaded through into the cursor keyset. - PageRequest.Mode.OFFSET requests for a CursoredPage applied neither a cursor condition nor a skip, so any page beyond the first returned the same records regardless of the requested page number. All three methods now skip to the requested page in that mode, mirroring the existing offset-page logic in doFindAllPaged(). Also in JdqlMethodBridge.toNumber(): AVG aggregate results are now always returned as double, since some drivers/the in-memory aggregator return an Integer/Long when every averaged value happens to be a whole number, which previously surfaced as a long instead of the double Jakarta Data callers expect. Reported by CodeRabbit/Codex review on PR #16. --- .../data/AbstractMorphiumRepository.java | 7 +++- .../morphium/data/FindMethodBridge.java | 35 ++++++++++++++--- .../morphium/data/JdqlMethodBridge.java | 39 ++++++++++++++++++- 3 files changed, 73 insertions(+), 8 deletions(-) diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java index 27f7a0148..94fa4bf7e 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java @@ -212,16 +212,21 @@ public CursoredPage doFindAllCursored(PageRequest pageRequest, Order sortB } boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + int requestedSize = pageRequest.size(); if (pageRequest.mode() != PageRequest.Mode.OFFSET) { PageRequest.Cursor cursor = pageRequest.cursor() .orElseThrow(() -> new IllegalArgumentException( "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); CursorHelper.applyCursorCondition(query, cursor, sortSpecs, morphium, entityClass(), isForward); + } else { + // CursoredPage requested in classic offset mode (PageRequest.Mode.OFFSET): no cursor + // condition applies, but we still need to skip to the requested page like doFindAllPaged(). + int skip = (int) ((pageRequest.page() - 1) * requestedSize); + query.skip(skip); } CursorHelper.applySort(query, sortSpecs, morphium, entityClass(), isForward); - int requestedSize = pageRequest.size(); query.limit(requestedSize + 1); List content = query.asList(); diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java index b2bdb7e61..71a2e80f7 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java @@ -98,12 +98,17 @@ public static Object executeFind(AbstractMorphiumRepository repo, } // Apply dynamic Sort parameter + // Also record it as CursorHelper.SortSpec, in the same field:direction shape as orderBySpec, + // so that executeCursoredFind() can use it as the cursor keyset below — otherwise a dynamic + // Sort/Order argument would silently be dropped for CursoredPage methods (see Bug 2). + List dynamicSortSpecs = new ArrayList<>(); if (sortParamIndex >= 0 && args[sortParamIndex] != null) { Sort sort = (Sort) args[sortParamIndex]; Map sortMap = new LinkedHashMap<>(); String mongoField = resolveMongoField(morphium, entityClass, sort.property()); sortMap.put(mongoField, sort.isAscending() ? 1 : -1); query.sort(sortMap); + dynamicSortSpecs.add(new CursorHelper.SortSpec(sort.property(), sort.isAscending())); } // Apply dynamic Order parameter (contains multiple Sort entries) @@ -115,6 +120,7 @@ public static Object executeFind(AbstractMorphiumRepository repo, Sort sort = (Sort) s; String mongoField = resolveMongoField(morphium, entityClass, sort.property()); sortMap.put(mongoField, sort.isAscending() ? 1 : -1); + dynamicSortSpecs.add(new CursorHelper.SortSpec(sort.property(), sort.isAscending())); } query.sort(sortMap); } @@ -133,7 +139,7 @@ public static Object executeFind(AbstractMorphiumRepository repo, if (returnsCursoredPage) { return executeCursoredFind(query, pageRequest, conditionsSpec, orderBySpec, - morphium, entityClass, args); + morphium, entityClass, args, dynamicSortSpecs); } int size = pageRequest.size(); @@ -178,10 +184,14 @@ public static Object executeFind(AbstractMorphiumRepository repo, * @param query the base query with conditions and static ordering already applied * @param pageRequest the requested page (cursor/offset mode, size, whether to compute totals) * @param conditionsSpec encoded conditions, used to rebuild an unrestricted count query - * @param orderBySpec encoded ordering, used to derive the keyset fields for cursor support + * @param orderBySpec encoded static {@code @OrderBy} ordering, used as the keyset fallback + * when no dynamic {@code Sort}/{@code Order} argument was supplied * @param morphium the Morphium instance * @param entityClass the entity class * @param args the method arguments (for re-applying conditions to the count query) + * @param dynamicSortSpecs the sort fields already derived from a dynamic {@code Sort}/{@code Order} + * method parameter and applied to {@code query} by {@link #executeFind}, + * empty if no such parameter was present * @return the cursored page of results * @throws IllegalArgumentException if {@code pageRequest} requires a cursor but none is present */ @@ -189,9 +199,19 @@ public static Object executeFind(AbstractMorphiumRepository repo, private static Object executeCursoredFind(Query query, PageRequest pageRequest, String conditionsSpec, String orderBySpec, Morphium morphium, Class entityClass, - Object[] args) { - List sortSpecs = CursorHelper.parseSortSpecs(orderBySpec); + Object[] args, List dynamicSortSpecs) { + // The keyset for cursor pagination can come from two independent sources: a dynamic + // Sort/Order method parameter (already applied to `query` by executeFind() above) and the + // static @OrderBy annotation (orderBySpec). Using only orderBySpec here (as before) silently + // dropped a dynamic Sort/Order argument, leaving the cursor without the actual sort key that + // was applied to the query. Decision: a dynamic Sort/Order parameter, when present, wins over + // the static @OrderBy annotation — it is the caller's explicit, per-call choice and mirrors + // how query.sort() itself is applied last (overwriting any static sort) in executeFind(). + List sortSpecs = !dynamicSortSpecs.isEmpty() + ? dynamicSortSpecs + : CursorHelper.parseSortSpecs(orderBySpec); boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + int requestedSize = pageRequest.size(); if (pageRequest.mode() != PageRequest.Mode.OFFSET) { // Cursor-based: apply cursor condition and adjusted sort @@ -199,12 +219,17 @@ private static Object executeCursoredFind(Query query, PageRequest pageRequest, .orElseThrow(() -> new IllegalArgumentException( "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); CursorHelper.applyCursorCondition(query, cursor, sortSpecs, morphium, entityClass, isForward); + } else { + // CursoredPage requested in classic offset mode (PageRequest.Mode.OFFSET): no cursor + // condition applies, but we still need to skip to the requested page, exactly like the + // Page branch above (query.skip(skip).limit(size)) does for a normal offset page. + int skip = (int) ((pageRequest.page() - 1) * requestedSize); + query.skip(skip); } // Apply sort (inverted for CURSOR_PREVIOUS) CursorHelper.applySort(query, sortSpecs, morphium, entityClass, isForward); // Fetch one extra to determine hasNext precisely - int requestedSize = pageRequest.size(); query.limit(requestedSize + 1); List content = query.asList(); diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java index 220b83e42..8cdcdab99 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java @@ -199,18 +199,43 @@ private static Object executeCursoredJdql(Query mQuery, PageRequest pageRequest, String orderBySpec, JdqlQuery jdqlQuery, Map paramValues, Morphium morphium, Class entityClass) { - List sortSpecs = CursorHelper.parseSortSpecs(orderBySpec); + // The keyset for cursor pagination can come from two independent sources: the JDQL string's + // own ORDER BY clause (jdqlQuery.orderBy(), already applied to mQuery further up in + // executeJdql() for the non-cursor path) and the separate @OrderBy annotation + // (orderBySpec). Using only orderBySpec here (as before) silently dropped the JDQL ORDER BY + // whenever it was the only one present, leaving the cursor without a sort key at all. + // Decision: if the JDQL query itself specifies an ORDER BY, it wins — it is part of the + // explicit query text and takes precedence over the declarative @OrderBy annotation, which + // is only a fallback for methods that don't spell out their own ordering. We do not attempt + // to merge the two field lists (e.g. JDQL primary keys + @OrderBy tiebreakers) because that + // ordering combination is ambiguous and not defined by JDQL semantics; callers wanting both + // should express both fields in their ORDER BY clause directly. + List sortSpecs; + if (!jdqlQuery.orderBy().isEmpty()) { + sortSpecs = new ArrayList<>(jdqlQuery.orderBy().size()); + for (JdqlQuery.OrderSpec spec : jdqlQuery.orderBy()) { + sortSpecs.add(new CursorHelper.SortSpec(spec.field(), spec.ascending())); + } + } else { + sortSpecs = CursorHelper.parseSortSpecs(orderBySpec); + } boolean isForward = pageRequest.mode() != PageRequest.Mode.CURSOR_PREVIOUS; + int requestedSize = pageRequest.size(); if (pageRequest.mode() != PageRequest.Mode.OFFSET) { PageRequest.Cursor cursor = pageRequest.cursor() .orElseThrow(() -> new IllegalArgumentException( "PageRequest mode is " + pageRequest.mode() + " but no cursor provided")); CursorHelper.applyCursorCondition(mQuery, cursor, sortSpecs, morphium, entityClass, isForward); + } else { + // CursoredPage requested in classic offset mode (PageRequest.Mode.OFFSET): no cursor + // condition applies, but we still need to skip to the requested page, exactly like the + // Page branch above (mQuery.skip(skip).limit(size)) does for a normal offset page. + int skip = (int) ((pageRequest.page() - 1) * requestedSize); + mQuery.skip(skip); } CursorHelper.applySort(mQuery, sortSpecs, morphium, entityClass, isForward); - int requestedSize = pageRequest.size(); mQuery.limit(requestedSize + 1); List content = mQuery.asList(); @@ -777,6 +802,16 @@ private static Object toNumber(Object value, JdqlQuery.AggregateType type) { return ((Number) value).longValue(); } if (value instanceof Number n) { + // AVG must always be returned as a double, regardless of the underlying MongoDB number + // subtype: if every value being averaged happens to be an integer, some drivers/the + // in-memory aggregator return an Integer/Long for the average instead of a Double, but + // Jakarta Data callers of an AVG aggregate expect a double/Double result unconditionally + // (e.g. AVG of exactly 5 must come back as 5.0, not 5L). SUM/MIN/MAX are left on the + // existing int/long-preserving-unless-fractional heuristic since a SUM or MIN/MAX of an + // integer field is plausibly a whole number and no caller convention requires double there. + if (type == JdqlQuery.AggregateType.AVG) { + return n.doubleValue(); + } return (n instanceof Integer || n instanceof Long) ? n.longValue() : n.doubleValue(); } return value; From c7aa6f3c983a5b16ed0da5df32255ea9250f9d62 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:12:38 +0200 Subject: [PATCH 09/14] fix(jakarta-data): fix JDQL ORDER BY without WHERE, HAVING-without-GROUP-BY detection, direction validation, and string-literal-aware AND/OR splitting --- .../de/caluga/morphium/data/JdqlParser.java | 42 ++++- .../caluga/morphium/data/JdqlParserTest.java | 173 ++++++++++++++++++ 2 files changed, 210 insertions(+), 5 deletions(-) diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java index 7501dfddd..fe0f788fe 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java @@ -58,9 +58,10 @@ public final class JdqlParser { private JdqlParser() {} - // Split ORDER BY from WHERE (case-insensitive) + // Split ORDER BY from WHERE (case-insensitive). Prefix may be empty when ORDER BY + // appears without a preceding WHERE/GROUP BY clause (WHERE is optional per class javadoc). private static final Pattern ORDER_BY_SPLIT = Pattern.compile( - "^(.+?)\\s+ORDER\\s+BY\\s+(.+)$", Pattern.CASE_INSENSITIVE); + "^(.*?)\\s*ORDER\\s+BY\\s+(.+)$", Pattern.CASE_INSENSITIVE); // Match aggregate function: COUNT(this), SUM(amount), AVG(field), MIN(field), MAX(field) private static final Pattern AGGREGATE_PATTERN = Pattern.compile( @@ -187,6 +188,16 @@ public static JdqlQuery parse(String jdql) { if (groupByIdx < 0 && whereUpper.startsWith("GROUP BY ")) { groupByIdx = 0; } + + // Detect HAVING without a preceding GROUP BY — must be checked before the + // WHERE parser gets a chance to misinterpret "HAVING ..." as a condition. + if (groupByIdx < 0) { + boolean hasHaving = whereUpper.contains(" HAVING ") || whereUpper.startsWith("HAVING "); + if (hasHaving) { + throw new IllegalArgumentException("HAVING without GROUP BY: " + jdql); + } + } + List havingConditions = null; JdqlQuery.Combinator havingCombinator = JdqlQuery.Combinator.AND; if (groupByIdx >= 0) { @@ -520,7 +531,15 @@ private static List parseOrderBy(String orderPart) { String field = tokens[0]; boolean ascending = true; if (tokens.length > 1) { - ascending = !"DESC".equalsIgnoreCase(tokens[1]); + String direction = tokens[1]; + if ("ASC".equalsIgnoreCase(direction)) { + ascending = true; + } else if ("DESC".equalsIgnoreCase(direction)) { + ascending = false; + } else { + throw new IllegalArgumentException("Invalid ORDER BY direction '" + direction + + "' for field '" + field + "': expected ASC or DESC"); + } } specs.add(new JdqlQuery.OrderSpec(field, ascending)); } @@ -532,9 +551,15 @@ private static List parseOrderBy(String orderPart) { private static boolean containsTopLevelOr(String wherePart) { String upper = wherePart.toUpperCase(Locale.ROOT); int depth = 0; + boolean insideStringLiteral = false; for (int i = 0; i < upper.length(); i++) { char c = upper.charAt(i); - if (c == '(') depth++; + if (c == '\'') { + insideStringLiteral = !insideStringLiteral; + } else if (insideStringLiteral) { + // skip parenthesis depth and keyword detection while inside a string literal + continue; + } else if (c == '(') depth++; else if (c == ')') depth--; else if (depth == 0 && i + 4 <= upper.length() && upper.startsWith(" OR ", i)) { @@ -548,6 +573,7 @@ else if (depth == 0 && i + 4 <= upper.length() * Splits a WHERE clause on a top-level combinator (AND or OR). * Handles BETWEEN...AND by not splitting inside it. * Respects parenthesis depth — never splits inside parenthesized groups. + * Respects string literals (single-quoted) — never splits inside a string literal. */ private static List splitTopLevel(String wherePart, String combinator) { List result = new ArrayList<>(); @@ -557,10 +583,16 @@ private static List splitTopLevel(String wherePart, String combinator) { int start = 0; int depth = 0; + boolean insideStringLiteral = false; for (int i = 0; i < upper.length(); i++) { char c = upper.charAt(i); - if (c == '(') { + if (c == '\'') { + insideStringLiteral = !insideStringLiteral; + } else if (insideStringLiteral) { + // skip parenthesis depth and separator detection while inside a string literal + continue; + } else if (c == '(') { depth++; } else if (c == ')') { depth--; diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java index a84c6109f..bba121dd0 100644 --- a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java @@ -479,4 +479,177 @@ void groupWithOrderBy() { assertThat(result.orderBy().get(0).ascending()).isTrue(); } } + + @Nested + @DisplayName("BUG 1: ORDER BY without a preceding WHERE clause") + class OrderByWithoutWhereTests { + + @Test + @DisplayName("ORDER BY name ASC (no WHERE) parses with empty conditions and correct order") + void orderByWithoutWhereParsesCorrectly() { + String jdql = "ORDER BY name ASC"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).isEmpty(); + assertThat(result.orderBy()).hasSize(1); + assertThat(result.orderBy().get(0).field()).isEqualTo("name"); + assertThat(result.orderBy().get(0).ascending()).isTrue(); + } + + @Test + @DisplayName("ORDER BY without leading keyword, multiple fields, no WHERE") + void orderByWithoutWhereMultipleFields() { + String jdql = "ORDER BY name ASC, age DESC"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).isEmpty(); + assertThat(result.orderBy()).hasSize(2); + assertThat(result.orderBy().get(0).field()).isEqualTo("name"); + assertThat(result.orderBy().get(0).ascending()).isTrue(); + assertThat(result.orderBy().get(1).field()).isEqualTo("age"); + assertThat(result.orderBy().get(1).ascending()).isFalse(); + } + + @Test + @DisplayName("Regression: WHERE + ORDER BY still splits correctly (age > 18 ORDER BY name)") + void whereWithOrderByStillWorks() { + String jdql = "WHERE age > 18 ORDER BY name"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.fieldName()).isEqualTo("age"); + assertThat(cond.operator()).isEqualTo(JdqlQuery.Operator.GT); + assertThat(cond.valueRef()).isEqualTo("18"); + + assertThat(result.orderBy()).hasSize(1); + assertThat(result.orderBy().get(0).field()).isEqualTo("name"); + assertThat(result.orderBy().get(0).ascending()).isTrue(); + } + } + + @Nested + @DisplayName("BUG 2: HAVING without GROUP BY must be rejected") + class HavingWithoutGroupByTests { + + @Test + @DisplayName("SELECT COUNT(this) FROM Entity HAVING COUNT(this) > 1 (no GROUP BY) throws") + void havingWithoutGroupByThrows() { + String jdql = "SELECT COUNT(this) FROM Entity HAVING COUNT(this) > 1"; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains("HAVING without GROUP BY"); + } + } + + @Test + @DisplayName("WHERE clause with HAVING but no GROUP BY throws") + void havingWithoutGroupByAfterWhereThrows() { + String jdql = "WHERE active = true HAVING COUNT(this) > 1"; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains("HAVING without GROUP BY"); + } + } + + @Test + @DisplayName("Regression: HAVING with GROUP BY still parses correctly") + void havingWithGroupByStillWorks() { + String jdql = "SELECT category, COUNT(this) FROM Product GROUP BY category HAVING COUNT(this) > 1"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.groupByFields()).containsExactly("category"); + assertThat(result.havingConditions()).hasSize(1); + } + } + + @Nested + @DisplayName("BUG 3: ORDER BY direction token must be ASC or DESC") + class OrderByDirectionValidationTests { + + @Test + @DisplayName("ORDER BY name DESCE (typo) throws IllegalArgumentException") + void invalidDirectionTypoThrows() { + String jdql = "WHERE a = :a ORDER BY name DESCE"; + try { + JdqlParser.parse(jdql); + org.junit.jupiter.api.Assertions.fail("Expected IllegalArgumentException"); + } catch (IllegalArgumentException e) { + assertThat(e.getMessage()).contains("Invalid ORDER BY direction"); + assertThat(e.getMessage()).contains("DESCE"); + assertThat(e.getMessage()).contains("name"); + } + } + + @Test + @DisplayName("Regression: ORDER BY name DESC (valid) still works") + void validDescStillWorks() { + String jdql = "WHERE a = :a ORDER BY name DESC"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.orderBy()).hasSize(1); + assertThat(result.orderBy().get(0).field()).isEqualTo("name"); + assertThat(result.orderBy().get(0).ascending()).isFalse(); + } + + @Test + @DisplayName("Regression: ORDER BY name ASC (valid) still works") + void validAscStillWorks() { + String jdql = "WHERE a = :a ORDER BY name ASC"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.orderBy()).hasSize(1); + assertThat(result.orderBy().get(0).field()).isEqualTo("name"); + assertThat(result.orderBy().get(0).ascending()).isTrue(); + } + } + + @Nested + @DisplayName("BUG 4: Top-level AND/OR splitting must ignore string literals") + class StringLiteralAwareSplitTests { + + @Test + @DisplayName("name = 'A OR B' is not split at the OR inside the string literal") + void orInsideStringLiteralIsNotSplit() { + String jdql = "WHERE name = 'A OR B'"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.conditions()).hasSize(1); + JdqlQuery.JdqlCondition cond = result.conditions().get(0); + assertThat(cond.fieldName()).isEqualTo("name"); + assertThat(cond.operator()).isEqualTo(JdqlQuery.Operator.EQ); + assertThat(cond.valueRef()).isEqualTo("'A OR B'"); + } + + @Test + @DisplayName("name = 'A AND B' AND active = true — only the real top-level AND is split") + void andInsideStringLiteralIsNotSplitButRealAndIs() { + String jdql = "WHERE name = 'A AND B' AND active = true"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + + JdqlQuery.JdqlCondition first = result.conditions().get(0); + assertThat(first.fieldName()).isEqualTo("name"); + assertThat(first.valueRef()).isEqualTo("'A AND B'"); + + JdqlQuery.JdqlCondition second = result.conditions().get(1); + assertThat(second.fieldName()).isEqualTo("active"); + } + + @Test + @DisplayName("Regression: real top-level OR outside string literals still splits correctly") + void realTopLevelOrStillSplits() { + String jdql = "WHERE a = :a OR b = :b"; + JdqlQuery result = JdqlParser.parse(jdql); + + assertThat(result.combinator()).isEqualTo(JdqlQuery.Combinator.OR); + assertThat(result.conditions()).hasSize(2); + } + } } From a80d5d52c417b634dbf88bcf4113a78bf944cee4 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:13:09 +0200 Subject: [PATCH 10/14] fix(jakarta-data): validate unknown fields in derived query methods and fix combinator detection for acronym-ending field segments --- .../morphium/data/MethodNameParser.java | 10 +++- .../morphium/data/MethodNameParserTest.java | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 2 deletions(-) diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java index a6acbe1df..9cf6c9163 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java @@ -255,6 +255,12 @@ private static String resolveFieldName(String part, java.util.Set entity return f; } } + // Not found in either form: this is not a valid field on the entity. + // Fail fast here instead of silently producing a query that will never + // match anything once executed against the database. + throw new IllegalArgumentException( + "Unknown field '" + camelCase + "' referenced in derived query method" + + " (not found in entity fields: " + entityFields + ")"); } return camelCase; } @@ -267,7 +273,7 @@ private static boolean containsCombinator(String text, String combinator) { while (idx > 0 && idx + combinator.length() < text.length()) { char before = text.charAt(idx - 1); char after = text.charAt(idx + combinator.length()); - if (Character.isLowerCase(before) && Character.isUpperCase(after)) { + if (Character.isLetterOrDigit(before) && Character.isUpperCase(after)) { return true; } idx = text.indexOf(combinator, idx + 1); @@ -282,7 +288,7 @@ private static String[] splitOnCombinator(String text, String combinator) { while (idx > 0 && idx + combinator.length() < text.length()) { char before = text.charAt(idx - 1); char after = text.charAt(idx + combinator.length()); - if (Character.isLowerCase(before) && Character.isUpperCase(after)) { + if (Character.isLetterOrDigit(before) && Character.isUpperCase(after)) { result.add(text.substring(start, idx)); start = idx + combinator.length(); } diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java index a195a62d1..72b53cea0 100644 --- a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java @@ -125,5 +125,64 @@ void mixedAndOrCombinatorsRejected() { .hasMessageContaining("Mixed And/Or combinators") .hasMessageContaining("findByStatusAndCategoryOrPriority"); } + + @Test + @DisplayName("Unknown field (typo) in derived query method throws IllegalArgumentException") + void unknownFieldRejected() { + assertThatThrownBy(() -> + MethodNameParser.parse("findByStatuss", ENTITY_FIELDS)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unknown field") + .hasMessageContaining("statuss"); + } + + @Test + @DisplayName("Unknown field validation is skipped when entityFields is null (no validation possible)") + void unknownFieldNotRejectedWhenEntityFieldsNull() { + QueryDescriptor result = MethodNameParser.parse("findByStatuss", null); + + assertThat(result.conditions()).hasSize(1); + assertThat(result.conditions().get(0).field()).isEqualTo("statuss"); + } + + @Test + @DisplayName("Unknown field validation is skipped when entityFields is empty (no validation possible)") + void unknownFieldNotRejectedWhenEntityFieldsEmpty() { + QueryDescriptor result = MethodNameParser.parse("findByStatuss", Set.of()); + + assertThat(result.conditions()).hasSize(1); + assertThat(result.conditions().get(0).field()).isEqualTo("statuss"); + } + } + + @Nested + @DisplayName("Combinator detection with acronym/digit-ending field segments") + class CombinatorAcronymTests { + + private static final Set URL_ENTITY_FIELDS = Set.of("url", "status", "category"); + + @Test + @DisplayName("findByURLOrStatus splits correctly into URL and Status despite acronym ending in uppercase") + void acronymEndingSegmentSplitsOnOr() { + QueryDescriptor result = MethodNameParser.parse("findByURLOrStatus", URL_ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.FIND); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.OR); + assertThat(result.conditions()).hasSize(2); + assertThat(result.conditions().get(0).field()).isEqualTo("url"); + assertThat(result.conditions().get(1).field()).isEqualTo("status"); + } + + @Test + @DisplayName("findByStatusAndCategory (normal lowercase-before-combinator case) still splits correctly") + void regularLowercaseSegmentStillSplitsOnAnd() { + QueryDescriptor result = MethodNameParser.parse("findByStatusAndCategory", URL_ENTITY_FIELDS); + + assertThat(result.prefix()).isEqualTo(QueryDescriptor.Prefix.FIND); + assertThat(result.combinator()).isEqualTo(QueryDescriptor.Combinator.AND); + assertThat(result.conditions()).hasSize(2); + assertThat(result.conditions().get(0).field()).isEqualTo("status"); + assertThat(result.conditions().get(1).field()).isEqualTo("category"); + } } } From 9fb2d0c047e20e22d7b877d79720686bb6181cce Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:14:00 +0200 Subject: [PATCH 11/14] fix(jakarta-data): require non-empty sort keyset for cursor pagination CursorHelper.applyCursorCondition silently produced an empty $or condition when sortSpecs was empty, causing cursor-based pagination to lose its keyset filter entirely without any indication of the problem. Cursor pagination without a sort keyset is conceptually undefined (there is no unique 'continue after here' criterion), so this now fails fast with an IllegalArgumentException instead of degrading into a silently broken/no-op paging condition. Adds CursorHelperTest covering the null/empty sortSpecs guard as well as a regression check that a non-empty keyset still produces the expected $or condition. --- .../de/caluga/morphium/data/CursorHelper.java | 5 + .../morphium/data/CursorHelperTest.java | 109 ++++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/CursorHelperTest.java diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java index 062459d0f..746350193 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java @@ -108,6 +108,11 @@ public static void applyCursorCondition(Query query, PageRequest.Cursor cursor, List sortSpecs, Morphium morphium, Class entityClass, boolean isForward) { + if (sortSpecs == null || sortSpecs.isEmpty()) { + throw new IllegalArgumentException( + "Cursor-based pagination requires a non-empty sort order to define the keyset; got no sort fields"); + } + List orQueries = new ArrayList(); for (int i = 0; i < sortSpecs.size(); i++) { diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/CursorHelperTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/CursorHelperTest.java new file mode 100644 index 000000000..e9a57ced6 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/CursorHelperTest.java @@ -0,0 +1,109 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.data.CursorHelper.SortSpec; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import de.caluga.morphium.query.Query; +import jakarta.data.page.PageRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests for {@link CursorHelper}, focusing on the keyset-must-be-non-empty guard in + * {@link CursorHelper#applyCursorCondition}. Uses a real {@link Morphium} instance backed by + * {@link InMemoryDriver}, following the same setup pattern as {@link QueryExecutorTest}. + */ +class CursorHelperTest { + + private static Morphium morphium; + + @Entity + static class Product { + @Id + private String id; + private String name; + private int amount; + + Product() {} + + Product(String id, String name, int amount) { + this.id = id; + this.name = name; + this.amount = amount; + } + + public String getId() { return id; } + public String getName() { return name; } + public int getAmount() { return amount; } + } + + @BeforeAll + static void setUp() { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.setDatabase("test"); + cfg.addHostToSeed("localhost"); + cfg.setDriverName(InMemoryDriver.driverName); + morphium = new Morphium(cfg); + } + + @AfterAll + static void tearDown() { + if (morphium != null) { + morphium.close(); + } + } + + @BeforeEach + void initCollection() { + morphium.clearCollection(Product.class); + } + + // -- BUG 1: cursor pagination without a sort keyset must fail loudly, not silently ----- + + @Test + @DisplayName("applyCursorCondition throws IllegalArgumentException when sortSpecs is empty") + void applyCursorConditionThrowsOnEmptySortSpecs() { + Query query = morphium.createQueryFor(Product.class); + PageRequest.Cursor cursor = PageRequest.Cursor.forKey(200); + + assertThatThrownBy(() -> CursorHelper.applyCursorCondition( + query, cursor, List.of(), morphium, Product.class, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty sort order"); + } + + @Test + @DisplayName("applyCursorCondition throws IllegalArgumentException when sortSpecs is null") + void applyCursorConditionThrowsOnNullSortSpecs() { + Query query = morphium.createQueryFor(Product.class); + PageRequest.Cursor cursor = PageRequest.Cursor.forKey(200); + + assertThatThrownBy(() -> CursorHelper.applyCursorCondition( + query, cursor, null, morphium, Product.class, true)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("non-empty sort order"); + } + + @Test + @DisplayName("applyCursorCondition with a non-empty sort keyset still builds the expected $or condition") + void applyCursorConditionWithSortSpecsBuildsOrCondition() { + Query query = morphium.createQueryFor(Product.class); + PageRequest.Cursor cursor = PageRequest.Cursor.forKey(200); + List sortSpecs = List.of(new SortSpec("amount", true)); + + CursorHelper.applyCursorCondition(query, cursor, sortSpecs, morphium, Product.class, true); + + assertThat(query.toQueryObject()).containsKey("$or"); + } +} From cb9950f9a7518d290380bc4b941ef3314b87fa83 Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 17:15:38 +0200 Subject: [PATCH 12/14] fix(jakarta-data): fall back to bridge classloader when loading GROUP BY record class Thread.currentThread().getContextClassLoader().loadClass(...) can fail to find the result record class in modular/OSGi/framework environments where the context classloader differs from the one that loaded this bridge class (or can be null in some embedded contexts). Fall back to JdqlMethodBridge's own classloader before giving up. Reported by CodeRabbit review on PR #16. --- .../de/caluga/morphium/data/JdqlMethodBridge.java | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java index 8cdcdab99..33ad8ce59 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java @@ -718,8 +718,16 @@ private static List mapGroupedResults(List> results, Class recordClass; try { recordClass = Thread.currentThread().getContextClassLoader().loadClass(resultRecordClass); - } catch (ClassNotFoundException e) { - throw new IllegalArgumentException("Record class not found: " + resultRecordClass, e); + } catch (ClassNotFoundException | NullPointerException e) { + // The context classloader may not see the record class in modular/OSGi/framework + // environments where it differs from the classloader that loaded this bridge class + // (or may be null, e.g. in some embedded/native-image contexts). Fall back to the + // bridge's own classloader before giving up. + try { + recordClass = JdqlMethodBridge.class.getClassLoader().loadClass(resultRecordClass); + } catch (ClassNotFoundException e2) { + throw new IllegalArgumentException("Record class not found: " + resultRecordClass, e2); + } } RecordComponent[] components = recordClass.getRecordComponents(); From e79de9f37deda68e7e30c98ad7a2f8fdb6c2567f Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 21:54:42 +0200 Subject: [PATCH 13/14] fix(jakarta-data): make NOT_CONTAINS a negated substring match, not exact inequality NOT_CONTAINS mapped to $ne (exact not-equal) instead of negating the substring match that CONTAINS uses, so findByFieldNotContaining(x) filtered on "field != x" instead of "field does not contain x" -- wrong results whenever the field wasn't an exact match to the argument. Now generates {$not: {$regex: ...}}, mirroring CONTAINS's $regex construction. Adds regression tests exercising both the generated MongoDB query shape and real InMemDriver data, following the existing CONTAINS test pattern in this file. Found in code review on PR #266 (sboesebeck/morphium). --- .../caluga/morphium/data/QueryExecutor.java | 2 +- .../morphium/data/QueryExecutorTest.java | 53 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java index 0f3127882..ed535899b 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java @@ -244,7 +244,7 @@ private static Map buildRawCondition(String fieldName, case STARTS_WITH -> result.put(fieldName, Map.of("$regex", "^" + Pattern.quote(args[cond.paramIndex()].toString()))); case ENDS_WITH -> result.put(fieldName, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()) + "$")); case CONTAINS -> result.put(fieldName, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()))); - case NOT_CONTAINS -> result.put(fieldName, nullSafeOp("$ne", args[cond.paramIndex()])); + case NOT_CONTAINS -> result.put(fieldName, Map.of("$not", Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString())))); case MATCHES -> result.put(fieldName, Map.of("$regex", args[cond.paramIndex()].toString())); case IGNORE_CASE -> { Map regex = new LinkedHashMap<>(); diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java index d17f18353..987891171 100644 --- a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java @@ -140,6 +140,59 @@ void containsMatchesSubstringAgainstRealData() { assertThat(found).extracting(Product::getName).containsExactlyInAnyOrder("Widget", "Gadget"); } + // -- BUG 3: NOT_CONTAINS must negate the substring match, not test for exact inequality -- + + @Test + @DisplayName("NOT_CONTAINS generates a negated $regex (substring exclusion), not exact-match $ne") + void notContainsGeneratesNegatedRegex() { + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("name", Operator.NOT_CONTAINS, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + Query query = morphium.createQueryFor(Product.class); + QueryExecutor.applyConditions(query, descriptor, new Object[]{"dget"}, morphium, Product.class); + Map queryObj = query.toQueryObject(); + + assertThat(queryObj).containsKey("name"); + Object nameCondition = queryObj.get("name"); + // Must NOT be a plain $ne (that tests for exact inequality, not substring absence). + assertThat(nameCondition).isInstanceOf(Map.class); + @SuppressWarnings("unchecked") + Map notMap = (Map) nameCondition; + assertThat(notMap).doesNotContainKey("$ne"); + assertThat(notMap).containsKey("$not"); + } + + @Test + @DisplayName("NOT_CONTAINS excludes documents where the argument occurs anywhere in the field value") + void notContainsExcludesSubstringMatchesAgainstRealData() { + morphium.store(new Product("p1", "Widget", "ACTIVE")); + morphium.store(new Product("p2", "Gadget", "ACTIVE")); + morphium.store(new Product("p3", "Gizmo", "ACTIVE")); + + QueryDescriptor descriptor = new QueryDescriptor( + Prefix.FIND, + List.of(new Condition("name", Operator.NOT_CONTAINS, 0)), + Combinator.AND, + List.of(), + ReturnType.LIST + ); + + @SuppressWarnings("unchecked") + Object result = QueryExecutor.execute(descriptor, new Object[]{"dget"}, repo); + @SuppressWarnings("unchecked") + List found = (List) result; + + // "dget" is a substring of "Widget" and "Gadget" but not "Gizmo" -- only "Gizmo" must + // remain. An exact-match NOT_CONTAINS (the bug: field != "dget") would incorrectly + // return all three, since none of them equals the literal string "dget". + assertThat(found).extracting(Product::getName).containsExactly("Gizmo"); + } + // -- BUG 2: DELETE must return the actually-deleted count, not a pre-delete count --- @Test From b72898d4a4aa901c0e10d6a0c866f4bb8148ff1a Mon Sep 17 00:00:00 2001 From: Heiko Kopp Date: Wed, 5 Aug 2026 21:54:42 +0200 Subject: [PATCH 14/14] fix(jakarta-data): escape and anchor JDQL LIKE patterns like the derived-query LIKE path JDQL LIKE/NOT LIKE built its regex from the raw literal without escaping regex metacharacters or anchoring the pattern, unlike the derived-query LIKE path (likeToRegex() in QueryExecutor), which already used Pattern.quote() and ^...$ anchoring correctly. WHERE code LIKE 'A.1' matched "AX1" (the literal dot was interpreted as a regex wildcard), and a wildcard-free pattern like WHERE name LIKE 'Widget' matched any value merely containing "Widget" instead of requiring an exact match. Now delegates to the already-correct QueryExecutor.likeToRegex(), which escapes literal segments with Pattern.quote() while still translating % and _ SQL wildcards, and anchors the whole pattern with ^...$. Adds JdqlMethodBridgeTest (no prior direct unit test existed for this class), exercising exact-match semantics, metacharacter escaping, and that SQL wildcards still work after the fix. Found in code review on PR #266 (sboesebeck/morphium). --- .../morphium/data/JdqlMethodBridge.java | 4 +- .../morphium/data/JdqlMethodBridgeTest.java | 141 ++++++++++++++++++ 2 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlMethodBridgeTest.java diff --git a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java index 33ad8ce59..cb39fa10b 100644 --- a/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java @@ -377,9 +377,7 @@ private static void applyCondition(Query mQuery, case IN -> field.in((Collection) value); case NOT_IN -> field.nin((Collection) value); case LIKE -> { - String pattern = value.toString() - .replace("%", ".*") - .replace("_", "."); + String pattern = QueryExecutor.likeToRegex(value.toString()); if (cond.negated()) { // NOT LIKE → $not with $regex field.not(); diff --git a/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlMethodBridgeTest.java b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlMethodBridgeTest.java new file mode 100644 index 000000000..cfbb8216f --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlMethodBridgeTest.java @@ -0,0 +1,141 @@ +package de.caluga.morphium.data; + +import de.caluga.morphium.Morphium; +import de.caluga.morphium.MorphiumConfig; +import de.caluga.morphium.annotations.Entity; +import de.caluga.morphium.annotations.Id; +import de.caluga.morphium.driver.inmem.InMemoryDriver; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies {@link JdqlMethodBridge#executeJdql} LIKE-pattern handling. Uses a real + * {@link Morphium} instance backed by {@link InMemoryDriver}, following the same setup pattern + * as {@link QueryExecutorTest}. + * + *

Regression test for a bug where JDQL {@code LIKE} built its regex directly from the raw + * literal (only translating {@code %}/{@code _} wildcards) without escaping regex metacharacters + * or anchoring the pattern with {@code ^...$} — unlike the derived-query {@code LIKE} path, which + * already used {@link QueryExecutor#likeToRegex} correctly. As a result, {@code WHERE code LIKE + * 'A.1'} would match {@code "AX1"} (the {@code .} was interpreted as regex "any character" + * instead of a literal dot), and a wildcard-free pattern like {@code WHERE name LIKE 'Widget'} + * matched any value merely containing {@code "Widget"} instead of requiring an exact match. + */ +class JdqlMethodBridgeTest { + + private static Morphium morphium; + + @Entity + static class Product { + @Id + private String id; + private String code; + private String name; + + Product() {} + + Product(String id, String code, String name) { + this.id = id; + this.code = code; + this.name = name; + } + + public String getId() { return id; } + public String getCode() { return code; } + public String getName() { return name; } + } + + static class ProductRepositoryImpl extends AbstractMorphiumRepository { + ProductRepositoryImpl(Morphium morphium) { + super(new RepositoryMetadata(Product.class, String.class, "id")); + setMorphium(morphium); + } + } + + private ProductRepositoryImpl repo; + + @BeforeAll + static void setUp() { + MorphiumConfig cfg = new MorphiumConfig(); + cfg.setDatabase("test"); + cfg.addHostToSeed("localhost"); + cfg.setDriverName(InMemoryDriver.driverName); + morphium = new Morphium(cfg); + } + + @AfterAll + static void tearDown() { + if (morphium != null) { + morphium.close(); + } + } + + @BeforeEach + void initRepo() { + morphium.clearCollection(Product.class); + repo = new ProductRepositoryImpl(morphium); + } + + @Test + @DisplayName("JDQL LIKE without wildcards requires an exact match, not a substring match") + void likeWithoutWildcardsRequiresExactMatch() { + morphium.store(new Product("p1", "C1", "Widget")); + morphium.store(new Product("p2", "C2", "SuperWidgetPro")); + + Object result = JdqlMethodBridge.executeJdql( + repo, "WHERE name LIKE :name", "name:0", + -1, -1, -1, -1, new Object[]{"Widget"}, + false, false, false, false, false, "", false, null); + + @SuppressWarnings("unchecked") + List found = (List) result; + + // "SuperWidgetPro" contains "Widget" as a substring; the unanchored-regex bug + // would incorrectly match it too. Only the exact match must be returned. + assertThat(found).extracting(Product::getName).containsExactly("Widget"); + } + + @Test + @DisplayName("JDQL LIKE escapes regex metacharacters in the literal portion of the pattern") + void likeEscapesRegexMetacharacters() { + morphium.store(new Product("p1", "A.1", "Dotted")); + morphium.store(new Product("p2", "AX1", "AnyChar")); + + Object result = JdqlMethodBridge.executeJdql( + repo, "WHERE code LIKE :code", "code:0", + -1, -1, -1, -1, new Object[]{"A.1"}, + false, false, false, false, false, "", false, null); + + @SuppressWarnings("unchecked") + List found = (List) result; + + // A literal "." in the LIKE pattern must match only a literal dot, not "any character" -- + // the unescaped-regex bug would treat it as a regex wildcard and also match "AX1". + assertThat(found).extracting(Product::getCode).containsExactly("A.1"); + } + + @Test + @DisplayName("JDQL LIKE still supports % and _ SQL wildcards after the fix") + void likeStillSupportsSqlWildcards() { + morphium.store(new Product("p1", "C1", "Widget")); + morphium.store(new Product("p2", "C2", "Gadget")); + morphium.store(new Product("p3", "C3", "Gizmo")); + + Object result = JdqlMethodBridge.executeJdql( + repo, "WHERE name LIKE :pattern", "pattern:0", + -1, -1, -1, -1, new Object[]{"%dget"}, + false, false, false, false, false, "", false, null); + + @SuppressWarnings("unchecked") + List found = (List) result; + + assertThat(found).extracting(Product::getName).containsExactlyInAnyOrder("Widget", "Gadget"); + } +}