> 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..802a47e10 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ default List findAllBy(@Nonnull Metamodel
field, @Nonnull Iterable extends V> 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.11
+ */
+ default List findAllByRef(@Nonnull Metamodel
field, @Nonnull Iterable extends Ref> 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ default List[> findAllRefBy(@Nonnull Metamodel] field, @Nonnull Iterable extends V> 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.11
+ */
+ default List[> findAllRefByRef(@Nonnull Metamodel] field, @Nonnull Iterable extends Ref> 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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.11
+ */
+ 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, V> 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..5e5845695 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.11
+ */
+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.11
+ */
+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.11
+ */
+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.11
+ */
+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.11
+ */
+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/src/pages/index.js b/website/src/pages/index.js
index 3dbcf3850..2d44650b4 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("}") ] },
@@ -304,12 +302,20 @@ 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("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",
@@ -349,14 +355,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'+
@@ -367,6 +371,11 @@ export default function Home() {
'COMMIT\n'+
'-- onCommit hook runs here, only after COMMIT succeeds',
+ '-- 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'+
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:
-     
+      
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]