From 29ed1e5fc26b6fcddba14a573361e83a8d8c4db4 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 2 Jul 2026 00:28:59 +0200 Subject: [PATCH 1/5] feat: findBy repository shortcuts (Java 21) and reified Query results (Kotlin) Java 21 API: - Add findBy/getBy/findAllBy/findRefBy convenience default methods to EntityRepository and ProjectionRepository (since 1.12). - Make template-based groupBy/having/orderBy public on QueryBuilder. Kotlin API: - Add reified extensions on Query: resultList(), singleResult(), optionalResult(), resultStream() and resultFlow(), making the advertised orm.query { }.resultList() syntax compile (since 1.12). Docs & skills: - Update Kotlin examples in the next docs to the reified syntax. - Fix Kotlin snippets that did not compile (error-handling.md used Java-style accessors on QueryBuilder) or were not parameterized as claimed (security.md interpolated into a plain string instead of a template lambda). - Skills accuracy pass across the storm-* skills; document the new reified extensions in storm-sql-kotlin and storm-query-kotlin. - Regenerate llms-full.txt from the current docs. Other: - Update developer email in the parent POM. --- docs/error-handling.md | 6 +- docs/hydration.md | 2 +- docs/security.md | 4 +- docs/sql-templates.md | 4 +- pom.xml | 2 +- .../st/orm/repository/EntityRepository.java | 350 ++++++ .../orm/repository/ProjectionRepository.java | 294 +++++ .../java/st/orm/template/QueryBuilder.java | 6 +- .../orm/template/impl/QueryBuilderImpl.java | 6 +- .../java/st/orm/template/RepositoryTest.java | 115 ++ .../src/main/kotlin/st/orm/template/Query.kt | 84 ++ .../kotlin/st/orm/template/ORMTemplateTest.kt | 45 + website/static/llms-full.txt | 1071 ++++++++++++----- website/static/skills/storm-entity-java.md | 13 +- website/static/skills/storm-entity-kotlin.md | 26 +- website/static/skills/storm-json-java.md | 2 +- website/static/skills/storm-json-kotlin.md | 2 +- website/static/skills/storm-migration.md | 9 +- website/static/skills/storm-query-java.md | 32 +- website/static/skills/storm-query-kotlin.md | 24 +- .../static/skills/storm-repository-java.md | 72 +- .../static/skills/storm-repository-kotlin.md | 70 +- .../static/skills/storm-serialization-java.md | 2 +- .../skills/storm-serialization-kotlin.md | 8 +- website/static/skills/storm-setup.md | 22 +- website/static/skills/storm-sql-java.md | 3 +- website/static/skills/storm-sql-kotlin.md | 5 +- website/static/skills/storm-validate.md | 9 +- 28 files changed, 1845 insertions(+), 443 deletions(-) diff --git a/docs/error-handling.md b/docs/error-handling.md index 5b12787d9..df3dae529 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -49,15 +49,15 @@ When you call `getSingleResult()` on a query that returns zero rows, Storm throw ```kotlin // Throws NoResultException if no user has this email. -val user = orm.entity(User::class).select(User_.email eq "nobody@example.com").getSingleResult() +val user = orm.entity(User::class).select(User_.email eq "nobody@example.com").singleResult ``` -To handle the missing-result case without exceptions, use `getOptionalResult()`: +To handle the missing-result case without exceptions, use `optionalResult`: ```kotlin val user: User? = orm.entity(User::class) .select(User_.email eq "nobody@example.com") - .getOptionalResult(User::class) + .optionalResult ``` Or use the repository's `findById` method: diff --git a/docs/hydration.md b/docs/hydration.md index 057655cd3..07937f8c3 100644 --- a/docs/hydration.md +++ b/docs/hydration.md @@ -90,7 +90,7 @@ val sales = orm.query(""" SELECT DATE_TRUNC('month', order_date), COUNT(*), SUM(amount) FROM orders GROUP BY DATE_TRUNC('month', order_date) -""").getResultList(MonthlySales::class) +""").resultList() ``` diff --git a/docs/security.md b/docs/security.md index 8e806684d..e3118b78c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -57,8 +57,8 @@ When using SQL templates directly, embedded values are also parameterized: ```kotlin // Both 'status' and 'minAge' become JDBC parameters. -val users = orm.query("SELECT * FROM user WHERE status = $status AND age > $minAge") - .getResultList(User::class) +val users = orm.query { "SELECT * FROM user WHERE status = $status AND age > $minAge" } + .resultList() ``` diff --git a/docs/sql-templates.md b/docs/sql-templates.md index 916e2ea4e..a62b44089 100644 --- a/docs/sql-templates.md +++ b/docs/sql-templates.md @@ -72,7 +72,7 @@ val pets = orm.query { """ SELECT ${PetWithOwner::class} FROM ${PetWithOwner::class} WHERE ${Owner_.city} = $city -""" }.getResultList(PetWithOwner::class) +""" }.resultList() ``` @@ -407,7 +407,7 @@ val users = orm.query { """ FROM ${User::class} WHERE ${User_.city.country.code} = ${"US"} AND ${User_.email} LIKE ${"%@example.com"} -""" }.getResultList(User::class) +""" }.resultList() ``` diff --git a/pom.xml b/pom.xml index f6ea8cacf..be9798816 100644 --- a/pom.xml +++ b/pom.xml @@ -34,7 +34,7 @@ Leon van Zantvoort - storm@zantvoort.biz + zantvoort@orm.st diff --git a/storm-java21/src/main/java/st/orm/repository/EntityRepository.java b/storm-java21/src/main/java/st/orm/repository/EntityRepository.java index 6a523267c..57513be87 100644 --- a/storm-java21/src/main/java/st/orm/repository/EntityRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/EntityRepository.java @@ -15,6 +15,9 @@ */ package st.orm.repository; +import static st.orm.Operator.EQUALS; +import static st.orm.Operator.IN; + import jakarta.annotation.Nonnull; import java.util.List; import java.util.Optional; @@ -25,6 +28,7 @@ import st.orm.Inline; import st.orm.Metamodel; import st.orm.NoResultException; +import st.orm.NonUniqueResultException; import st.orm.PK; import st.orm.Page; import st.orm.Pageable; @@ -634,6 +638,352 @@ public interface EntityRepository, ID> extends Repository { */ E getByRef(@Nonnull Metamodel.Key key, @Nonnull Ref value); + // Field-based finder methods. + + /** + * Retrieves an entity based on a single field and its value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return the entity matching the given field value, or empty if none exists. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional findBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getOptionalResult(); + } + + /** + * Retrieves an entity based on a single field and its referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return the entity matching the given ref value, or empty if none exists. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional findBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getOptionalResult(); + } + + /** + * Retrieves entities matching a single field and a single value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return a list of matching entities, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List findAllBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getResultList(); + } + + /** + * Retrieves entities matching a single field and a single referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return a list of matching entities, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List findAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getResultList(); + } + + /** + * Retrieves entities matching a single field against multiple values. + * + * @param field metamodel reference of the entity field. + * @param values the values to match against. + * @return a list of matching entities, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List findAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { + return select().where(field, IN, values).getResultList(); + } + + /** + * Retrieves entities matching a single field against multiple referenced values. + * + * @param field metamodel reference of the entity field. + * @param values the referenced values to match against. + * @return a list of matching entities, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List findAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { + return select().whereRef(field, values).getResultList(); + } + + /** + * Retrieves exactly one entity based on a single field and its value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return the matching entity. + * @param the type of the field. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default E getBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Retrieves exactly one entity based on a single field and its referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return the matching entity. + * @param the type of the referenced entity. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default E getBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getSingleResult(); + } + + /** + * Retrieves a ref to an entity based on a single field and its value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return a ref to the matching entity, or empty if none exists. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getOptionalResult(); + } + + /** + * Retrieves a ref to an entity based on a single field and its referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return a ref to the matching entity, or empty if none exists. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getOptionalResult(); + } + + /** + * Retrieves refs to entities matching a single field and a single value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return a list of refs to matching entities, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getResultList(); + } + + /** + * Retrieves refs to entities matching a single field and a single referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return a list of refs to matching entities, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getResultList(); + } + + /** + * Retrieves refs to entities matching a single field against multiple values. + * + * @param field metamodel reference of the entity field. + * @param values the values to match against. + * @return a list of refs to matching entities, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Iterable values) { + return selectRef().where(field, IN, values).getResultList(); + } + + /** + * Retrieves refs to entities matching a single field against multiple referenced values. + * + * @param field metamodel reference of the entity field. + * @param values the referenced values to match against. + * @return a list of refs to matching entities, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { + return selectRef().whereRef(field, values).getResultList(); + } + + /** + * Retrieves a ref to exactly one entity based on a single field and its value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return a ref to the matching entity. + * @param the type of the field. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Ref getRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Retrieves a ref to exactly one entity based on a single field and its referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return a ref to the matching entity. + * @param the type of the referenced entity. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Ref getRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getSingleResult(); + } + + /** + * Counts entities matching the specified field and value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return the count of matching entities. + * @param the type of the field. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default long countBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectCount().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Counts entities matching the specified field and referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return the count of matching entities. + * @param the type of the referenced entity. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default long countBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectCount().where(field, value).getSingleResult(); + } + + /** + * Checks if any entity matching the specified field and value exists. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return true if any matching entities exist, false otherwise. + * @param the type of the field. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { + return countBy(field, value) > 0; + } + + /** + * Checks if any entity matching the specified field and referenced value exists. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return true if any matching entities exist, false otherwise. + * @param the type of the referenced entity. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default boolean existsBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return countBy(field, value) > 0; + } + + /** + * Removes entities matching the specified field and value. + * + * @param field metamodel reference of the entity field. + * @param value the value to match against. + * @return the number of entities removed. + * @param the type of the field. + * @throws PersistenceException if the removal operation fails due to underlying database issues. + * @since 1.12 + */ + default int removeAllBy(@Nonnull Metamodel field, @Nonnull V value) { + return delete().where(field, EQUALS, value).executeUpdate(); + } + + /** + * Removes entities matching the specified field and referenced value. + * + * @param field metamodel reference of the entity field. + * @param value the referenced value to match against. + * @return the number of entities removed. + * @param the type of the referenced entity. + * @throws PersistenceException if the removal operation fails due to underlying database issues. + * @since 1.12 + */ + default int removeAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return delete().where(field, value).executeUpdate(); + } + + /** + * Removes entities matching the specified field against multiple values. + * + * @param field metamodel reference of the entity field. + * @param values the values to match against. + * @return the number of entities removed. + * @param the type of the field. + * @throws PersistenceException if the removal operation fails due to underlying database issues. + * @since 1.12 + */ + default int removeAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { + return delete().where(field, IN, values).executeUpdate(); + } + + /** + * Removes entities matching the specified field against multiple referenced values. + * + * @param field metamodel reference of the entity field. + * @param values the referenced values to match against. + * @return the number of entities removed. + * @param the type of the referenced entity. + * @throws PersistenceException if the removal operation fails due to underlying database issues. + * @since 1.12 + */ + default int removeAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { + return delete().whereRef(field, values).executeUpdate(); + } + // Page methods. /** diff --git a/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java b/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java index a00c62aca..a5a2c5aa6 100644 --- a/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java @@ -15,6 +15,9 @@ */ package st.orm.repository; +import static st.orm.Operator.EQUALS; +import static st.orm.Operator.IN; + import jakarta.annotation.Nonnull; import java.util.List; import java.util.Optional; @@ -22,6 +25,7 @@ import st.orm.Data; import st.orm.Metamodel; import st.orm.NoResultException; +import st.orm.NonUniqueResultException; import st.orm.Page; import st.orm.Pageable; import st.orm.PersistenceException; @@ -287,6 +291,296 @@ public interface ProjectionRepository

, ID> extends Repo */ P getByRef(@Nonnull Metamodel.Key key, @Nonnull Ref value); + // Field-based finder methods. + + /** + * Retrieves a projection based on a single field and its value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return the projection matching the given field value, or empty if none exists. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional

findBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getOptionalResult(); + } + + /** + * Retrieves a projection based on a single field and its referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return the projection matching the given ref value, or empty if none exists. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional

findBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getOptionalResult(); + } + + /** + * Retrieves projections matching a single field and a single value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return a list of matching projections, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List

findAllBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getResultList(); + } + + /** + * Retrieves projections matching a single field and a single referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return a list of matching projections, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List

findAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getResultList(); + } + + /** + * Retrieves projections matching a single field against multiple values. + * + * @param field metamodel reference of the projection field. + * @param values the values to match against. + * @return a list of matching projections, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List

findAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { + return select().where(field, IN, values).getResultList(); + } + + /** + * Retrieves projections matching a single field against multiple referenced values. + * + * @param field metamodel reference of the projection field. + * @param values the referenced values to match against. + * @return a list of matching projections, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List

findAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { + return select().whereRef(field, values).getResultList(); + } + + /** + * Retrieves exactly one projection based on a single field and its value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return the matching projection. + * @param the type of the field. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default P getBy(@Nonnull Metamodel field, @Nonnull V value) { + return select().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Retrieves exactly one projection based on a single field and its referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return the matching projection. + * @param the type of the referenced entity. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default P getBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return select().where(field, value).getSingleResult(); + } + + /** + * Retrieves a ref to a projection based on a single field and its value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return a ref to the matching projection, or empty if none exists. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getOptionalResult(); + } + + /** + * Retrieves a ref to a projection based on a single field and its referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return a ref to the matching projection, or empty if none exists. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getOptionalResult(); + } + + /** + * Retrieves refs to projections matching a single field and a single value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return a list of refs to matching projections, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getResultList(); + } + + /** + * Retrieves refs to projections matching a single field and a single referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return a list of refs to matching projections, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getResultList(); + } + + /** + * Retrieves refs to projections matching a single field against multiple values. + * + * @param field metamodel reference of the projection field. + * @param values the values to match against. + * @return a list of refs to matching projections, or an empty list if none found. + * @param the type of the field. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Iterable values) { + return selectRef().where(field, IN, values).getResultList(); + } + + /** + * Retrieves refs to projections matching a single field against multiple referenced values. + * + * @param field metamodel reference of the projection field. + * @param values the referenced values to match against. + * @return a list of refs to matching projections, or an empty list if none found. + * @param the type of the referenced entity. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default List> findAllRefByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { + return selectRef().whereRef(field, values).getResultList(); + } + + /** + * Retrieves a ref to exactly one projection based on a single field and its value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return a ref to the matching projection. + * @param the type of the field. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Ref

getRefBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectRef().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Retrieves a ref to exactly one projection based on a single field and its referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return a ref to the matching projection. + * @param the type of the referenced entity. + * @throws NoResultException if there is no result. + * @throws NonUniqueResultException if more than one result. + * @throws PersistenceException if the retrieval operation fails due to underlying database issues. + * @since 1.12 + */ + default Ref

getRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectRef().where(field, value).getSingleResult(); + } + + /** + * Counts projections matching the specified field and value. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return the count of matching projections. + * @param the type of the field. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default long countBy(@Nonnull Metamodel field, @Nonnull V value) { + return selectCount().where(field, EQUALS, value).getSingleResult(); + } + + /** + * Counts projections matching the specified field and referenced value. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return the count of matching projections. + * @param the type of the referenced entity. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default long countBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return selectCount().where(field, value).getSingleResult(); + } + + /** + * Checks if any projection matching the specified field and value exists. + * + * @param field metamodel reference of the projection field. + * @param value the value to match against. + * @return true if any matching projections exist, false otherwise. + * @param the type of the field. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { + return countBy(field, value) > 0; + } + + /** + * Checks if any projection matching the specified field and referenced value exists. + * + * @param field metamodel reference of the projection field. + * @param value the referenced value to match against. + * @return true if any matching projections exist, false otherwise. + * @param the type of the referenced entity. + * @throws PersistenceException if the count operation fails due to underlying database issues. + * @since 1.12 + */ + default boolean existsBy(@Nonnull Metamodel field, @Nonnull Ref value) { + return countBy(field, value) > 0; + } + // Page methods. /** diff --git a/storm-java21/src/main/java/st/orm/template/QueryBuilder.java b/storm-java21/src/main/java/st/orm/template/QueryBuilder.java index 4c5f7dcf3..8ca0a21b2 100644 --- a/storm-java21/src/main/java/st/orm/template/QueryBuilder.java +++ b/storm-java21/src/main/java/st/orm/template/QueryBuilder.java @@ -446,7 +446,7 @@ public final QueryBuilder groupByAny(@Nonnull Metamodel... path) * @return the query builder. * @since 1.2 */ - protected abstract QueryBuilder groupBy(@Nonnull StringTemplate template); + public abstract QueryBuilder groupBy(@Nonnull StringTemplate template); /** * Adds a HAVING clause to the query using the specified expression. @@ -490,7 +490,7 @@ public final QueryBuilder havingAny(@Nonnull Metamodel path, * @return the query builder. * @since 1.2 */ - protected abstract QueryBuilder having(@Nonnull StringTemplate template); + public abstract QueryBuilder having(@Nonnull StringTemplate template); /** * Adds an ORDER BY clause to the query for the field at the specified path in the table graph. @@ -600,7 +600,7 @@ public final QueryBuilder orderByAny(@Nonnull Metamodel... path) * @return the query builder. * @since 1.2 */ - protected abstract QueryBuilder orderBy(@Nonnull StringTemplate template); + public abstract QueryBuilder orderBy(@Nonnull StringTemplate template); /** * Returns {@code true} if any ORDER BY columns have been added to this query builder. diff --git a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java index 4001c6ccb..6ae171c5e 100644 --- a/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java +++ b/storm-java21/src/main/java/st/orm/template/impl/QueryBuilderImpl.java @@ -112,7 +112,7 @@ public QueryBuilder append(@Nonnull StringTemplate template) { * @since 1.2 */ @Override - protected QueryBuilder orderBy(@Nonnull StringTemplate template) { + public QueryBuilder orderBy(@Nonnull StringTemplate template) { return new QueryBuilderImpl<>(core.orderBy(convert(template))); } @@ -125,7 +125,7 @@ protected QueryBuilder orderBy(@Nonnull StringTemplate template) { * @since 1.2 */ @Override - protected QueryBuilder groupBy(@Nonnull StringTemplate template) { + public QueryBuilder groupBy(@Nonnull StringTemplate template) { return new QueryBuilderImpl<>(core.groupBy(convert(template))); } @@ -138,7 +138,7 @@ protected QueryBuilder groupBy(@Nonnull StringTemplate template) { * @since 1.2 */ @Override - protected QueryBuilder having(@Nonnull StringTemplate template) { + public QueryBuilder having(@Nonnull StringTemplate template) { return new QueryBuilderImpl<>(core.having(convert(template))); } diff --git a/storm-java21/src/test/java/st/orm/template/RepositoryTest.java b/storm-java21/src/test/java/st/orm/template/RepositoryTest.java index c68769dad..206811b23 100644 --- a/storm-java21/src/test/java/st/orm/template/RepositoryTest.java +++ b/storm-java21/src/test/java/st/orm/template/RepositoryTest.java @@ -30,6 +30,7 @@ import st.orm.template.model.OwnerView; import st.orm.template.model.OwnerView_; import st.orm.template.model.Pet; +import st.orm.template.model.Pet_; import st.orm.template.model.Visit; @SuppressWarnings("ALL") @@ -806,4 +807,118 @@ public void testProjectionProxyFindById() { Optional view = viewRepo.findById(1); assertTrue(view.isPresent()); } + + // Field-based finder methods (default methods on EntityRepository/ProjectionRepository). + + @Test + public void testFindByField() { + var cities = orm.entity(City.class); + Optional city = cities.findBy(City_.name, "Madison"); + assertTrue(city.isPresent()); + assertEquals("Madison", city.get().name()); + assertTrue(cities.findBy(City_.name, "Nonexistent").isEmpty()); + } + + @Test + public void testGetByField() { + var cities = orm.entity(City.class); + City city = cities.getBy(City_.name, "Madison"); + assertEquals("Madison", city.name()); + } + + @Test + public void testFindAllByField() { + var cities = orm.entity(City.class); + List matches = cities.findAllBy(City_.name, "Madison"); + assertEquals(1, matches.size()); + } + + @Test + public void testFindAllByFieldMultipleValues() { + var cities = orm.entity(City.class); + List matches = cities.findAllBy(City_.name, List.of("Madison", "Monona")); + assertEquals(2, matches.size()); + } + + @Test + public void testFindAllByFieldRef() { + var pets = orm.entity(Pet.class); + List petsOfOwner = pets.findAllBy(Pet_.owner, Ref.of(Owner.class, 1)); + assertFalse(petsOfOwner.isEmpty()); + assertTrue(petsOfOwner.stream().allMatch(pet -> pet.owner().id() == 1)); + } + + @Test + public void testFindAllByRefField() { + var pets = orm.entity(Pet.class); + List matches = pets.findAllByRef(Pet_.owner, + List.of(Ref.of(Owner.class, 1), Ref.of(Owner.class, 6))); + assertEquals(3, matches.size()); + } + + @Test + public void testFindRefByField() { + var cities = orm.entity(City.class); + Optional> ref = cities.findRefBy(City_.name, "Madison"); + assertTrue(ref.isPresent()); + assertEquals("Madison", ref.get().fetch().name()); + } + + @Test + public void testFindAllRefByField() { + var pets = orm.entity(Pet.class); + List> refs = pets.findAllRefBy(Pet_.owner, Ref.of(Owner.class, 6)); + assertEquals(2, refs.size()); + } + + @Test + public void testGetRefByField() { + var cities = orm.entity(City.class); + Ref ref = cities.getRefBy(City_.name, "Madison"); + assertNotNull(ref); + assertEquals("Madison", ref.fetch().name()); + } + + @Test + public void testCountByField() { + var pets = orm.entity(Pet.class); + assertEquals(2, pets.countBy(Pet_.owner, Ref.of(Owner.class, 6))); + var cities = orm.entity(City.class); + assertEquals(1, cities.countBy(City_.name, "Madison")); + } + + @Test + public void testExistsByField() { + var cities = orm.entity(City.class); + assertTrue(cities.existsBy(City_.name, "Madison")); + assertFalse(cities.existsBy(City_.name, "Nonexistent")); + } + + @Test + public void testRemoveAllByField() { + var cities = orm.entity(City.class); + cities.insertAndFetch(new City(null, "ToRemoveByField")); + int removed = cities.removeAllBy(City_.name, "ToRemoveByField"); + assertEquals(1, removed); + assertFalse(cities.existsBy(City_.name, "ToRemoveByField")); + } + + @Test + public void testFieldFinderThroughRepositoryProxy() { + // Default interface methods must dispatch correctly through the repository proxy. + CityRepository cityRepo = orm.repository(CityRepository.class); + Optional city = cityRepo.findBy(City_.name, "Madison"); + assertTrue(city.isPresent()); + assertEquals(1, cityRepo.countBy(City_.name, "Madison")); + assertTrue(cityRepo.existsBy(City_.name, "Madison")); + } + + @Test + public void testProjectionFieldFinder() { + var ownerViews = orm.projection(OwnerView.class); + List views = ownerViews.findAllBy(OwnerView_.firstName, "George"); + assertFalse(views.isEmpty()); + assertTrue(views.stream().allMatch(view -> view.firstName().equals("George"))); + assertTrue(ownerViews.existsBy(OwnerView_.firstName, "George")); + } } diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt index 8499f888d..9a3bfb227 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt @@ -364,3 +364,87 @@ interface Query { } } } + +/** + * Execute a SELECT query and returns a single row, where the columns of the row are mapped to the constructor + * arguments of type [T]. + * + * @param T the type of the result. + * @return a single row, where the columns of the row corresponds to the order of values the list. + * @throws st.orm.NoResultException if there is no result. + * @throws st.orm.NonUniqueResultException if more than one result. + * @throws st.orm.PersistenceException if the query fails. + * @see Query.getSingleResult + * @since 1.12 + */ +inline fun Query.singleResult(): T = getSingleResult(T::class) + +/** + * Execute a SELECT query and returns a single row, where the columns of the row are mapped to the constructor + * arguments of type [T]. + * + * @param T the type of the result. + * @return a single row, where the columns of the row corresponds to the order of values the list, or `null` if there + * is no result. + * @throws st.orm.NonUniqueResultException if more than one result. + * @throws st.orm.PersistenceException if the query fails. + * @see Query.getOptionalResult + * @since 1.12 + */ +inline fun Query.optionalResult(): T? = getOptionalResult(T::class) + +/** + * Execute a SELECT query and return the resulting rows as a list of row instances. + * + * Each element in the list represents a row in the result, where the columns of the row are mapped to the + * constructor arguments of type [T]: + * ```kotlin + * val users = orm.query { """ + * SELECT ${User::class} + * FROM ${User::class} + * WHERE ${User_.city.name} = $city""" + * }.resultList() + * ``` + * + * @param T the type of the result. + * @return the result list. + * @throws st.orm.PersistenceException if the query fails. + * @see Query.getResultList + * @since 1.12 + */ +inline fun Query.resultList(): List = getResultList(T::class) + +/** + * Execute a SELECT query and return the resulting rows as a stream of row instances. + * + * Each element in the stream represents a row in the result, where the columns of the row are mapped to the + * constructor arguments of type [T]. + * + * **Note:** Calling this method does trigger the execution of the underlying query, so it should + * only be invoked when the query is intended to run. Since the stream holds resources open while in use, it must be + * closed after usage to prevent resource leaks. As the stream is `AutoCloseable`, it is recommended to use it + * within a `use` block. + * + * @param T the type of the result. + * @return a stream of results. + * @throws st.orm.PersistenceException if the query operation fails due to underlying database issues, such as + * connectivity. + * @see Query.getResultStream + * @since 1.12 + */ +inline fun Query.resultStream(): Stream = getResultStream(T::class) + +/** + * Execute a SELECT query and return the resulting rows as a flow of row instances. + * + * Each element in the flow represents a row in the result, where the columns of the row are mapped to the + * constructor arguments of type [T]. + * + * @param T the type of the result. + * @return a flow of results. + * @throws st.orm.PersistenceException if the query operation fails due to underlying database issues, such as + * connectivity. + * @see Query.getResultFlow + * @since 1.12 + */ +inline fun Query.resultFlow(): Flow = getResultFlow(T::class) diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt index e9de4b4fc..6cac7b33c 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt @@ -1189,6 +1189,51 @@ open class ORMTemplateTest( city.name shouldBe "Sun Paririe" } + // Query: reified resultList, singleResult, optionalResult, resultStream, resultFlow extensions + + @Test + fun `query resultList with reified type should return typed results`() { + val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } + val cities = query.resultList() + cities shouldHaveSize 6 + } + + @Test + fun `query resultList with reified type should work on prepared query`() { + orm.entity(City::class).select().prepare().use { query -> + val cities = query.resultList() + cities shouldHaveSize 6 + } + } + + @Test + fun `query singleResult with reified type should return typed result`() { + val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE ${t(Templates.alias(City::class))}.id = ${t(1)}" } + val city = query.singleResult() + city.name shouldBe "Sun Paririe" + } + + @Test + fun `query optionalResult with reified type should return null when no match`() { + val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)} WHERE ${t(Templates.alias(City::class))}.id = ${t(-1)}" } + val city = query.optionalResult() + city.shouldBeNull() + } + + @Test + fun `query resultStream with reified type should return typed stream`() { + val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } + val count = query.resultStream().use { it.count() } + count shouldBe 6L + } + + @Test + fun `query resultFlow with reified type should return typed flow`() { + val query = orm.query { "SELECT ${t(City::class)} FROM ${t(City::class)}" } + val cities = runBlocking { query.resultFlow().toList() } + cities shouldHaveSize 6 + } + // QueryTemplate: selectFrom variants @Test diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 9925513f5..d8cf0ff5a 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -1,20 +1,24 @@ # Storm Framework - Complete Documentation -> Storm is an AI-first ORM framework for Kotlin 2.0+ and Java 21+. +> Storm is an AI-first ORM framework for Kotlin 2.0+ and Java 21+, the gold +> standard for AI-assisted database development. +> > It uses immutable data classes and records instead of proxied entities, > providing type-safe queries, predictable performance, and zero hidden magic. > Storm works perfectly standalone, but its design and tooling make it uniquely > suited for AI-assisted development: immutable entities produce stable code, > the CLI installs per-tool skills, and a locally running MCP server exposes > only schema metadata (table definitions, column types, constraints) while -> shielding your database credentials and data from the LLM. +> shielding your database credentials and data from the LLM. Built-in +> verification (validateSchema(), SqlCapture) lets the AI validate its own work +> correct before anything is committed. > > Get started: `npx @storm-orm/cli` > Website: https://orm.st > GitHub: https://github.com/storm-orm/storm-framework > License: Apache 2.0 -# Generated: 2026-03-23T08:27:40Z +# Generated: 2026-07-01T22:23:42Z ======================================== ## Source: index.md @@ -22,9 +26,6 @@ # ST/ORM -> **Info:** -Storm's concise API, strict conventions, and absence of hidden complexity make it optimized for AI-assisted development. Combined with AI skills and secure schema access via MCP, AI coding tools generate correct Storm code consistently. Install with `npm install -g @storm-orm/cli` and run `storm init` in your project. See [AI-Assisted Development](ai.md). - **Storm** is a modern, high-performance ORM for Kotlin 2.0+ and Java 21+, built around a powerful SQL template engine. It focuses on simplicity, type safety, and predictable performance through immutable models and compile-time metadata. **Key benefits:** @@ -141,6 +142,18 @@ List users = orm.query(RAW.""" WHERE \{User_.city.name} = \{cityName} """).getResultList(User.class); ``` +## AI Assisted Development + +Storm is the ORM that AI coding assistants get right. Its stateless, immutable entities mean what you see in the source code is exactly what exists at runtime: no hidden proxies, no lazy loading surprises, no persistence context rules that trip up AI-generated code. When you ask your AI tool to write a query, define an entity, or build a repository, the output is straightforward data classes and explicit SQL, the same code a senior developer would write by hand. + +**Get started in seconds:** + +```bash +npx @storm-orm/cli +``` + +This configures your AI tool (Claude Code, Cursor, Copilot, Windsurf, or Codex) with Storm's patterns, conventions, and slash commands. See [more on ai](ai.md) for details. + ## Quick Start Storm provides a Bill of Materials (BOM) for centralized version management. Import the BOM once and omit version numbers from individual Storm dependencies. @@ -149,7 +162,7 @@ Storm provides a Bill of Materials (BOM) for centralized version management. Imp ```kotlin dependencies { - implementation(platform("st.orm:storm-bom:1.11.0")) + implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@")) implementation("st.orm:storm-kotlin") runtimeOnly("st.orm:storm-core") // Use storm-compiler-plugin-2.0 for Kotlin 2.0.x, -2.1 for 2.1.x, etc. @@ -165,7 +178,7 @@ dependencies { st.orm storm-bom - 1.11.0 + @@STORM_VERSION@@ pom import @@ -219,7 +232,8 @@ If you are a tech lead or architect evaluating Storm for a production system, th 1. [Storm vs Other Frameworks](comparison.md) -- feature-level comparison across frameworks 2. [Spring Integration](spring-integration.md) -- Spring Boot auto-configuration, repository scanning, DI -3. [Batch Processing and Streaming](batch-streaming.md) -- bulk operations and large dataset handling +3. [Ktor Integration](ktor-integration.md) -- Ktor plugin, HOCON configuration, coroutine-native transactions +4. [Batch Processing and Streaming](batch-streaming.md) -- bulk operations and large dataset handling 4. [Testing](testing.md) -- JUnit 5 integration, statement capture, and test isolation 5. [Configuration](configuration.md) -- runtime tuning, dirty checking modes, cache retention 6. [Database Dialects](dialects.md) -- database-specific optimizations @@ -228,7 +242,7 @@ If you are a tech lead or architect evaluating Storm for a production system, th Storm is focused on being a great ORM and SQL template engine. It intentionally does not include: -- **Schema migration or DDL generation.** Storm does not automatically create, alter, or drop tables at runtime. With Storm's AI integration, your coding assistant can read your database schema and generate Flyway or Liquibase migration scripts on demand. For schema versioning, use Flyway (https://flywaydb.org/) or Liquibase (https://www.liquibase.com/). +- **Schema migration or DDL generation.** Storm does not automatically create, alter, or drop tables at runtime. With Storm's [AI integration](ai.md), your coding assistant can read your database schema and generate Flyway or Liquibase migration scripts on demand. For schema versioning, use [Flyway](https://flywaydb.org/) or [Liquibase](https://www.liquibase.com/). - **Second-level cache.** Storm's entity cache is transaction-scoped and cleared on commit. For cross-transaction caching, use Spring's `@Cacheable` or a dedicated cache layer like Caffeine or Redis. - **Lazy loading proxies.** Entities are plain records with no proxies. Related entities are loaded eagerly in a single query via JOINs. For deferred loading, use [Refs](refs.md) to explicitly control when related data is fetched. @@ -236,7 +250,7 @@ Storm is focused on being a great ORM and SQL template engine. It intentionally Storm works with any JDBC-compatible database. Dialect packages provide optimized support for: -![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white) ![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white) ![Oracle](https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white) ![SQL Server](https://img.shields.io/badge/SQL_Server-CC2927?logo=microsoftsqlserver&logoColor=white) ![H2](https://img.shields.io/badge/H2-0000bb?logoColor=white) +![Oracle](https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white) ![SQL Server](https://img.shields.io/badge/SQL_Server-CC2927?logo=microsoftsqlserver&logoColor=white) ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white) ![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white) ![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white) ![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite&logoColor=white) ![H2](https://img.shields.io/badge/H2-0000bb?logoColor=white) See [Database Dialects](dialects.md) for installation and configuration details. @@ -266,7 +280,7 @@ Storm is a modern SQL Template and ORM framework for Kotlin 2.0+ and Java 21+. I Storm is built around a simple idea: your data model should be a plain value, not a framework-managed object. In Storm, entities are Kotlin data classes or Java records. They carry no hidden state, no change-tracking proxies, and no lazy-loading hooks. You can create them, pass them across layers, serialize them, compare them by value, and store them in collections without worrying about session scope, detachment, or side effects. What you see in the source code is exactly what exists at runtime. -This stateless design is a deliberate trade-off. Traditional ORMs like JPA/Hibernate give you automatic dirty checking and transparent lazy loading, but at the cost of complexity: you must reason about managed vs. detached state, proxy initialization, persistence context boundaries, and cascading rules that interact in subtle ways. Storm avoids all of this. When you call `update`, you pass the full entity. When you query a relationship, you get the result in the same query. There are no surprises. +This stateless design is a deliberate trade-off. Traditional ORMs like JPA/Hibernate give you transparent lazy loading and proxy-based dirty checking, but at the cost of complexity: you must reason about managed vs. detached state, proxy initialization, persistence context boundaries, and cascading rules that interact in subtle ways. Storm avoids all of this. It still performs dirty checking, but by comparing entity state within a transaction rather than through proxies or bytecode manipulation. When you query a relationship, you get the result in the same query. There are no surprises. Storm is also SQL-first. Rather than abstracting SQL away behind a query language (like JPQL) or a verbose criteria builder, Storm embraces SQL directly. Its SQL Template API lets you write real SQL with type-safe parameter interpolation and automatic result mapping. For common CRUD patterns, the type-safe DSL and repository interfaces provide concise, compiler-checked alternatives, but the full power of SQL is always available when you need it. @@ -278,32 +292,62 @@ The framework is organized around three core abstractions: These abstractions share a common principle: explicit behavior over implicit magic. Every query is visible in the source code. Every relationship is loaded when you ask for it. Every transaction boundary is declared, not inferred. This makes Storm applications straightforward to debug, profile, and reason about. -## How Storm Differs from JPA +## Choose Your Path + +Storm supports two ways to get started. Pick the one that fits your workflow. +[AI-Assisted] + +### AI-Assisted Setup + +If you use an AI coding tool (Claude Code, Cursor, GitHub Copilot, Windsurf, or Codex), Storm provides rules, skills, and an optional database-aware MCP server that give the AI deep knowledge of Storm's conventions. The AI can generate entities from your schema, write queries, and verify its own work against a real database. + +**1. Install the Storm CLI and run it in your project:** + +```bash +npx @storm-orm/cli init +``` + +The interactive setup configures your AI tool with Storm's rules and skills, and optionally connects it to your development database for schema-aware code generation. + +**2. Ask your AI tool to set up Storm:** + +Once `storm init` has configured your tool, you can ask it to add the right dependencies, create entities from your database tables, and write queries. The AI has access to Storm's full documentation and your database schema. -If you are coming from JPA/Hibernate, the biggest shift is moving from mutable, proxy-backed entities with a managed lifecycle to stateless, immutable values. Storm has no persistence context, no first-level cache, no `EntityManager`, and no automatic change detection. This eliminates entire categories of bugs (LazyInitializationException, detached entity errors, unexpected flush ordering) while making performance predictable. For a detailed comparison, see the [Migration from JPA](migration-from-jpa.md) guide and the [Storm vs Other Frameworks](comparison.md) feature comparison. +For example: +- "Add Storm to this project with Spring Boot and PostgreSQL" +- "Set up Storm with Ktor and PostgreSQL" +- "Create entities for the users and orders tables" +- "Write a repository method that finds orders by status with pagination" -## Step-by-Step Setup +**3. Verify:** -This guide is split into three steps. Follow them in order for the fastest path from zero to a working application. +Storm's AI workflow includes built-in verification. The AI can run `ORMTemplate.validateSchema()` to prove entities match the database and `SqlCapture` to inspect generated SQL, all in an isolated H2 test database before anything touches production. -## 1. Installation +See [AI-Assisted Development](ai.md) for the full setup guide, available skills, and MCP server configuration. + +[Manual] + +### Manual Setup + +Follow these three steps in order for the fastest path from zero to a working application. + +**1. Installation** Set up your project with the right dependencies, build flags, and optional modules. **[Go to Installation](installation.md)** -## 2. First Entity +**2. First Entity** Define your first entity, create an ORM template, and perform insert, read, update, and remove operations. **[Go to First Entity](first-entity.md)** -## 3. First Query +**3. First Query** Write custom queries, build repositories, stream results, and use the type-safe metamodel. **[Go to First Query](first-query.md)** - --- ## What's Next @@ -366,7 +410,7 @@ Storm provides a Bill of Materials (BOM) for centralized version management. Imp ```kotlin dependencies { - implementation(platform("st.orm:storm-bom:1.11.0")) + implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@")) } ``` @@ -380,7 +424,7 @@ dependencies { st.orm storm-bom - 1.11.0 + @@STORM_VERSION@@ pom import @@ -392,7 +436,7 @@ dependencies { ```kotlin dependencies { - implementation(platform("st.orm:storm-bom:1.11.0")) + implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@")) } ``` ## Add the Core Dependencies @@ -405,7 +449,7 @@ plugins { } dependencies { - implementation(platform("st.orm:storm-bom:1.11.0")) + implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@")) implementation("st.orm:storm-kotlin") runtimeOnly("st.orm:storm-core") @@ -422,7 +466,7 @@ The `storm-metamodel-ksp` dependency generates type-safe metamodel classes (e.g. ```kotlin dependencies { - implementation(platform("st.orm:storm-bom:1.11.0")) + implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@")) implementation("st.orm:storm-java21") runtimeOnly("st.orm:storm-core") @@ -485,11 +529,13 @@ Storm works with any JDBC-compatible database out of the box. Dialect modules pr | Module | Database | |--------|----------| +| `storm-oracle` | Oracle | +| `storm-mssqlserver` | SQL Server | | `storm-postgresql` | PostgreSQL | | `storm-mysql` | MySQL | | `storm-mariadb` | MariaDB | -| `storm-oracle` | Oracle | -| `storm-mssqlserver` | SQL Server | +| `storm-sqlite` | SQLite | +| `storm-h2` | H2 | ```kotlin runtimeOnly("st.orm:storm-postgresql") @@ -515,6 +561,20 @@ implementation("st.orm:storm-kotlin-spring-boot-starter") storm-spring-boot-starter ``` +### Ktor Integration + +For Ktor applications, add the Ktor plugin module. It provides a `Storm` plugin that manages the DataSource lifecycle, reads HOCON configuration, and exposes the `ORMTemplate` through extension properties on `Application`, `ApplicationCall`, and `RoutingContext`. See [Ktor Integration](ktor-integration.md) for full setup details. + +```kotlin +implementation("st.orm:storm-ktor") +``` + +For testing: + +```kotlin +testImplementation("st.orm:storm-ktor-test") +``` + ### JSON Support Storm supports storing and reading JSON-typed columns. Pick the module that matches your serialization library: @@ -536,7 +596,9 @@ storm-foundation (base interfaces) └── storm-kotlin / storm-java21 (your primary dependency) ├── storm-kotlin-spring / storm-spring (Spring Framework) │ └── storm-kotlin-spring-boot-starter / storm-spring-boot-starter - ├── dialect modules (postgresql, mysql, mariadb, oracle, mssqlserver) + ├── storm-ktor (Ktor) + │ └── storm-ktor-test (testing support) + ├── dialect modules (postgresql, mysql, mariadb, oracle, mssqlserver, sqlite, h2) └── JSON modules (jackson2, jackson3, kotlinx-serialization) ``` @@ -609,7 +671,7 @@ Storm automatically appends `_id` to foreign key column names. See [Entities](en ## Create the ORM Template -The `ORMTemplate` is the central entry point for all database operations. It is thread-safe and typically created once at application startup (or provided as a Spring bean). You can create one from a JDBC `DataSource` or `Connection`. +The `ORMTemplate` is the central entry point for all database operations. It is thread-safe and typically created once at application startup (or provided as a Spring bean). You can create one from a JDBC `DataSource`, `Connection`, or JPA `EntityManager`. [Kotlin] @@ -621,6 +683,9 @@ val orm = dataSource.orm // From a Connection val orm = connection.orm + +// From a JPA EntityManager +val orm = entityManager.orm ``` [Java] @@ -633,6 +698,9 @@ var orm = ORMTemplate.of(dataSource); // From a Connection var orm = ORMTemplate.of(connection); + +// From a JPA EntityManager +var orm = ORMTemplate.of(entityManager); ``` If you are using Spring Boot with one of the starter modules, the `ORMTemplate` bean is created automatically. See [Spring Integration](spring-integration.md) for details. @@ -683,7 +751,7 @@ The `insertAndFetch` method sends an INSERT statement, retrieves the auto-genera ```kotlin // Find by ID -val user: User? = orm.entity(User::class).findById(userId) +val user: User? = orm.entity().findById(userId) // Find by field value using the metamodel (requires storm-metamodel-processor) val user: User? = orm.find(User_.email eq "alice@example.com") @@ -857,7 +925,7 @@ interface UserRepository : EntityRepository { findAll((User_.city eq city) and (User_.name eq name)) fun streamByCity(city: City): Flow = - select { User_.city eq city }.resultFlow + select { User_.city eq city } } // Get the repository from the ORM template @@ -1229,9 +1297,7 @@ Use `NONE` when: ## Composite Primary Keys -For join tables or entities whose identity is defined by a combination of columns, wrap the key fields in a separate data class and annotate the entity field with `@PK(generation = NONE)`. Storm treats all fields in the composite key class as part of the primary key. The PK class is implicitly `@Inline` — its fields become columns in the parent table. - -**Important:** The composite PK class must contain only raw column types (`Int`, `String`, etc.). Never place `@FK`, entity types, or `Ref` inside the PK class — this causes model initialization errors. Instead, declare `@FK` fields on the entity itself with `@Persist(insertable = false, updatable = false)` so that Storm loads the related entities via JOIN without duplicating column values on insert/update. +For join tables or entities whose identity is defined by a combination of columns, wrap the key fields in a separate data class and annotate it with `@PK`. Storm treats all fields in the composite key class as part of the primary key. [Kotlin] @@ -1242,9 +1308,9 @@ data class UserRolePk( ) data class UserRole( - @PK(generation = NONE) val id: UserRolePk, - @FK @Persist(insertable = false, updatable = false) val user: User, - @FK @Persist(insertable = false, updatable = false) val role: Role + @PK val userRolePk: UserRolePk, + @FK val user: User, + @FK val role: Role ) : Entity ``` @@ -1253,9 +1319,9 @@ data class UserRole( ```java record UserRolePk(int userId, int roleId) {} -record UserRole(@PK(generation = NONE) UserRolePk id, - @Nonnull @FK @Persist(insertable = false, updatable = false) User user, - @Nonnull @FK @Persist(insertable = false, updatable = false) Role role +record UserRole(@PK UserRolePk userRolePk, + @Nonnull @FK User user, + @Nonnull @FK Role role ) implements Entity {} ``` --- @@ -1305,39 +1371,46 @@ data class User( ) : Entity ``` -For compound unique constraints spanning multiple columns, use an inline record annotated with `@UK`. When the compound key columns overlap with other fields on the entity, use `@Persist(insertable = false, updatable = false)` to prevent duplicate persistence: +[Java] + +```java +record User(@PK Integer id, + @UK String email, + String name +) implements Entity {} +``` +### Compound Unique Keys + +For compound unique constraints that need a metamodel key (e.g., for keyset pagination or type-safe lookups), use an inline record annotated with `@UK`. When the compound key columns overlap with other fields on the entity, use `@Persist(insertable = false, updatable = false)` to prevent duplicate persistence: + +[Kotlin] ```kotlin -data class UserEmailUK(val userId: Int, val email: String) +data class UserEmailUk(val userId: Int, val email: String) data class SomeEntity( @PK val id: Int = 0, @FK val user: User, val email: String, - @UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUK + @UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUk ) : Entity ``` [Java] ```java -record User(@PK Integer id, - @UK String email, - String name -) implements Entity {} -``` - -For compound unique constraints spanning multiple columns, use an inline record annotated with `@UK`. When the compound key columns overlap with other fields on the entity, use `@Persist(insertable = false, updatable = false)` to prevent duplicate persistence: - -```java -record UserEmailUK(int userId, String email) {} +record UserEmailUk(int userId, String email) {} record SomeEntity(@PK Integer id, @Nonnull @FK User user, @Nonnull String email, - @UK @Persist(insertable = false, updatable = false) UserEmailUK uniqueKey + @UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey ) implements Entity {} ``` +Compound unique constraints that do not require a metamodel key do not need to be modeled in the entity. Schema validation does not warn about unmodeled compound constraints. + +Use `@UK(constraint = false)` when the unique constraint does not exist in the database — for example, when uniqueness is enforced at the application level. + When a column is not annotated with `@UK` but becomes unique in a specific query context (for example, a GROUP BY column produces unique values in the result set), wrap the metamodel with `.key()` (Kotlin) or `Metamodel.key()` (Java) to indicate it can serve as a scrolling cursor. See [Manual Key Wrapping](metamodel.md#manual-key-wrapping) for details. --- @@ -1346,8 +1419,6 @@ When a column is not annotated with `@UK` but becomes unique in a specific query Embedded components group related fields into a reusable data class without creating a separate database table. The component's fields are stored as columns in the parent entity's table. This is useful for value objects like addresses, coordinates, or monetary amounts that appear in multiple entities. -Inlining is implicit — `@Inline` never needs to be specified explicitly on embedded component fields. Storm automatically inlines any non-entity, non-`@FK` data class or record field. When `@Inline` is used explicitly, the field must be an inline (embedded) type, not a scalar or entity. - [Kotlin] Use data classes for embedded components: @@ -2039,8 +2110,8 @@ val ids = listOf(1, 2, 3) val owners = ownerViews.findAllById(ids) // Flow-based batch fetching (lazy evaluation) -val allOwners: Flow = ownerViews.select().resultFlow -allOwners.collect { owner -> +val idFlow = flowOf(1, 2, 3, 4, 5) +ownerViews.selectById(idFlow).collect { owner -> // Process each owner } ``` @@ -2052,8 +2123,8 @@ allOwners.collect { owner -> List ids = List.of(1, 2, 3); List owners = ownerViews.findAllById(ids); -// Stream-based fetching (must close) -try (Stream stream = ownerViews.select().getResultStream()) { +// Stream-based batch fetching (must close) +try (Stream stream = ownerViews.selectById(ids.stream())) { stream.forEach(owner -> { // Process each owner }); @@ -2200,6 +2271,7 @@ Use `@DbColumn` to map fields to columns with different names. | `findAll()` | Fetch all as a list | | `findAllById(ids)` | Fetch multiple by IDs | | `select().resultFlow` | Lazy Flow of all projections | +| `selectById(ids)` | Lazy Flow by IDs | | `select()` | Query builder for filtering | | `selectCount()` | Query builder for counting | @@ -2379,8 +2451,8 @@ data class User( When you query a `User`, the related `City` is automatically loaded: ```kotlin -val user = orm.find(User_.id eq userId) -println(user?.city.name) // City is already loaded +val user = orm.get(User_.id eq userId) +println(user.city.name) // City is already loaded ``` [Java] @@ -2472,13 +2544,13 @@ data class UserRolePk( ) data class UserRole( - @PK(generation = NONE) val id: UserRolePk, + @PK val userRolePk: UserRolePk, @FK @Persist(insertable = false, updatable = false) val user: User, @FK @Persist(insertable = false, updatable = false) val role: Role ) : Entity ``` -The composite PK class contains only raw column types — never `@FK`, entity types, or `Ref`. The `@FK` fields on the entity use `@Persist(insertable = false, updatable = false)` because their columns overlap with the PK columns. The FK fields are used to load the related entities via JOIN, but the column values come from the PK during insert/update operations. +The `@Persist(insertable = false, updatable = false)` annotation indicates that the FK columns overlap with the composite PK columns. The FK fields are used to load the related entities, but the column values come from the PK during insert/update operations. Query through the join entity: @@ -2507,13 +2579,13 @@ val roles: List = orm.entity(Role::class) ```java record UserRolePk(int userId, int roleId) {} -record UserRole(@PK(generation = NONE) UserRolePk id, +record UserRole(@PK UserRolePk userRolePk, @Nonnull @FK @Persist(insertable = false, updatable = false) User user, @Nonnull @FK @Persist(insertable = false, updatable = false) Role role ) implements Entity {} ``` -The composite PK record contains only raw column types — never `@FK`, entity types, or `Ref`. The `@FK` fields on the entity use `@Persist(insertable = false, updatable = false)` because their columns overlap with the PK columns. The FK fields are used to load the related entities via JOIN, but the column values come from the PK during insert/update operations. +The `@Persist(insertable = false, updatable = false)` annotation indicates that the FK columns overlap with the composite PK columns. The FK fields are used to load the related entities, but the column values come from the PK during insert/update operations. Query through the join entity: @@ -2903,7 +2975,7 @@ EntityRepository userRepository = orm.entity(User.class); [Kotlin] -All CRUD operations use the entity's primary key (marked with `@PK`) for identity. Insert returns the entity with any database-generated fields populated (such as auto-increment IDs). Update and delete match by primary key. Query methods accept metamodel-based filter expressions that compile to parameterized WHERE clauses. +All CRUD operations use the entity's primary key (marked with `@PK`) for identity. Insert returns the entity with any database-generated fields populated (such as auto-increment IDs). Update and remove match by primary key. Query methods accept metamodel-based filter expressions that compile to parameterized WHERE clauses. ```kotlin // Create @@ -2914,7 +2986,8 @@ val user = orm insert User( ) // Read -val found: User? = orm.find(User_.id eq user.id) +val found: User? = orm.entity().findById(user.id) +val alice: User? = orm.find(User_.name eq "Alice") val all: List = orm.findAll(User_.city eq city) // Update @@ -2923,9 +2996,12 @@ orm update user.copy(name = "Alice Johnson") // Remove orm remove user -// Remove by field +// Remove by condition orm.removeBy(User_.city, city) +// Remove by predicate +orm.removeAll(User_.active eq false) + // Remove all orm.removeAll() @@ -3149,7 +3225,7 @@ The scroll methods handle ordering internally and reject explicit `orderBy()` ca [Java] -The same scrolling methods described in the Kotlin section are available on Java repositories. The `scroll` method accepts a `Scrollable` and returns a `Window` containing the page `content()`, informational `hasNext()`/`hasPrevious()` flags, and `Scrollable` navigation tokens that are always present when the window has content. +The same scrolling methods described in the Kotlin section are available on Java repositories. The `scroll` method accepts a `Scrollable` and returns a `Window` containing the page `content()`, informational `hasNext()`/`hasPrevious()` flags, and `Scrollable` navigation tokens (`next()`, `previous()`) that are always present when the window has content. ```java // First page of 20 users ordered by ID @@ -3243,11 +3319,11 @@ Refs are lightweight identifiers that carry only the record type and primary key [Kotlin] ```kotlin -// Select refs (lightweight identifiers, builder method + terminal) +// Select refs (lightweight identifiers) val refs: Flow> = userRepository.selectRef().resultFlow -// Find all by refs (convenience method, executes immediately) -val users: List = userRepository.findAllByRef(refList) +// Select by refs +val users: Flow = userRepository.selectByRef(refs) ``` [Java] @@ -3255,14 +3331,16 @@ val users: List = userRepository.findAllByRef(refList) Ref operations in Java return `Stream` objects that must be closed. Refs carry only the primary key and record type, making them suitable for batch operations where loading full records would be wasteful. ```java -// Select refs (lightweight identifiers, builder method + terminal) +// Select refs (lightweight identifiers) try (Stream> refs = userRepository.selectRef().getResultStream()) { // Process refs } -// Find all by refs (convenience method, executes immediately) +// Select by refs List> refList = ...; -List users = userRepository.findAllByRef(refList); +try (Stream users = userRepository.selectByRef(refList.stream())) { + // Process users +} ``` --- @@ -3417,34 +3495,11 @@ Storm offers three ways to query data, each suited to different complexity level | Approach | Best for | Type safety | Flexibility | |----------|----------|-------------|-------------| -| **Convenience methods** (`find`, `findAll`, `findBy`, `removeAll`, `count`, `exists`) | Simple lookups and operations | Full compile-time | Low (single-field or predicate equality) | +| **Repository `findBy`** | Simple key lookups by primary key or unique key | Full compile-time | Low (single-field equality only) | | **Query DSL** | Filtering, ordering, pagination with type-safe conditions | Full compile-time | Medium (AND/OR predicates, joins, ordering) | -| **SQL Templates** | CTEs, window functions, UNIONs, database-specific SQL | Column references checked at compile time, SQL structure at runtime | High (full SQL control) | - -Start with the simplest approach that meets your needs. Escalate only when the simpler level cannot express what you need. - -### When to use each — and when NOT to +| **SQL Templates** | Complex joins, subqueries, CTEs, window functions, database-specific SQL | Column references checked at compile time, SQL structure at runtime | High (full SQL control) | -| Need | Use (simplest) | Don't use (unnecessarily complex) | -|------|----------------|-----------------------------------| -| All rows as list | `findAll()` | `select().resultList` / `select().getResultList()` | -| Filter by single field | `findAllBy(field, value)` | `select().where(field, op, value).resultList` | -| Filter by predicate (Kotlin) | `findAll(predicate)` | `select(predicate).resultList` | -| Single result by predicate (Kotlin) | `find(predicate)` | `select(predicate).optionalResult` | -| Count by predicate (Kotlin) | `count(predicate)` | `selectCount().where(predicate).singleResult` | -| Delete by predicate (Kotlin) | `removeAll(predicate)` | `delete(predicate).executeUpdate()` | -| Delete by field | `removeAllBy(field, value)` | `delete().where(field, op, value).executeUpdate()` | -| Filtered + **ordering/pagination** | `select(predicate).orderBy(...)` | convenience methods (can't add ordering) | -| Filtered + **joins** | `select().innerJoin(...)` or block DSL | convenience methods (can't add joins) | -| Filtered + **streaming** | `select(predicate).resultFlow` / `.getResultStream()` | convenience methods (return List) | -| Aggregates, CTEs, window functions | SQL Template | QueryBuilder (can't express these) | - -### SQL Template Escalation - -SQL Templates are an escape hatch for things the QueryBuilder cannot express. Three rules: -1. **Code-first:** If it can be done with QueryBuilder methods (joins, where, orderBy, groupBy, having), do it in code. Never use a template for a WHERE clause or ORDER BY that the QueryBuilder can express. -2. **Metamodel in templates:** When you do need a template fragment (e.g., `COUNT(*)` in a SELECT clause), still use metamodel references inside it (`${User_.email}`, not `"email"`). This keeps column references type-safe and refactor-proof. -3. **Full SQL last resort:** A full `SELECT ... FROM ...` SQL template should only be used for totally custom queries (CTEs, UNIONs, window functions) that cannot be built at all with the QueryBuilder. Even then, users benefit from bind variables and metamodel references. +Start with the simplest approach that meets your needs. Use `findBy` or `findAll` for straightforward lookups. Move to the query builder when you need compound filters or pagination. Use SQL templates when you need SQL features the DSL does not cover. --- @@ -3919,7 +3974,7 @@ List cities = orm.entity(User.class) [Kotlin] -For large result sets, use `select().resultFlow` which returns a Kotlin `Flow`. Rows are fetched lazily from the database as you collect, so memory usage stays constant regardless of result set size. Flow also handles resource cleanup automatically when collection completes or is cancelled. +For large result sets, use `select().resultFlow`, which returns a Kotlin `Flow`. Rows are fetched lazily from the database as you collect, so memory usage stays constant regardless of result set size. Flow also handles resource cleanup automatically when collection completes or is cancelled. ```kotlin val users: Flow = orm.entity(User::class).select().resultFlow @@ -4215,7 +4270,7 @@ Storm provides three ways to retrieve a subset of query results. The right choic | Feature | Offset and Limit | Pagination | Scrolling | |---------|-----------------|------------|-----------| | Navigation | manual | page number | cursor | -| Result type | `List` | `Page` | `Window` | +| Result type | `List` | `Page` | `Window` | | Count query | no | yes | no | | Random access | yes | yes | no | | Navigation tokens | no | `nextPageable()` / `previousPageable()` | `next()` / `previous()` | @@ -4342,22 +4397,24 @@ For the full `Page` and `Pageable` API reference, see [Repositories: Offset-Base ## Scrolling -Scrolling navigates sequentially using a cursor and returns a `Window`. A `Window` represents a portion of the result set: it contains the data, informational flags (`hasNext`, `hasPrevious`) that indicate whether adjacent results existed at query time, and `Scrollable` navigation tokens for sequential traversal, but no total count or page number. The navigation tokens `next()` and `previous()` are always available when the window has content, regardless of whether `hasNext` or `hasPrevious` is `true`. This allows the developer to decide whether to follow a cursor, since new data may appear after the query was executed. +Scrolling navigates sequentially using a cursor and returns a `Window`. A `Window` represents a portion of the result set: it contains the data, informational flags (`hasNext`, `hasPrevious`) that indicate whether adjacent results existed at query time, and navigation tokens for sequential traversal, but no total count or page number. The typed navigation methods `next()` and `previous()` are always available when the window has content, regardless of whether `hasNext` or `hasPrevious` is `true`. This allows the developer to decide whether to follow a cursor, since new data may appear after the query was executed. Under the hood, scrolling uses keyset pagination: it remembers the last value seen on the current page and asks the database for rows after (or before) that value. This avoids the performance cliff of `OFFSET` on large tables, because the database can seek directly to the cursor position using an index. > **Info:** Scrolling requires a stable sort order. The final sort column must be unique (typically the primary key). Using a non-unique sort column like `createdAt` without a tiebreaker will produce duplicate or missing rows at page boundaries. Use the [sort overload](#sorting-by-non-unique-columns) (`Scrollable.of(key, sort, size)`) when sorting by a non-unique column. -The `scroll` method is available directly on repositories and on the query builder. It accepts a `Scrollable` that captures the cursor state and returns a `Window` containing: +The `scroll` method is available directly on repositories and on the query builder. It accepts a `Scrollable` that captures the cursor state and returns a `Window` containing: | Field / Method | Description | |-------|-------------| | `content()` | The list of results for this window. | | `hasNext()` | `true` if more results existed beyond this window at query time. | | `hasPrevious()` | `true` if this window was fetched with a cursor position (i.e., not the first page). | -| `next()` | Returns a `Scrollable` for the next window, or `null` if the window is empty. | -| `previous()` | Returns a `Scrollable` for the previous window, or `null` if the window is empty. | +| `next()` | Returns a typed `Scrollable` for the next window, or `null` if the window is empty. | +| `previous()` | Returns a typed `Scrollable` for the previous window, or `null` if the window is empty. | + +The `nextScrollable()` and `previousScrollable()` raw record component accessors also exist, returning `Scrollable`. The typed `next()` and `previous()` methods are preferred for programmatic navigation. Create a `Scrollable` using the factory methods, or obtain one from a `Window`: @@ -4486,17 +4543,17 @@ var window = postRepository.select() // You can check hasNext() if you only want to proceed when more results // were known to exist at query time, or follow the cursor unconditionally // to pick up data that may have arrived after the query. -var nextPage = window.next(); -if (nextPage != null) { - var next = postRepository.select() - .scroll(nextPage); +var next = window.next(); +if (next != null) { + var nextWindow = postRepository.select() + .scroll(next); } // Previous page -var previousPage = window.previous(); -if (previousPage != null) { +var previous = window.previous(); +if (previous != null) { var prev = postRepository.select() - .scroll(previousPage); + .scroll(previous); } ``` The `Window` carries navigation tokens (`next()`, `previous()`) that encode the cursor values internally, so the client does not need to extract cursor values manually. @@ -4544,7 +4601,9 @@ See [Manual Key Wrapping](metamodel.md#manual-key-wrapping) for more details. ### Window Type Parameters -When calling `scroll` on the query builder directly (rather than through a repository), the return type is `Window` where `R` is the result type and `T` is the entity type from the FROM clause. For entity queries where `R` and `T` are the same type, `Window` carries `Scrollable` navigation tokens. Repository convenience methods return `Window`. +`Window` is a record with a single type parameter: `R` is the result type. It provides result content, cursor-based string navigation (`nextCursor()`, `previousCursor()`), and typed `Scrollable` navigation via the generic `next()` and `previous()` convenience methods for programmatic traversal. The raw record component accessors `nextScrollable()` and `previousScrollable()` return `Scrollable`. + +The repository convenience method `scroll()` returns `Window`. The query builder `scroll()` also returns `Window`. For entity queries, `Window` carries `Scrollable` navigation tokens and the typed `next()` / `previous()` methods provide typed access. For queries where the result type differs from the entity type (for example, selecting into a data class that combines columns from multiple sources), `Window` does not carry navigation tokens because Storm cannot extract cursor values from a result type it does not know how to navigate. In this case, `next()` and `previous()` return `null` (even when the window has content), and `hasNext()` still works correctly as an informational flag. To continue scrolling, check `hasNext()` and construct the next `Scrollable` manually using cursor values from your result: @@ -4648,14 +4707,14 @@ plugins { } dependencies { - ksp("st.orm:storm-metamodel-processor:1.11.0") + ksp("st.orm:storm-metamodel-processor:@@STORM_VERSION@@") } ``` ### Gradle (Java) ```kotlin -annotationProcessor("st.orm:storm-metamodel-processor:1.11.0") +annotationProcessor("st.orm:storm-metamodel-processor:@@STORM_VERSION@@") ``` ### Maven (Java) @@ -4664,7 +4723,7 @@ annotationProcessor("st.orm:storm-metamodel-processor:1.11.0") st.orm storm-metamodel-processor - 1.11.0 + @@STORM_VERSION@@ provided ``` @@ -4983,25 +5042,25 @@ For compound unique constraints spanning multiple columns, use an inline record [Kotlin] ```kotlin -data class UserEmailUK(val userId: Int, val email: String) +data class UserEmailUk(val userId: Int, val email: String) data class SomeEntity( @PK val id: Int = 0, @FK val user: User, val email: String, - @UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUK + @UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUk ) : Entity ``` [Java] ```java -record UserEmailUK(int userId, String email) {} +record UserEmailUk(int userId, String email) {} record SomeEntity(@PK Integer id, @Nonnull @FK User user, @Nonnull String email, - @UK @Persist(insertable = false, updatable = false) UserEmailUK uniqueKey + @UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey ) implements Entity {} ``` The metamodel processor generates a `Metamodel.Key` for the compound field, which can be used for lookups and scrolling just like a single-column key. @@ -6896,6 +6955,28 @@ try (Connection connection = dataSource.getConnection()) { } ``` +### JPA EntityManager + +Storm can coexist with JPA in the same application. This is useful when migrating from JPA to Storm gradually, or when you want to use Storm for specific operations (like bulk inserts or complex queries) while keeping JPA for others. Storm can create an `ORMTemplate` directly from a JPA `EntityManager`, sharing the same underlying connection and transaction. + +```java +@Service +public class HybridService { + + @PersistenceContext + private EntityManager entityManager; + + @Transactional + public void processWithBothOrms(User user) { + // Use Storm for efficient bulk operations + var orm = ORMTemplate.of(entityManager); + orm.entity(User.class).insert(user); + + // JPA and Storm share the same transaction + entityManager.flush(); + } +} +``` --- ## Important Notes @@ -6937,7 +7018,7 @@ The starter modules provide zero-configuration setup: an `ORMTemplate` bean is c ```kotlin // Gradle (Kotlin DSL) -implementation("st.orm:storm-kotlin-spring-boot-starter:1.11.0") +implementation("st.orm:storm-kotlin-spring-boot-starter:@@STORM_VERSION@@") ``` ```xml @@ -6945,7 +7026,7 @@ implementation("st.orm:storm-kotlin-spring-boot-starter:1.11.0") st.orm storm-kotlin-spring-boot-starter - 1.11.0 + @@STORM_VERSION@@ ``` @@ -6956,13 +7037,13 @@ implementation("st.orm:storm-kotlin-spring-boot-starter:1.11.0") st.orm storm-spring-boot-starter - 1.11.0 + @@STORM_VERSION@@ ``` ```kotlin // Gradle (Kotlin DSL) -implementation("st.orm:storm-spring-boot-starter:1.11.0") +implementation("st.orm:storm-spring-boot-starter:@@STORM_VERSION@@") ``` ### Spring Integration Without Auto-Configuration @@ -6972,7 +7053,7 @@ If you prefer manual configuration, or need to customize the setup beyond what t ```kotlin // Gradle (Kotlin DSL) -implementation("st.orm:storm-kotlin-spring:1.11.0") +implementation("st.orm:storm-kotlin-spring:@@STORM_VERSION@@") ``` ```xml @@ -6980,7 +7061,7 @@ implementation("st.orm:storm-kotlin-spring:1.11.0") st.orm storm-kotlin-spring - 1.11.0 + @@STORM_VERSION@@ ``` @@ -6991,13 +7072,13 @@ implementation("st.orm:storm-kotlin-spring:1.11.0") st.orm storm-spring - 1.11.0 + @@STORM_VERSION@@ ``` ```kotlin // Gradle (Kotlin DSL) -implementation("st.orm:storm-spring:1.11.0") +implementation("st.orm:storm-spring:@@STORM_VERSION@@") ``` The Spring integration modules provide transaction integration and repository auto-discovery. They are in addition to the base `storm-kotlin` or `storm-java21` dependency. @@ -7453,6 +7534,21 @@ This gives you: - Transaction integration between Spring and Storm - Repository auto-discovery and injection +## JPA Entity Manager + +Storm can create an `ORMTemplate` from a JPA `EntityManager`, which lets you use Storm queries within existing JPA transactions and services. This is particularly useful during incremental [migration from JPA](migration-from-jpa.md), where you can convert one repository or query at a time without changing your transaction management strategy. + +```java +@PersistenceContext +private EntityManager entityManager; + +@Transactional +public void doWork() { + var orm = ORMTemplate.of(entityManager); + // Use orm alongside existing JPA code +} +``` + ## Transaction Propagation When `@EnableTransactionIntegration` is active, Storm's programmatic transactions participate in Spring's transaction propagation. This means a `transaction` or `transactionBlocking` block checks for an existing Spring-managed transaction before starting a new one. If a transaction already exists, the block joins it. If not, it creates a new independent transaction. @@ -7554,12 +7650,13 @@ Storm works with any JDBC-compatible database using standard SQL. However, datab | | Database | Dialect Package | Key Features | |---|----------|-----------------|--------------| +| ![Oracle](https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white) | Oracle | `storm-oracle` | Merge (`MERGE INTO`), sequences | +| ![SQL Server](https://img.shields.io/badge/SQL_Server-CC2927?logo=microsoftsqlserver&logoColor=white) | MS SQL Server | `storm-mssqlserver` | Merge (`MERGE INTO`), identity columns | | ![PostgreSQL](https://img.shields.io/badge/PostgreSQL-4169E1?logo=postgresql&logoColor=white) | PostgreSQL | `storm-postgresql` | Upsert (`ON CONFLICT`), JSONB, arrays | | ![MySQL](https://img.shields.io/badge/MySQL-4479A1?logo=mysql&logoColor=white) | MySQL | `storm-mysql` | Upsert (`ON DUPLICATE KEY`), JSON | | ![MariaDB](https://img.shields.io/badge/MariaDB-003545?logo=mariadb&logoColor=white) | MariaDB | `storm-mariadb` | Upsert (`ON DUPLICATE KEY`), JSON | -| ![Oracle](https://img.shields.io/badge/Oracle-F80000?logo=oracle&logoColor=white) | Oracle | `storm-oracle` | Merge (`MERGE INTO`), sequences | -| ![SQL Server](https://img.shields.io/badge/SQL_Server-CC2927?logo=microsoftsqlserver&logoColor=white) | MS SQL Server | `storm-mssqlserver` | Merge (`MERGE INTO`), identity columns | -| ![H2](https://img.shields.io/badge/H2-0000bb?logoColor=white) | H2 | Built-in | Testing and development (no extra dependency) | +| ![SQLite](https://img.shields.io/badge/SQLite-003B57?logo=sqlite&logoColor=white) | SQLite | `storm-sqlite` | Upsert (`ON CONFLICT`), file-based storage | +| ![H2](https://img.shields.io/badge/H2-0000bb?logoColor=white) | H2 | `storm-h2` | Merge (`MERGE INTO`), sequences, native UUID | ## Installation @@ -7568,11 +7665,27 @@ Add the dialect dependency for your database. Dialects are runtime-only dependen ### Maven ```xml + + + st.orm + storm-oracle + @@STORM_VERSION@@ + runtime + + + + + st.orm + storm-mssqlserver + @@STORM_VERSION@@ + runtime + + st.orm storm-postgresql - 1.11.0 + @@STORM_VERSION@@ runtime @@ -7580,7 +7693,7 @@ Add the dialect dependency for your database. Dialects are runtime-only dependen st.orm storm-mysql - 1.11.0 + @@STORM_VERSION@@ runtime @@ -7588,23 +7701,23 @@ Add the dialect dependency for your database. Dialects are runtime-only dependen st.orm storm-mariadb - 1.11.0 + @@STORM_VERSION@@ runtime - + st.orm - storm-oracle - 1.11.0 + storm-sqlite + @@STORM_VERSION@@ runtime - + st.orm - storm-mssqlserver - 1.11.0 + storm-h2 + @@STORM_VERSION@@ runtime ``` @@ -7612,39 +7725,51 @@ Add the dialect dependency for your database. Dialects are runtime-only dependen ### Gradle (Groovy DSL) ```groovy +// Oracle +runtimeOnly 'st.orm:storm-oracle:@@STORM_VERSION@@' + +// MS SQL Server +runtimeOnly 'st.orm:storm-mssqlserver:@@STORM_VERSION@@' + // PostgreSQL -runtimeOnly 'st.orm:storm-postgresql:1.11.0' +runtimeOnly 'st.orm:storm-postgresql:@@STORM_VERSION@@' // MySQL -runtimeOnly 'st.orm:storm-mysql:1.11.0' +runtimeOnly 'st.orm:storm-mysql:@@STORM_VERSION@@' // MariaDB -runtimeOnly 'st.orm:storm-mariadb:1.11.0' +runtimeOnly 'st.orm:storm-mariadb:@@STORM_VERSION@@' -// Oracle -runtimeOnly 'st.orm:storm-oracle:1.11.0' +// SQLite +runtimeOnly 'st.orm:storm-sqlite:@@STORM_VERSION@@' -// MS SQL Server -runtimeOnly 'st.orm:storm-mssqlserver:1.11.0' +// H2 +runtimeOnly 'st.orm:storm-h2:@@STORM_VERSION@@' ``` ### Gradle (Kotlin DSL) ```kotlin +// Oracle +runtimeOnly("st.orm:storm-oracle:@@STORM_VERSION@@") + +// MS SQL Server +runtimeOnly("st.orm:storm-mssqlserver:@@STORM_VERSION@@") + // PostgreSQL -runtimeOnly("st.orm:storm-postgresql:1.11.0") +runtimeOnly("st.orm:storm-postgresql:@@STORM_VERSION@@") // MySQL -runtimeOnly("st.orm:storm-mysql:1.11.0") +runtimeOnly("st.orm:storm-mysql:@@STORM_VERSION@@") // MariaDB -runtimeOnly("st.orm:storm-mariadb:1.11.0") +runtimeOnly("st.orm:storm-mariadb:@@STORM_VERSION@@") -// Oracle -runtimeOnly("st.orm:storm-oracle:1.11.0") +// SQLite +runtimeOnly("st.orm:storm-sqlite:@@STORM_VERSION@@") -// MS SQL Server -runtimeOnly("st.orm:storm-mssqlserver:1.11.0") +// H2 +runtimeOnly("st.orm:storm-h2:@@STORM_VERSION@@") ``` ## Automatic Detection @@ -7661,11 +7786,13 @@ Upsert operations are the primary reason most applications need a dialect. Witho | Database | SQL Strategy | Conflict Detection | |----------|--------------|--------------------| +| Oracle | `MERGE INTO ...` | Explicit match conditions | +| MS SQL Server | `MERGE INTO ...` | Explicit match conditions | | PostgreSQL | `INSERT ... ON CONFLICT DO UPDATE` | Targets a specific unique constraint or index | | MySQL | `INSERT ... ON DUPLICATE KEY UPDATE` | Primary key or any unique constraint | | MariaDB | `INSERT ... ON DUPLICATE KEY UPDATE` | Primary key or any unique constraint | -| Oracle | `MERGE INTO ...` | Explicit match conditions | -| MS SQL Server | `MERGE INTO ...` | Explicit match conditions | +| SQLite | `INSERT ... ON CONFLICT DO UPDATE` | Targets a specific unique constraint | +| H2 | `MERGE INTO ...` | Explicit match conditions | See [Upserts](upserts.md) for usage examples. @@ -7677,23 +7804,47 @@ PostgreSQL's JSONB and MySQL/MariaDB's JSON types are fully supported when using Beyond SQL syntax differences, databases support different native data types. Dialects handle the mapping between Kotlin/Java types and database-specific types automatically, so you can use idiomatic types in your entities without worrying about the underlying storage format. -- **PostgreSQL:** JSONB, UUID, arrays, INET, CIDR -- **MySQL/MariaDB:** JSON, TINYINT for booleans, ENUM - **Oracle:** NUMBER, CLOB, sequences for ID generation - **MS SQL Server:** NVARCHAR, UNIQUEIDENTIFIER, IDENTITY +- **PostgreSQL:** JSONB, UUID, arrays, INET, CIDR +- **MySQL/MariaDB:** JSON, TINYINT for booleans, ENUM +- **SQLite:** Dynamic typing, AUTOINCREMENT, file-based storage +- **H2:** Native UUID, sequences, ARRAY types ## Without a Dialect -Storm works without a specific dialect package by generating standard SQL. This is the typical setup during development and testing when using H2 as an in-memory database. The core framework handles entity mapping, queries, joins, transactions, streaming, dirty checking, and caching using only standard SQL. However, some features require database-specific syntax and will be unavailable without a dialect: +Storm works without a specific dialect package by generating standard SQL. The core framework handles entity mapping, queries, joins, transactions, streaming, dirty checking, and caching using only standard SQL. However, some features require database-specific syntax and will be unavailable without a dialect: - **Upsert operations** require database-specific syntax - **Database-specific optimizations** such as native pagination strategies All other features (entity mapping, queries, joins, transactions, streaming, dirty checking, and caching) work identically regardless of dialect. +## Testing with SQLite + +SQLite is a lightweight option for testing. It stores data in a single file (or in memory) and requires no server process. Add the `storm-sqlite` dialect dependency to enable SQLite-specific features like upsert support. + +[Kotlin] + +```kotlin +val dataSource = SQLiteDataSource().apply { + url = "jdbc:sqlite::memory:" +} +val orm = ORMTemplate.of(dataSource) +``` + +[Java] + +```java +var dataSource = new SQLiteDataSource(); +dataSource.setUrl("jdbc:sqlite::memory:"); +var orm = ORMTemplate.of(dataSource); +``` +Note that SQLite does not support sequences, row-level locking, or `INFORMATION_SCHEMA`. Constraint discovery uses JDBC metadata, and locking relies on SQLite's file-level locking mechanism. + ## Testing with H2 -H2 is an in-memory Java SQL database that starts instantly and requires no external processes. Storm includes built-in support for H2, making it the default choice for unit tests. Because H2 runs in-process, tests start in milliseconds and do not require Docker, network access, or database installation. +H2 is an in-memory Java SQL database that starts instantly and requires no external processes, making it the default choice for unit tests. Because H2 runs in-process, tests start in milliseconds and do not require Docker, network access, or database installation. [Kotlin] @@ -7711,7 +7862,7 @@ var dataSource = new JdbcDataSource(); dataSource.setUrl("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"); var orm = ORMTemplate.of(dataSource); ``` -No additional dialect dependency is needed for H2. This makes it easy to write fast tests that run without Docker or external databases. +For basic testing without upsert support, H2 works without any dialect dependency. To enable upsert support and other H2-specific optimizations (native UUID handling, tuple comparisons), add the `storm-h2` dialect dependency. ## Integration Testing with Real Databases @@ -7726,10 +7877,10 @@ mvn test -pl storm-postgresql ## Tips 1. **Always include the dialect** for production databases to unlock all features -2. **Use H2** for unit tests; no additional dialect needed, fast startup +2. **Use H2 or SQLite** for unit tests; add `storm-h2` or `storm-sqlite` for upsert support 3. **Dialect is runtime-only**; it doesn't affect your compile-time code or entity definitions 4. **One dialect per application**; Storm auto-detects the right dialect from your connection URL -5. **Test with both**: Use H2 for fast unit tests and the production dialect for integration tests +5. **Test with both**: Use H2/SQLite for fast unit tests and the production dialect for integration tests --- @@ -7775,14 +7926,9 @@ Add `storm-test` as a test dependency. testImplementation("st.orm:storm-test") ``` -The module uses H2 as its default in-memory database. Both the Storm H2 dialect and the H2 JDBC driver are required as test dependencies (`storm-h2` declares H2 as `provided`, so it must be added explicitly): +The module uses H2 as its default in-memory database. To use H2, add it as a test dependency if it is not already present: ```xml - - st.orm - storm-h2 - test - com.h2database h2 @@ -7838,9 +7984,9 @@ The annotation accepts the following attributes: | Attribute | Default | Description | |------------|---------------------------------|-------------------------------------------------------------------------------------------| | `scripts` | `{}` | Classpath SQL scripts to execute before tests run. Executed once per test class. | -| `url` | `""` | JDBC URL. Defaults to an H2 in-memory database with a unique name derived from the class. | -| `username` | `"sa"` | Database username. | -| `password` | `""` | Database password. | +| `url` | `""` | JDBC URL. Defaults to an H2 in-memory database with a unique name derived from the class. Ignored when a static `dataSource()` factory method is present (see [DataSource Factory Method](#datasource-factory-method)). | +| `username` | `"sa"` | Database username. Ignored when a static `dataSource()` factory method is present. | +| `password` | `""` | Database password. Ignored when a static `dataSource()` factory method is present. | ### Parameter Injection @@ -7913,7 +8059,9 @@ class ItemRepositoryTest { ``` ### Using a Custom Database -By default, `@StormTest` creates an H2 in-memory database. To test against a different database, specify a JDBC URL: +By default, `@StormTest` creates an H2 in-memory database. This works well for dialect-agnostic logic, but H2 has its own SQL dialect. If your schema scripts or queries use database-specific syntax (for example, PostgreSQL's `SERIAL` type, MySQL's `AUTO_INCREMENT`, or Oracle's sequence syntax), they will not run against H2. In these cases, you need to test against the actual target database. + +To point `@StormTest` at a different database, specify a JDBC URL. Storm auto-detects the correct `SqlDialect` from the URL: ```java @StormTest( @@ -7927,6 +8075,74 @@ class PostgresTest { } ``` +This requires a running database instance at the given URL. For local development you can start one manually (the dialect modules include `docker-compose.yml` files as a reference), but for automated and CI testing, [Testcontainers](https://testcontainers.com/) is the recommended approach. Testcontainers starts a disposable Docker container before the test and tears it down afterwards, so tests remain self-contained and reproducible. + +### DataSource Factory Method + +Since `@StormTest` takes its URL as a compile-time annotation attribute, it cannot receive the dynamic URL that Testcontainers assigns at runtime. To solve this, define a static `dataSource()` method on the test class. When `StormExtension` finds this method, it uses the returned `DataSource` instead of creating one from the annotation's `url`, `username`, and `password` attributes. SQL scripts still execute against the returned `DataSource`, and all parameter injection (including `ORMTemplate`, `SqlCapture`, and `DataSource`) works as usual. + +[Kotlin] + +```kotlin +@StormTest(scripts = ["/schema-postgres.sql", "/data.sql"]) +@Testcontainers +class PostgresTest { + + companion object { + @Container + val postgres = PostgreSQLContainer("postgres:latest") + .withDatabaseName("test") + .withUsername("test") + .withPassword("test") + + @JvmStatic + fun dataSource(): DataSource { + val dataSource = PGSimpleDataSource() + dataSource.setUrl(postgres.jdbcUrl) + dataSource.user = postgres.username + dataSource.password = postgres.password + return dataSource + } + } + + @Test + fun `should use PostgreSQL dialect`(orm: ORMTemplate) { + // orm is connected to the Testcontainers PostgreSQL instance, + // scripts have been executed, and parameter injection works as usual. + } +} +``` + +[Java] + +```java +@StormTest(scripts = {"/schema-postgres.sql", "/data.sql"}) +@Testcontainers +class PostgresTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:latest") + .withDatabaseName("test") + .withUsername("test") + .withPassword("test"); + + static DataSource dataSource() { + var dataSource = new PGSimpleDataSource(); + dataSource.setUrl(postgres.getJdbcUrl()); + dataSource.setUser(postgres.getUsername()); + dataSource.setPassword(postgres.getPassword()); + return dataSource; + } + + @Test + void shouldUsePostgreSQLDialect(ORMTemplate orm) { + // orm is connected to the Testcontainers PostgreSQL instance, + // scripts have been executed, and parameter injection works as usual. + } +} +``` +The factory method must be static, take no arguments, and return a `DataSource`. Kotlin companion object methods are also supported. + --- ## Statement Capture @@ -8133,6 +8349,49 @@ class QueryCountTest { --- +## Ktor Testing + +The `storm-ktor-test` module provides a `testStormApplication` function that combines Storm's H2 setup with Ktor's `testApplication` builder. It creates an in-memory database, executes SQL scripts, and exposes a `StormTestScope` with `stormDataSource`, `stormOrm`, and `stormSqlCapture`. + +```kotlin +@Test +fun `GET users returns list`() = testStormApplication( + scripts = listOf("/schema.sql", "/data.sql"), +) { scope -> + application { + install(Storm) { dataSource = scope.stormDataSource } + routing { userRoutes() } + } + + client.get("/users").apply { + assertEquals(HttpStatusCode.OK, status) + } +} +``` + +You can also combine the existing `@StormTest` annotation with Ktor's `testApplication` for a more concise setup: + +```kotlin +@StormTest(scripts = ["/schema.sql", "/data.sql"]) +class UserRouteTest { + + @Test + fun `users endpoint returns data`(dataSource: DataSource) = testApplication { + application { + install(Storm) { this.dataSource = dataSource } + routing { userRoutes() } + } + client.get("/users").apply { + assertEquals(HttpStatusCode.OK, status) + } + } +} +``` + +See [Ktor Integration](ktor-integration.md#testing) for more details. + +--- + ## Tips 1. **Keep SQL scripts small and focused.** Each test class should set up only the tables and data it needs. This keeps tests fast and independent. @@ -8470,12 +8729,12 @@ Works with both Kotlin and Java projects. Two variants are available, matching t st.orm storm-jackson2 - 1.11.0 + @@STORM_VERSION@@ ``` ```groovy -implementation 'st.orm:storm-jackson2:1.11.0' +implementation 'st.orm:storm-jackson2:@@STORM_VERSION@@' ``` **Jackson 3** (requires Jackson 3.0+): @@ -8484,12 +8743,12 @@ implementation 'st.orm:storm-jackson2:1.11.0' st.orm storm-jackson3 - 1.11.0 + @@STORM_VERSION@@ ``` ```groovy -implementation 'st.orm:storm-jackson3:1.11.0' +implementation 'st.orm:storm-jackson3:@@STORM_VERSION@@' ``` The two modules are mutually exclusive on the classpath. Both provide the same public API (`st.orm.jackson` package), so switching between them requires only changing the Maven dependency. @@ -8504,7 +8763,7 @@ plugins { } dependencies { - implementation("st.orm:storm-kotlinx-serialization:1.11.0") + implementation("st.orm:storm-kotlinx-serialization:@@STORM_VERSION@@") } ``` @@ -10845,7 +11104,7 @@ By default, warnings (type narrowing and nullability mismatches) do not cause va [Kotlin] ```kotlin -val config = StormConfig.of(mapOf("storm.validation.strict" to "true")) +val config = StormConfig.of(mapOf(VALIDATION_STRICT to "true")) val orm = ORMTemplate.of(dataSource, config) orm.validateSchemaOrThrow() // Warnings now cause failure ``` @@ -10853,7 +11112,7 @@ orm.validateSchemaOrThrow() // Warnings now cause failure [Java] ```java -var config = StormConfig.of(Map.of("storm.validation.strict", "true")); +var config = StormConfig.of(Map.of(VALIDATION_STRICT, "true")); var orm = ORMTemplate.of(dataSource, config); orm.validateSchemaOrThrow(); // Warnings now cause failure ``` @@ -11311,16 +11570,19 @@ Storm does not implement upsert logic in application code. Instead, it delegates | Database | SQL Strategy | Conflict Detection | |----------|--------------|--------------------| -| PostgreSQL | `INSERT ... ON CONFLICT DO UPDATE` | Targets a specific unique constraint or index | -| MySQL/MariaDB | `INSERT ... ON DUPLICATE KEY UPDATE` | Primary key or any unique constraint | | Oracle | `MERGE INTO ...` | Explicit match conditions | | MS SQL Server | `MERGE INTO ...` | Explicit match conditions | +| PostgreSQL | `INSERT ... ON CONFLICT DO UPDATE` | Targets a specific unique constraint or index | +| MySQL/MariaDB | `INSERT ... ON DUPLICATE KEY UPDATE` | Primary key or any unique constraint | +| SQLite | `INSERT ... ON CONFLICT DO UPDATE` | Targets a specific unique constraint | +| H2 | `MERGE INTO ...` | Explicit match conditions | ### Database-Specific Behavior +- **Oracle**, **MS SQL Server**, and **H2** define upsert behavior through explicit match conditions in the `MERGE` statement, giving you control over how conflicts are detected. - **PostgreSQL** upserts target a specific conflict source (a unique constraint or index), making conflict resolution explicit and predictable. This is the most granular approach. - **MySQL/MariaDB** upserts trigger the update branch when an insert would violate the primary key **or any unique constraint**. When multiple unique constraints exist, the database decides which conflict applies. Be aware of this if your table has multiple unique constraints. -- **Oracle** and **MS SQL Server** define upsert behavior through explicit match conditions in the `MERGE` statement, giving you control over how conflicts are detected. +- **SQLite** uses the same `ON CONFLICT` syntax as PostgreSQL, targeting a specific unique constraint (available since SQLite 3.24). ## Failure Modes @@ -11329,9 +11591,10 @@ Understanding how upserts fail helps you diagnose issues quickly and design your **Missing dialect dependency:** Upsert requires a database-specific dialect module (e.g., `storm-postgresql`, `storm-mysql`). If no dialect is on the classpath, Storm throws an `UnsupportedOperationException` at runtime when you call `upsert()`. The error message indicates that the current dialect does not support upsert operations. Add the appropriate dialect dependency to resolve this. See [Dialects](dialects.md) for the full list. **Missing unique constraint:** Upsert relies on database-level unique constraints to detect conflicts. If the table has no unique constraint (or the constraint does not cover the fields you expect), the behavior depends on the database: +- **Oracle/MS SQL Server/H2:** The `MERGE` statement's match condition determines conflict detection. If the match condition references columns without a unique constraint, concurrent upserts may produce duplicates. - **PostgreSQL:** The `ON CONFLICT` clause references a specific constraint. If the constraint does not exist, the database returns a SQL error. - **MySQL/MariaDB:** Without any unique constraint, every row is treated as a new insert. No update branch is triggered, and duplicates accumulate silently. -- **Oracle/MS SQL Server:** The `MERGE` statement's match condition determines conflict detection. If the match condition references columns without a unique constraint, concurrent upserts may produce duplicates. +- **SQLite:** Behaves similarly to PostgreSQL. The `ON CONFLICT` clause references a specific constraint. In all cases, Storm does **not** fall back to a plain insert. It always generates the upsert SQL for the configured dialect. If the SQL fails at the database level, the exception propagates to the caller. @@ -11480,7 +11743,7 @@ val pets = orm.query { """ SELECT ${PetWithOwner::class} FROM ${PetWithOwner::class} WHERE ${Owner_.city} = $city -""" }.getResultList(PetWithOwner::class) +""" }.resultList() ``` [Java] @@ -11785,7 +12048,7 @@ val users = orm.query { """ FROM ${User::class} WHERE ${User_.city.country.code} = ${"US"} AND ${User_.email} LIKE ${"%@example.com"} -""" }.getResultList(User::class) +""" }.resultList() ``` [Java] @@ -11925,7 +12188,7 @@ Add the Storm compiler plugin to your Kotlin compiler configuration. The plugin | 2.3.x | `storm-compiler-plugin-2.3` | | 2.4.x | `storm-compiler-plugin-2.4` | -The artifact version matches the Storm version (e.g., `1.11.0`). +The artifact version matches the Storm version (e.g., `@@STORM_VERSION@@`). [Gradle (Kotlin DSL)] @@ -12212,7 +12475,7 @@ val sales = orm.query(""" SELECT DATE_TRUNC('month', order_date), COUNT(*), SUM(amount) FROM orders GROUP BY DATE_TRUNC('month', order_date) -""").getResultList(MonthlySales::class) +""").resultList() ``` [Java] @@ -13131,7 +13394,7 @@ data class User( Globally via `StormConfig` or system property: ```kotlin -val config = StormConfig.of(mapOf("storm.update.dirty_check" to "VALUE")) +val config = StormConfig.of(mapOf(UPDATE_DIRTY_CHECK to "VALUE")) ``` ```bash @@ -13182,7 +13445,7 @@ Storm enforces a **maximum number of UPDATE shapes per entity**. Once this limit **Configure via `StormConfig` or system property:** ```kotlin -val config = StormConfig.of(mapOf("storm.update.max_shapes" to "10")) +val config = StormConfig.of(mapOf(UPDATE_MAX_SHAPES to "10")) ``` ```bash @@ -13220,9 +13483,9 @@ For cache retention settings, see [Entity Cache Configuration](entity-cache.md#c ```kotlin // Via StormConfig val config = StormConfig.of(mapOf( - "storm.update.default_mode" to "FIELD", - "storm.update.dirty_check" to "VALUE", - "storm.update.max_shapes" to "10" + UPDATE_DEFAULT_MODE to "FIELD", + UPDATE_DIRTY_CHECK to "VALUE", + UPDATE_MAX_SHAPES to "10" )) val orm = ORMTemplate.of(dataSource, config) ``` @@ -13534,6 +13797,7 @@ transaction { This applies to: - `findById()` / `getById()` - `findByRef()` / `getByRef()` +- `selectById()` / `selectByRef()` ### Ref Resolution @@ -13663,7 +13927,7 @@ The `default` mode retains entities for the duration of the transaction, which p Configure retention behavior via `StormConfig` or system property: ```kotlin -val config = StormConfig.of(mapOf("storm.entity_cache.retention" to "light")) +val config = StormConfig.of(mapOf(ENTITY_CACHE_RETENTION to "light")) val orm = ORMTemplate.of(dataSource, config) ``` @@ -13994,7 +14258,7 @@ Cursor strings carry a page size that is validated during deserialization. The m # Configuration -Storm can be configured through `StormConfig`, system properties, or Spring Boot's `application.yml`. These properties control runtime behavior for features like dirty checking and entity caching. All properties have sensible defaults, so **configuration is optional**. Storm works out of the box without any configuration. +Storm can be configured through `StormConfig`, system properties, Spring Boot's `application.yml`, or Ktor's `application.conf`. These properties control runtime behavior for features like dirty checking and entity caching. All properties have sensible defaults, so **configuration is optional**. Storm works out of the box without any configuration. --- @@ -14008,7 +14272,7 @@ Storm can be configured through `StormConfig`, system properties, or Spring Boot | `storm.entity_cache.retention` | `default` | Cache retention mode: `default` or `light` | | `storm.template_cache.size` | `2048` | Maximum number of compiled templates to cache | | `storm.validation.record_mode` | `fail` | Record validation mode: `fail`, `warn`, or `none` | -| `storm.validation.schema_mode` | `none` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot only) | +| `storm.validation.schema_mode` | `none` | Schema validation mode: `none`, `warn`, or `fail` (Spring Boot and Ktor) | | `storm.validation.strict` | `false` | Treat schema validation warnings as errors | | `storm.validation.interpolation_mode` | `warn` | Interpolation safety mode: `warn`, `fail`, or `none` (see [Interpolation Safety](#interpolation-safety)) | | `st.orm.scrollable.maxSize` | `1000` | Maximum window size allowed in a serialized cursor (system property only) | @@ -14034,9 +14298,9 @@ java -Dstorm.update.default_mode=FIELD \ ```kotlin val config = StormConfig.of(mapOf( - "storm.update.default_mode" to "FIELD", - "storm.entity_cache.retention" to "light", - "storm.template_cache.size" to "4096" + UPDATE_DEFAULT_MODE to "FIELD", + ENTITY_CACHE_RETENTION to "light", + TEMPLATE_CACHE_SIZE to "4096" )) val orm = ORMTemplate.of(dataSource, config) @@ -14049,9 +14313,9 @@ val orm = dataSource.orm(config) ```java var config = StormConfig.of(Map.of( - "storm.update.default_mode", "FIELD", - "storm.entity_cache.retention", "light", - "storm.template_cache.size", "4096" + UPDATE_DEFAULT_MODE, "FIELD", + ENTITY_CACHE_RETENTION, "light", + TEMPLATE_CACHE_SIZE, "4096" )); var orm = ORMTemplate.of(dataSource, config); @@ -14079,6 +14343,32 @@ storm: The Spring Boot Starter binds these properties and builds a `StormConfig` that is passed to the `ORMTemplate` factory. Values not set in YAML fall back to system properties and then to built-in defaults. See [Spring Integration](spring-integration.md#configuration-via-applicationyml) for details. +**In Ktor's `application.conf`** (requires `storm-ktor`): + +```hocon +storm { + ansiEscaping = false + update { + defaultMode = "ENTITY" + dirtyCheck = "INSTANCE" + maxShapes = 5 + } + entityCache { + retention = "default" + } + templateCache { + size = 2048 + } + validation { + recordMode = "fail" + schemaMode = "none" + strict = false + } +} +``` + +The Storm Ktor plugin reads these properties and builds a `StormConfig` that is passed to the `ORMTemplate` factory. HOCON supports environment variable substitution with `${?VAR_NAME}` syntax. See [Ktor Integration](ktor-integration.md#configuration) for details. + --- ## ORMTemplate Factory Overloads @@ -14522,7 +14812,7 @@ Controls whether record (structural) validation runs when Storm first encounters | `warn` | Errors are logged as warnings; startup continues. | | `none` | Record validation is skipped entirely. | -### storm.validation.schema_mode +### storm.validation.schema_mode {#schema-validation} Controls whether schema validation runs at startup (Spring Boot only; for programmatic use, see [Validation](validation.md#programmatic-api)). @@ -15111,8 +15401,8 @@ When using SQL templates directly, embedded values are also parameterized: ```kotlin // Both 'status' and 'minAge' become JDBC parameters. -val users = orm.query("SELECT * FROM user WHERE status = $status AND age > $minAge") - .getResultList(User::class) +val users = orm.query { "SELECT * FROM user WHERE status = $status AND age > $minAge" } + .resultList() ``` [Java] @@ -15516,15 +15806,15 @@ When you call `getSingleResult()` on a query that returns zero rows, Storm throw ```kotlin // Throws NoResultException if no user has this email. -val user = orm.entity(User::class).select(User_.email eq "nobody@example.com").getSingleResult() +val user = orm.entity(User::class).select(User_.email eq "nobody@example.com").singleResult ``` -To handle the missing-result case without exceptions, use `getOptionalResult()`: +To handle the missing-result case without exceptions, use `optionalResult`: ```kotlin val user: User? = orm.entity(User::class) .select(User_.email eq "nobody@example.com") - .getOptionalResult(User::class) + .optionalResult ``` Or use the repository's `findById` method: @@ -15964,7 +16254,7 @@ Or configure programmatically: ```java StormConfig config = StormConfig.of(Map.of( - "storm.template_cache.size", "4096" + TEMPLATE_CACHE_SIZE, "4096" )); ORMTemplate orm = ORMTemplate.of(dataSource, config); ``` @@ -16988,18 +17278,18 @@ The following frameworks are Kotlin-only. Storm supports both Kotlin and Java. Exposed is JetBrains' official Kotlin database framework. It offers two APIs: a DSL that mirrors SQL syntax and a DAO layer for ORM-style access. Exposed defines tables as Kotlin objects rather than annotations on data classes. Storm and Exposed share the goal of idiomatic Kotlin database access but differ in entity design (mutable DAO entities vs. immutable data classes) and relationship loading strategy (lazy references vs. eager single-query loading). -| Aspect | Exposed | Storm | -|--------|---------|--------------------------------------| -| **Language** | Kotlin only | Kotlin + Java | -| **Polymorphism** | No | Sealed types (Single-Table, Joined, Polymorphic FK) | -| **APIs** | DSL (SQL) + DAO (ORM) | Unified ORM + SQL Templates | -| **Table Definition** | DSL objects (`object Users : Table()`) | Annotations on data classes | -| **Entities (DAO)** | Mutable, extend `Entity` class | Immutable data classes (Kotlin) / records (Java) | -| **Relationships** | Lazy references, manual loading | Loading in single query | -| **N+1 Problem** | Possible with DAO | Prevented by design; requires explicit opt-in | -| **Coroutines** | Supported (added later) | First-class from the start | -| **Type Safety** | Column references | Metamodel DSL | -| **Transactions** | `transaction {}` block, declarative via Spring module | Optional, programmatic + declarative | +| Aspect | Storm | Exposed | +| --- | --- | --- | +| **Language** | Kotlin + Java | Kotlin only | +| **Polymorphism** | Sealed types (Single-Table, Joined, Polymorphic FK) | No | +| **APIs** | Unified ORM + SQL Templates | DSL (SQL) + DAO (ORM) | +| **Table Definition** | Annotations on data classes | DSL objects (`object Users : Table()`) | +| **Entities (DAO)** | Immutable data classes (Kotlin) / records (Java) | Mutable, extend `Entity` class | +| **Relationships** | Loading in single query | Lazy references, manual loading | +| **N+1 Problem** | Prevented by design; requires explicit opt-in | Possible with DAO | +| **Coroutines** | First-class from the start | Supported (added later) | +| **Type Safety** | Metamodel DSL | Column references | +| **Transactions** | Optional, programmatic + declarative | `transaction {}` block, declarative via Spring module | #### Transaction Propagation @@ -17090,7 +17380,6 @@ Storm does not include schema management or migration utilities. Schema manageme ### When to Choose Exposed -- You're building a Kotlin-only project - You prefer DSL-based table definitions - You want to switch between SQL DSL and DAO styles - You like the JetBrains ecosystem integration @@ -17103,19 +17392,19 @@ Storm does not include schema management or migration utilities. Schema manageme Ktorm is a lightweight Kotlin ORM that uses entity interfaces and DSL-based table definitions. It requires no code generation and has minimal dependencies. Storm differs primarily in its use of immutable data classes (instead of mutable interfaces), automatic relationship loading, and optional metamodel generation for compile-time type safety. -| Aspect | Ktorm | Storm | -|--------|-------|-------| -| **Language** | Kotlin only | Kotlin + Java | -| **Polymorphism** | No | Sealed types (Single-Table, Joined, Polymorphic FK) | -| **Entities** | Interfaces extending `Entity` | Data classes with annotations | -| **Table Definition** | DSL objects (`object Users : Table`) | Annotations on data classes | -| **Query Style** | Sequence API, DSL | ORM DSL + SQL Templates | -| **Relationships** | References, manual loading | Automatic loading | -| **N+1 Problem** | Possible | Prevented by design; requires explicit opt-in | -| **Code Generation** | None required | Optional metamodel | -| **Immutability** | Mutable entity interfaces | Immutable data classes | -| **Coroutines** | Limited | First-class support | -| **Transactions** | `useTransaction {}` block | Programmatic + `@Transactional` (Spring) | +| Aspect | Storm | Ktorm | +| --- | --- | --- | +| **Language** | Kotlin + Java | Kotlin only | +| **Polymorphism** | Sealed types (Single-Table, Joined, Polymorphic FK) | No | +| **Entities** | Data classes with annotations | Interfaces extending `Entity` | +| **Table Definition** | Annotations on data classes | DSL objects (`object Users : Table`) | +| **Query Style** | ORM DSL + SQL Templates | Sequence API, DSL | +| **Relationships** | Automatic loading | References, manual loading | +| **N+1 Problem** | Prevented by design; requires explicit opt-in | Possible | +| **Code Generation** | Optional metamodel | None required | +| **Immutability** | Immutable data classes | Mutable entity interfaces | +| **Coroutines** | First-class support | Limited | +| **Transactions** | Programmatic + `@Transactional` (Spring) | `useTransaction {}` block | ### When to Choose Storm @@ -17127,7 +17416,6 @@ Ktorm is a lightweight Kotlin ORM that uses entity interfaces and DSL-based tabl ### When to Choose Ktorm -- You're building a Kotlin-only project - You prefer no code generation - You like the Sequence API style - You prefer DSL-based table definitions @@ -17221,7 +17509,7 @@ Storm never issues DDL statements (CREATE TABLE, ALTER TABLE, DROP TABLE). It re ### No Lazy-Loading Proxies -Storm does not use bytecode manipulation or runtime proxies to intercept field access. This eliminates `LazyInitializationException`, hidden database queries, and session-dependent entity behavior. Relationships declared with `@FK` are loaded eagerly in a single query. When you need deferred loading (for example, a rarely-accessed large sub-graph), use `Ref` to make the database access explicit and intentional. See [Entities: Deferred Loading](entities.md#deferred-loading-with-ref) for details. +Storm does not use bytecode manipulation or runtime proxies to intercept field access. This eliminates `LazyInitializationException`, hidden database queries, and session-dependent entity behavior. Relationships declared with `@FK` are loaded eagerly in a single query. When you need deferred loading (for example, a rarely-accessed large sub-graph), use `Ref` to make the database access explicit and intentional. See [Refs: Deferred Loading](refs.md#deferred-loading) for details. ### No Second-Level Cache @@ -18404,7 +18692,7 @@ A plain data class or record (without implementing `Entity`) that is embedded wi A set of companion classes (e.g., `User_`, `City_`) generated at compile time by Storm's KSP processor (Kotlin) or annotation processor (Java). The metamodel provides type-safe references to entity fields for use in queries, predicates, and ordering. See [Metamodel](metamodel.md). **ORM Template** -The central entry point for all Storm database operations (`ORMTemplate`). Created from a JDBC `DataSource` or `Connection`, it is thread-safe and typically instantiated once at application startup. It provides access to entity repositories, query builders, and SQL template execution. See [First Entity](first-entity.md#create-the-orm-template). +The central entry point for all Storm database operations (`ORMTemplate`). Created from a JDBC `DataSource`, `Connection`, or JPA `EntityManager`, it is thread-safe and typically instantiated once at application startup. It provides access to entity repositories, query builders, and SQL template execution. See [First Entity](first-entity.md#create-the-orm-template). **Projection** A read-only data class or record that implements the `Projection` interface. Projections represent database views or complex query results defined via `@ProjectionQuery`. Unlike entities, projections only support read operations. See [Projections](projections.md). @@ -18436,9 +18724,10 @@ A window of query results from a scrolling operation. A `Window` contains the ======================================== # AI-Assisted Development -Storm is an AI-first ORM. It works perfectly standalone, but its design and tooling make it uniquely suited for AI-assisted development. Immutable entities produce stable, predictable code. Skills guide AI tools through entity creation, queries, repositories, and migrations. A locally running MCP server exposes only schema metadata (table definitions, column types, constraints) while shielding your database credentials and data from the LLM. +Storm is an AI-first ORM. Entities are plain Kotlin data classes or Java records. Queries are explicit SQL. Built-in verification lets AI validate its own work before anything touches production. -Traditional ORMs carry hidden complexity (proxies, lazy loading, persistence contexts, cascading rules) that AI tools struggle to reason about. Storm eliminates all of that: entities are plain data classes, queries are explicit SQL, and what you see in the source code is exactly what happens at runtime. +> **Info:** +Storm keeps you in control. `ORMTemplate.validateSchema()` validates that entities match the database. `SqlCapture` validates that queries match the intent. `@StormTest` runs both checks in an isolated in-memory database before anything reaches production. The AI generates code, then Storm verifies it. That is what AI-first means here. --- @@ -18461,59 +18750,30 @@ The interactive setup walks you through three steps: ### 1. Select AI tools -Choose which AI coding tools to configure. Storm supports: - -| Tool | Rules | Skills | MCP | -|------|-------|--------|-----| -| Claude Code | CLAUDE.md (optional) | .claude/skills/ | .mcp.json | -| Cursor | - | .cursor/rules/ | .cursor/mcp.json | -| GitHub Copilot | .github/copilot-instructions.md | .github/instructions/ | (tool-dependent) | -| Windsurf | - | .windsurf/rules/ | (manual config) | -| Codex | AGENTS.md | - | (experimental) | - -The MCP server configuration file location depends on the AI tool. +Choose which AI coding tools you use. Storm configures each one with rules, skills, and (optionally) a database-aware MCP server. You can select multiple tools if your team uses different editors. Storm currently supports Claude Code, Cursor, GitHub Copilot, Windsurf, and Codex. Each tool stores its configuration in a different location, but the content is the same: Storm's conventions, entity rules, query patterns, and verification guidelines. See [AI Tools Reference](ai-reference.md) for the full list of configuration locations. ### 2. Rules and skills -For each selected tool, Storm installs: +For each selected tool, Storm installs two types of AI context: -- **Rules**: A project-level configuration file with Storm's key patterns and conventions. -- **Skills**: Per-topic guides fetched from orm.st that help the AI tool with specific tasks. Skills can be updated automatically on each run without requiring a CLI update. +**Rules** are a project-level configuration file that is always loaded by the AI tool. They contain Storm's key patterns, naming conventions, and critical constraints (immutable QueryBuilder, no collection fields on entities, `Ref` for circular references, etc.). The rules ensure the AI follows Storm's conventions in every interaction, without you having to repeat them. -Available skills: - -| Skill | Purpose | -|-------|---------| -| storm-docs | Load full Storm documentation | -| storm-entity-kotlin | Create Kotlin entities | -| storm-entity-java | Create Java entities | -| storm-repository-kotlin | Write Kotlin repositories | -| storm-repository-java | Write Java repositories | -| storm-query-kotlin | Kotlin QueryBuilder queries | -| storm-query-java | Java QueryBuilder queries | -| storm-sql-kotlin | Kotlin SQL Templates | -| storm-sql-java | Java SQL Templates | -| storm-migration | Write Flyway/Liquibase migration SQL | +**Skills** are per-topic guides that the AI loads on demand when working on a specific task. Each skill contains focused instructions, code examples, and common pitfalls for one area of Storm (entities, queries, repositories, migrations, JSON, serialization, and more). Skills are fetched from orm.st during setup and can be updated automatically on each run without requiring a CLI update. See [AI Tools Reference](ai-reference.md#skills) for the full list. ### 3. Database connection (optional) If you have a local development database running, Storm can set up a schema-aware MCP server. This gives your AI tool access to your actual database structure (table definitions, column types, foreign keys) without exposing credentials or data. -The MCP server: -- Runs locally on your machine -- Exposes only schema metadata, never actual data -- Stores credentials in `~/.storm/` (outside your project, outside the LLM's reach) -- Supports PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, SQLite, and H2 +The MCP server runs locally on your machine, exposes only schema metadata by default, and stores credentials in `~/.storm/` (outside your project, outside the LLM's reach). It supports PostgreSQL, MySQL, MariaDB, Oracle, SQL Server, SQLite, and H2. You can connect multiple databases to a single project, even across different database types. -With the database connected, three additional skills become available: +Optionally, you can enable read-only data access per connection. This lets the AI query individual records to inform type decisions — for example, recognizing that a `VARCHAR` column contains enum-like values, or that a `TEXT` column stores JSON. Data access is disabled by default because it means actual data from your database flows through the AI's context. When enabled, the database connection is read-only (enforced at both the application and database driver level), and the AI cannot write, modify, or delete data. See [Database Connections & MCP — Security](database-and-mcp.md#security) for the full details. -| Skill | Purpose | -|-------|---------| -| storm-schema | Inspect your live database schema | -| storm-validate | Compare entities against the live schema | -| storm-entity-from-schema | Generate, update, or refactor entities from database tables | +With the database connected, three additional skills become available for schema inspection, entity validation against the live schema, and entity generation from database tables. See [AI Tools Reference](ai-reference.md#database-skills) for details. + +To manage database connections later, use `storm db` for the global connection library and `storm mcp` for project-level configuration. See [Database Connections & MCP](database-and-mcp.md) for the full guide. -To reconfigure the database connection later, run `storm mcp`. +> **Tip:** +The Storm MCP server works standalone — no Storm ORM required. Run `npx @storm-orm/cli mcp` to set up schema access and optional read-only data queries without installing Storm rules or skills. See [Using Without Storm ORM](database-and-mcp.md#using-without-storm-orm). --- @@ -18549,18 +18809,221 @@ Most AI coding tools support adding context through URLs or pasted text. Point y ## Why Storm Works Well With AI -Storm's design principles align naturally with how AI coding tools operate: +AI works better when framework behavior is explicit and visible in source code. + +Traditional ORMs rely on mechanisms that are powerful but implicit: proxy objects that intercept field access, lazy loading that triggers queries at unpredictable moments, persistence contexts that track entity state across transaction boundaries, and cascading rules that propagate changes through the object graph. These features serve real purposes, but they make AI-assisted development harder. The AI has to account for behavior that does not appear in the code. Code that compiles and looks correct can still break at runtime because of invisible framework state. + +Storm eliminates all of that. Entities are plain Kotlin data classes or Java records. There are no proxies, no managed state, no persistence context, and no lazy loading. Queries are explicit, and what you see in the source code is exactly what happens at runtime. This makes Storm's behavior predictable for AI tools: the code is the complete picture. + +The design choices that matter most: + +- **Immutable entities.** No hidden state transitions for the AI to track or miss. +- **No proxies.** The entity class is the entity. No invisible bytecode transformations to account for. +- **No persistence context.** No session scope, flush ordering, or detachment rules that require deep framework knowledge. +- **Convention over configuration.** Fewer annotations and config files for the AI to keep consistent. +- **Compile-time metamodel.** Type errors caught at build time, not at runtime. The AI gets immediate feedback. +- **Secure schema access.** The MCP server gives AI tools structural database knowledge without exposing credentials. Data access is opt-in, read-only by construction, and enforced at the database driver level. + +Beyond the data model, Storm provides dedicated tooling for AI-assisted workflows: + +- **Skills** guide AI tools through specific tasks (entity creation, queries, repositories, migrations) with framework-aware conventions and rules. +- **A locally running MCP server** gives AI tools access to your live database schema: table definitions, column types, constraints, and foreign keys. Optionally, the AI can also query individual records (read-only) when sample data would improve type decisions. The AI can inspect your actual database structure to generate entities that match, or validate entities it just created. +- **Built-in verification** through `ORMTemplate.validateSchema()` and `SqlCapture` lets the AI validate its own work. After generating entities, the AI can validate them against the database. After writing queries, it can capture and inspect the actual SQL. Both checks run in an isolated in-memory database through `@StormTest`, so verification happens before anything touches production. For dialect-specific code, `@StormTest` supports a static `dataSource()` factory method on the test class, allowing integration with Testcontainers to test against the actual target database. + +--- + +## Schema-First and Entity-First + +Storm fully supports both directions of working: starting from the database schema and generating entities to match, or starting from the entity model and generating the migration scripts to create the schema. Both approaches share the same development cycle; they just enter it at a different point. + +``` + Entity-first Schema-first + starts here starts here + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ Define/update │◀─────────────────▶│ Generate/update │ ┐ + │ entities │ │ migration │ ├─ You / AI (generate) + └────────┬────────┘ └────────┬────────┘ ┘ + │ │ + └──────────────────┬──────────────────┘ + ▼ + ┌─────────────────────────────┐ ┐ + │ Apply schema │ ├─ Flyway / H2 (apply) + └──────────────┬──────────────┘ ┘ + ▼ + ┌─────────────────┐ ┐ + │ Validate │ ├─ Storm (verify) + └─────────────────┘ ┘ +``` + +The AI generates and updates code (entities, migrations, queries). Storm validates correctness (`ORMTemplate.validateSchema()`, `SqlCapture`). The cycle repeats whenever either side changes: a schema change triggers entity updates; an entity change triggers a new migration. Schema validation closes the loop by proving that entities and schema agree after every change. + +### Schema-first + +In a schema-first workflow, the database is the source of truth. The schema already exists (or is managed by a DBA), and entities need to match it. + +When the MCP server is configured, the AI has access to the live database through `list_tables` and `describe_table`. This gives it full visibility into table definitions, column types, constraints, and foreign key relationships. When data access is enabled, the AI can also use `select_data` to sample individual records — useful when the schema alone is ambiguous about intent (e.g., a `VARCHAR` that holds enum values, or a `TEXT` column that stores JSON). + +The AI workflow: + +1. **Inspect the schema.** The AI calls `list_tables` to discover tables, then `describe_table` for each relevant table. +2. **Sample data (if available).** When `select_data` is enabled and the schema leaves a type decision ambiguous, the AI queries a few rows to inform the choice. +3. **Generate entities.** Based on the schema metadata (and optional sample data) and Storm's entity conventions (naming, `@PK`, `@FK`, `@UK`, nullability, `Ref` for circular or self-references), the AI generates Kotlin data classes or Java records. +4. **Validate.** The AI writes a temporary test that validates the generated entities against the database using `ORMTemplate.validateSchema()`. + +When the database schema evolves, the same flow applies: the AI inspects the changed tables, updates the affected entities, and re-validates. + +### Entity-first + +In an entity-first workflow, the code is the source of truth. You design your domain model as entities, and the database schema is derived from them. + +The AI workflow: + +1. **Design entities.** The AI creates Kotlin data classes or Java records based on the domain model you describe. +2. **Generate migration.** The AI writes a Flyway or Liquibase migration script that creates the tables, columns, constraints, and indexes to match the entity definitions, following Storm's naming conventions. +3. **Validate.** The AI writes a temporary test that applies the migration to an H2 in-memory database and validates the entities against the resulting schema using `ORMTemplate.validateSchema()`. This confirms that the entity definitions and the migration script are consistent with each other, before anything touches the real database. + +### Verification with Schema Validation + +Both approaches converge on the same verification step. `ORMTemplate.validateSchema()` checks entities against the database at the JDBC level, catching mismatches that are difficult to spot by inspection: type incompatibilities, nullability disagreements, missing constraints, unmapped NOT NULL columns, and more. The AI can validate only the specific entities it created or modified: + +[Kotlin] + +```kotlin +@StormTest(scripts = ["schema.sql"]) +class EntitySchemaTest { + @Test + fun validateNewEntities(orm: ORMTemplate) { + val errors = orm.validateSchema( + Order::class, + OrderLine::class, + Product::class + ) + assertTrue(errors.isEmpty()) { "Schema validation errors: $errors" } + } +} +``` + +[Java] + +```java +@StormTest(scripts = {"schema.sql"}) +class EntitySchemaTest { + @Test + void validateNewEntities(ORMTemplate orm) { + orm.validateSchemaOrThrow(List.of( + Order.class, + OrderLine.class, + Product.class + )); + } +} +``` +In the schema-first case, `schema.sql` is the existing migration or DDL. In the entity-first case, it is the migration the AI just generated. Either way, schema validation confirms that entities and schema agree. + +--- + +## Query Verification With SqlCapture + +The same pattern applies to queries. A query that compiles and runs without errors is not necessarily correct: the WHERE clause might filter on the wrong column, a JOIN might be missing, or an ORDER BY might not match the user's intent. After the AI writes a query, it can write a test that captures the actual SQL Storm generates and verifies it matches the intended behavior. + +`SqlCapture` records every SQL statement, its operation type, and its bind parameters: + +[Kotlin] + +```kotlin +@StormTest(scripts = ["schema.sql", "data.sql"]) +class OrderQueryTest { + @Test + fun findShippedOrders(orm: ORMTemplate, capture: SqlCapture) { + val orders = capture.execute { + orm.entity(Order::class).select() + .where(Order_.status eq "SHIPPED") + .orderBy(Order_.createdAt) + .resultList + } + // Verify the query structure matches the intent. + val sql = capture.statements().first().statement() + assertContains(sql, "WHERE") + assertContains(sql, "ORDER BY") + } +} +``` + +[Java] + +```java +@StormTest(scripts = {"schema.sql", "data.sql"}) +class OrderQueryTest { + @Test + void findShippedOrders(ORMTemplate orm, SqlCapture capture) { + List orders = capture.execute(() -> + orm.entity(Order.class).select() + .where(Order_.status, EQUALS, "SHIPPED") + .orderBy(Order_.createdAt) + .getResultList()); + // Verify the query structure matches the intent. + String sql = capture.statements().getFirst().statement(); + assertTrue(sql.contains("WHERE")); + assertTrue(sql.contains("ORDER BY")); + } +} +``` +`SqlCapture` is injected automatically in `@StormTest` methods. The AI can verify: + +- **SQL structure**: check that the expected WHERE, JOIN, GROUP BY, and ORDER BY clauses are present. +- **Query count**: `capture.count(SELECT)` confirms the expected number of statements were issued. +- **Operation types**: `capture.count(INSERT)`, `capture.count(UPDATE)`, etc. for mutation tests. +- **Bind parameters**: `capture.statements().first().parameters()` to inspect parameterized values. + +If the test fails, the AI has the actual SQL in the failure output and can correct the query immediately. + +--- + +## Temporary Self-Verification Tests + +The verification tests the AI writes do not need to become part of your codebase. The AI can write a test, run it, and remove it again, all within a single conversation. This gives the AI a way to validate its own work without leaving behind test artifacts you did not ask for. + +The workflow: + +1. **Write.** The AI creates a test file in the project's test source directory (e.g., `src/test/kotlin/StormAIVerificationTest.kt`). For entity-first work, it may also write a temporary schema SQL file to `src/test/resources/`. +2. **Run.** The AI executes only that test using a targeted command: + ```bash + # Maven + mvn test -pl your-module -Dtest=StormAIVerificationTest + + # Gradle + ./gradlew :your-module:test --tests StormAIVerificationTest + ``` +3. **Fix (if needed).** If the test fails, the error messages tell the AI exactly what is wrong. It fixes the entities, queries, or migration and re-runs the test. +4. **Clean up.** Once the test passes, the AI deletes the temporary test file (and any temporary SQL scripts it created). The verified code stays; the scaffolding goes. + +This works because `@StormTest` spins up an H2 in-memory database by default, executes the setup scripts, and tears everything down after the test. No external database, no persistent state, no side effects. When the code under test uses dialect-specific SQL, define a static `dataSource()` factory method on the test class to provide a Testcontainers-backed `DataSource` for the target database instead of H2. + +You can also ask the AI to keep the test as a permanent regression test. The choice is yours, and the AI should ask. + +--- + +## The Gold Standard: Verify, Then Trust + +This is what makes Storm the gold standard for AI-assisted database development. The AI does not just generate code and hope for the best. It generates code, then validates it through Storm's own verification, before anything is committed. + +| Task | AI generates | Storm verifies | +|------|-------------|-------------------| +| **Entities (schema-first)** | Data classes/records from live schema | `validateSchema()` checks types, nullability, constraints, unmapped columns | +| **Entities (entity-first)** | Data classes/records + migration script | `validateSchema()` confirms entity and migration agree | +| **Queries** | QueryBuilder or SQL Template code | `SqlCapture` verifies the generated SQL matches the intended structure and parameters | +| **Repositories** | Custom query methods | `SqlCapture` confirms each method produces the expected SQL | + +Storm's immutable entities, explicit queries, and convention-based naming make AI-generated code straightforward to verify. The verify-then-trust pattern below closes the gap between "looks right" and "is right": -| Design Choice | Why it helps AI | -|---------------|-----------------| -| **Immutable entities** | No hidden state transitions for the AI to track or miss | -| **No proxies** | The entity class *is* the entity; no invisible bytecode transformations to account for | -| **No persistence context** | No session scope, flush ordering, or detachment rules that require deep framework knowledge | -| **Convention over configuration** | Fewer annotations and config files for the AI to keep consistent | -| **Compile-time metamodel** | Type errors caught at build time, not at runtime; the AI gets immediate feedback | -| **Secure schema access** | The MCP server gives AI tools structural database knowledge without exposing credentials or data | +1. **The AI generates code** using Storm's skills, documentation, and (when configured) live schema metadata from the MCP server. +2. **The AI writes a focused test** that exercises exactly the code it just wrote, using `ORMTemplate.validateSchema()` for entities or `SqlCapture` for queries. +3. **The AI runs the test.** If it passes, the code is correct by construction, verified by the same validation logic that Storm uses internally. If it fails, the error messages tell the AI exactly what to fix. +4. **The test stays or goes.** Keep it as a regression test, or let the AI remove it once verified. Either way, the verification happened. -When you ask an AI tool to write Storm code, it produces the same straightforward data classes and explicit queries that a human developer would write. There is no framework magic to get wrong. +This is the combination that makes it work: an AI-friendly data model that produces stable code, a schema-aware MCP server that gives the AI structural knowledge, and built-in test tooling that lets the AI verify its own work through the framework rather than around it. ======================================== @@ -18580,7 +19043,7 @@ The main Kotlin API module. It provides the `ORMTemplate` interface, extension f ```kotlin // Gradle (Kotlin DSL) -implementation("st.orm:storm-kotlin:1.11.0") +implementation("st.orm:storm-kotlin:@@STORM_VERSION@@") ``` ```xml @@ -18588,7 +19051,7 @@ implementation("st.orm:storm-kotlin:1.11.0") st.orm storm-kotlin - 1.11.0 + @@STORM_VERSION@@ ``` @@ -18599,7 +19062,7 @@ The Kotlin API does not depend on any preview features. All APIs are stable and Spring Framework integration for Kotlin. Provides `RepositoryBeanFactoryPostProcessor` for repository auto-discovery and injection, `@EnableTransactionIntegration` for bridging Storm's programmatic transactions with Spring's `@Transactional`, and transaction-aware coroutine support. Add this module when you use Spring Framework without Spring Boot. ```kotlin -implementation("st.orm:storm-kotlin-spring:1.11.0") +implementation("st.orm:storm-kotlin-spring:@@STORM_VERSION@@") ``` See [Spring Integration](spring-integration.md) for configuration details. @@ -18609,7 +19072,7 @@ See [Spring Integration](spring-integration.md) for configuration details. Spring Boot auto-configuration for Kotlin. Automatically creates an `ORMTemplate` bean from the `DataSource`, discovers repositories, enables transaction integration, and binds `storm.*` properties from `application.yml`. This is the recommended dependency for Spring Boot applications. ```kotlin -implementation("st.orm:storm-kotlin-spring-boot-starter:1.11.0") +implementation("st.orm:storm-kotlin-spring-boot-starter:@@STORM_VERSION@@") ``` See [Spring Integration: Spring Boot Starter](spring-integration.md#spring-boot-starter) for what the starter provides and how to override its defaults. @@ -18662,7 +19125,7 @@ plugins { } dependencies { - ksp("st.orm:storm-metamodel-ksp:1.11.0") + ksp("st.orm:storm-metamodel-ksp:@@STORM_VERSION@@") } ``` @@ -18681,7 +19144,7 @@ dependencies { st.orm storm-metamodel-processor - 1.11.0 + @@STORM_VERSION@@ @@ -18724,7 +19187,7 @@ The main Java API module. It provides the `ORMTemplate` entry point, repository st.orm storm-java21 - 1.11.0 + @@STORM_VERSION@@ ``` @@ -18752,7 +19215,7 @@ Spring Framework integration for Java. Provides `RepositoryBeanFactoryPostProces st.orm storm-spring - 1.11.0 + @@STORM_VERSION@@ ``` @@ -18766,7 +19229,7 @@ Spring Boot auto-configuration for Java. Automatically creates an `ORMTemplate` st.orm storm-spring-boot-starter - 1.11.0 + @@STORM_VERSION@@ ``` @@ -18791,7 +19254,7 @@ The `storm-metamodel-processor` annotation processor generates type-safe metamod st.orm storm-metamodel-processor - 1.11.0 + @@STORM_VERSION@@ provided ``` @@ -18802,6 +19265,6 @@ See [Metamodel](metamodel.md) for setup and usage. The aggregated Javadoc covers all Java modules in the Storm framework: -[Browse the Javadoc](../api/java/index.html) +[Browse the Javadoc](../api/java/storm.foundation/st/orm/package-summary.html) diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md index 148c215ff..6232fe802 100644 --- a/website/static/skills/storm-entity-java.md +++ b/website/static/skills/storm-entity-java.md @@ -45,7 +45,7 @@ Generation rules: 6. NO COLLECTION FIELDS. Query the "many" side instead. 7. Naming: camelCase to snake_case automatically. FK appends _id. - - For individual overrides: \`@DbTable("custom_name")\` / \`@DbColumn("custom_name")\`. + - For individual overrides: \`@DbTable("custom_name")\` / \`@DbColumn("custom_name")\`. For tables in another schema: \`@DbTable(name = "custom_name", schema = "other_schema")\`. - For database-wide conventions (e.g., UPPER_CASE, prefixed tables like \`tbl_\`, or non-standard FK naming): configure a custom \`TableNameResolver\`, \`ColumnNameResolver\`, or \`ForeignKeyResolver\` via the \`TemplateDecorator\` on \`ORMTemplate.of()\` instead of annotating every entity. Example: \`\`\`java var orm = ORMTemplate.of(dataSource, decorator -> decorator @@ -104,7 +104,16 @@ Generation rules: - **Composite** (only when needed in code): use an inline record + `@UK @Persist(insertable = false, updatable = false)`. Only add this when the user explicitly needs a composite `Metamodel.Key` for keyset pagination or type-safe lookups. Composite unique constraints that don't need a Key don't need to be modeled. - `@UK(constraint = false)` suppresses schema validation when no database constraint exists. -11. Embedded components, enums, optimistic locking: same rules as Kotlin. +11. Embedded components, enums, optimistic locking: same rules as Kotlin. Enums are stored by name (string) by default; \`@DbEnum(ORDINAL)\` for integer storage (import \`st.orm.EnumType.ORDINAL\`). + +11b. Database-managed columns: annotate columns the database computes or maintains (e.g. \`DEFAULT CURRENT_TIMESTAMP\`, \`ON UPDATE\` timestamps, computed values) with \`@Persist(insertable = false, updatable = false)\`. Storm then never writes the column and always reads it back: + \`\`\`java + record User(@PK Integer id, + @Nonnull String email, + @Persist(insertable = false, updatable = false) Instant registeredAt + ) implements Entity {} + \`\`\` + Use \`insertable = false\` alone for columns set by the database only on INSERT, or \`updatable = false\` alone for columns written once and never modified. 12. Java records are immutable. Consider Lombok \`@Builder(toBuilder = true)\` for copy-with-modification. diff --git a/website/static/skills/storm-entity-kotlin.md b/website/static/skills/storm-entity-kotlin.md index f31d4d2d3..44e4cf14b 100644 --- a/website/static/skills/storm-entity-kotlin.md +++ b/website/static/skills/storm-entity-kotlin.md @@ -35,6 +35,18 @@ Generation rules: - Use `Ref` when the entity hierarchy gets too deep or loading the full related entity is overkill for the use case. - Non-nullable \`@FK val city: City\` produces INNER JOIN. - Nullable \`@FK val city: City?\` produces LEFT JOIN. + - For entities with `Ref` FK fields, add a secondary constructor that accepts the entities and converts them — client code then never constructs refs by hand: + ```kotlin + data class Address( + @PK val id: Int = 0, + @FK val user: Ref, + @FK val city: Ref, + val street: String + ) : Entity { + constructor(user: User, city: City, street: String) : + this(0, user.ref(), city.ref(), street) + } + ``` 4. CIRCULAR REFERENCES ARE NOT SUPPORTED. If Entity A references B and B references A, at least one MUST use \`Ref\`. Self-references MUST always use \`Ref\`: \`@FK val invitedBy: Ref?\` @@ -106,7 +118,7 @@ Generation rules: ``` 10. Naming: camelCase to snake_case automatically. FK appends _id. - - For individual overrides: \`@DbTable("custom_name")\` / \`@DbColumn("custom_name")\`. + - For individual overrides: \`@DbTable("custom_name")\` / \`@DbColumn("custom_name")\`. For tables in another schema: \`@DbTable(name = "custom_name", schema = "other_schema")\`. - For database-wide conventions (e.g., UPPER_CASE, prefixed tables like \`tbl_\`, or non-standard FK naming): configure a custom \`TableNameResolver\`, \`ColumnNameResolver\`, or \`ForeignKeyResolver\` via the \`TemplateDecorator\` on \`ORMTemplate.of()\` instead of annotating every entity. Example: \`\`\`kotlin val orm = dataSource.orm { decorator -> @@ -118,10 +130,20 @@ Generation rules: - Resolvers are functional interfaces. Compose them with built-in decorators (\`toUpperCase\`) or write custom lambdas that receive \`RecordType\` (for tables) or \`RecordField\` (for columns) with full access to class/field metadata and annotations. - Use \`@DbTable\`/\`@DbColumn\` only for exceptions to the global convention. If the entire database follows one pattern, a resolver handles it without any annotations. -11. Enums: String by default. \`@DbEnum(ORDINAL)\` for integer. +11. Enums: stored by name (string) by default. \`@DbEnum(ORDINAL)\` for integer storage (import \`st.orm.EnumType.ORDINAL\` — the \`EnumType\` constants are \`NAME\` and \`ORDINAL\`). 12. Optimistic locking: \`@Version val version: Int\`. +12b. Database-managed columns: annotate columns the database computes or maintains (e.g. \`DEFAULT CURRENT_TIMESTAMP\`, \`ON UPDATE\` timestamps, computed values) with \`@Persist(insertable = false, updatable = false)\` and give the field a default value so entity construction doesn't require it. Storm then never writes the column and always reads it back: + \`\`\`kotlin + data class User( + @PK val id: Int = 0, + val email: String, + @Persist(insertable = false, updatable = false) val registeredAt: Instant = Instant.EPOCH + ) : Entity + \`\`\` + Use \`insertable = false\` alone for columns set by the database only on INSERT, or \`updatable = false\` alone for columns that are written once and never modified. + 13. Use descriptive variable names, never abbreviated. 14. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for all entity lookups, map keys, and set membership. `Ref` provides identity-based `equals`/`hashCode` on the primary key, making it safe and efficient. When a projection already returns `Ref`, use it directly as a map key without calling `.ref()` again. diff --git a/website/static/skills/storm-json-java.md b/website/static/skills/storm-json-java.md index dd3b557a7..2f62d91c8 100644 --- a/website/static/skills/storm-json-java.md +++ b/website/static/skills/storm-json-java.md @@ -63,7 +63,7 @@ When writing migrations, use the correct JSON column type for the target databas ### JSON aggregation functions -JSON aggregation syntax differs by database. Always ask or detect which dialect the user is targeting: +In Storm templates, `JSON_OBJECTAGG(\{Role.class})` with the entity class as single argument is valid — Storm expands `\{Role.class}` to the entity's projected columns, producing the dialect-appropriate key/value arguments. The table below shows the underlying raw SQL forms per database (relevant when writing the SQL by hand or debugging generated SQL). Always ask or detect which dialect the user is targeting: | Database | Object aggregation | Array aggregation | |----------|-------------------|-------------------| diff --git a/website/static/skills/storm-json-kotlin.md b/website/static/skills/storm-json-kotlin.md index d3aa4fd66..f66e39775 100644 --- a/website/static/skills/storm-json-kotlin.md +++ b/website/static/skills/storm-json-kotlin.md @@ -69,7 +69,7 @@ When writing migrations, use the correct JSON column type for the target databas ### JSON aggregation functions -JSON aggregation syntax differs by database. Always ask or detect which dialect the user is targeting: +In Storm templates, `JSON_OBJECTAGG(${Role::class})` with the entity class as single argument is valid — Storm expands `${Role::class}` to the entity's projected columns, producing the dialect-appropriate key/value arguments. The table below shows the underlying raw SQL forms per database (relevant when writing the SQL by hand or debugging generated SQL). Always ask or detect which dialect the user is targeting: | Database | Object aggregation | Array aggregation | |----------|-------------------|-------------------| diff --git a/website/static/skills/storm-migration.md b/website/static/skills/storm-migration.md index 0abb5b224..3d70cbc79 100644 --- a/website/static/skills/storm-migration.md +++ b/website/static/skills/storm-migration.md @@ -42,14 +42,13 @@ After writing a migration, rebuild the project for metamodel regeneration. After generating or updating entities and migrations, offer to write a temporary `@StormTest` to verify that the entities and migration are consistent: ```kotlin -@StormTest(scripts = ["V1__create_users.sql"]) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +@StormTest(scripts = ["/V1__create_users.sql"]) class MigrationVerificationTest { @Test fun validateEntities(orm: ORMTemplate) { - val errors = orm.validateSchema(listOf( - User::class.java, - City::class.java - )) + // Kotlin: vararg KClass form. (Java: orm.validateSchema(List.of(User.class, City.class))) + val errors = orm.validateSchema(User::class, City::class) assertTrue(errors.isEmpty()) { "Schema validation errors: $errors" } } } diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md index 85a9a0fd5..e44579f13 100644 --- a/website/static/skills/storm-query-java.md +++ b/website/static/skills/storm-query-java.md @@ -5,7 +5,7 @@ Help the user write Storm queries using Java. ## Key Imports ```java -import st.orm.core.template.QueryBuilder; // Query builder +import st.orm.template.QueryBuilder; // Query builder import st.orm.Operator; // EQUALS, NOT_EQUALS, LIKE, IN, IS_NULL, etc. import static st.orm.Operator.*; // Static import for operator constants import st.orm.Metamodel; // Generated metamodel fields (User_, City_, etc.) @@ -16,7 +16,7 @@ import st.orm.Scrollable; // Keyset scrolling cursor (si import st.orm.Window; // Keyset scrolling result (Window) ``` -The `Operator` enum is in `st.orm` and contains: `EQUALS`, `NOT_EQUALS`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LIKE`, `NOT_LIKE`, `IS_NULL`, `IS_NOT_NULL`, `IS_TRUE`, `IS_FALSE`, `IN`, `NOT_IN`, `BETWEEN`. +Do NOT import from `st.orm.core.*` — those are Storm's internal core-engine packages; the Java API lives in `st.orm.repository` and `st.orm.template`. `st.orm.Operator` is an interface with static constants (static-importable like an enum): `EQUALS`, `NOT_EQUALS`, `LESS_THAN`, `LESS_THAN_OR_EQUAL`, `GREATER_THAN`, `GREATER_THAN_OR_EQUAL`, `LIKE`, `NOT_LIKE`, `IS_NULL`, `IS_NOT_NULL`, `IS_TRUE`, `IS_FALSE`, `IN`, `NOT_IN`, `BETWEEN`. Ask what data they need, filters, ordering, or pagination. @@ -27,10 +27,12 @@ Ask what data they need, filters, ordering, or pagination. Repository/entity methods fall into two categories: **Builder methods** return `QueryBuilder` for composable, chainable queries. They never execute immediately: -- `select()`, `select(predicate)` -- build SELECT queries -- `selectRef()`, `selectRef(predicate)` -- build SELECT queries returning Refs +- `select()` -- build SELECT queries +- `selectRef()` -- build SELECT queries returning Refs - `selectCount()` -- build COUNT queries -- `delete()`, `delete(predicate)` -- build DELETE queries +- `delete()` -- build DELETE queries + +(The `select(predicate)` / `delete(predicate)` shorthands are Kotlin-only — in Java, chain `.where(...)` on the builder.) Terminal operations: `.getResultList()`, `.getSingleResult()`, `.getOptionalResult()`, `.getResultStream()`, `.getResultCount()`, `.page()`, `.scroll()`, `.executeUpdate()` @@ -44,7 +46,7 @@ Prefer the simplest approach that works. Three query levels, from simplest to mo | Level | Approach | Best for | |-------|----------|----------| | 1 | Convenience methods (`findBy`, `findAllBy`, `removeAllBy`, `countBy`, `existsBy`) | Simple lookups and operations | -| 2 | Builder with predicate (`select(predicate)`, `delete(predicate)`) or chained (`select().where()`) | Most application queries needing ordering, pagination, or joins | +| 2 | Builder chained (`select().where(...)`, `delete().where(...)`) | Most application queries needing ordering, pagination, or joins | | 3 | SQL Templates (/storm-sql-java) | CTEs, window functions, database-specific features | ### When to use each — and when NOT to @@ -74,12 +76,6 @@ long count = users.count(); **Level 2 — Builder** (returns `QueryBuilder`, chain terminal + ordering/pagination): ```java -// With predicate shorthand -List list = users.select(it -> it.where(User_.city, EQUALS, city)) - .orderBy(User_.name) - .getResultList(); - -// Or equivalently, chained with .where() List list = users.select() .where(User_.city, EQUALS, city) .orderBy(User_.name) @@ -210,17 +206,19 @@ List> refs = orm.entity(User.class).selectRef() ## Subqueries (EXISTS / NOT EXISTS) +In Java, EXISTS conditions are expressed inside the where-lambda via `WhereBuilder.exists(subquery)` / `notExists(subquery)` — there is no `whereExists` method on the Java QueryBuilder (that form is Kotlin-only). Build the subquery with `orm.selectFrom(...)`; it is automatically correlated with the outer query: + ```java // WHERE EXISTS — filter entities that have related data List citiesWithUsers = orm.entity(City.class) .select() - .whereExists(it -> it.subquery(User.class)) + .where(it -> it.exists(orm.selectFrom(User.class))) .getResultList(); // WHERE NOT EXISTS List citiesWithoutUsers = orm.entity(City.class) .select() - .whereNotExists(it -> it.subquery(User.class)) + .where(it -> it.notExists(orm.selectFrom(User.class))) .getResultList(); ``` @@ -253,7 +251,7 @@ users.select() Keyset scrolling uses cursor-based navigation instead of offset, making it efficient for large tables. **Scrollable manages ORDER BY internally** — do NOT add `orderBy()` when using `scroll(Scrollable)`, or Storm throws `PersistenceException`. -**Composite PK limitation:** Keyset scrolling requires a simple (non-composite) primary key as the scroll key. Entities with composite PKs (e.g., junction tables) cannot be scrolled directly — Storm throws `SqlTemplateException: Column not found for metamodel`. To scroll filtered results from a junction table, query the related entity with a simple PK and JOIN through the junction table for filtering: +**Composite PK limitation:** The scroll key must be a single-column, non-nullable unique key (a `Metamodel.Key`, e.g. a simple `@PK` or `@UK` field). Entities whose only unique key is a composite PK (e.g., junction tables) cannot be scrolled directly — the key doesn't resolve to a single column. To scroll filtered results from a junction table, query the related entity with a simple PK and JOIN through the junction table for filtering: ```java // ❌ Cannot scroll a junction table with composite PK userRoles.scroll(Scrollable.of(UserRole_.id, 20)); // fails — UserRole has composite PK @@ -368,7 +366,9 @@ After writing queries, write a test using `@StormTest` and `SqlCapture` to verif Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the query produces the result the user intended — correct tables joined, correct columns filtered, correct ordering, correct number of statements. This is Storm's verify-then-trust pattern. ```java -@StormTest(scripts = {"schema.sql", "data.sql"}) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +// Without it, paths resolve relative to the test class's package. +@StormTest(scripts = {"/schema.sql", "/data.sql"}) class UserQueryTest { @Test void findActiveUsersInCity(ORMTemplate orm, SqlCapture capture) { diff --git a/website/static/skills/storm-query-kotlin.md b/website/static/skills/storm-query-kotlin.md index fbba91a63..88197e762 100644 --- a/website/static/skills/storm-query-kotlin.md +++ b/website/static/skills/storm-query-kotlin.md @@ -59,6 +59,7 @@ User_.name like "%alice%" // LIKE User_.name notLike "%test%" // NOT_LIKE User_.roles inList listOf("a","b") // IN User_.roles notInList listOf("x") // NOT_IN +User_.city inRefs cityRefs // IN over Iterable> — for FK fields with refs User_.age.between(18, 65) // BETWEEN User_.active.isTrue() // IS_TRUE User_.archived.isFalse() // IS_FALSE @@ -157,7 +158,7 @@ val users = orm.entity() Compound filters: `(A eq x) and (B eq y)`, `(A eq x) or (B eq y)` Nested paths: `User_.city.country.code eq "US"` Ordering: `.orderBy(User_.name)`, `.orderByDescending(User_.createdAt)` -Pagination: `.page(0, 20)` or `.page(Pageable.ofSize(20).sortBy(User_.name))`. Page API methods: `page.content()`, `page.totalPages()`, `page.totalElements()`, `page.number()`, `page.size()`, `page.hasNext()`, `page.hasPrevious()` — all are methods, not properties. +Pagination: `.page(0, 20)` or `.page(Pageable.ofSize(20).sortBy(User_.name))`. Page API methods (Java record accessors — always call with `()`): `page.content()`, `page.totalPages()`, `page.totalCount()`, `page.pageNumber()`, `page.pageSize()`, `page.hasNext()`, `page.hasPrevious()`, `page.nextPageable()`. Scrolling (keyset, better for large tables): `.scroll(Scrollable.of(User_.id, 20))` — do NOT combine with `orderBy()` (Scrollable manages ORDER BY internally, see Keyset Scrolling section) Explicit joins — two syntax forms depending on context: - **Block DSL** (inside `select { }`): `innerJoin(UserRole::class, Role::class)` — two-arg, no `.on()` @@ -190,7 +191,7 @@ val citySummaries = orm.entity(City::class) **Computed aggregates (COUNT, AVG, SUM, etc.):** When the SELECT clause needs expressions that QueryBuilder can't produce, use `select(ResultType::class) { template }` for the SELECT only — keep joins, groupBy, having, orderBy, and limit in code. -**Important:** The `{ template }` provides the SELECT list only — not a full SQL query. If you put a full `SELECT ... FROM ... WHERE ...` inside, Storm wraps it as a scalar subquery, causing errors. For full custom SQL, use `orm.query { }.getResultList(T::class)` (see /storm-sql-kotlin). +**Important:** The `{ template }` provides the SELECT list only — not a full SQL query. If you put a full `SELECT ... FROM ... WHERE ...` inside, Storm wraps it as a scalar subquery, causing errors. For full custom SQL, use `orm.query { }.resultList()` (see /storm-sql-kotlin). ```kotlin data class CityUserCount(val city: City, val userCount: Long) : Data @@ -322,7 +323,7 @@ The `where()` and `orderBy()` methods in the block DSL are typed to the root ent - `whereAny(predicate)` — accepts `PredicateBuilder<*, *, *>` (any entity type) - `orderByAny(path)` — accepts `Metamodel<*, *>` (any entity type) - `orderByDescendingAny(path)` — same, descending -- `groupByAny(path)` — accepts `Metamodel<*, *>` (any entity type) +- `groupByAny(path)` — accepts `Metamodel<*, *>` (any entity type; chained API only, not in the block DSL) The `Any` variants (`whereAny`, `orderByAny`, `orderByDescendingAny`, `groupByAny`) are needed when referencing fields from joined (non-root) entities. @@ -340,7 +341,7 @@ These are also available on the chained QueryBuilder API: `.whereAny(...)`, `.or Keyset scrolling uses cursor-based navigation instead of offset, making it efficient for large tables. `Scrollable` takes a **single type parameter** — the entity type (e.g., `Scrollable`). Do not pass a second type parameter. **Scrollable manages ORDER BY internally** — do NOT add `orderBy()` when using `scroll(Scrollable)`, or Storm throws `PersistenceException`. -**Composite PK limitation:** Keyset scrolling requires a simple (non-composite) primary key as the scroll key. Entities with composite PKs (e.g., junction tables) cannot be scrolled directly — Storm throws `SqlTemplateException: Column not found for metamodel`. To scroll filtered results from a junction table, query the related entity with a simple PK and JOIN through the junction table for filtering: +**Composite PK limitation:** The scroll key must be a single-column, non-nullable unique key (e.g. a simple `@PK` or `@UK` field). Entities whose only unique key is a composite PK (e.g., junction tables) cannot be scrolled directly — the key doesn't resolve to a single column. To scroll filtered results from a junction table, query the related entity with a simple PK and JOIN through the junction table for filtering: ```kotlin // ❌ Cannot scroll a junction table with composite PK userRoles.scroll(Scrollable.of(UserRole_.id, 20)) // fails — UserRole has composite PK @@ -383,13 +384,13 @@ val scrollable = Scrollable.of(User_.id, User_.name, 20) val window = users.scroll(scrollable) ``` -**Backward scrolling and navigation:** +**Backward scrolling and navigation** (`Window` is a Java record — accessors are methods, call with `()`): ```kotlin val window = users.scroll(Scrollable.of(User_.id, 20)) -if (window.hasNext) { +if (window.hasNext()) { val next = users.scroll(window.next()!!) } -if (window.hasPrevious) { +if (window.hasPrevious()) { val previous = users.scroll(window.previous()!!) } ``` @@ -417,7 +418,8 @@ users.removeAll() Use `select { }` / `delete { }` to build queries without chaining. Both are **builder methods** that return `QueryBuilder` -- they never execute immediately. Inside the block, use scope methods like `where()`, `orderBy()`, `limit()` to construct the query. Then call a terminal operation to execute: ```kotlin -orm.select { +// Standalone: get the entity repository first — there is NO orm.select { block } reified form +orm.entity().select { where(User_.active eq true) orderBy(User_.name) limit(10) @@ -472,7 +474,7 @@ select { }.page(page, size) ``` -Available in the block: `where`, `whereAny`, `orderBy`, `orderByAny`, `orderByDescending`, `orderByDescendingAny`, `groupBy`, `groupByAny`, `having`, `limit`, `offset`, `distinct`, `forUpdate`, `forShare`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`, `append`. +Available in the block: `where`, `whereAny`, `whereBuilder`, `whereExists`, `whereNotExists`, `orderBy`, `orderByAny`, `orderByDescending`, `orderByDescendingAny`, `groupBy`, `having`, `limit`, `offset`, `distinct`, `unsafe`, `forUpdate`, `forShare`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`, `append`. Note: `groupByAny` is NOT available in the block — it exists only on the chained QueryBuilder API. **Note:** The block DSL has `orderBy { template }` but NOT `orderByDescending { template }`. For template-based descending order, use the chained API: `.orderByDescending { template }` or escape to raw SQL. @@ -536,7 +538,9 @@ After writing queries, write a test using `@StormTest` and `SqlCapture` to verif Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the query produces the result the user intended — correct tables joined, correct columns filtered, correct ordering, correct number of statements. This is Storm's verify-then-trust pattern. ```kotlin -@StormTest(scripts = ["schema.sql", "data.sql"]) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +// Without it, paths resolve relative to the test class's package. +@StormTest(scripts = ["/schema.sql", "/data.sql"]) class UserQueryTest { @Test fun findActiveUsersInCity(orm: ORMTemplate, capture: SqlCapture) { diff --git a/website/static/skills/storm-repository-java.md b/website/static/skills/storm-repository-java.md index 14d77acba..51a3b61eb 100644 --- a/website/static/skills/storm-repository-java.md +++ b/website/static/skills/storm-repository-java.md @@ -5,9 +5,9 @@ Help the user write a Storm repository using Java. ## Key Imports ```java -import st.orm.core.repository.EntityRepository; // Repository base interface -import st.orm.core.template.ORMTemplate; // ORM entry point -import st.orm.core.template.QueryBuilder; // Query builder +import st.orm.repository.EntityRepository; // Repository base interface +import st.orm.template.ORMTemplate; // ORM entry point +import st.orm.template.QueryBuilder; // Query builder import st.orm.Operator; // EQUALS, NOT_EQUALS, IN, etc. import static st.orm.Operator.*; // Static import for operator constants import st.orm.Ref; // Lazy-loaded reference @@ -17,9 +17,11 @@ import st.orm.Scrollable; // Keyset scrolling cursor import st.orm.Window; // Keyset scrolling result import st.orm.test.StormTest; // Test annotation import st.orm.test.SqlCapture; // SQL capture for verification -import st.orm.test.CapturedSql.Operation; // SELECT, INSERT, UPDATE, DELETE +import st.orm.test.CapturedSql.Operation; // SELECT, INSERT, UPDATE, DELETE, UNDEFINED ``` +Do NOT import from `st.orm.core.*` — those are Storm's internal core-engine packages. The Java API lives in `st.orm.repository` and `st.orm.template`; shared types (`Operator`, `Ref`, `Page`, `Metamodel`, ...) live in `st.orm`. `Operator` is an interface with static constants (not an enum) — the static import works as shown. + Ask: which entity, what custom queries? Detect the project's framework from its build file (pom.xml or build.gradle): look for `storm-spring-boot-starter` or `spring-boot-starter` (Spring Boot) or neither (standalone). Use the detected framework to suggest the appropriate repository registration pattern. @@ -92,9 +94,11 @@ Three levels, from simplest to most powerful — always prefer the simplest that | Level | Approach | Best for | |-------|----------|----------| | 1 | Convenience methods (`findBy`, `findAllBy`, `removeAllBy`, `countBy`, `existsBy`) | Simple lookups and operations | -| 2 | Builder with predicate (`select(predicate)`, `delete(predicate)`) or chained (`select().where()`) | Most application queries needing ordering, pagination, or joins | +| 2 | Builder chained (`select().where(...)`, `delete().where(...)`) | Most application queries needing ordering, pagination, or joins | | 3 | SQL Templates (/storm-sql-java) | CTEs, window functions, database-specific features | +Unlike Kotlin, Java has no `select(predicate)` / `delete(predicate)` shorthand and no block DSL — always chain `.where(...)` on the builder. + **Level 1 — Convenience methods** execute immediately and return results directly: - **Read:** `findById()`, `findByRef()`, `findAll()`, `findAllRef()`, `findAllById()`, `findAllByRef()`, `findBy(key, value)`, `findAllBy(field, value)`, `findRefBy(...)`, `findAllRefBy(...)` - **Read (throw):** `getById()`, `getByRef()`, `getBy(key, value)` @@ -105,11 +109,6 @@ Three levels, from simplest to most powerful — always prefer the simplest that **Level 2 — Builder** returns `QueryBuilder` for chaining ordering, pagination, or joins: ```java -// With predicate shorthand -users.select(it -> it.where(User_.city, EQUALS, city)) - .orderBy(User_.name).getResultList(); - -// Or equivalently, chained with .where() users.select().where(User_.city, EQUALS, city) .orderBy(User_.name).getResultList(); ``` @@ -165,18 +164,28 @@ Java records are immutable. For convenient copy-with-modification, consider Lomb ## Field-Based Lookups -Query by a specific metamodel key without writing a full QueryBuilder chain: +Query by a specific metamodel field without writing a full QueryBuilder chain (requires Storm 1.12+; on older versions use `select().where(...)`): ```java -// Unique key lookups +// Find by field value Optional user = users.findBy(User_.email, "alice@example.com"); User user = users.getBy(User_.email, "alice@example.com"); // throws if not found -// Ref-based key lookups -Optional user = users.findByRef(User_.city, cityRef); -User user = users.getByRef(User_.city, cityRef); +// Find all by field value — pass the entity or a Ref for FK fields +List cityUsers = users.findAllBy(User_.city, city); +List byRef = users.findAllBy(User_.city, Ref.of(City.class, cityId)); +List byNames = users.findAllBy(User_.name, List.of("Alice", "Bob")); + +// Count / Exists by field +long count = users.countBy(User_.city, Ref.of(city)); +boolean exists = users.existsBy(User_.email, "alice@example.com"); + +// Remove by field +int deleted = users.removeAllBy(User_.city, Ref.of(city)); ``` +Field-based methods accept a `Ref` value for FK fields. Unique-key fields (`@PK`/`@UK`) additionally have `Metamodel.Key`-typed overloads (`findBy`, `getBy`, `findByRef`, `getByRef`) that were available before 1.12. + ## Ref-Based Operations ```java @@ -279,12 +288,12 @@ Page page = users.page(0, 20); Page page = users.page(Pageable.ofSize(20).sortBy(User_.name)); Page next = users.page(page.nextPageable()); -// Page API: +// Page API (record accessors): // page.content() — List of results for this page // page.totalPages() — total number of pages -// page.totalElements() — total number of elements across all pages -// page.number() — current page number (0-based) -// page.size() — page size +// page.totalCount() — total number of elements across all pages +// page.pageNumber() — current page number (0-based) +// page.pageSize() — page size // page.hasNext() — whether a next page exists // page.hasPrevious() — whether a previous page exists // page.nextPageable() — Pageable for the next page @@ -294,7 +303,8 @@ Page> refPage = users.pageRef(0, 20); // Keyset scrolling (better for large tables — no COUNT, cursor-based) // ⚠️ Scrollable manages ORDER BY internally — do NOT add orderBy() when using scroll(Scrollable) -// ⚠️ Requires a simple (non-composite) PK — junction tables with composite PKs cannot be scrolled. +// ⚠️ The scroll key must be a single-column, non-nullable unique key (Metamodel.Key, e.g. a simple +// @PK or @UK field) — junction tables with composite PKs cannot be scrolled directly. // To scroll filtered results from a junction table, query the entity with a simple PK // and JOIN through the junction table (e.g., scroll User with a JOIN through UserRole). var window = users.scroll(Scrollable.of(User_.id, 20)); // prefer var — avoids Window verbosity @@ -321,7 +331,7 @@ var window = users.scroll(scrollable); ## Framework-Specific Repository Registration ### Spring Boot -Define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` to auto-register repos as beans: +With `storm-spring-boot-starter`, repository interfaces are auto-discovered and registered as beans — no configuration needed; just inject them. Only when using plain `storm-spring` (no starter) do you define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages`: ```java @Service public class UserService { @@ -338,6 +348,8 @@ UserRepository userRepository = orm.repository(UserRepository.class); ## Transactions +The Java API has **no Storm-managed transaction API** (the `transaction { }` blocks are Kotlin-only). Do not invent methods like `orm.transaction(...)` — they do not exist. + ### Spring Boot Use `@Transactional` on service methods (standard Spring): ```java @@ -351,13 +363,11 @@ public class UserService { ``` ### Standalone -Use programmatic transaction blocks: -```java -orm.transactionBlocking(tx -> { - var user = tx.entity(User.class).insertAndFetch(new User(null, email, "Alice", city)); - // All operations share the same transaction. -}); -``` +Without Spring, manage transactions at the JDBC level: wrap the `DataSource` in a +transaction-aware proxy or use a library that provides one (e.g. Spring's +`DataSourceTransactionManager`/`TransactionTemplate` with +`TransactionAwareDataSourceProxy`). Storm participates in whatever transaction is +active on the connection it obtains from the `DataSource`. ## Verification @@ -366,7 +376,9 @@ After writing repository methods, write a test using `@StormTest` and `SqlCaptur Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the repository method produces the query the user intended — correct tables joined, correct columns filtered, correct ordering, correct number of statements. This is Storm's verify-then-trust pattern. ```java -@StormTest(scripts = {"schema.sql", "data.sql"}) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +// Without it, paths resolve relative to the test class's package. +@StormTest(scripts = {"/schema.sql", "/data.sql"}) class UserRepositoryTest { @Test void findByCity(ORMTemplate orm, SqlCapture capture) { @@ -383,6 +395,8 @@ class UserRepositoryTest { Run the test. Show the user the captured SQL and explain how it aligns with the intended behavior. If a query produces unexpected SQL or the right approach is unclear, ask the user for feedback before changing the query. +**SQL visibility outside tests:** annotate a repository interface or individual method with `@SqlLog` (`st.orm.SqlLog`) to log the generated SQL at runtime — useful for debugging without a test harness. + **Test isolation:** `SqlCapture` accumulates SQL across the entire test method. When writing multiple verification tests in one class, use `capture.clear()` between logical operations, or put each verification in its own `@Test` method. To avoid order-dependent failures, make assertions idempotent (don't assume specific row counts from prior inserts in other test methods) or use `@TestMethodOrder(MethodOrderer.OrderAnnotation.class)` with `@Order` if test ordering matters. The test can be temporary — verify and remove, or keep as a regression test. Ask the user which they prefer. diff --git a/website/static/skills/storm-repository-kotlin.md b/website/static/skills/storm-repository-kotlin.md index ccfd729a0..8c751cbff 100644 --- a/website/static/skills/storm-repository-kotlin.md +++ b/website/static/skills/storm-repository-kotlin.md @@ -17,7 +17,7 @@ import st.orm.Scrollable // Keyset scrolling cursor (sin import st.orm.Window // Keyset scrolling result (Window) import st.orm.test.StormTest // Test annotation import st.orm.test.SqlCapture // SQL capture for verification -import st.orm.test.CapturedSql.Operation // SELECT, INSERT, UPDATE, DELETE +import st.orm.test.CapturedSql.Operation // SELECT, INSERT, UPDATE, DELETE, UNDEFINED import org.junit.jupiter.api.Assertions.* // assertEquals, assertTrue, assertFalse ``` @@ -72,14 +72,13 @@ val users = orm.entity(User::class) // also works, no import needed val userRepository = orm.repository() // import st.orm.repository.repository ``` -**Star projection caveat:** `orm.entity()` returns `EntityRepository` — the ID type is erased. Methods that depend on the ID type parameter (`existsById`, `findById`, `removeById`, etc.) will fail with star projection errors. For ID-based operations, use a typed custom repository (`EntityRepository`) or call the method via `orm.entity(User::class)` with an explicit cast. +**Star projection caveat:** `orm.entity()` returns `EntityRepository` — the ID type is erased. Methods that depend on the ID type parameter (`existsById`, `findById`, `removeById`, etc.) will fail with star projection errors. For ID-based operations, use `orm.entityWithId()` (reified, preserves the ID type), a typed custom repository (`EntityRepository`), or `orm.entity(User::class)` (the ID type is inferred from context). ```kotlin -// ⚠️ Repository interfaces MUST import predicate operators — they are Kotlin extension functions: -// import st.orm.template.eq (and neq, like, greater, less, etc.) -// import st.orm.template.and -// import st.orm.template.or -// Without these imports, `eq`, `and`, `or` etc. will not resolve in the interface file. +// ⚠️ Repository interfaces MUST import the predicate operators — they are Kotlin extension functions: +// import st.orm.template.eq (and neq, like, greater, less, etc.) — or simply import st.orm.template.* +// Without the import, `eq` etc. will not resolve in the interface file. +// (`and`/`or` are member functions on PredicateBuilder — they need no import.) interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = find(User_.email eq email) @@ -387,22 +386,23 @@ users.removeAll() // removes all entities // When accepting 1-based page numbers from a URL (e.g., ?page=1), pass page - 1. val page: Page = users.page(0, 20) val page: Page = users.page(Pageable.ofSize(20).sortBy(User_.name)) -val nextPage = users.page(page.nextPageable) +val nextPage = users.page(page.nextPageable()) -// Page API — note: these are methods (not properties), call with () +// Page API — Page is a Java record; ALL accessors are methods, call with () // page.content() — List of results for this page // page.totalPages() — total number of pages -// page.totalElements() — total number of elements across all pages -// page.number() — current page number (0-based) -// page.size() — page size +// page.totalCount() — total number of elements across all pages +// page.pageNumber() — current page number (0-based) +// page.pageSize() — page size // page.hasNext() — whether a next page exists // page.hasPrevious() — whether a previous page exists -// page.nextPageable — Pageable for the next page (property, not method) +// page.nextPageable() — Pageable for the next page // Keyset scrolling (better for large tables — no COUNT, cursor-based) // Scrollable takes a single type parameter (the entity type) // ⚠️ Scrollable manages ORDER BY internally — do NOT add orderBy() when using scroll(Scrollable) -// ⚠️ Requires a simple (non-composite) PK — junction tables with composite PKs cannot be scrolled. +// ⚠️ The scroll key must be a single-column, non-nullable unique key (e.g. a simple @PK or @UK +// field) — junction tables with composite PKs cannot be scrolled directly. // To scroll filtered results from a junction table, query the entity with a simple PK // and JOIN through the junction table (e.g., scroll User with a JOIN through UserRole). val window = users.scroll(Scrollable.of(User_.id, 20)) @@ -420,12 +420,12 @@ val scrollable = if (cursor != null) { val window = users.scroll(scrollable) // Window is the scroll result record. Both scroll() methods return Window. -// Window API: -// window.content — List of results -// window.hasNext / window.hasPrevious — bounds checking +// Window API — Window is a Java record; ALL accessors are methods, call with () +// window.content() — List of results +// window.hasNext() / window.hasPrevious() — bounds checking // window.nextCursor() / window.previousCursor() — serialized cursors for REST APIs // window.next() / window.previous() — typed Scrollable for programmatic navigation -// window.nextScrollable / window.previousScrollable — raw Scrollable record component accessors (use next()/previous() instead) +// window.nextScrollable() / window.previousScrollable() — raw Scrollable record component accessors (use next()/previous() instead) ``` ## Framework-Specific Repository Registration @@ -433,7 +433,7 @@ val window = users.scroll(scrollable) Detect the project's framework from its build file and dependencies, then suggest the appropriate pattern: ### Spring Boot -Define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` to auto-register repos as beans. Or use the Spring Boot Starter which auto-discovers them. +With `storm-kotlin-spring-boot-starter`, repository interfaces are auto-discovered and registered as beans — no configuration needed; just inject them. Only when using plain `storm-kotlin-spring` (no starter) do you define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages`. ```kotlin @Service class UserService(private val userRepository: UserRepository) { @@ -478,25 +478,21 @@ class UserService(private val userRepository: UserRepository) { } ``` -### Ktor -Use `transaction { }` blocks: +### Ktor / Standalone + +Use the top-level `transaction { }` function (import `st.orm.template.transaction`). It is a **suspend function**, not a method on `ORMTemplate` — never write `orm.transaction { }` or `call.orm.transaction { }`. The lambda receiver is a `Transaction` (exposing `setRollbackOnly()`, `onCommit { }`, `onRollback { }`) — it does NOT provide `entity(...)`; use repositories or `orm` captured from the enclosing scope: + ```kotlin get("/users") { - call.orm.transaction { - val users = entity(User::class).findAll() - call.respond(users) + val users = call.repository() + transaction { + // All operations within the block share the same transaction. + call.respond(users.findAll()) } } ``` -### Standalone -Use programmatic `transaction { }` blocks on the ORM template: -```kotlin -orm.transaction { - val user = entity(User::class).insertAndFetch(User(email = "alice@example.com", city = city)) - // All operations within the block share the same transaction. -} -``` +Outside a coroutine, use `transactionBlocking { }` instead. Transaction options are available via `withTransactionOptions { }` (isolation via `TransactionIsolation`, propagation via `TransactionPropagation`). ## Block-Based Query DSL @@ -568,9 +564,9 @@ users.select(User_.active eq true).resultList users.delete(User_.active eq false).executeUpdate() ``` -Standalone usage on `ORMTemplate`: +Standalone usage via `ORMTemplate` — note there is **no** `orm.select { block }` reified form; get the entity repository first: ```kotlin -val users = orm.select { +val users = orm.entity().select { where(User_.name eq "Alice") orderBy(User_.email) limit(10) @@ -584,7 +580,9 @@ After writing repository methods, write a test using `@StormTest` and `SqlCaptur Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the repository method produces the query the user intended — correct tables joined, correct columns filtered, correct ordering, correct number of statements. This is Storm's verify-then-trust pattern. ```kotlin -@StormTest(scripts = ["schema.sql", "data.sql"]) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +// Without it, paths resolve relative to the test class's package. +@StormTest(scripts = ["/schema.sql", "/data.sql"]) class UserRepositoryTest { @Test fun findByCity(orm: ORMTemplate, capture: SqlCapture) { @@ -601,6 +599,8 @@ class UserRepositoryTest { Run the test. Show the user the captured SQL and explain how it aligns with the intended behavior. If a query produces unexpected SQL or the right approach is unclear, ask the user for feedback before changing the query. +**SQL visibility outside tests:** annotate a repository interface or individual method with `@SqlLog` (`st.orm.SqlLog`) to log the generated SQL at runtime — useful for debugging without a test harness. + **Test isolation:** `SqlCapture` accumulates SQL across the entire test method. When writing multiple verification tests in one class, use `capture.clear()` between logical operations, or put each verification in its own `@Test` method. To avoid order-dependent failures, make assertions idempotent (don't assume specific row counts from prior inserts in other test methods) or use `@TestMethodOrder(MethodOrderer.OrderAnnotation::class)` with `@Order` if test ordering matters. The test can be temporary — verify and remove, or keep as a regression test. Ask the user which they prefer. diff --git a/website/static/skills/storm-serialization-java.md b/website/static/skills/storm-serialization-java.md index 125c3cf66..c28ab01d2 100644 --- a/website/static/skills/storm-serialization-java.md +++ b/website/static/skills/storm-serialization-java.md @@ -45,7 +45,7 @@ The format is fully round-trippable. ## Rules -- Refs deserialized from JSON are **detached**: they carry the ID but have no database connection. Calling `fetch()` on a deserialized ref throws `PersistenceException`. Use the deserialized ID to query the database directly. +- Refs deserialized from JSON are **detached** by default: they carry the ID but have no database connection. Calling `fetch()` on a deserialized ref throws `PersistenceException`. Use the deserialized ID to query the database directly. (Supplying a `RefFactory` to `StormModule` yields attached, fetchable refs instead.) - Entities without `Ref` fields need no Storm module registration. - Both Jackson modules (`storm-jackson2`, `storm-jackson3`) provide the same `StormModule` API. - Jackson supports `java.time` natively via the `jackson-datatype-jsr310` module (included by Spring Boot). diff --git a/website/static/skills/storm-serialization-kotlin.md b/website/static/skills/storm-serialization-kotlin.md index 99a2727c6..0f8889100 100644 --- a/website/static/skills/storm-serialization-kotlin.md +++ b/website/static/skills/storm-serialization-kotlin.md @@ -50,6 +50,8 @@ install(ContentNegotiation) { ## Kotlinx Serialization Setup +Both `StormSerializersModule()` and the pre-built `StormSerializers` instance live in package `st.orm.serialization` (import `st.orm.serialization.StormSerializersModule` / `st.orm.serialization.StormSerializers`): + ```kotlin val json = Json { serializersModule = StormSerializersModule() @@ -81,7 +83,7 @@ data class TeamMembers( ) ``` -Without `@Contextual`, the kotlinx compiler plugin tries to serialize `Ref` directly and fails at runtime with "RefImpl is not found in the polymorphic scope". +Without `@Contextual`, the kotlinx compiler plugin tries to serialize `Ref` directly and fails at runtime with "Class 'RefImpl' is not registered for polymorphic serialization in the scope of 'Ref'". ## Kotlinx Serialization Cascade Rule @@ -136,8 +138,8 @@ The format is fully round-trippable. Jackson and kotlinx.serialization produce i ## Rules -- Refs deserialized from JSON are **detached**: they carry the ID but have no database connection. Calling `fetch()` on a deserialized ref throws `PersistenceException`. Use the deserialized ID to query the database directly. +- Refs deserialized from JSON are **detached** by default: they carry the ID but have no database connection. Calling `fetch()` on a deserialized ref throws `PersistenceException`. Use the deserialized ID to query the database directly. (Supplying a `RefFactory` to the Storm module yields attached, fetchable refs instead.) - Entities without `Ref` fields need no Storm module registration. - Both Jackson modules (`storm-jackson2`, `storm-jackson3`) provide the same `StormModule` API. - **Kotlinx cascade**: if an entity is `@Serializable`, every entity it references (directly or via `Ref`) must also be `@Serializable`. -- **`@Contextual` on `Ref`**: without it, the kotlinx compiler plugin tries to serialize `Ref` directly and fails at runtime with "RefImpl is not found in the polymorphic scope". +- **`@Contextual` on `Ref`**: without it, the kotlinx compiler plugin tries to serialize `Ref` directly and fails at runtime with "Class 'RefImpl' is not registered for polymorphic serialization in the scope of 'Ref'". diff --git a/website/static/skills/storm-setup.md b/website/static/skills/storm-setup.md index 5292adc16..63aeeb478 100644 --- a/website/static/skills/storm-setup.md +++ b/website/static/skills/storm-setup.md @@ -6,19 +6,19 @@ Before suggesting dependencies, read the project's build file (pom.xml, build.gr - Language and version (Kotlin version from kotlin plugin, Java version from sourceCompatibility/release) - Existing dependencies (Spring Boot, Ktor, database driver, etc.) - If no Storm version is specified in the project, use version `@@STORM_VERSION@@` -- If no Kotlin version is specified in the project, use Kotlin `2.3.20` (the current stable release) -- If no KSP version is specified in the project, use KSP `2.3.6` (the current stable release) -- If no Spring Boot version is specified, use Spring Boot `4.0.5` (the current stable release) +- If no Kotlin version is specified in the project, use the latest stable Kotlin release that Storm supports (any 2.0.x–2.4.x — the compiler plugin ships variants `storm-compiler-plugin-2.0` through `-2.4`) +- KSP plugin versions are always prefixed with the Kotlin version: `-` (e.g. `2.0.21-1.0.28` for Kotlin 2.0.21). Pick the KSP release matching the project's Kotlin version — a bare version like `2.3.6` is NOT a valid KSP plugin version +- If no Spring Boot version is specified, use the current stable Spring Boot release (3.x works with `storm-jackson2`, 4.x with `storm-jackson3`) ## Core Dependencies ### Kotlin (Gradle) - Recommended -**Important:** The KSP plugin version must match the project's Kotlin version. Declare it in `plugins { }`: +**Important:** The KSP plugin version must match the project's Kotlin version — it is always `-`. Declare it in `plugins { }`: ```kotlin plugins { kotlin("jvm") version "" - id("com.google.devtools.ksp") version "-" // e.g., 2.3.6 + id("com.google.devtools.ksp") version "-" // e.g., 2.0.21-1.0.28 for Kotlin 2.0.21 } ``` @@ -37,7 +37,7 @@ dependencies { } ``` -Match the compiler plugin suffix to the project's Kotlin version: 2.0.x uses `storm-compiler-plugin-2.0`, 2.1.x uses `storm-compiler-plugin-2.1`, etc. +Match the compiler plugin suffix to the project's Kotlin version: 2.0.x uses `storm-compiler-plugin-2.0`, 2.1.x uses `storm-compiler-plugin-2.1`, and so on. Published variants: `-2.0`, `-2.1`, `-2.2`, `-2.3`, `-2.4` (Kotlin 2.0–2.4), all version-managed by the Storm BOM. ### Kotlin (Maven) - Import `st.orm:storm-bom` in dependencyManagement @@ -59,12 +59,12 @@ Match the compiler plugin suffix to the project's Kotlin version: 2.0.x uses `st - Kotlin: `st.orm:storm-kotlin-spring-boot-starter` (replaces `storm-kotlin` + `storm-core`) - Java: `st.orm:storm-spring-boot-starter` (replaces `storm-java21` + `storm-core`) - These include auto-configuration: `ORMTemplate` is auto-registered as a Spring bean -- Repositories are discoverable via `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` +- The starters also auto-discover repository interfaces and register them as beans — no configuration needed. Only plain `storm-spring`/`storm-kotlin-spring` (without the starter) requires defining a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` ### Ktor - Kotlin: `st.orm:storm-ktor` - Optionally: `st.orm:storm-ktor-test` (test scope, for `testStormApplication` DSL) -- Requires `com.zaxxer:HikariCP` for connection pooling (unless providing your own DataSource) +- Add `com.zaxxer:HikariCP` when using the built-in HOCON-configured DataSource (`storm.datasource.jdbcUrl` etc. in application.conf) — not needed when passing your own DataSource to `install(Storm)` - Install with `install(Storm)`, access ORM via `call.orm` in routes - Register repositories via `stormRepositories { register(UserRepository::class) }` @@ -95,7 +95,7 @@ Serialization (pick one if needed): Testing: - `st.orm:storm-test` (test scope) — provides `@StormTest`, `SqlCapture`, and H2 in-memory database support - `st.orm:storm-h2` (test runtime scope) — Storm's H2 dialect -- `com.h2database:h2:2.3.232` (test runtime scope) — the H2 JDBC driver itself (required — `storm-h2` declares it as `provided`, and H2 is **not** version-managed by the Storm BOM, so specify the version explicitly) +- `com.h2database:h2:2.3.232` (test runtime scope) — the H2 JDBC driver itself (required — the driver is not a transitive dependency of `storm-h2`, and H2 is **not** version-managed by the Storm BOM, so specify the version explicitly) - All three are needed. Without the H2 driver, `@StormTest` fails with `No suitable driver found`. - Key imports: `st.orm.test.StormTest`, `st.orm.test.SqlCapture`, `st.orm.test.CapturedSql.Operation` - `@StormTest` injects `ORMTemplate` and `SqlCapture` as test method parameters @@ -119,8 +119,10 @@ Database dialects (add as runtime dependency): - `st.orm:storm-mariadb` - `st.orm:storm-oracle` - `st.orm:storm-mssqlserver` +- `st.orm:storm-sqlite` +- `st.orm:storm-h2` (also usable as a runtime dialect, not just for tests) -**Auto-validation on startup:** Storm automatically validates all registered entity types against the database schema on startup, logging "Successfully validated N Data types for correctness". This provides free schema validation without writing explicit tests. +**Validation on startup:** Storm automatically validates the *structure* of all discovered entity types (PK/FK/inline consistency, cyclic references) when the `ORMTemplate` is created, logging "Successfully validated N Data types for correctness". This does NOT check the database schema. Schema validation is a separate, opt-in step: call `validateSchema()`/`validateSchemaOrThrow()` (e.g. at startup or in a `@StormTest`), or in Ktor set `storm.validation.schemaMode` in the plugin config to run it automatically at startup. After configuring dependencies, remind the user to rebuild so the metamodel classes are generated. diff --git a/website/static/skills/storm-sql-java.md b/website/static/skills/storm-sql-java.md index f07dede2b..3f049d281 100644 --- a/website/static/skills/storm-sql-java.md +++ b/website/static/skills/storm-sql-java.md @@ -96,7 +96,8 @@ After writing SQL templates, write a test using `@StormTest` and `SqlCapture` to Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the SQL template produces the result the user intended — correct tables joined, correct grouping, correct aggregation. This is Storm's verify-then-trust pattern. ```java -@StormTest(scripts = {"schema.sql", "data.sql"}) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +@StormTest(scripts = {"/schema.sql", "/data.sql"}) class CityCountQueryTest { @Test void citiesWithUserCounts(ORMTemplate orm, SqlCapture capture) { diff --git a/website/static/skills/storm-sql-kotlin.md b/website/static/skills/storm-sql-kotlin.md index 0bc968eb0..af9fe59f9 100644 --- a/website/static/skills/storm-sql-kotlin.md +++ b/website/static/skills/storm-sql-kotlin.md @@ -82,7 +82,7 @@ The `Data` interface marks types for SQL generation without CRUD. It tells Storm All interpolated values become bind parameters. SQL injection safe by design. -**Note:** `Query.resultList` (Kotlin property, no type parameter) returns `List>`. For typed results, use `query.resultList()` or `query.getResultList(T::class)`. This is different from QueryBuilder's `.resultList` which returns `List` already typed to the query's result type. +**Note:** `Query.resultList` (Kotlin property, no type parameter) returns `List>` — raw rows. For typed results, use the reified extension `query.resultList()` (since 1.12, `import st.orm.template.resultList`), or `query.getResultList(T::class)` on earlier versions. Reified counterparts also exist for `singleResult()`, `optionalResult()`, `resultStream()`, and `resultFlow()`. This is different from QueryBuilder's `.resultList` which returns `List` already typed to the query's result type. Critical rules: - **Always use lambdas, never `TemplateString.raw()`**: Template expressions should always be written as lambdas (`{ "..." }`) so the compiler plugin can process them. Never construct `TemplateString.raw("...")` manually. @@ -116,7 +116,8 @@ After writing SQL templates, write a test using `@StormTest` and `SqlCapture` to Tell the user what you are doing and why: explain that `SqlCapture` records every SQL statement Storm generates. The goal is not to test Storm itself, but to verify that the SQL template produces the result the user intended — correct tables joined, correct grouping, correct aggregation. This is Storm's verify-then-trust pattern. ```kotlin -@StormTest(scripts = ["schema.sql", "data.sql"]) +// Leading "/" resolves scripts from the classpath root (src/test/resources/). +@StormTest(scripts = ["/schema.sql", "/data.sql"]) class CityCountQueryTest { @Test fun citiesWithUserCounts(orm: ORMTemplate, capture: SqlCapture) { diff --git a/website/static/skills/storm-validate.md b/website/static/skills/storm-validate.md index 7a243e301..413ed136f 100644 --- a/website/static/skills/storm-validate.md +++ b/website/static/skills/storm-validate.md @@ -29,16 +29,13 @@ Projections intentionally map a subset of columns; do not flag extra DB columns After LLM-assisted entity changes, write a targeted test to verify only the affected entities: ```kotlin -@StormTest(scripts = ["schema.sql"]) +@StormTest(scripts = ["/schema.sql"]) class EntitySchemaTest { @Test fun validateNewEntities(orm: ORMTemplate) { // Validate only the specific entities that were created or modified. - val errors = orm.validateSchema(listOf( - User::class.java, - City::class.java, - Address::class.java - )) + // Kotlin: vararg KClass form. (Java: orm.validateSchema(List.of(User.class, ...))) + val errors = orm.validateSchema(User::class, City::class, Address::class) assertTrue(errors.isEmpty()) { "Schema validation errors: $errors" } } } From 0d37b377fd30c3bd9a40ebada0f46bbd2fe79d8a Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 2 Jul 2026 08:07:38 +0200 Subject: [PATCH 2/5] docs(website): simplify landing aggregate example to usersPerCity Drop orderByDescending/limit from the repository aggregate example and rename topCities(country, limit) to usersPerCity(country); align the Show SQL panel accordingly. --- website/src/pages/index.js | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 3dbcf3850..abb9fe067 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -277,12 +277,10 @@ export default function Home() { K("interface "),T("UserRepository"),P(" : "),T("EntityRepository"),P("<"),T("User"),P(", "),T("Int"),P("> {\n"), P(" "),K("fun "),F("findByCity"),P("(city: "),T("City"),P(") = "),F("findAll"),P("(User_.city "),K("eq "),P("city)\n\n"), C(" // Query builder with SQL templates for the aggregate.\n"), - P(" "),K("fun "),F("topCities"),P("(country: "),T("String"),P(", limit: "),T("Int"),P(") =\n"), + P(" "),K("fun "),F("usersPerCity"),P("(country: "),T("String"),P(") =\n"), P(" "),F("select"),P("("),T("CityCount"),P("::"),K("class"),P(") { "),S('"${City::class}, COUNT(*)"'),P(" }\n"), P(" ."),F("where"),P("(User_.city.country "),K("eq "),P("country)\n"), P(" ."),F("groupBy"),P("(User_.city)\n"), - P(" ."),F("orderByDescending"),P(" { "),S('"COUNT(*)"'),P(" }\n"), - P(" ."),F("limit"),P("(limit)\n"), P(" .resultList\n"), P("}") ] }, @@ -349,14 +347,12 @@ export default function Home() { 'FROM "user" u\n'+ 'INNER JOIN city c ON c.id = u.city_id\n'+ 'WHERE u.city_id = ?\n\n'+ - '-- topCities(country, limit)\n'+ + '-- usersPerCity(country)\n'+ 'SELECT c.id, c.name, c.population, c.country, COUNT(*)\n'+ 'FROM "user" u\n'+ 'INNER JOIN city c ON c.id = u.city_id\n'+ 'WHERE c.country = ?\n'+ - 'GROUP BY u.city_id\n'+ - 'ORDER BY COUNT(*) DESC\n'+ - 'LIMIT ?', + 'GROUP BY u.city_id', '-- the second block wraps these two inserts in one configured transaction:\n'+ '-- transaction(REQUIRES_NEW, REPEATABLE_READ, timeoutSeconds = 5)\n'+ From 2b0823bb27375e10fe5360ef86b2863945de7c4e Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 2 Jul 2026 08:13:31 +0200 Subject: [PATCH 3/5] docs(website): lead the landing sql scene with a plain-SQL example Show plain SQL mapped to an ad-hoc data class (CityCount) before the templated query, and add the passed-through SQL to the Show SQL panel. --- website/src/pages/index.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/website/src/pages/index.js b/website/src/pages/index.js index abb9fe067..4be2c39d8 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -302,7 +302,15 @@ export default function Home() { { name:'5 · sql', file:'UserService.kt', caption:"full SQL when you want it — never locked in", - code:[ C("// Need full control of SQL? Use the powerful template engine behind the ORM.\n// You can also use plain SQL here.\n"), + code:[ C("// Need full control of SQL? Plain SQL works — rows map to any data class.\n"), + K("data class "),T("CityCount"),P("("),K("val "),P("city: "),T("String"),P(", "),K("val "),P("count: "),T("Long"),P(")\n\n"), + K("val "),P("cityCounts = orm."),F("query"),P(" { "),S('"""'),P("\n"), + P(" "),K("SELECT "),P("c.name, COUNT(*)\n"), + P(" "),K("FROM "),P('"user" u\n'), + P(" "),K("INNER JOIN "),P("city c "),K("ON "),P("u.city_id = c.id\n"), + P(" "),K("GROUP BY "),P("c.id\n"), + S('"""'),P(" }."),F("resultList"),P("<"),T("CityCount"),P(">()\n\n"), + C("// Or use the powerful template engine behind the ORM.\n"), K("val "),P("users = orm."),F("query"),P(" { "),S('"""'),P("\n"), P(" "),K("SELECT "),T("${User::class}"),P("\n"), P(" "),K("FROM "),T("${User::class}"),P("\n"), @@ -363,6 +371,11 @@ export default function Home() { 'COMMIT\n'+ '-- onCommit hook runs here, only after COMMIT succeeds', + '-- plain SQL runs exactly as written\n'+ + 'SELECT c.name, COUNT(*)\n'+ + 'FROM "user" u\n'+ + 'INNER JOIN city c ON u.city_id = c.id\n'+ + 'GROUP BY c.id\n\n'+ '-- ${User::class} expands to columns · $city becomes ?\n'+ 'SELECT u.id, u.email, u.name, c.id, c.name, c.population, c.country\n'+ 'FROM "user" u\n'+ From 32484479f6b042335f47de20726c7b62bee6d9b4 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 2 Jul 2026 08:37:30 +0200 Subject: [PATCH 4/5] docs(website): rework landing sql scene with window-function example Lead with plain SQL the QueryBuilder can't express (RANK() OVER), filtered by a $country bind variable, mapped to an ad-hoc data class. Annotate bind variables inline with SQL comments in both queries. --- website/src/pages/index.js | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 4be2c39d8..2d44650b4 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -303,19 +303,19 @@ export default function Home() { { name:'5 · sql', file:'UserService.kt', caption:"full SQL when you want it — never locked in", code:[ C("// Need full control of SQL? Plain SQL works — rows map to any data class.\n"), - K("data class "),T("CityCount"),P("("),K("val "),P("city: "),T("String"),P(", "),K("val "),P("count: "),T("Long"),P(")\n\n"), - K("val "),P("cityCounts = orm."),F("query"),P(" { "),S('"""'),P("\n"), - P(" "),K("SELECT "),P("c.name, COUNT(*)\n"), - P(" "),K("FROM "),P('"user" u\n'), - P(" "),K("INNER JOIN "),P("city c "),K("ON "),P("u.city_id = c.id\n"), - P(" "),K("GROUP BY "),P("c.id\n"), - S('"""'),P(" }."),F("resultList"),P("<"),T("CityCount"),P(">()\n\n"), + K("data class "),T("RankedCity"),P("("),K("val "),P("name: "),T("String"),P(", "),K("val "),P("population: "),T("Int"),P(", "),K("val "),P("rank: "),T("Long"),P(")\n\n"), + K("val "),P("ranked = orm."),F("query"),P(" { "),S('"""'),P("\n"), + P(" "),K("SELECT "),P("name, population,\n"), + P(" RANK() "),K("OVER"),P(" ("),K("ORDER BY "),P("population "),K("DESC"),P(")\n"), + P(" "),K("FROM "),P("city\n"), + P(" "),K("WHERE "),P("country = "),T("$country"),P(" "),C("-- bind variable\n"), + S('"""'),P(" }."),F("resultList"),P("<"),T("RankedCity"),P(">()\n\n"), C("// Or use the powerful template engine behind the ORM.\n"), K("val "),P("users = orm."),F("query"),P(" { "),S('"""'),P("\n"), P(" "),K("SELECT "),T("${User::class}"),P("\n"), P(" "),K("FROM "),T("${User::class}"),P("\n"), - P(" "),K("WHERE "),T("${User_.city.name}"),P(" = "),T("$city"),P("\n"), - S('"""'),P(" }."),F("resultList"),P("<"),T("User"),P(">()"),P(" "),C("// $city → bind variable, never concatenated") ] }, + P(" "),K("WHERE "),T("${User_.city.name}"),P(" = "),T("$city"),P(" "),C("-- bind variable\n"), + S('"""'),P(" }."),F("resultList"),P("<"),T("User"),P(">()") ] }, { name:'6 · principles', file:'Core Principles', caption:"the core principles", @@ -371,11 +371,11 @@ export default function Home() { 'COMMIT\n'+ '-- onCommit hook runs here, only after COMMIT succeeds', - '-- plain SQL runs exactly as written\n'+ - 'SELECT c.name, COUNT(*)\n'+ - 'FROM "user" u\n'+ - 'INNER JOIN city c ON u.city_id = c.id\n'+ - 'GROUP BY c.id\n\n'+ + '-- plain SQL passes through · $country becomes ?\n'+ + 'SELECT name, population,\n'+ + ' RANK() OVER (ORDER BY population DESC)\n'+ + 'FROM city\n'+ + 'WHERE country = ?\n\n'+ '-- ${User::class} expands to columns · $city becomes ?\n'+ 'SELECT u.id, u.email, u.name, c.id, c.name, c.population, c.country\n'+ 'FROM "user" u\n'+ From 23a7a34f5258a8d49b3579cdf6f22dad0c38cd53 Mon Sep 17 00:00:00 2001 From: Leon van Zantvoort Date: Thu, 2 Jul 2026 08:37:35 +0200 Subject: [PATCH 5/5] docs: retarget new API since-tags to 1.11 and drop versions from skills The next release is 1.11.7 rather than 1.12.0; @since tags follow the project's major.minor convention. Also corrects the stale @since 1.12 on TransactionContext, which shipped in 1.11.4. Skill files no longer reference concrete Storm versions. --- .../st/orm/core/spi/TransactionContext.java | 2 +- .../st/orm/repository/EntityRepository.java | 48 +++++++++---------- .../orm/repository/ProjectionRepository.java | 40 ++++++++-------- .../src/main/kotlin/st/orm/template/Query.kt | 10 ++-- .../static/skills/storm-repository-java.md | 4 +- website/static/skills/storm-sql-kotlin.md | 2 +- 6 files changed, 53 insertions(+), 53 deletions(-) diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java b/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java index b629767bf..f752e706f 100644 --- a/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java +++ b/storm-core/src/main/java/st/orm/core/spi/TransactionContext.java @@ -34,7 +34,7 @@ public interface TransactionContext { * when no transaction is currently active.

* * @return a description of the transaction characteristics, or empty if not available. - * @since 1.12 + * @since 1.11 */ default Optional describe() { return Optional.empty(); diff --git a/storm-java21/src/main/java/st/orm/repository/EntityRepository.java b/storm-java21/src/main/java/st/orm/repository/EntityRepository.java index 57513be87..11551bd5b 100644 --- a/storm-java21/src/main/java/st/orm/repository/EntityRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/EntityRepository.java @@ -648,7 +648,7 @@ public interface EntityRepository, ID> extends Repository { * @return the entity matching the given field value, or empty if none exists. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional findBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getOptionalResult(); @@ -662,7 +662,7 @@ default Optional findBy(@Nonnull Metamodel field, @Nonnull V value) * @return the entity matching the given ref value, or empty if none exists. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional findBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getOptionalResult(); @@ -676,7 +676,7 @@ default Optional findBy(@Nonnull Metamodel field, @Non * @return a list of matching entities, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List findAllBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getResultList(); @@ -690,7 +690,7 @@ default List findAllBy(@Nonnull Metamodel field, @Nonnull V value) * @return a list of matching entities, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List findAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getResultList(); @@ -704,7 +704,7 @@ default List findAllBy(@Nonnull Metamodel field, @Nonn * @return a list of matching entities, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List findAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { return select().where(field, IN, values).getResultList(); @@ -718,7 +718,7 @@ default List findAllBy(@Nonnull Metamodel field, @Nonnull Iterable< * @return a list of matching entities, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List findAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { return select().whereRef(field, values).getResultList(); @@ -734,7 +734,7 @@ default List findAllByRef(@Nonnull Metamodel field, @N * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default E getBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getSingleResult(); @@ -750,7 +750,7 @@ default E getBy(@Nonnull Metamodel field, @Nonnull V value) { * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default E getBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getSingleResult(); @@ -764,7 +764,7 @@ default E getBy(@Nonnull Metamodel field, @Nonnull Ref * @return a ref to the matching entity, or empty if none exists. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getOptionalResult(); @@ -778,7 +778,7 @@ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull * @return a ref to the matching entity, or empty if none exists. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getOptionalResult(); @@ -792,7 +792,7 @@ default Optional> findRefBy(@Nonnull Metamodel fie * @return a list of refs to matching entities, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getResultList(); @@ -806,7 +806,7 @@ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V * @return a list of refs to matching entities, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getResultList(); @@ -820,7 +820,7 @@ default List> findAllRefBy(@Nonnull Metamodel fiel * @return a list of refs to matching entities, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Iterable values) { return selectRef().where(field, IN, values).getResultList(); @@ -834,7 +834,7 @@ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull I * @return a list of refs to matching entities, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { return selectRef().whereRef(field, values).getResultList(); @@ -850,7 +850,7 @@ default List> findAllRefByRef(@Nonnull Metamodel f * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Ref getRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getSingleResult(); @@ -866,7 +866,7 @@ default Ref getRefBy(@Nonnull Metamodel field, @Nonnull V value) { * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Ref getRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getSingleResult(); @@ -880,7 +880,7 @@ default Ref getRefBy(@Nonnull Metamodel field, @Nonnul * @return the count of matching entities. * @param the type of the field. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default long countBy(@Nonnull Metamodel field, @Nonnull V value) { return selectCount().where(field, EQUALS, value).getSingleResult(); @@ -894,7 +894,7 @@ default long countBy(@Nonnull Metamodel field, @Nonnull V value) { * @return the count of matching entities. * @param the type of the referenced entity. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default long countBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectCount().where(field, value).getSingleResult(); @@ -908,7 +908,7 @@ default long countBy(@Nonnull Metamodel field, @Nonnull R * @return true if any matching entities exist, false otherwise. * @param the type of the field. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { return countBy(field, value) > 0; @@ -922,7 +922,7 @@ default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { * @return true if any matching entities exist, false otherwise. * @param the type of the referenced entity. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default boolean existsBy(@Nonnull Metamodel field, @Nonnull Ref value) { return countBy(field, value) > 0; @@ -936,7 +936,7 @@ default boolean existsBy(@Nonnull Metamodel field, @Nonnu * @return the number of entities removed. * @param the type of the field. * @throws PersistenceException if the removal operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default int removeAllBy(@Nonnull Metamodel field, @Nonnull V value) { return delete().where(field, EQUALS, value).executeUpdate(); @@ -950,7 +950,7 @@ default int removeAllBy(@Nonnull Metamodel field, @Nonnull V value) { * @return the number of entities removed. * @param the type of the referenced entity. * @throws PersistenceException if the removal operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default int removeAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { return delete().where(field, value).executeUpdate(); @@ -964,7 +964,7 @@ default int removeAllBy(@Nonnull Metamodel field, @Nonnul * @return the number of entities removed. * @param the type of the field. * @throws PersistenceException if the removal operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default int removeAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { return delete().where(field, IN, values).executeUpdate(); @@ -978,7 +978,7 @@ default int removeAllBy(@Nonnull Metamodel field, @Nonnull Iterable the type of the referenced entity. * @throws PersistenceException if the removal operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default int removeAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { return delete().whereRef(field, values).executeUpdate(); diff --git a/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java b/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java index a5a2c5aa6..802a47e10 100644 --- a/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java @@ -301,7 +301,7 @@ public interface ProjectionRepository

, ID> extends Repo * @return the projection matching the given field value, or empty if none exists. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional

findBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getOptionalResult(); @@ -315,7 +315,7 @@ default Optional

findBy(@Nonnull Metamodel field, @Nonnull V value) * @return the projection matching the given ref value, or empty if none exists. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional

findBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getOptionalResult(); @@ -329,7 +329,7 @@ default Optional

findBy(@Nonnull Metamodel field, @Non * @return a list of matching projections, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List

findAllBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getResultList(); @@ -343,7 +343,7 @@ default List

findAllBy(@Nonnull Metamodel field, @Nonnull V value) * @return a list of matching projections, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List

findAllBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getResultList(); @@ -357,7 +357,7 @@ default List

findAllBy(@Nonnull Metamodel field, @Nonn * @return a list of matching projections, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List

findAllBy(@Nonnull Metamodel field, @Nonnull Iterable values) { return select().where(field, IN, values).getResultList(); @@ -371,7 +371,7 @@ default List

findAllBy(@Nonnull Metamodel field, @Nonnull Iterable< * @return a list of matching projections, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List

findAllByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { return select().whereRef(field, values).getResultList(); @@ -387,7 +387,7 @@ default List

findAllByRef(@Nonnull Metamodel field, @N * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default P getBy(@Nonnull Metamodel field, @Nonnull V value) { return select().where(field, EQUALS, value).getSingleResult(); @@ -403,7 +403,7 @@ default P getBy(@Nonnull Metamodel field, @Nonnull V value) { * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default P getBy(@Nonnull Metamodel field, @Nonnull Ref value) { return select().where(field, value).getSingleResult(); @@ -417,7 +417,7 @@ default P getBy(@Nonnull Metamodel field, @Nonnull Ref * @return a ref to the matching projection, or empty if none exists. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getOptionalResult(); @@ -431,7 +431,7 @@ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull * @return a ref to the matching projection, or empty if none exists. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Optional> findRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getOptionalResult(); @@ -445,7 +445,7 @@ default Optional> findRefBy(@Nonnull Metamodel fie * @return a list of refs to matching projections, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getResultList(); @@ -459,7 +459,7 @@ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull V * @return a list of refs to matching projections, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getResultList(); @@ -473,7 +473,7 @@ default List> findAllRefBy(@Nonnull Metamodel fiel * @return a list of refs to matching projections, or an empty list if none found. * @param the type of the field. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull Iterable values) { return selectRef().where(field, IN, values).getResultList(); @@ -487,7 +487,7 @@ default List> findAllRefBy(@Nonnull Metamodel field, @Nonnull I * @return a list of refs to matching projections, or an empty list if none found. * @param the type of the referenced entity. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default List> findAllRefByRef(@Nonnull Metamodel field, @Nonnull Iterable> values) { return selectRef().whereRef(field, values).getResultList(); @@ -503,7 +503,7 @@ default List> findAllRefByRef(@Nonnull Metamodel f * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Ref

getRefBy(@Nonnull Metamodel field, @Nonnull V value) { return selectRef().where(field, EQUALS, value).getSingleResult(); @@ -519,7 +519,7 @@ default Ref

getRefBy(@Nonnull Metamodel field, @Nonnull V value) { * @throws NoResultException if there is no result. * @throws NonUniqueResultException if more than one result. * @throws PersistenceException if the retrieval operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default Ref

getRefBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectRef().where(field, value).getSingleResult(); @@ -533,7 +533,7 @@ default Ref

getRefBy(@Nonnull Metamodel field, @Nonnul * @return the count of matching projections. * @param the type of the field. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default long countBy(@Nonnull Metamodel field, @Nonnull V value) { return selectCount().where(field, EQUALS, value).getSingleResult(); @@ -547,7 +547,7 @@ default long countBy(@Nonnull Metamodel field, @Nonnull V value) { * @return the count of matching projections. * @param the type of the referenced entity. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default long countBy(@Nonnull Metamodel field, @Nonnull Ref value) { return selectCount().where(field, value).getSingleResult(); @@ -561,7 +561,7 @@ default long countBy(@Nonnull Metamodel field, @Nonnull R * @return true if any matching projections exist, false otherwise. * @param the type of the field. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { return countBy(field, value) > 0; @@ -575,7 +575,7 @@ default boolean existsBy(@Nonnull Metamodel field, @Nonnull V value) { * @return true if any matching projections exist, false otherwise. * @param the type of the referenced entity. * @throws PersistenceException if the count operation fails due to underlying database issues. - * @since 1.12 + * @since 1.11 */ default boolean existsBy(@Nonnull Metamodel field, @Nonnull Ref value) { return countBy(field, value) > 0; diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt index 9a3bfb227..5e5845695 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/Query.kt @@ -375,7 +375,7 @@ interface Query { * @throws st.orm.NonUniqueResultException if more than one result. * @throws st.orm.PersistenceException if the query fails. * @see Query.getSingleResult - * @since 1.12 + * @since 1.11 */ inline fun Query.singleResult(): T = getSingleResult(T::class) @@ -389,7 +389,7 @@ inline fun Query.singleResult(): T = getSingleResult(T::class) * @throws st.orm.NonUniqueResultException if more than one result. * @throws st.orm.PersistenceException if the query fails. * @see Query.getOptionalResult - * @since 1.12 + * @since 1.11 */ inline fun Query.optionalResult(): T? = getOptionalResult(T::class) @@ -410,7 +410,7 @@ inline fun Query.optionalResult(): T? = getOptionalResult(T::c * @return the result list. * @throws st.orm.PersistenceException if the query fails. * @see Query.getResultList - * @since 1.12 + * @since 1.11 */ inline fun Query.resultList(): List = getResultList(T::class) @@ -430,7 +430,7 @@ inline fun Query.resultList(): List = getResultList(T::clas * @throws st.orm.PersistenceException if the query operation fails due to underlying database issues, such as * connectivity. * @see Query.getResultStream - * @since 1.12 + * @since 1.11 */ inline fun Query.resultStream(): Stream = getResultStream(T::class) @@ -445,6 +445,6 @@ inline fun Query.resultStream(): Stream = getResultStream(T * @throws st.orm.PersistenceException if the query operation fails due to underlying database issues, such as * connectivity. * @see Query.getResultFlow - * @since 1.12 + * @since 1.11 */ inline fun Query.resultFlow(): Flow = getResultFlow(T::class) diff --git a/website/static/skills/storm-repository-java.md b/website/static/skills/storm-repository-java.md index 51a3b61eb..ba5b35b92 100644 --- a/website/static/skills/storm-repository-java.md +++ b/website/static/skills/storm-repository-java.md @@ -164,7 +164,7 @@ Java records are immutable. For convenient copy-with-modification, consider Lomb ## Field-Based Lookups -Query by a specific metamodel field without writing a full QueryBuilder chain (requires Storm 1.12+; on older versions use `select().where(...)`): +Query by a specific metamodel field without writing a full QueryBuilder chain: ```java // Find by field value @@ -184,7 +184,7 @@ boolean exists = users.existsBy(User_.email, "alice@example.com"); int deleted = users.removeAllBy(User_.city, Ref.of(city)); ``` -Field-based methods accept a `Ref` value for FK fields. Unique-key fields (`@PK`/`@UK`) additionally have `Metamodel.Key`-typed overloads (`findBy`, `getBy`, `findByRef`, `getByRef`) that were available before 1.12. +Field-based methods accept a `Ref` value for FK fields. Unique-key fields (`@PK`/`@UK`) additionally have `Metamodel.Key`-typed overloads (`findBy`, `getBy`, `findByRef`, `getByRef`). ## Ref-Based Operations diff --git a/website/static/skills/storm-sql-kotlin.md b/website/static/skills/storm-sql-kotlin.md index af9fe59f9..de886d183 100644 --- a/website/static/skills/storm-sql-kotlin.md +++ b/website/static/skills/storm-sql-kotlin.md @@ -82,7 +82,7 @@ The `Data` interface marks types for SQL generation without CRUD. It tells Storm All interpolated values become bind parameters. SQL injection safe by design. -**Note:** `Query.resultList` (Kotlin property, no type parameter) returns `List>` — raw rows. For typed results, use the reified extension `query.resultList()` (since 1.12, `import st.orm.template.resultList`), or `query.getResultList(T::class)` on earlier versions. Reified counterparts also exist for `singleResult()`, `optionalResult()`, `resultStream()`, and `resultFlow()`. This is different from QueryBuilder's `.resultList` which returns `List` already typed to the query's result type. +**Note:** `Query.resultList` (Kotlin property, no type parameter) returns `List>` — raw rows. For typed results, use the reified extension `query.resultList()` (`import st.orm.template.resultList`) or `query.getResultList(T::class)`. Reified counterparts also exist for `singleResult()`, `optionalResult()`, `resultStream()`, and `resultFlow()`. This is different from QueryBuilder's `.resultList` which returns `List` already typed to the query's result type. Critical rules: - **Always use lambdas, never `TemplateString.raw()`**: Template expressions should always be written as lambdas (`{ "..." }`) so the compiler plugin can process them. Never construct `TemplateString.raw("...")` manually.