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 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 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..94fa4bf7e --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/AbstractMorphiumRepository.java @@ -0,0 +1,581 @@ +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; + 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); + 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()}, 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()}, 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()}. + * + * @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..746350193 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/CursorHelper.java @@ -0,0 +1,213 @@ +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) { + 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++) { + 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..71a2e80f7 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/FindMethodBridge.java @@ -0,0 +1,349 @@ +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 + // 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) + 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); + dynamicSortSpecs.add(new CursorHelper.SortSpec(sort.property(), sort.isAscending())); + } + 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, dynamicSortSpecs); + } + + 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 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 + */ + @SuppressWarnings({"unchecked", "rawtypes"}) + private static Object executeCursoredFind(Query query, PageRequest pageRequest, + String conditionsSpec, String orderBySpec, + Morphium morphium, Class entityClass, + 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 + 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, 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 + 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..cb39fa10b --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlMethodBridge.java @@ -0,0 +1,880 @@ +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) { + // 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); + 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 = QueryExecutor.likeToRegex(value.toString()); + 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 | 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(); + 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) { + // 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; + } + + /** + * 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..fe0f788fe --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/JdqlParser.java @@ -0,0 +1,698 @@ +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). 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); + + // 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; + } + + // 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) { + 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) { + 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)); + } + 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; + boolean insideStringLiteral = false; + for (int i = 0; i < upper.length(); i++) { + char c = upper.charAt(i); + 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)) { + 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. + * Respects string literals (single-quoted) — never splits inside a string literal. + */ + 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; + boolean insideStringLiteral = false; + + for (int i = 0; i < upper.length(); i++) { + char c = upper.charAt(i); + 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--; + } 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..9cf6c9163 --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/MethodNameParser.java @@ -0,0 +1,328 @@ +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 + // + // 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 (hasOr) { + 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; + } + } + // 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; + } + + // -- 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.isLetterOrDigit(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.isLetterOrDigit(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..ed535899b --- /dev/null +++ b/morphium-jakarta-data/src/main/java/de/caluga/morphium/data/QueryExecutor.java @@ -0,0 +1,318 @@ +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; + // 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 preCount = query.countAll(); + Map deleteResult = query.delete(); + Object n = deleteResult == null ? null : deleteResult.get("n"); + yield (n instanceof Number) ? ((Number) n).longValue() : preCount; + } + }; + } + + // 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 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) { + aliasBranches.add(buildRawCondition(f, cond, args)); + } + FilterExpression fe = new FilterExpression(); + fe.setField(isNegatingOperator(cond.operator()) ? "$and" : "$or"); + fe.setValue(aliasBranches); + 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)); + } + } + + /** + * 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. + */ + @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, Map.of("$regex", Pattern.quote(args[cond.paramIndex()].toString()))); + 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<>(); + 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/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/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"); + } +} 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"); + } +} 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..bba121dd0 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/JdqlParserTest.java @@ -0,0 +1,655 @@ +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(); + } + } + + @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); + } + } +} 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..72b53cea0 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/MethodNameParserTest.java @@ -0,0 +1,188 @@ +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"); + } + + @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"); + } + + @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"); + } + } +} 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..38f4ade41 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorAliasTest.java @@ -0,0 +1,508 @@ +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; + } + + @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..987891171 --- /dev/null +++ b/morphium-jakarta-data/src/test/java/de/caluga/morphium/data/QueryExecutorTest.java @@ -0,0 +1,243 @@ +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 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 + @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); + } +} 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 + + 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 ""