diff --git a/README.md b/README.md index de2d64630..50b980055 100644 --- a/README.md +++ b/README.md @@ -57,21 +57,20 @@ data class User( @FK val city: City ) : Entity -// DSL—query nested properties like city.name in one go -val users = orm.findAll { User_.city.name eq "Sunnyvale" } +// Type-safe predicates — query nested properties like city.name in one go +val users = orm.findAll(User_.city.name eq "Sunnyvale") -// Custom repository—inherits all CRUD operations, add your own queries +// Custom repository — inherits all CRUD operations, add your own queries interface UserRepository : EntityRepository { - fun findByCityName(name: String) = findAll { User_.city.name eq name } + fun findByCityName(name: String) = findAll(User_.city.name eq name) } val users = userRepository.findByCityName("Sunnyvale") -// Query Builder for more complex operations -val users = orm.entity(User::class) - .select() - .where(User_.city.name eq "Sunnyvale") - .orderBy(User_.name) - .resultList +// Block DSL — build queries with where, orderBy, joins, pagination +val users = userRepository.select { + where(User_.city.name eq "Sunnyvale") + orderBy(User_.name) +}.resultList // SQL Template for full control; parameterized by default, SQL injection safe val users = orm.query { """ @@ -85,7 +84,7 @@ Full coroutine support with `Flow` for streaming and programmatic transactions: ```kotlin // Streaming with Flow -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow users.collect { user -> println(user.name) } // Programmatic transactions diff --git a/docs/common-patterns.md b/docs/common-patterns.md index 2a88a6ad5..6c013887c 100644 --- a/docs/common-patterns.md +++ b/docs/common-patterns.md @@ -75,7 +75,7 @@ data class OrderWithItems( fun findOrderWithItems(orderId: Long): OrderWithItems? { val order = orm.entity(Order::class).findById(orderId) ?: return null - val lineItems = orm.entity(LineItem::class).findAll { LineItem_.order eq order } + val lineItems = orm.entity(LineItem::class).findAll(LineItem_.order eq order) return OrderWithItems(order, lineItems) } ``` @@ -261,11 +261,11 @@ interface CustomerRepository : EntityRepository { /** Find only non-deleted customers. */ fun findActive(): List = - findAll { Customer_.deletedAt.isNull() } + findAll(Customer_.deletedAt.isNull()) /** Find a non-deleted customer by ID. */ fun findActiveOrNull(customerId: Int): Customer? = - find { (Customer_.id eq customerId) and Customer_.deletedAt.isNull() } + find((Customer_.id eq customerId) and Customer_.deletedAt.isNull()) /** Soft-delete a customer by setting the deletedAt timestamp. */ fun softDelete(customer: Customer): Customer { diff --git a/docs/dirty-checking.md b/docs/dirty-checking.md index bf6f96d5e..f78987927 100644 --- a/docs/dirty-checking.md +++ b/docs/dirty-checking.md @@ -101,7 +101,7 @@ The selected update mode controls: In `OFF` mode, Storm bypasses dirty checking entirely. Every call to `update()` generates a full UPDATE statement that writes all columns, regardless of whether values have actually changed. ```kotlin -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) val updatedUser = user.copy(name = "New Name") // Always generates: UPDATE user SET email=?, name=?, city_id=? WHERE id=? @@ -144,7 +144,7 @@ userRepository.update(users.map { processUser(it) }) - If **any field changed**: A full-row UPDATE is executed (all columns are written) ```kotlin -val user = orm.get { User_.id eq 1 } // Storm observes: {id=1, email="a@b.com", name="Alice"} +val user = orm.get(User_.id eq 1) // Storm observes: {id=1, email="a@b.com", name="Alice"} // Scenario 1: No changes orm update user // No SQL executed - entity unchanged @@ -163,7 +163,7 @@ When multiple entities of the same type are updated in a transaction, JDBC can b ```kotlin // All updates have identical SQL shape - JDBC batches them -val users = userRepository.findAll { User_.city eq city } +val users = userRepository.findAll(User_.city eq city) userRepository.update(users.map { it.copy(lastLogin = now()) }) ``` @@ -174,7 +174,7 @@ userRepository.update(users.map { it.copy(lastLogin = now()) }) **Read-modify-write patterns:** When you load an entity and pass it back to update without modifications, ENTITY mode skips the UPDATE entirely. ```kotlin -val user = orm.get { User_.id eq userId } +val user = orm.get(User_.id eq userId) // No changes made - UPDATE is skipped orm update user @@ -201,7 +201,7 @@ orm update updated `FIELD` mode provides the most granular control. Storm compares each field individually and generates UPDATE statements that include only the columns that actually changed. Like ENTITY mode, if no fields changed, Storm skips the UPDATE entirely. ```kotlin -val user = orm.get { User_.id eq 1 } // {id=1, email="a@b.com", name="Alice", bio="...", settings="..."} +val user = orm.get(User_.id eq 1) // {id=1, email="a@b.com", name="Alice", bio="...", settings="..."} // Only name changed val updated = user.copy(name = "Bob") @@ -221,7 +221,7 @@ orm update updated2 ```kotlin // Article has 20 columns including large 'content' field // But we're only updating the view count -val article = orm.find { Article_.id eq articleId } +val article = orm.find(Article_.id eq articleId) orm update article.copy(viewCount = article.viewCount + 1) // UPDATE article SET view_count=? WHERE id=? // The large 'content' column is NOT written @@ -561,7 +561,7 @@ Dirty checking determines *what to update*. Optimistic locking determines *wheth ```kotlin // This does NOT prevent lost updates: -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) // ... another transaction modifies the same user ... orm update user.copy(name = "New Name") // May overwrite other transaction's changes! @@ -582,8 +582,8 @@ orm update user.copy(name = "New Name") Storm tracks which entity types are affected by each mutation so it can selectively invalidate observed state. For template-based updates (using `orm update entity`), Storm knows the entity type and only clears observed state of that type. However, when you execute raw SQL mutations without entity type information, Storm cannot determine which entities may have been affected. Rather than risk stale comparisons that could silently skip a necessary UPDATE, Storm clears all observed state in the current transaction: ```kotlin -val user = orm.get { User_.id eq 1 } // Storm observes User state -val city = orm.get { City_.id eq 100 } // Storm observes City state +val user = orm.get(User_.id eq 1) // Storm observes User state +val city = orm.get(City_.id eq 100) // Storm observes City state // Raw SQL mutation - Storm clears all observed state orm.execute("UPDATE user SET name = 'Changed' WHERE id = 1") @@ -608,7 +608,7 @@ data class User( @Embedded val address: Address ) : Entity -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) // With FIELD mode: only changed columns in Address are updated orm update user.copy(address = user.address.copy(city = "New City")) // UPDATE user SET city=? WHERE id=? diff --git a/docs/entity-cache.md b/docs/entity-cache.md index 47cc0a69b..d05d62213 100644 --- a/docs/entity-cache.md +++ b/docs/entity-cache.md @@ -306,7 +306,7 @@ Even without transaction-level caching, Storm preserves entity identity within a ```kotlin // Even at READ_COMMITTED in a read-write transaction: -val orders = orderRepository.findAll { Order_.status eq "pending" } +val orders = orderRepository.findAll(Order_.status eq "pending") // If order1 and order2 have the same customer, they share the instance val customer1 = orders[0].customer.fetch() @@ -355,7 +355,7 @@ When navigating relationships, `Ref.fetch()` automatically uses the cache: ```kotlin transaction { // Load orders with their users - val orders = orderRepository.findAll { Order_.status eq "pending" } + val orders = orderRepository.findAll(Order_.status eq "pending") // If multiple orders share the same user, only one query per unique user orders.forEach { order -> diff --git a/docs/faq.md b/docs/faq.md index f84905c2d..23d048549 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -418,7 +418,7 @@ Storm does not store collections on entities. This is intentional: collection fi ```kotlin // Instead of user.orders (not supported) -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ### Why doesn't Storm support lazy loading? diff --git a/docs/first-entity.md b/docs/first-entity.md index 4dad4d9ec..d1d05c058 100644 --- a/docs/first-entity.md +++ b/docs/first-entity.md @@ -161,7 +161,7 @@ The `insertAndFetch` method sends an INSERT statement, retrieves the auto-genera 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" } +val user: User? = orm.find(User_.email eq "alice@example.com") ``` diff --git a/docs/first-query.md b/docs/first-query.md index 2fdb59547..075f1fe1f 100644 --- a/docs/first-query.md +++ b/docs/first-query.md @@ -16,15 +16,15 @@ The simplest way to query is with predicate methods directly on the ORM template val users = orm.entity(User::class) // Find all users in a city -val usersInCity: List = users.findAll { User_.city eq city } +val usersInCity: List = users.findAll(User_.city eq city) // Find a single user by email -val user: User? = users.find { User_.email eq "alice@example.com" } +val user: User? = users.find(User_.email eq "alice@example.com") // Combine conditions with and / or -val results: List = users.findAll { +val results: List = users.findAll( (User_.city eq city) and (User_.name like "A%") -} +) // Check existence val exists: Boolean = users.existsById(userId) @@ -78,7 +78,7 @@ For domain-specific queries that you will reuse, define a custom repository inte interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByNameInCity(name: String, city: City): List = findAll((User_.city eq city) and (User_.name eq name)) diff --git a/docs/hydration.md b/docs/hydration.md index cbe919fe5..f52b469b6 100644 --- a/docs/hydration.md +++ b/docs/hydration.md @@ -428,7 +428,7 @@ When a cache hit occurs, Storm skips the entire construction process for that en ```kotlin // Query returns 1000 users, but only 50 unique cities -val users = userRepository.findAll { User_.city eq city } +val users = userRepository.findAll(User_.city eq city) ``` Without early lookup, Storm would construct 1000 `City` objects and then deduplicate. With early lookup: diff --git a/docs/index.md b/docs/index.md index a296c415b..62aa9dc5c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,21 +62,20 @@ data class User( @FK val city: City ) : Entity -// DSL—query nested properties like city.name in one go -val users = orm.findAll { User_.city.name eq "Sunnyvale" } +// Type-safe predicates — query nested properties like city.name in one go +val users = orm.findAll(User_.city.name eq "Sunnyvale") -// Custom repository—inherits all CRUD operations, add your own queries +// Custom repository — inherits all CRUD operations, add your own queries interface UserRepository : EntityRepository { - fun findByCityName(name: String) = findAll { User_.city.name eq name } + fun findByCityName(name: String) = findAll(User_.city.name eq name) } val users = userRepository.findByCityName("Sunnyvale") -// Query Builder for more complex operations -val users = orm.entity(User::class) - .select() - .where(User_.city.name eq "Sunnyvale") - .orderBy(User_.name) - .resultList +// Block DSL — build queries with where, orderBy, joins, pagination +val users = userRepository.select { + where(User_.city.name eq "Sunnyvale") + orderBy(User_.name) +}.resultList // SQL Template for full control; parameterized by default, SQL injection safe val users = orm.query { """ diff --git a/docs/json.md b/docs/json.md index 79292c7a6..5e772d153 100644 --- a/docs/json.md +++ b/docs/json.md @@ -272,7 +272,7 @@ Query separately: val users = orm.findAll() // Batch fetch roles and group by user -val rolesByUser = orm.findAll { UserRole_.user inList users } +val rolesByUser = orm.findAll(UserRole_.user inList users) .groupBy({ it.user }, { it.role }) ``` diff --git a/docs/metamodel.md b/docs/metamodel.md index fc8cf4db6..4c3e47c33 100644 --- a/docs/metamodel.md +++ b/docs/metamodel.md @@ -81,10 +81,10 @@ Once the metamodel is generated, you use the `_` suffixed classes in place of st ```kotlin // Type-safe field reference -val users = orm.findAll { User_.email eq email } +val users = orm.findAll(User_.email eq email) // Type-safe access to nested fields throughout the entire entity graph -val users = orm.findAll { User_.city.country.code eq "US" } +val users = orm.findAll(User_.city.country.code eq "US") // Multiple conditions val users = orm.entity(User::class) diff --git a/docs/migration-from-jpa.md b/docs/migration-from-jpa.md index 7d0503947..a739f669f 100644 --- a/docs/migration-from-jpa.md +++ b/docs/migration-from-jpa.md @@ -113,7 +113,7 @@ public interface UserRepository extends JpaRepository { interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByCity(city: City): List = select() @@ -122,7 +122,7 @@ interface UserRepository : EntityRepository { .resultList fun findRecentUsers(since: LocalDateTime): List = - findAll { User_.createdAt gt since } + findAll(User_.createdAt gt since) } ``` @@ -195,10 +195,10 @@ public interface UserRepository extends JpaRepository { interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByCity(city: City): List = - findAll { User_.city eq city } + findAll(User_.city eq city) } ``` @@ -243,7 +243,7 @@ Optional findByEmail(@Param("email") String email); ```kotlin // Storm fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) ``` @@ -281,7 +281,7 @@ return em.createQuery(cq).getResultList(); ```kotlin // Storm -orm.findAll { User_.city eq city } +orm.findAll(User_.city eq city) ``` @@ -355,7 +355,7 @@ private List orders; Storm approach (query the "many" side): ```kotlin -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ## Transaction Migration @@ -504,7 +504,7 @@ data class CustomerProfile( // Storm repository (new) interface CustomerProfileRepository : EntityRepository { fun findByCustomerId(customerId: Long): CustomerProfile? = - find { CustomerProfile_.customerId eq customerId } + find(CustomerProfile_.customerId eq customerId) } // Service that uses both @@ -642,7 +642,7 @@ Storm intentionally does not support collection fields on entities. This is a de val orders = user.orders // Not supported // Right approach -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ## Schema Validation diff --git a/docs/queries.md b/docs/queries.md index 78ecb29d5..b913c5833 100644 --- a/docs/queries.md +++ b/docs/queries.md @@ -38,10 +38,10 @@ For simple queries, use methods directly on the ORM template: ```kotlin // Find single entity with predicate -val user: User? = orm.find { User_.email eq email } +val user: User? = orm.find(User_.email eq email) // Find all matching -val users: List = orm.findAll { User_.city eq city } +val users: List = orm.findAll(User_.city eq city) // Find by field value val user: User? = orm.findBy(User_.email, email) @@ -94,10 +94,10 @@ val users = orm.entity(User::class) val user: User? = users.findById(userId) // Find with predicate -val user: User? = users.find { User_.email eq email } +val user: User? = users.find(User_.email eq email) // Find all matching -val usersInCity: List = users.findAll { User_.city eq city } +val usersInCity: List = users.findAll(User_.city eq city) // Count val count: Long = users.count() @@ -148,14 +148,14 @@ Combine conditions with `and` and `or`: ```kotlin // AND condition -val users = orm.findAll { +val users = orm.findAll( (User_.city eq city) and (User_.birthDate less LocalDate.of(2000, 1, 1)) -} +) // OR condition -val users = orm.findAll { +val users = orm.findAll( (User_.role eq adminRole) or (User_.role eq superUserRole) -} +) // Complex conditions val users = orm.entity(User::class) @@ -186,9 +186,9 @@ val users = orm.entity(User::class) | `notInList` | NOT IN (list) | ```kotlin -val users = orm.findAll { User_.email like "%@example.com" } -val users = orm.findAll { User_.deletedAt.isNull() } -val users = orm.findAll { User_.role inList listOf(adminRole, userRole) } +val users = orm.findAll(User_.email like "%@example.com") +val users = orm.findAll(User_.deletedAt.isNull()) +val users = orm.findAll(User_.role inList listOf(adminRole, userRole)) ``` @@ -845,7 +845,7 @@ When you expect at most one matching row, use `find` (Kotlin, returns `null` if ```kotlin -val user: User? = orm.find { User_.email eq email } +val user: User? = orm.find(User_.email eq email) ``` diff --git a/docs/refs.md b/docs/refs.md index 26fddd74c..9ffe4c085 100644 --- a/docs/refs.md +++ b/docs/refs.md @@ -51,7 +51,7 @@ When you need the full referenced entity, call `fetch()`. This triggers a databa ```kotlin -val user = orm.get { User_.id eq userId } +val user = orm.get(User_.id eq userId) val city: City = user.city.fetch() // Loads from database ``` @@ -338,7 +338,7 @@ data class Report( ) : Entity // Later, when you need the author -val report = orm.find { Report_.id eq reportId } +val report = orm.find(Report_.id eq reportId) if (needsAuthorInfo) { val author = report?.author?.fetch() } diff --git a/docs/relationships.md b/docs/relationships.md index 9d302b778..ad891c745 100644 --- a/docs/relationships.md +++ b/docs/relationships.md @@ -32,7 +32,7 @@ data class User( ) : Entity // Query with type-safe access to nested fields throughout the entire entity graph -val users = orm.findAll { User_.city.country.code eq "US" } +val users = orm.findAll(User_.city.country.code eq "US") // Result: fully populated User with City and Country included users.forEach { println("${it.name} lives in ${it.city.name}, ${it.city.country.name}") } @@ -68,7 +68,7 @@ data class User( When you query a `User`, the related `City` is automatically loaded: ```kotlin -val user = orm.find { User_.id eq userId } +val user = orm.find(User_.id eq userId) println(user?.city.name) // City is already loaded ``` @@ -146,7 +146,7 @@ Storm does not store collections on the "one" side of a relationship. Instead, q ```kotlin // Find all users in a city -val usersInCity: List = orm.findAll { User_.city eq city } +val usersInCity: List = orm.findAll(User_.city eq city) ``` @@ -191,11 +191,11 @@ Query through the join entity: ```kotlin // Find all roles for a user -val userRoles: List = orm.findAll { UserRole_.user eq user } +val userRoles: List = orm.findAll(UserRole_.user eq user) val roles: List = userRoles.map { it.role } // Find all users with a specific role -val userRoles: List = orm.findAll { UserRole_.role eq role } +val userRoles: List = orm.findAll(UserRole_.role eq role) val users: List = userRoles.map { it.user } ``` diff --git a/docs/repositories.md b/docs/repositories.md index 0536751f9..24f1cb812 100644 --- a/docs/repositories.md +++ b/docs/repositories.md @@ -58,8 +58,8 @@ val user = orm insert User( // Read 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 } +val alice: User? = orm.find(User_.name eq "Alice") +val all: List = orm.findAll(User_.city eq city) // Update orm update user.copy(name = "Alice Johnson") @@ -68,7 +68,7 @@ orm update user.copy(name = "Alice Johnson") orm delete user // Delete by condition -orm.delete { User_.city eq city } +orm.delete(User_.city eq city) // Delete all orm.deleteAll() @@ -474,7 +474,7 @@ interface UserRepository : EntityRepository { // Custom query method fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) // Custom query with multiple conditions fun findByNameInCity(name: String, city: City): List = diff --git a/docs/security.md b/docs/security.md index 5cb979ae1..f29ee965c 100644 --- a/docs/security.md +++ b/docs/security.md @@ -20,7 +20,7 @@ The most important security property of Storm is that **all values are parameter ```kotlin // The 'email' value is sent as a JDBC parameter, not interpolated into SQL. -val user = userRepository.find { User_.email eq email } +val user = userRepository.find(User_.email eq email) ``` Generated SQL: diff --git a/docs/serialization.md b/docs/serialization.md index 6c35743d8..05028bdc5 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -180,7 +180,7 @@ data class Pet( @FK @Contextual val owner: Ref?, ) : Entity -val pet = orm.get { Pet_.id eq 1 } +val pet = orm.get(Pet_.id eq 1) val json = Json { serializersModule = StormSerializers } .encodeToString(pet) // {"id":1,"name":"Leo","owner":1} @@ -217,7 +217,7 @@ When the application calls `fetch()` on a ref before serialization, the referenc ```kotlin -val pet = orm.get { Pet_.id eq 1 } +val pet = orm.get(Pet_.id eq 1) pet.owner?.fetch() // Load the owner into the ref val json = Json { serializersModule = StormSerializers } @@ -266,7 +266,7 @@ data class PetWithProjectionOwner( @FK @Contextual val owner: Ref?, ) : Entity -val pet = orm.get { PetWithProjectionOwner_.id eq 1 } +val pet = orm.get(PetWithProjectionOwner_.id eq 1) pet.owner?.fetch() val json = Json { serializersModule = StormSerializers } diff --git a/docs/spring-integration.md b/docs/spring-integration.md index 198771a6a..c80c16835 100644 --- a/docs/spring-integration.md +++ b/docs/spring-integration.md @@ -159,7 +159,7 @@ Define repositories: interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) } ``` diff --git a/docs/sql-logging.md b/docs/sql-logging.md index b7cb23f5e..4c8a7f674 100644 --- a/docs/sql-logging.md +++ b/docs/sql-logging.md @@ -23,10 +23,10 @@ The simplest way to enable SQL logging is to annotate the repository interface i interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findActiveUsers(): List = - findAll { User_.active eq true } + findAll(User_.active eq true) } ``` @@ -75,12 +75,12 @@ interface OrderRepository : EntityRepository { // No logging fun findById(id: Int): Order? = - find { Order_.id eq id } + find(Order_.id eq id) // Logged @SqlLog fun findExpiredOrders(cutoff: LocalDate): List = - findAll { Order_.expiresAt lt cutoff } + findAll(Order_.expiresAt lt cutoff) } ``` @@ -124,7 +124,7 @@ Setting `inlineParameters = true` replaces the `?` placeholders with the actual interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) } ``` diff --git a/docs/sql-templates.md b/docs/sql-templates.md index d83b2fef2..66a55364b 100644 --- a/docs/sql-templates.md +++ b/docs/sql-templates.md @@ -476,11 +476,11 @@ Storm caches compiled templates to eliminate even this small overhead on repeate ```kotlin // First execution: full compilation + binding -userRepository.find { User_.email eq "alice@example.com" } +userRepository.find(User_.email eq "alice@example.com") // Subsequent executions: cache hit, binding only -userRepository.find { User_.email eq "bob@example.com" } -userRepository.find { User_.email eq "charlie@example.com" } +userRepository.find(User_.email eq "bob@example.com") +userRepository.find(User_.email eq "charlie@example.com") ``` This applies to all Storm operations. Repository methods like `findAll()`, `insert()`, and `update()` benefit from the same caching mechanism. Once a query pattern has been compiled, repeated use across your application reuses the cached compilation. diff --git a/storm-bom/pom.xml b/storm-bom/pom.xml index 9493dc363..db7303409 100644 --- a/storm-bom/pom.xml +++ b/storm-bom/pom.xml @@ -96,6 +96,26 @@ storm-metamodel-processor ${project.version} + + st.orm + storm-compiler-plugin-2.0 + ${project.version} + + + st.orm + storm-compiler-plugin-2.1 + ${project.version} + + + st.orm + storm-compiler-plugin-2.2 + ${project.version} + + + st.orm + storm-compiler-plugin-2.3 + ${project.version} + st.orm storm-metamodel-ksp diff --git a/storm-cli/storm.mjs b/storm-cli/storm.mjs index a8921d0f2..4cb4126e7 100644 --- a/storm-cli/storm.mjs +++ b/storm-cli/storm.mjs @@ -45,28 +45,26 @@ process.on('exit', () => process.stdout.write(SHOW_CURSOR)); // ─── Welcome screen ───────────────────────────────────────────────────────── const BODY_SOURCE = ` - - - @@@@@@@@@@@@@@@@@@@@ - @@@@ @@@@ - @@@ # @@@ - @@@@@ ### @@@@ - @@@@@@@@@@@@@@ ### @@@@@@@@@ - @@@@@@@@@@@@ ### @@@@@@@@@ - @ @@@@@@@@ ### @@@@@@@ @ - @@@@ #### @@@@ - @@@@@@@@@ #### @@@@@@@@@ - @@@@@@@ ########## @@@@@@@ - @ @@ ######### @@@ @ - @@@@@ #### @@@@@ - @@@@@@@@@@ #### @@@@@@@@@@@@ - @@@@@@@@@ ### @@@@@@@@@@@@@ - @@@@@ ### @@@@@@@@@@@ - ### - ## + @@@@@@@@@@@@@@@@@@@@ + @@@@ @@@@ +@@@ # @@@ +@@@@@ ### @@@@ +@@@@@@@@@@@@@@ ### @@@@@@@@@ + @@@@@@@@@@@@ ### @@@@@@@@@ +@ @@@@@@@@ ### @@@@@@@ @ +@@@@ #### @@@@ +@@@@@@@@@ #### @@@@@@@@@ + @@@@@@@ ########## @@@@@@@ +@ @@ ######### @@@ @ +@@@@@ #### @@@@@ +@@@@@@@@@@ #### @@@@@@@@@@@@ + @@@@@@@@@ ### @@@@@@@@@@@@@ + @@@@@ ### @@@@@@@@@@@ + ### + ## `.trimEnd(); -const bodyLines = BODY_SOURCE.split('\n'); +const bodyLines = BODY_SOURCE.replace(/^\n/, '').split('\n'); const artWidth = Math.max(...bodyLines.map(s => s.length)); const artHeight = bodyLines.length; const paddedLines = Array.from({ length: artHeight }, (_, i) => @@ -189,6 +187,90 @@ function ringGlow(x, y, now) { return false; } +// --- Matrix rain (demo mode) --- + +let demoMode = false; +const matrixColumns = []; +const MATRIX_MARGIN = 12; + +function newDrop() { + return { + y: -1 - Math.random() * artHeight * 0.6, + speed: 0.06 + Math.random() * 0.14, + trailLength: 3 + Math.floor(Math.random() * 7), + }; +} + +function initMatrixRain() { + matrixColumns.length = 0; + const totalWidth = artWidth + 2 * MATRIX_MARGIN; + for (let x = 0; x < totalWidth; x++) { + const drops = []; + const count = 1 + Math.floor(Math.random() * 3); + for (let i = 0; i < count; i++) { + const drop = newDrop(); + drop.y = Math.random() * artHeight * 2 - artHeight; + drops.push(drop); + } + matrixColumns.push(drops); + } +} + +const MATRIX_GLYPHS = 'ヲァィゥェォャュョッーアイウエオカキクケコサシスセソタチツテトナニヌネノハヒフヘホマミムメモヤユヨラリルレロワン'; + +function matrixGlyph(x, y, now) { + const idx = (x * 17 + y * 31 + Math.floor(now / 80)) % MATRIX_GLYPHS.length; + return MATRIX_GLYPHS[idx]; +} + +function updateMatrixRain() { + for (const drops of matrixColumns) { + for (const drop of drops) { + drop.y += drop.speed; + if (drop.y - drop.trailLength > artHeight + 3) { + drop.y = -1 - Math.random() * artHeight * 0.6; + drop.speed = 0.06 + Math.random() * 0.14; + drop.trailLength = 3 + Math.floor(Math.random() * 7); + } + } + } +} + +function matrixIntensityAt(x, y) { + const drops = matrixColumns[x]; + if (!drops) return 0; + let best = 0; + for (const drop of drops) { + const dist = drop.y - y; + if (dist < -0.5 || dist > drop.trailLength) continue; + const t = Math.max(0, dist) / drop.trailLength; + let intensity; + if (dist < 0.5) intensity = 5; + else if (t < 0.15) intensity = 4; + else if (t < 0.35) intensity = 3; + else if (t < 0.6) intensity = 2; + else intensity = 1; + if (intensity > best) best = intensity; + } + return best; +} + +function matrixColor(intensity) { + if (intensity === 1) return DIM + BOLT_BASE; + if (intensity === 2) return BOLD + BOLT_BASE; + if (intensity === 3) return BOLD + BOLT_WARM; + if (intensity === 4) return BOLD + BOLT_HOT; + return BOLD + BOLT_WHITE; +} + +function matrixDbColor(intensity) { + if (intensity === 1) return '\x1b[38;5;244m'; + if (intensity === 2) return '\x1b[38;5;245m'; + if (intensity === 3) return '\x1b[38;5;247m'; + if (intensity === 4) return '\x1b[38;5;248m'; + return '\x1b[38;5;249m'; +} + // --- Text overlay --- function stripAnsi(s) { return s.replace(/\x1b\[[0-9;]*m/g, ''); } @@ -198,16 +280,28 @@ function centerLine(line, cols) { return ' '.repeat(pad) + line; } -const textLines = [ +const INIT_TEXT_LINES = [ '', `${GRAY}Bootstrap your project for Storm ORM${RESET}`, '', - `${BOLD}${BOLT_HOT}\u2022${WHITE} Configure Storm rules${RESET}`, - `${BOLD}${BOLT_HOT}\u2022${WHITE} Install Storm skills${RESET}`, + `${BOLD}${BOLT_HOT}\u2022${WHITE} Install Storm rules and skills${RESET}`, + `${BOLD}${BOLT_HOT}\u2022${WHITE} Storm MCP for database awareness and validation (optional)${RESET}`, '', - `${GRAY}Press Enter to select tools...${RESET}`, + `${GRAY}Press Enter to select tools${RESET}`, ]; +const DEMO_TEXT_LINES = [ + '', + `${GRAY}Storm Fu - Training Program${RESET}`, + '', + `${BOLD}${BOLT_HOT}\u2022${WHITE} Live demo of building an app with the Storm AI workflow${RESET}`, + `${BOLD}${BOLT_HOT}\u2022${WHITE} Storm MCP enables database awareness and validation${RESET}`, + '', + `${GRAY}Press Enter to follow the white rabbit${RESET}`, +]; + +let activeTextLines = INIT_TEXT_LINES; + // --- Render --- function renderFrame(now) { @@ -215,17 +309,44 @@ function renderFrame(now) { const rows = process.stdout.rows || 40; const rendered = []; + if (demoMode) updateMatrixRain(); + + const startX = demoMode ? -MATRIX_MARGIN : 0; + const endX = demoMode ? artWidth + MATRIX_MARGIN : artWidth; + for (let y = 0; y < artHeight; y++) { let out = ''; - for (let x = 0; x < artWidth; x++) { - if (!hasBody(x, y)) { out += ' '; continue; } - if (hasBolt(x, y)) out += boltColorCode(x, y, now) + '#' + RESET; - else out += dbColor(y, ringGlow(x, y, now)) + '@' + RESET; + for (let x = startX; x < endX; x++) { + const inArt = x >= 0 && x < artWidth; + if (!inArt || !hasBody(x, y)) { + if (demoMode) { + const mx = x + MATRIX_MARGIN; + const mi = matrixIntensityAt(mx, y); + if (mi >= 4) out += '\x1b[38;5;237m' + matrixGlyph(mx, y, now) + RESET; + else if (mi >= 2) out += '\x1b[38;5;235m' + matrixGlyph(mx, y, now) + RESET; + else out += '\x1b[38;5;233m' + matrixGlyph(mx, y, now) + RESET; + } else out += ' '; + continue; + } + if (demoMode) { + const mx = x + MATRIX_MARGIN; + const mi = matrixIntensityAt(mx, y); + if (hasBolt(x, y)) { + if (mi >= 3) out += matrixColor(mi) + matrixGlyph(mx, y, now) + RESET; + else if (mi > 0) out += matrixColor(mi) + '#' + RESET; + else out += dbColor(y, false) + '#' + RESET; + } else { + out += dbColor(y, false) + '@' + RESET; + } + } else { + if (hasBolt(x, y)) out += boltColorCode(x, y, now) + '#' + RESET; + else out += dbColor(y, ringGlow(x, y, now)) + '@' + RESET; + } } if (out.trim().length > 0) rendered.push(centerLine(out, cols)); } - for (const tl of textLines) rendered.push(centerLine(tl, cols)); + for (const tl of activeTextLines) rendered.push(centerLine(tl, cols)); const topPad = Math.max(0, Math.floor((rows - rendered.length) / 3)); const blankLine = ' '.repeat(cols); @@ -238,8 +359,14 @@ function renderFrame(now) { return frame; } -async function printWelcome() { - nextStrike = Date.now() + Math.random() * 100; +async function printWelcome(customTextLines) { + activeTextLines = customTextLines || INIT_TEXT_LINES; + demoMode = customTextLines === DEMO_TEXT_LINES; + if (demoMode) { + initMatrixRain(); + } else { + nextStrike = Date.now() + Math.random() * 100; + } process.stdout.write(HIDE_CURSOR + CLEAR); const onResize = () => process.stdout.write(CLEAR); @@ -247,10 +374,12 @@ async function printWelcome() { const timer = setInterval(() => { const now = Date.now(); - if (!strikeActive && now > nextStrike) startStrike(now); - if (strikeActive && now > strikeStart + strikeDuration) { - strikeActive = false; - scheduleStrike(now); + if (!demoMode) { + if (!strikeActive && now > nextStrike) startStrike(now); + if (strikeActive && now > strikeStart + strikeDuration) { + strikeActive = false; + scheduleStrike(now); + } } process.stdout.write(HOME); process.stdout.write(renderFrame(now)); @@ -543,13 +672,43 @@ async function textInput({ message, defaultValue = '', mask = false }) { }); } +// ─── Project config (.storm.json) ──────────────────────────────────────────── + +const CONFIG_FILE = '.storm.json'; + +function readProjectConfig() { + const configPath = join(process.cwd(), CONFIG_FILE); + try { + return JSON.parse(readFileSync(configPath, 'utf-8')); + } catch { + return null; + } +} + +function writeProjectConfig(tools, languages) { + const configPath = join(process.cwd(), CONFIG_FILE); + writeFileSync(configPath, JSON.stringify({ tools, languages }, null, 2) + '\n'); +} + // ─── Content (fetched from orm.st at runtime) ─────────────────────────────── const SKILLS_BASE_URL = 'https://orm.st/skills'; const STORM_SKILL_MARKER = ''; +// --dev flag: read skills from a local directory instead of orm.st. +let devSkillsDir = null; +{ + const devIdx = process.argv.indexOf('--dev'); + if (devIdx !== -1 && process.argv[devIdx + 1]) { + devSkillsDir = process.argv[devIdx + 1]; + } +} + async function fetchRules() { try { + if (devSkillsDir) { + return readFileSync(join(devSkillsDir, 'storm-rules.md'), 'utf-8'); + } const res = await fetch(`${SKILLS_BASE_URL}/storm-rules.md`); if (!res.ok) throw new Error(`${res.status}`); return await res.text(); @@ -561,6 +720,9 @@ async function fetchRules() { async function fetchSkillIndex(language) { try { + if (devSkillsDir) { + return JSON.parse(readFileSync(join(devSkillsDir, `index-${language}.json`), 'utf-8')); + } const res = await fetch(`${SKILLS_BASE_URL}/index-${language}.json`); if (!res.ok) throw new Error(`${res.status}`); return await res.json(); @@ -569,9 +731,28 @@ async function fetchSkillIndex(language) { } } +const DEV_SETUP_APPEND = ` + +## Development Mode + +This project uses a local (unpublished) version of Storm. Add \`mavenLocal()\` as the first repository in Gradle so it resolves Storm artifacts from the local Maven cache (\`~/.m2/repository\`): + +\`\`\`kotlin +repositories { + mavenLocal() + mavenCentral() +} +\`\`\` +`; + async function fetchSkill(name) { - const url = `${SKILLS_BASE_URL}/${name}.md`; try { + if (devSkillsDir) { + let content = readFileSync(join(devSkillsDir, `${name}.md`), 'utf-8'); + if (name === 'storm-setup') content = content.trimEnd() + '\n' + DEV_SETUP_APPEND; + return content.trimEnd() + '\n\n' + STORM_SKILL_MARKER + '\n'; + } + const url = `${SKILLS_BASE_URL}/${name}.md`; const res = await fetch(url); if (!res.ok) throw new Error(`${res.status}`); const content = await res.text(); @@ -1109,79 +1290,85 @@ function registerMcp(toolConfig, stormDir, created, appended) { // ─── Database setup ────────────────────────────────────────────────────────── -async function setupDatabase() { +async function setupDatabase(preConfigured) { const stormDir = join(homedir(), '.storm'); const connectionPath = join(stormDir, 'connection.json'); - // Check for existing config. - if (existsSync(connectionPath)) { - try { - const existing = JSON.parse(readFileSync(connectionPath, 'utf-8')); - console.log(dimText(` Current connection: ${existing.dialect}://${existing.host || ''}${existing.port ? ':' + existing.port : ''}/${existing.database}`)); - console.log(); - const reconfigure = await confirm({ message: 'Reconfigure?', defaultValue: false }); - if (!reconfigure) return stormDir; - console.log(); - } catch {} - } - - const dialect = await select({ - message: 'Database dialect', - choices: [ - { name: 'PostgreSQL', value: 'postgresql' }, - { name: 'MySQL', value: 'mysql' }, - { name: 'MariaDB', value: 'mariadb' }, - { name: 'Oracle', value: 'oracle' }, - { name: 'SQL Server', value: 'mssqlserver' }, - { name: 'SQLite', value: 'sqlite' }, - { name: 'H2', value: 'h2' }, - ], - }); - - const dialectConfig = DIALECTS[dialect]; let connection; - if (dialectConfig.fileBased) { - const database = await textInput({ message: 'Database file path' }); - if (!database) { - console.log(boltYellow('\n Database file path is required.')); - return null; - } - connection = { dialect, database }; + if (preConfigured) { + // Non-interactive: use the provided connection details directly. + connection = preConfigured; } else { - if (dialect === 'h2') { - console.log(dimText(' Note: Requires H2 running with PG wire protocol (-pgPort)')); - } - if (dialect === 'oracle') { - console.log(dimText(' Note: Uses oracledb thin mode (no Oracle Client required)')); + // Interactive: prompt the user. + if (existsSync(connectionPath)) { + try { + const existing = JSON.parse(readFileSync(connectionPath, 'utf-8')); + console.log(dimText(` Current connection: ${existing.dialect}://${existing.host || ''}${existing.port ? ':' + existing.port : ''}/${existing.database}`)); + console.log(); + const reconfigure = await confirm({ message: 'Reconfigure?', defaultValue: false }); + if (!reconfigure) return stormDir; + console.log(); + } catch {} } - const defaultPort = String(dialectConfig.defaultPort); - const host = await textInput({ message: 'Host', defaultValue: 'localhost' }); - const port = await textInput({ message: 'Port', defaultValue: defaultPort }); - const database = await textInput({ message: 'Database' }); - const username = await textInput({ message: 'Username', defaultValue: 'storm' }); - const password = await textInput({ message: 'Password', mask: true }); + const dialect = await select({ + message: 'Database dialect', + choices: [ + { name: 'PostgreSQL', value: 'postgresql' }, + { name: 'MySQL', value: 'mysql' }, + { name: 'MariaDB', value: 'mariadb' }, + { name: 'Oracle', value: 'oracle' }, + { name: 'SQL Server', value: 'mssqlserver' }, + { name: 'SQLite', value: 'sqlite' }, + { name: 'H2', value: 'h2' }, + ], + }); - if (!database) { - console.log(boltYellow('\n Database name is required.')); - return null; - } + const dialectConfig = DIALECTS[dialect]; - connection = { - dialect, - host: host || 'localhost', - port: parseInt(port || defaultPort, 10), - database, - username: username || 'storm', - password: password || '', - }; + if (dialectConfig.fileBased) { + const database = await textInput({ message: 'Database file path' }); + if (!database) { + console.log(boltYellow('\n Database file path is required.')); + return null; + } + connection = { dialect, database }; + } else { + if (dialect === 'h2') { + console.log(dimText(' Note: Requires H2 running with PG wire protocol (-pgPort)')); + } + if (dialect === 'oracle') { + console.log(dimText(' Note: Uses oracledb thin mode (no Oracle Client required)')); + } + + const defaultPort = String(dialectConfig.defaultPort); + const host = await textInput({ message: 'Host', defaultValue: 'localhost' }); + const port = await textInput({ message: 'Port', defaultValue: defaultPort }); + const database = await textInput({ message: 'Database' }); + const username = await textInput({ message: 'Username', defaultValue: 'storm' }); + const password = await textInput({ message: 'Password', mask: true }); + + if (!database) { + console.log(boltYellow('\n Database name is required.')); + return null; + } + + connection = { + dialect, + host: host || 'localhost', + port: parseInt(port || defaultPort, 10), + database, + username: username || 'storm', + password: password || '', + }; + } } // Install driver. - const driverPackage = dialectConfig.driver; + const driverPackage = DIALECTS[connection.dialect].driver; console.log(); - console.log(dimText(` Installing ${dialectConfig.name} driver...`)); + console.log(dimText(` Installing ${DIALECTS[connection.dialect].name} driver...`)); mkdirSync(stormDir, { recursive: true }); @@ -1266,7 +1453,9 @@ function detectConfiguredLanguages(toolConfigs) { } async function update() { - const tools = detectConfiguredTools(); + const projectConfig = readProjectConfig(); + + const tools = projectConfig?.tools ?? detectConfiguredTools(); if (tools.length === 0) { console.log(boltYellow('\n No configured AI tools found. Run `storm init` first.\n')); return; @@ -1275,10 +1464,12 @@ async function update() { const toolConfigs = tools.map(t => TOOL_CONFIGS[t]); const skillToolConfigs = toolConfigs.filter(c => c.skillPath); - // Detect languages from existing installed skills. - let languages = detectConfiguredLanguages(skillToolConfigs); + // Use persisted language choice, fall back to detection from skill markers, then both. + let languages = projectConfig?.languages; + if (!languages || languages.length === 0) { + languages = detectConfiguredLanguages(skillToolConfigs); + } if (languages.length === 0) { - // Fallback: if we can't detect languages, fetch both indexes and use whichever has skills. languages = ['kotlin', 'java']; } @@ -1287,7 +1478,7 @@ async function update() { console.log(); // Fetch rules and skill indexes. - console.log(dimText(' Fetching content from https://orm.st...')); + console.log(dimText(` Fetching content from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); const fetchPromises = [fetchRules(), ...languages.map(l => fetchSkillIndex(l))]; const [stormRules, ...skillIndexes] = await Promise.all(fetchPromises); if (!stormRules) { @@ -1313,7 +1504,7 @@ async function update() { // Fetch and install skills. if (skillToolConfigs.length > 0) { - console.log(dimText(' Fetching skills from https://orm.st...')); + console.log(dimText(` Fetching skills from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); const fetchedSkills = new Map(); for (const skillName of skillNames) { const content = await fetchSkill(skillName); @@ -1481,6 +1672,16 @@ async function setup() { return; } + // Persist tool and language choices so `storm update` can use them. + writeProjectConfig(tools, languages); + + // Add .storm.json to .gitignore (machine-specific config). + const gitignorePath = join(process.cwd(), '.gitignore'); + let gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; + if (!gitignore.includes(CONFIG_FILE)) { + appendFileSync(gitignorePath, `\n# Storm config (machine-specific)\n${CONFIG_FILE}\n`); + } + console.log(); // Step 2: Fetch and install rules. @@ -1488,7 +1689,7 @@ async function setup() { const appended = []; const skipped = []; - console.log(dimText(' Fetching content from https://orm.st...')); + console.log(dimText(` Fetching content from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); const fetchPromises = [fetchRules(), ...languages.map(l => fetchSkillIndex(l))]; const [stormRules, ...skillIndexes] = await Promise.all(fetchPromises); if (!stormRules) { @@ -1512,7 +1713,7 @@ async function setup() { const fetchedSkills = new Map(); if (skillToolConfigs.length > 0) { - console.log(dimText(' Fetching skills from https://orm.st...')); + console.log(dimText(` Fetching skills from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); for (const skillName of skillNames) { const content = await fetchSkill(skillName); if (!content) { skipped.push(skillName + ' (fetch failed)'); continue; } @@ -1645,6 +1846,209 @@ async function setup() { console.log(); } +// ─── Demo ───────────────────────────────────────────────────────────────────── + +async function demo() { + await printWelcome(DEMO_TEXT_LINES); + + // Step 1: Select AI tool. + console.log(' Storm Demo creates a Kotlin project in this empty directory and'); + console.log(' installs a skill that guides your AI tool to build a web application'); + console.log(' demonstrating Storm ORM using the public IMDB dataset.'); + console.log(); + const tool = await select({ + message: 'Which AI tool will you use?', + choices: [ + { name: 'Claude Code', value: 'claude' }, + { name: 'Cursor', value: 'cursor' }, + { name: 'GitHub Copilot', value: 'copilot' }, + { name: 'Windsurf', value: 'windsurf' }, + ], + }); + + // Step 2: Check if directory is empty (ignoring hidden files/directories). + const cwd = process.cwd(); + const entries = readdirSync(cwd).filter(e => !e.startsWith('.')); + if (entries.length > 0) { + console.log(); + console.log(boltYellow(' This directory is not empty.')); + console.log(dimText(' Storm Demo requires an empty directory. Create a new directory and')); + console.log(dimText(' run storm demo from there.')); + console.log(); + return; + } + + const tools = [tool]; + const config = TOOL_CONFIGS[tool]; + const created = []; + const appended = []; + const skipped = []; + + // Step 3: Fetch rules, Kotlin skill index, and demo skill index. + console.log(); + console.log(dimText(` Fetching content from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); + const [stormRules, skillIndex, demoIndex] = await Promise.all([ + fetchRules(), + fetchSkillIndex('kotlin'), + fetchSkillIndex('demo'), + ]); + + if (!stormRules) { + console.log(boltYellow('\n Could not fetch Storm rules from https://orm.st. Check your connection.')); + return; + } + + const skillNames = skillIndex?.skills ?? []; + const demoSkillNames = demoIndex?.skills ?? []; + + // Step 4: Install rules block. + if (config.rulesFile) { + installRulesBlock(join(cwd, config.rulesFile), stormRules, created, appended); + } + + // Step 5: Fetch and install Kotlin skills + demo skills. + const installedSkillNames = []; + if (config.skillPath) { + console.log(dimText(` Fetching skills from ${devSkillsDir ? devSkillsDir : 'https://orm.st'}...`)); + const fetchedSkills = new Map(); + for (const skillName of [...skillNames, ...demoSkillNames]) { + const content = await fetchSkill(skillName); + if (!content) { skipped.push(skillName + ' (fetch failed)'); continue; } + fetchedSkills.set(skillName, content); + installedSkillNames.push(skillName); + } + for (const [name, content] of fetchedSkills) { + installSkill(name, content, config, created); + } + } + + // Step 6: Choose database and set up MCP server. + const dialect = await select({ + message: 'Which database?', + choices: [ + { name: 'PostgreSQL', value: 'postgresql' }, + { name: 'MySQL', value: 'mysql' }, + { name: 'MariaDB', value: 'mariadb' }, + { name: 'Oracle', value: 'oracle' }, + { name: 'SQL Server', value: 'mssqlserver' }, + { name: 'SQLite', value: 'sqlite' }, + { name: 'H2', value: 'h2' }, + ], + }); + const dialectInfo = DIALECTS[dialect] || DIALECTS.postgresql; + let demoConnection; + if (dialectInfo.fileBased) { + demoConnection = { dialect, database: 'imdb.db' }; + } else { + demoConnection = { + dialect, + host: 'localhost', + port: dialectInfo.defaultPort, + database: 'imdb', + username: 'storm', + password: 'storm', + }; + } + + console.log(dimText(' Setting up MCP server for database schema access...')); + const stormDir = await setupDatabase(demoConnection); + const dbConfigured = stormDir !== null; + + if (dbConfigured) { + // Register MCP for the selected tool. + registerMcp(config, stormDir, created, appended); + + // Add MCP config file to .gitignore. + if (config.mcpFile) { + const gitignorePath = join(cwd, '.gitignore'); + let gitignore = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : ''; + if (!gitignore.includes(config.mcpFile)) { + appendFileSync(gitignorePath, `\n# Storm MCP (machine-specific paths)\n${config.mcpFile}\n`); + } + } + + // Fetch and install schema rules into the rules block. + const schemaRules = await fetchSkill('storm-schema-rules'); + if (config.rulesFile && schemaRules) { + const rulesPath = join(cwd, config.rulesFile); + if (existsSync(rulesPath)) { + const existing = readFileSync(rulesPath, 'utf-8'); + if (!existing.includes('Database Schema Access')) { + const endMarker = existing.indexOf(MARKER_END); + if (endMarker !== -1) { + const updated = existing.substring(0, endMarker) + '\n' + schemaRules.replace('\n' + STORM_SKILL_MARKER, '') + '\n' + existing.substring(endMarker); + writeFileSync(rulesPath, updated); + appended.push(config.rulesFile); + } + } + } + } + + // Fetch and install schema-dependent skills. + const schemaSkillNames = skillIndex?.schemaSkills ?? []; + if (config.skillPath) { + for (const skillName of schemaSkillNames) { + const content = await fetchSkill(skillName); + if (!content) { skipped.push(skillName + ' (fetch failed)'); continue; } + installSkill(skillName, content, config, created); + installedSkillNames.push(skillName); + } + } + } + + // Step 7: Write project config and .gitignore. + writeProjectConfig(tools, ['kotlin']); + + const gitignorePath = join(cwd, '.gitignore'); + if (!existsSync(gitignorePath)) { + writeFileSync(gitignorePath, `# Storm config (machine-specific)\n${CONFIG_FILE}\n`); + created.push('.gitignore'); + } else { + let gitignore = readFileSync(gitignorePath, 'utf-8'); + if (!gitignore.includes(CONFIG_FILE)) { + appendFileSync(gitignorePath, `\n# Storm config (machine-specific)\n${CONFIG_FILE}\n`); + } + } + + // Step 8: Clean stale skills. + if (config.skillPath) { + cleanStaleSkills([config], installedSkillNames, skipped); + } + + // Summary. + const uniqueCreated = [...new Set(created)]; + const uniqueAppended = [...new Set(appended)]; + + console.log(); + if (uniqueCreated.length > 0) { + console.log(boltYellow(' Created:')); + uniqueCreated.forEach(f => console.log(boltYellow(` + ${f}`))); + } + if (uniqueAppended.length > 0) { + console.log(boltYellow(' Updated:')); + uniqueAppended.forEach(f => console.log(boltYellow(` ~ ${f}`))); + } + if (skipped.length > 0) { + console.log(dimText(' Skipped:')); + skipped.forEach(f => console.log(dimText(` - ${f}`))); + } + + // Instructions. + console.log(); + console.log(bold(" You're all set!")); + console.log(); + if (tool === 'claude') { + console.log(` Start ${boltYellow('Claude Code')} in this directory and type:`); + console.log(); + console.log(` ${bold('/storm-demo')}`); + } else { + console.log(` Open this directory in ${boltYellow(config.name)} and ask:`); + console.log(); + console.log(` ${bold('Run the Storm demo')}`); + } + console.log(); +} + // ─── Entry ─────────────────────────────────────────────────────────────────── async function run() { @@ -1657,12 +2061,14 @@ async function run() { ${dimText('Usage:')} storm init Configure rules, skills, and database (default) + storm demo Create a demo project in an empty directory storm update Update rules and skills (non-interactive) storm mcp Re-register MCP servers for configured tools ${dimText('Options:')} --help, -h Show this help message --version, -v Show version + --dev Read skills from a local directory instead of orm.st ${dimText('Learn more:')} https://orm.st/ai `); @@ -1678,6 +2084,8 @@ async function run() { await update(); } else if (command === 'mcp') { await updateMcp(); + } else if (command === 'demo') { + await demo(); } else { await setup(); } diff --git a/storm-core/src/main/java/module-info.java b/storm-core/src/main/java/module-info.java index 0ffedc6bd..f74372b6d 100644 --- a/storm-core/src/main/java/module-info.java +++ b/storm-core/src/main/java/module-info.java @@ -15,7 +15,7 @@ exports st.orm.core.repository.impl; requires java.management; requires java.sql; - requires jakarta.persistence; + requires static jakarta.persistence; requires jakarta.annotation; requires java.compiler; requires storm.foundation; diff --git a/storm-core/src/main/java/st/orm/core/repository/EntityRepository.java b/storm-core/src/main/java/st/orm/core/repository/EntityRepository.java index 30d7549e9..31a2e5a06 100644 --- a/storm-core/src/main/java/st/orm/core/repository/EntityRepository.java +++ b/storm-core/src/main/java/st/orm/core/repository/EntityRepository.java @@ -990,25 +990,6 @@ default Window scroll(@Nonnull Scrollable scrollable) { // Stream based methods. - /** - * Returns a stream of all entities of the type supported by this repository. Each element in the stream represents - * an entity in the database, encapsulating all relevant data as mapped by the entity model. - * - *

The resulting stream is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities.

- * - *

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 {@code AutoCloseable}, it - * is recommended to use it within a {@code try-with-resources} block.

- * - * @return a stream of all entities of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - Stream selectAll(); - /** * Retrieves a stream of entities based on their primary keys. * diff --git a/storm-core/src/main/java/st/orm/core/repository/ProjectionRepository.java b/storm-core/src/main/java/st/orm/core/repository/ProjectionRepository.java index f781bd4c1..2841233a7 100644 --- a/storm-core/src/main/java/st/orm/core/repository/ProjectionRepository.java +++ b/storm-core/src/main/java/st/orm/core/repository/ProjectionRepository.java @@ -429,25 +429,6 @@ default Window

scroll(@Nonnull Scrollable

scrollable) { // Stream based methods. - /** - * Returns a stream of all projections of the type supported by this repository. Each element in the stream represents - * a projection in the database, encapsulating all relevant data as mapped by the projection model. - * - *

The resulting stream is lazily loaded, meaning that the projections are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of projections.

- * - *

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 {@code AutoCloseable}, it is recommended to use it - * within a {@code try-with-resources} block.

- * - * @return a stream of all projections of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - Stream

selectAll(); - /** * Retrieves a stream of projections based on their primary keys. * diff --git a/storm-core/src/main/java/st/orm/core/repository/impl/BaseRepositoryImpl.java b/storm-core/src/main/java/st/orm/core/repository/impl/BaseRepositoryImpl.java index c2fc8c218..97d273d4c 100644 --- a/storm-core/src/main/java/st/orm/core/repository/impl/BaseRepositoryImpl.java +++ b/storm-core/src/main/java/st/orm/core/repository/impl/BaseRepositoryImpl.java @@ -435,27 +435,6 @@ public List findAllByRef(@Nonnull Iterable> refs) { // Stream based methods. These methods operate in multiple batches. - /** - * Returns a stream of all entities of the type supported by this repository. Each element in the stream represents - * an entity in the database, encapsulating all relevant data as mapped by the entity model. - * - *

The resulting stream is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities.

- * - *

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 {@code AutoCloseable}, it is recommended to use it - * within a {@code try-with-resources} block.

- * - * @return a stream of all entities of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - public Stream selectAll() { - return select().getResultStream(); - } - /** * Retrieves a stream of entities based on their primary keys. * diff --git a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java index d0178dfda..fa0a8e636 100644 --- a/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java +++ b/storm-core/src/main/java/st/orm/core/spi/DefaultTransactionTemplateProviderImpl.java @@ -18,7 +18,6 @@ import static java.util.Optional.empty; import jakarta.annotation.Nonnull; -import jakarta.persistence.PersistenceException; import java.lang.reflect.Method; import java.lang.reflect.Proxy; import java.sql.Connection; @@ -26,6 +25,7 @@ import java.util.Map; import java.util.Optional; import st.orm.Entity; +import st.orm.PersistenceException; import st.orm.core.spi.Orderable.AfterAny; @AfterAny diff --git a/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java b/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java index c504129fd..aabc57c78 100644 --- a/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java +++ b/storm-core/src/main/java/st/orm/core/spi/QueryFactory.java @@ -60,7 +60,7 @@ public interface QueryFactory { /** * Returns the {@link DataSource} backing this factory, or {@code null} if the factory was created from a raw - * {@link java.sql.Connection} or JPA {@link jakarta.persistence.EntityManager}. + * {@link java.sql.Connection} or JPA {@code EntityManager}. * * @return the data source, or {@code null}. * @since 1.9 diff --git a/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java b/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java index a7317b49a..3564691cb 100644 --- a/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java +++ b/storm-core/src/main/java/st/orm/core/spi/TransactionTemplate.java @@ -18,8 +18,8 @@ import static java.util.Optional.ofNullable; import jakarta.annotation.Nonnull; -import jakarta.persistence.PersistenceException; import java.util.Optional; +import st.orm.PersistenceException; /** * The transaction template is a functional interface that allows callers to let logic be executed in the scope of a diff --git a/storm-core/src/main/java/st/orm/core/spi/TypeDiscovery.java b/storm-core/src/main/java/st/orm/core/spi/TypeDiscovery.java index e7c236255..e6d832e7f 100644 --- a/storm-core/src/main/java/st/orm/core/spi/TypeDiscovery.java +++ b/storm-core/src/main/java/st/orm/core/spi/TypeDiscovery.java @@ -15,6 +15,7 @@ */ package st.orm.core.spi; +import jakarta.annotation.Nonnull; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; @@ -22,7 +23,6 @@ import java.util.*; import st.orm.Converter; import st.orm.Data; -import st.orm.core.repository.Repository; public final class TypeDiscovery { @@ -55,25 +55,52 @@ public static List> getDataTypes() { *

The index is generated at compile time by the Storm metamodel processor (annotation processor or KSP). * It contains all interfaces in the user's project that extend {@code EntityRepository} or * {@code ProjectionRepository}.

+ * + *

The type check is intentionally lenient: the index is already curated at compile time, and the + * repository interface may come from different modules ({@code st.orm.core.repository.Repository}, + * {@code st.orm.repository.Repository} in Java 21 or Kotlin). Checking against a single base type + * would silently reject valid entries.

*/ - public static List> getRepositoryTypes() { - return loadTypes(REPOSITORY_TYPE, Repository.class); + public static List> getRepositoryTypes() { + return loadClasses(REPOSITORY_TYPE); + } + + @SuppressWarnings("SameParameterValue") + private static List> loadClasses(@Nonnull String typeFqName) { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = TypeDiscovery.class.getClassLoader(); + } + String resourceName = INDEX_DIRECTORY + typeFqName + ".idx"; + List classNames = loadResourceLines(classLoader, resourceName); + if (classNames.isEmpty()) { + return List.of(); + } + List> result = new ArrayList<>(); + for (String fqClassName : new LinkedHashSet<>(classNames)) { + try { + result.add(Class.forName(fqClassName, false, classLoader)); + } catch (Throwable ignore) { + // Skip bad entries or missing classes. + } + } + return result; } - private static List> loadTypes(String typeFqName, Class expectedType) { - ClassLoader cl = Thread.currentThread().getContextClassLoader(); - if (cl == null) { - cl = TypeDiscovery.class.getClassLoader(); + private static List> loadTypes(@Nonnull String typeFqName, @Nonnull Class expectedType) { + ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); + if (classLoader == null) { + classLoader = TypeDiscovery.class.getClassLoader(); } String resourceName = INDEX_DIRECTORY + typeFqName + ".idx"; - List classNames = loadResourceLines(cl, resourceName); + List classNames = loadResourceLines(classLoader, resourceName); if (classNames.isEmpty()) { return List.of(); } List> result = new ArrayList<>(); - for (String fqcn : new LinkedHashSet<>(classNames)) { + for (String fqClassName : new LinkedHashSet<>(classNames)) { try { - Class cls = Class.forName(fqcn, false, cl); + Class cls = Class.forName(fqClassName, false, classLoader); if (expectedType.isAssignableFrom(cls)) { @SuppressWarnings("unchecked") Class cast = (Class) cls; @@ -86,9 +113,9 @@ private static List> loadTypes(String typeFqName, Class loadResourceLines(ClassLoader cl, String resourceName) { + private static List loadResourceLines(@Nonnull ClassLoader classLoader, @Nonnull String resourceName) { try { - Enumeration resources = cl.getResources(resourceName); + Enumeration resources = classLoader.getResources(resourceName); if (!resources.hasMoreElements()) { return List.of(); } diff --git a/storm-core/src/main/java/st/orm/core/template/JpaTemplate.java b/storm-core/src/main/java/st/orm/core/template/JpaTemplate.java index ac4712381..9aaa98ca9 100644 --- a/storm-core/src/main/java/st/orm/core/template/JpaTemplate.java +++ b/storm-core/src/main/java/st/orm/core/template/JpaTemplate.java @@ -19,6 +19,8 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.Query; import java.util.function.Predicate; +import java.util.function.UnaryOperator; +import st.orm.PersistenceException; import st.orm.StormConfig; import st.orm.core.spi.Provider; import st.orm.core.template.impl.JpaTemplateImpl; @@ -63,6 +65,23 @@ static ORMTemplate ORM(@Nonnull EntityManager entityManager) { return new JpaTemplateImpl(entityManager).toORM(); } + /** + * Creates a new ORM template for the given entity manager, with a custom template decorator. + * + * @param entityManager the entity manager. + * @param decorator a function that transforms the {@link TemplateDecorator} to customize template processing. + * @return the ORM template. + */ + static ORMTemplate ORM(@Nonnull EntityManager entityManager, + @Nonnull UnaryOperator decorator) { + var template = new JpaTemplateImpl(entityManager); + var decorated = decorator.apply(template); + if (!(decorated instanceof JpaTemplateImpl)) { + throw new PersistenceException("Decorator must return the same template type."); + } + return ((JpaTemplateImpl) decorated).toORM(); + } + /** * Creates a new ORM template for the given entity manager, configured with the provided {@link StormConfig}. * @@ -74,6 +93,25 @@ static ORMTemplate ORM(@Nonnull EntityManager entityManager, @Nonnull StormConfi return new JpaTemplateImpl(entityManager, config).toORM(); } + /** + * Creates a new ORM template for the given entity manager, configured with the provided {@link StormConfig} + * and a custom template decorator. + * + * @param entityManager the entity manager. + * @param config the Storm configuration to apply. + * @param decorator a function that transforms the {@link TemplateDecorator} to customize template processing. + * @return the ORM template. + */ + static ORMTemplate ORM(@Nonnull EntityManager entityManager, @Nonnull StormConfig config, + @Nonnull UnaryOperator decorator) { + var template = new JpaTemplateImpl(entityManager, config); + var decorated = decorator.apply(template); + if (!(decorated instanceof JpaTemplateImpl)) { + throw new PersistenceException("Decorator must return the same template type."); + } + return ((JpaTemplateImpl) decorated).toORM(); + } + /** * Returns an ORM template for this JPA template. */ diff --git a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java index 29480cb7d..39ea6bd7d 100644 --- a/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java +++ b/storm-core/src/main/java/st/orm/core/template/ORMTemplate.java @@ -16,7 +16,6 @@ package st.orm.core.template; import jakarta.annotation.Nonnull; -import jakarta.persistence.EntityManager; import java.sql.Connection; import java.util.List; import java.util.function.UnaryOperator; @@ -28,7 +27,6 @@ import st.orm.core.repository.EntityRepository; import st.orm.core.repository.ProjectionRepository; import st.orm.core.repository.RepositoryLookup; -import st.orm.core.template.impl.JpaTemplateImpl; import st.orm.core.template.impl.PreparedStatementTemplateImpl; import st.orm.mapping.TemplateDecorator; @@ -96,7 +94,7 @@ default ORMTemplate withEntityCallback(@Nonnull EntityCallback callback) { * confirmation message and returns an empty list.

* *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link jakarta.persistence.EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @return the list of validation error messages (empty on success). * @throws PersistenceException if the template does not support schema validation. @@ -113,7 +111,7 @@ default List validateSchema() { * confirmation message and returns an empty list.

* *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link jakarta.persistence.EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @param types the entity and projection types to validate. * @return the list of validation error messages (empty on success). @@ -128,7 +126,7 @@ default List validateSchema(@Nonnull Iterable> typ * Validates all discovered types and throws if any errors are found. * *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link jakarta.persistence.EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @throws PersistenceException if validation fails or the template does not support schema validation. * @since 1.9 @@ -141,7 +139,7 @@ default void validateSchemaOrThrow() { * Validates the specified types and throws if any errors are found. * *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link jakarta.persistence.EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @param types the entity and projection types to validate. * @throws PersistenceException if validation fails or the template does not support schema validation. @@ -151,59 +149,6 @@ default void validateSchemaOrThrow(@Nonnull Iterable> type throw new PersistenceException("Schema validation is not supported by this template."); } - /** - * Returns an {@link ORMTemplate} for use with JPA. - * - *

This method creates an ORM repository template using the provided {@link EntityManager}. - * It allows you to perform database operations using JPA in a type-safe manner. - * - *

Example usage: - *

{@code
-     * EntityManager entityManager = ...;
-     * ORMTemplate orm = Templates.ORM(entityManager);
-     * List otherTables = orm.query(RAW."""
-     *         SELECT \{MyTable.class}
-     *         FROM \{MyTable.class}
-     *         WHERE \{MyTable_.name} = \{"ABC"}""")
-     *     .getResultList(MyTable.class);
-     * }
- * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager) { - return new JpaTemplateImpl(entityManager).toORM(); - } - - /** - * Returns an {@link ORMTemplate} for use with JPA. - * - *

This method creates an ORM repository template using the provided {@link EntityManager}. - * It allows you to perform database operations using JPA in a type-safe manner. - * - *

Example usage: - *

{@code
-     * EntityManager entityManager = ...;
-     * ORMTemplate orm = Templates.ORM(entityManager);
-     * List otherTables = orm.query(RAW."""
-     *         SELECT \{MyTable.class}
-     *         FROM \{MyTable.class}
-     *         WHERE \{MyTable_.name} = \{"ABC"}""")
-     *     .getResultList(MyTable.class);
-     * }
- * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager, @Nonnull UnaryOperator decorator) { - var template = new JpaTemplateImpl(entityManager); - var decorated = decorator.apply(template); - if (!(decorated instanceof JpaTemplateImpl)) { - throw new PersistenceException("Decorator must return the same template type."); - } - return ((JpaTemplateImpl) decorated).toORM(); - } - /** * Returns an {@link ORMTemplate} for use with JDBC. * @@ -317,36 +262,6 @@ static ORMTemplate of(@Nonnull Connection connection, return ((PreparedStatementTemplateImpl) decorated).toORM(); } - /** - * Returns an {@link ORMTemplate} for use with JPA, configured with the provided {@link StormConfig}. - * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @param config the Storm configuration to apply; must not be {@code null}. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager, @Nonnull StormConfig config) { - return new JpaTemplateImpl(entityManager, config).toORM(); - } - - /** - * Returns an {@link ORMTemplate} for use with JPA, configured with the provided {@link StormConfig} and a custom - * template decorator. - * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @param config the Storm configuration to apply; must not be {@code null}. - * @param decorator a function that transforms the {@link TemplateDecorator} to customize template processing. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager, @Nonnull StormConfig config, - @Nonnull UnaryOperator decorator) { - var template = new JpaTemplateImpl(entityManager, config); - var decorated = decorator.apply(template); - if (!(decorated instanceof JpaTemplateImpl)) { - throw new PersistenceException("Decorator must return the same template type."); - } - return ((JpaTemplateImpl) decorated).toORM(); - } - /** * Returns an {@link ORMTemplate} for use with JDBC, configured with the provided {@link StormConfig}. * diff --git a/storm-core/src/main/java/st/orm/core/template/Templates.java b/storm-core/src/main/java/st/orm/core/template/Templates.java index 4712f5e54..54227dbdb 100644 --- a/storm-core/src/main/java/st/orm/core/template/Templates.java +++ b/storm-core/src/main/java/st/orm/core/template/Templates.java @@ -22,7 +22,6 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import jakarta.persistence.EntityManager; import java.sql.Connection; import java.util.Arrays; import java.util.Calendar; @@ -71,8 +70,8 @@ * tables, aliases, and more. * *

Additionally, the {@code Templates} interface provides methods to create {@link ORMTemplate} - * instances for use with different data sources like JPA's {@link EntityManager}, JDBC's {@link DataSource}, or - * {@link Connection}. + * instances for use with different data sources like JDBC's {@link DataSource} or {@link Connection}, or + * JPA's {@code EntityManager} (see {@link JpaTemplate}). * *

Using Templates

* diff --git a/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java b/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java index 92c711f4d..40c4374ee 100644 --- a/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/EntityRepositoryIntegrationTest.java @@ -721,7 +721,7 @@ public void testExistsByRef() { public void testSelectAll() { var orm = ORMTemplate.of(dataSource); var cities = orm.entity(City.class); - try (var stream = cities.selectAll()) { + try (var stream = cities.select().getResultStream()) { long count = stream.count(); assertEquals(cities.count(), count); } diff --git a/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java b/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java index 033eb1a82..934438a41 100644 --- a/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/JpaIntegrationTest.java @@ -243,34 +243,34 @@ void jpaTemplate_toORM() { @Test void ormTemplate_of_entityManager_basic() { - ORMTemplate orm = ORMTemplate.of(entityManager); + ORMTemplate orm = JpaTemplate.ORM(entityManager); assertNotNull(orm); } @Test void ormTemplate_of_entityManager_withConfig() { StormConfig config = StormConfig.of(Map.of()); - ORMTemplate orm = ORMTemplate.of(entityManager, config); + ORMTemplate orm = JpaTemplate.ORM(entityManager, config); assertNotNull(orm); assertEquals(config, orm.config()); } @Test void ormTemplate_of_entityManager_withDecorator() { - ORMTemplate orm = ORMTemplate.of(entityManager, t -> t); + ORMTemplate orm = JpaTemplate.ORM(entityManager, t -> t); assertNotNull(orm); } @Test void ormTemplate_of_entityManager_withDecorator_customTableNameResolver() { - ORMTemplate orm = ORMTemplate.of(entityManager, t -> t.withTableNameResolver(null)); + ORMTemplate orm = JpaTemplate.ORM(entityManager, t -> t.withTableNameResolver(null)); assertNotNull(orm); } @Test void ormTemplate_of_entityManager_withConfigAndDecorator() { StormConfig config = StormConfig.of(Map.of()); - ORMTemplate orm = ORMTemplate.of(entityManager, config, t -> t); + ORMTemplate orm = JpaTemplate.ORM(entityManager, config, t -> t); assertNotNull(orm); assertEquals(config, orm.config()); } @@ -279,14 +279,14 @@ void ormTemplate_of_entityManager_withConfigAndDecorator() { void ormTemplate_of_entityManager_decoratorMustReturnSameType() { // Decorator that returns a non-JpaTemplateImpl should throw. assertThrows(st.orm.PersistenceException.class, () -> - ORMTemplate.of(entityManager, t -> new TemplateDecoratorStub())); + JpaTemplate.ORM(entityManager, t -> new TemplateDecoratorStub())); } @Test void ormTemplate_of_entityManager_configAndDecorator_decoratorMustReturnSameType() { StormConfig config = StormConfig.of(Map.of()); assertThrows(st.orm.PersistenceException.class, () -> - ORMTemplate.of(entityManager, config, t -> new TemplateDecoratorStub())); + JpaTemplate.ORM(entityManager, config, t -> new TemplateDecoratorStub())); } @Test diff --git a/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java b/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java index 540d4d53c..e841f4653 100644 --- a/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java +++ b/storm-core/src/test/java/st/orm/core/RepositoryPreparedStatementIntegrationTest.java @@ -358,10 +358,10 @@ public void testInsertReturningIdsCompoundPk() { @Test public void testUpdateList() { var repository = ORMTemplate.of(dataSource).entity(Vet.class); - try (var stream1 = repository.selectAll()) { + try (var stream1 = repository.select().getResultStream()) { var list1 = stream1.toList(); repository.update(list1); - try (var stream2 = repository.selectAll()) { + try (var stream2 = repository.select().getResultStream()) { var list2 = stream2.toList(); assertEquals(list1, list2); } @@ -675,7 +675,7 @@ void testInsertNullablePetRefWithNonnull() { @Test public void testSelectNullOwnerRef() { - try (var stream = ORMTemplate.of(dataSource).entity(PetWithNullableOwnerRef.class).selectAll()) { + try (var stream = ORMTemplate.of(dataSource).entity(PetWithNullableOwnerRef.class).select().getResultStream()) { var pet = stream.filter(p -> p.name().equals("Sly")).findFirst().orElseThrow(); assertNull(pet.owner()); } @@ -1240,7 +1240,7 @@ public void deleteAll() { @Test public void deleteBatch() { var repo = ORMTemplate.of(dataSource).entity(Visit.class); - try (var stream = repo.selectAll()) { + try (var stream = repo.select().getResultStream()) { repo.delete(stream); } assertEquals(0, repo.count()); @@ -1249,7 +1249,7 @@ public void deleteBatch() { @Test public void deleteRefBatch() { var repo = ORMTemplate.of(dataSource).entity(Visit.class); - try (var stream = repo.selectAll().map(Ref::of)) { + try (var stream = repo.select().getResultStream().map(Ref::of)) { repo.deleteByRef(stream); } assertEquals(0, repo.count()); diff --git a/storm-foundation/src/main/java/module-info.java b/storm-foundation/src/main/java/module-info.java index d30053ebd..c180e487c 100644 --- a/storm-foundation/src/main/java/module-info.java +++ b/storm-foundation/src/main/java/module-info.java @@ -1,6 +1,6 @@ module storm.foundation { exports st.orm; exports st.orm.mapping; - requires jakarta.persistence; + requires static jakarta.persistence; requires jakarta.annotation; } diff --git a/storm-java21/src/main/java/module-info.java b/storm-java21/src/main/java/module-info.java index 491b9debf..0742bbd6e 100644 --- a/storm-java21/src/main/java/module-info.java +++ b/storm-java21/src/main/java/module-info.java @@ -2,7 +2,7 @@ exports st.orm.repository; exports st.orm.template; requires java.sql; - requires jakarta.persistence; + requires static jakarta.persistence; requires jakarta.annotation; requires java.compiler; requires storm.foundation; 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 2224990ed..ca22c1b11 100644 --- a/storm-java21/src/main/java/st/orm/repository/EntityRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/EntityRepository.java @@ -943,48 +943,6 @@ default Window scroll(@Nonnull Scrollable scrollable) { // processed. The BatchCallback approach prevents the caller from accidentally misusing the API. // - /** - * Returns a stream of all entities of the type supported by this repository. Each element in the stream represents - * an entity in the database, encapsulating all relevant data as mapped by the entity model. - * - *

The resulting stream is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities.

- * - *

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 {@code AutoCloseable}, it - * is recommended to use it within a {@code try-with-resources} block.

- * - * @return a stream of all entities of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - Stream selectAll(); - - /** - * Returns a stream of refs to all entities of the type supported by this repository. Each element in the stream - * represents a lightweight reference to an entity in the database, containing only the primary key. - * - *

This method is useful when you need to retrieve all entity identifiers without loading the full entity data. - * The complete entity can be fetched on demand by calling {@link Ref#fetch()} on any of the returned refs.

- * - *

The resulting stream is lazily loaded, meaning that the refs are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities.

- * - *

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 {@code AutoCloseable}, it - * is recommended to use it within a {@code try-with-resources} block.

- * - * @return a stream of refs to all entities of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - * @since 1.3 - */ - Stream> selectAllRef(); - /** * Retrieves a stream of entities based on their primary keys. * 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 b755b0d05..28d36f58f 100644 --- a/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java +++ b/storm-java21/src/main/java/st/orm/repository/ProjectionRepository.java @@ -424,25 +424,6 @@ default Window

scroll(@Nonnull Scrollable

scrollable) { // processed. The BatchCallback approach prevents the caller from accidentally misusing the API. // - /** - * Returns a stream of all projections of the type supported by this repository. Each element in the stream represents - * a projection in the database, encapsulating all relevant data as mapped by the projection model. - * - *

The resulting stream is lazily loaded, meaning that the projections are only retrieved from the database as they - * are consumed by the stream. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of projections.

- * - *

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 {@code AutoCloseable}, it is recommended to use it - * within a {@code try-with-resources} block.

- * - * @return a stream of all projections of the type supported by this repository. - * @throws PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - Stream

selectAll(); - /** * Retrieves a stream of projections based on their primary keys. * diff --git a/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java b/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java index 56e2f1440..decbfc705 100644 --- a/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java +++ b/storm-java21/src/main/java/st/orm/repository/impl/EntityRepositoryImpl.java @@ -319,16 +319,6 @@ public void deleteByRef(@Nonnull Iterable> refs) { core.deleteByRef(refs); } - @Override - public Stream selectAll() { - return core.selectAll(); - } - - @Override - public Stream> selectAllRef() { - return selectRef().getResultStream(); - } - @Override public Stream selectById(@Nonnull Stream ids) { return core.selectById(ids); diff --git a/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java b/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java index e78249a96..50e8eecf6 100644 --- a/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java +++ b/storm-java21/src/main/java/st/orm/repository/impl/ProjectionRepositoryImpl.java @@ -196,11 +196,6 @@ public List

findAllByRef(@Nonnull Iterable> refs) { return core.findAllByRef(refs); } - @Override - public Stream

selectAll() { - return core.selectAll(); - } - @Override public Stream

selectById(@Nonnull Stream ids) { return core.selectById(ids); diff --git a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java index 8aba94543..92572bf5d 100644 --- a/storm-java21/src/main/java/st/orm/template/ORMTemplate.java +++ b/storm-java21/src/main/java/st/orm/template/ORMTemplate.java @@ -16,7 +16,6 @@ package st.orm.template; import jakarta.annotation.Nonnull; -import jakarta.persistence.EntityManager; import java.sql.Connection; import java.util.List; import java.util.function.UnaryOperator; @@ -38,9 +37,9 @@ * {@link RepositoryLookup} (for obtaining type-safe {@link EntityRepository} and {@link ProjectionRepository} * instances). It is the central interface from which all database operations originate.

* - *

Instances are created using the static factory methods {@link #of(jakarta.persistence.EntityManager)}, - * {@link #of(javax.sql.DataSource)}, or {@link #of(java.sql.Connection)}, or via the convenience methods - * in the {@link Templates} class.

+ *

Instances are created using the static factory methods {@link #of(javax.sql.DataSource)} or + * {@link #of(java.sql.Connection)}, or via the convenience methods in the {@link Templates} class. + * For JPA-based usage, see {@code JpaTemplate}.

* *

Example

*
{@code
@@ -98,7 +97,7 @@ public interface ORMTemplate extends QueryTemplate, RepositoryLookup {
      * confirmation message and returns an empty list.

* *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @return the list of validation error messages (empty on success). * @throws st.orm.PersistenceException if the template does not support schema validation. @@ -113,7 +112,7 @@ public interface ORMTemplate extends QueryTemplate, RepositoryLookup { * confirmation message and returns an empty list.

* *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @param types the entity and projection types to validate. * @return the list of validation error messages (empty on success). @@ -126,7 +125,7 @@ public interface ORMTemplate extends QueryTemplate, RepositoryLookup { * Validates all discovered types and throws if any errors are found. * *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @throws st.orm.PersistenceException if validation fails or the template does not support schema validation. * @since 1.9 @@ -137,7 +136,7 @@ public interface ORMTemplate extends QueryTemplate, RepositoryLookup { * Validates the specified types and throws if any errors are found. * *

This method requires a DataSource-backed template. Templates created from a raw - * {@link Connection} or {@link EntityManager} do not support schema validation.

+ * {@link Connection} or {@code EntityManager} do not support schema validation.

* * @param types the entity and projection types to validate. * @throws st.orm.PersistenceException if validation fails or the template does not support schema validation. @@ -145,30 +144,6 @@ public interface ORMTemplate extends QueryTemplate, RepositoryLookup { */ void validateSchemaOrThrow(@Nonnull Iterable> types); - /** - * Returns an {@link ORMTemplate} for use with JPA. - * - *

This method creates an ORM repository template using the provided {@link EntityManager}. - * It allows you to perform database operations using JPA in a type-safe manner. - * - *

Example usage: - *

{@code
-     * EntityManager entityManager = ...;
-     * ORMTemplate orm = ORMTemplate.of(entityManager);
-     * List otherTables = orm.query(RAW."""
-     *         SELECT \{MyTable.class}
-     *         FROM \{MyTable.class}
-     *         WHERE \{MyTable_.name} = \{"ABC"}""")
-     *     .getResultList(MyTable.class);
-     * }
- * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager) { - return new ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager)); - } - /** * Returns an {@link ORMTemplate} for use with JDBC. * @@ -220,21 +195,6 @@ static ORMTemplate of(@Nonnull Connection connection) { return new ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(connection)); } - /** - * Returns an {@link ORMTemplate} for use with JPA, with a custom template decorator. - * - *

This method creates an ORM repository template using the provided {@link EntityManager} and applies - * the specified decorator to customize template processing behavior. - * - * @param entityManager the {@link EntityManager} to use for database operations; must not be {@code null}. - * @param decorator a function that transforms the {@link TemplateDecorator} to customize template processing. - * @return an {@link ORMTemplate} configured for use with JPA. - */ - static ORMTemplate of(@Nonnull EntityManager entityManager, @Nonnull UnaryOperator decorator) { - return new ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager, decorator)); - - } - /** * Returns an {@link ORMTemplate} for use with JDBC, with a custom template decorator. * @@ -265,31 +225,6 @@ static ORMTemplate of(@Nonnull Connection connection, @Nonnull UnaryOperator decorator) { - return new ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager, config, decorator)); - } - /** * Returns an {@link ORMTemplate} for use with JDBC, configured with the provided {@link StormConfig}. * diff --git a/storm-java21/src/main/java/st/orm/template/Templates.java b/storm-java21/src/main/java/st/orm/template/Templates.java index a940e57a9..ec0e0a10b 100644 --- a/storm-java21/src/main/java/st/orm/template/Templates.java +++ b/storm-java21/src/main/java/st/orm/template/Templates.java @@ -23,7 +23,6 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import jakarta.persistence.EntityManager; import java.sql.Connection; import java.util.Arrays; import java.util.Calendar; @@ -72,8 +71,8 @@ * tables, aliases, and more. * *

Additionally, the {@code Templates} interface provides methods to create {@link ORMTemplate} - * instances for use with different data sources like JPA's {@link EntityManager}, JDBC's {@link DataSource}, or - * {@link Connection}. + * instances for use with different data sources like JDBC's {@link DataSource} or {@link Connection}, or + * JPA's {@code EntityManager} (see {@code JpaTemplate}). * *

Using Templates

* diff --git a/storm-java21/src/test/java/st/orm/template/ORMTemplateTest.java b/storm-java21/src/test/java/st/orm/template/ORMTemplateTest.java index 408f3e57f..1ace0b056 100644 --- a/storm-java21/src/test/java/st/orm/template/ORMTemplateTest.java +++ b/storm-java21/src/test/java/st/orm/template/ORMTemplateTest.java @@ -307,7 +307,7 @@ public void testEntityFindAllRef() { @Test public void testEntitySelectAll() { EntityRepository cities = orm.entity(City.class); - try (Stream stream = cities.selectAll()) { + try (Stream stream = cities.select().getResultStream()) { assertEquals(6, stream.count()); } } @@ -315,7 +315,7 @@ public void testEntitySelectAll() { @Test public void testEntitySelectAllRef() { EntityRepository cities = orm.entity(City.class); - try (Stream> stream = cities.selectAllRef()) { + try (Stream> stream = cities.selectRef().getResultStream()) { assertEquals(6, stream.count()); } } @@ -658,7 +658,7 @@ public void testProjectionFindAllByRef() { @Test public void testProjectionSelectAll() { ProjectionRepository views = orm.projection(OwnerView.class); - try (Stream stream = views.selectAll()) { + try (Stream stream = views.select().getResultStream()) { assertEquals(10, stream.count()); } } diff --git a/storm-kotlin/src/main/java/module-info.java b/storm-kotlin/src/main/java/module-info.java index 7e38bd426..2124f92d2 100644 --- a/storm-kotlin/src/main/java/module-info.java +++ b/storm-kotlin/src/main/java/module-info.java @@ -5,7 +5,7 @@ exports st.orm.repository; exports st.orm.template.impl to kotlin.reflect; requires java.sql; - requires jakarta.persistence; + requires static jakarta.persistence; requires jakarta.annotation; requires kotlin.reflect; requires kotlin.stdlib; diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/EntityRepository.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/EntityRepository.kt index dc3282a84..e0e0837ba 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/EntityRepository.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/EntityRepository.kt @@ -224,6 +224,28 @@ interface EntityRepository : Repository where E : Entity { */ fun select(): QueryBuilder + /** + * Constructs a SELECT query using a block-based DSL. + * + * A [PredicateBuilder] returned as the block's last expression is automatically applied as a WHERE clause, + * so `select { path eq value }` is equivalent to `select { where(path eq value) }`. + * + * ```kotlin + * interface UserRepository : EntityRepository { + * fun findActive(): List = select { + * where(User_.active eq true) + * orderBy(User_.name) + * }.resultList + * } + * ``` + */ + @Suppress("UNCHECKED_CAST") + fun select(block: SqlScope.() -> Any?): QueryBuilder { + val scope = SqlScope(select()) + scope.applyResult(scope.block()) + return scope.builder + } + /** * Creates a new query builder for the entity type managed by this repository. * @@ -296,6 +318,29 @@ interface EntityRepository : Repository where E : Entity { */ fun delete(): QueryBuilder + /** + * Constructs and executes a DELETE statement using a block-based DSL. + * + * A [PredicateBuilder] returned as the block's last expression is automatically applied as a WHERE clause, + * so `delete { path eq value }` is equivalent to `delete { where(path eq value) }`. + * + * ```kotlin + * interface UserRepository : EntityRepository { + * fun deleteInactive(): Int = delete { + * where(User_.active eq false) + * } + * } + * ``` + * + * @return the number of rows deleted. + */ + @Suppress("UNCHECKED_CAST") + fun delete(block: SqlScope.() -> Any?): Int { + val scope = SqlScope(delete() as QueryBuilder) + scope.applyResult(scope.block()) + return scope.builder.executeUpdate() + } + // Base methods. /** @@ -901,25 +946,6 @@ interface EntityRepository : Repository where E : Entity { */ fun deleteByRef(refs: Iterable>) - /** - * Returns a flow of all entities of the type supported by this repository. Each element in the flow represents - * an entity in the database, encapsulating all relevant data as mapped by the entity model. - * - * The resulting flow is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the flow. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * **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 flow holds resources open - * while in use, it must be closed after usage to prevent resource leaks. As the flow is `AutoCloseable`, it - * is recommended to use it within a `try-with-resources` block. - * - * @return a flow of all entities of the type supported by this repository. - * @throws st.orm.PersistenceException if the selection operation fails due to underlying database issues, such as - * connectivity. - */ - fun selectAll(): Flow - /** * Retrieves a flow of entities based on their primary keys. * @@ -1468,21 +1494,6 @@ interface EntityRepository : Repository where E : Entity { */ fun findAllRef(): List> = selectRef().resultList - /** - * Retrieves all entities of type [E] from the repository. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence containing all entities. - */ - fun selectAllRef(): Flow> = selectRef().resultFlow - /** * Retrieves an optional entity of type [E] based on a single field and its value. * Returns null if no matching entity is found. @@ -1794,18 +1805,6 @@ interface EntityRepository : Repository where E : Entity { */ fun findAll(predicate: PredicateBuilder): List = select().where(predicate).resultList - /** - * Retrieves entities of type [E] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val entities = repository.findAll { Entity_.active eq true } - * ``` - * - * @return a list of matching entities. - */ - fun findAll(predicate: () -> PredicateBuilder): List = findAll(predicate()) - /** * Retrieves entities of type [E] matching the specified predicate. * @@ -1813,18 +1812,6 @@ interface EntityRepository : Repository where E : Entity { */ fun findAllRef(predicate: PredicateBuilder): List> = selectRef().where(predicate).resultList - /** - * Retrieves entity references of type [E] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val refs = repository.findAllRef { Entity_.active eq true } - * ``` - * - * @return a list of matching entity references. - */ - fun findAllRef(predicate: () -> PredicateBuilder): List> = findAllRef(predicate()) - /** * Retrieves an optional entity of type [E] matching the specified predicate. * Returns null if no matching entity is found. @@ -1835,19 +1822,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): E? = select().where(predicate).optionalResult - /** - * Retrieves an optional entity of type [E] matching the specified predicate lambda. - * Returns null if no matching entity is found. - * - * Example: - * ```kotlin - * val entity = repository.find { Entity_.name eq "Storm" } - * ``` - * - * @return an optional entity, or null if none found. - */ - fun find(predicate: () -> PredicateBuilder): E? = find(predicate()) - /** * Retrieves an optional entity of type [E] matching the specified predicate. * Returns a ref with a null value if no matching entity is found. @@ -1858,19 +1832,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): Ref? = selectRef().where(predicate).optionalResult - /** - * Retrieves an optional entity reference of type [E] matching the specified predicate lambda. - * Returns null if no matching entity is found. - * - * Example: - * ```kotlin - * val ref = repository.findRef { Entity_.name eq "Storm" } - * ``` - * - * @return an optional entity reference, or null if none found. - */ - fun findRef(predicate: () -> PredicateBuilder): Ref? = findRef(predicate()) - /** * Retrieves a single entity of type [E] matching the specified predicate. * Throws an exception if no entity or more than one entity is found. @@ -1883,21 +1844,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): E = select().where(predicate).singleResult - /** - * Retrieves a single entity of type [E] matching the specified predicate lambda. - * Throws an exception if no entity or more than one entity is found. - * - * Example: - * ```kotlin - * val entity = repository.get { Entity_.name eq "Storm" } - * ``` - * - * @return the matching entity. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ - fun get(predicate: () -> PredicateBuilder): E = get(predicate()) - /** * Retrieves a single entity of type [E] matching the specified predicate. * Throws an exception if no entity or more than one entity is found. @@ -1910,85 +1856,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): Ref = selectRef().where(predicate).singleResult - /** - * Retrieves a single entity reference of type [E] matching the specified predicate lambda. - * Throws an exception if no entity or more than one entity is found. - * - * Example: - * ```kotlin - * val ref = repository.getRef { Entity_.name eq "Storm" } - * ``` - * - * @return the matching entity reference. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ - fun getRef(predicate: () -> PredicateBuilder): Ref = getRef(predicate()) - - /** - * Retrieves entities of type [E] matching the specified predicate. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence of matching entities. - */ - fun select( - predicate: PredicateBuilder, - ): Flow = select().where(predicate).resultFlow - - /** - * Retrieves entities of type [E] matching the specified predicate lambda as a flow. - * - * The resulting flow is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the flow. - * - * Example: - * ```kotlin - * val entities = repository.select { Entity_.active eq true } - * ``` - * - * @return a flow of matching entities. - */ - fun select(predicate: () -> PredicateBuilder): Flow = select(predicate()) - - /** - * Retrieves entities of type [E] matching the specified predicate. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence of matching entities. - */ - fun selectRef( - predicate: PredicateBuilder, - ): Flow> = selectRef().where(predicate).resultFlow - - /** - * Retrieves entity references of type [E] matching the specified predicate lambda as a flow. - * - * The resulting flow is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the flow. - * - * Example: - * ```kotlin - * val refs = repository.selectRef { Entity_.active eq true } - * ``` - * - * @return a flow of matching entity references. - */ - fun selectRef(predicate: () -> PredicateBuilder): Flow> = selectRef(predicate()) - /** * Counts entities of type [E] matching the specified field and value. * @@ -2023,18 +1890,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): Long = selectCount().where(predicate).singleResult - /** - * Counts entities of type [E] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val count = repository.count { Entity_.active eq true } - * ``` - * - * @return the count of matching entities. - */ - fun count(predicate: () -> PredicateBuilder): Long = count(predicate()) - /** * Checks if entities of type [E] matching the specified field and value exists. * @@ -2069,18 +1924,6 @@ interface EntityRepository : Repository where E : Entity { predicate: PredicateBuilder, ): Boolean = selectCount().where(predicate).singleResult > 0 - /** - * Checks if entities of type [E] matching the specified predicate lambda exist. - * - * Example: - * ```kotlin - * val hasActive = repository.exists { Entity_.active eq true } - * ``` - * - * @return true if any matching entities exist, false otherwise. - */ - fun exists(predicate: () -> PredicateBuilder): Boolean = exists(predicate()) - /** * Deletes entities of type [E] matching the specified field and value. * @@ -2129,26 +1972,6 @@ interface EntityRepository : Repository where E : Entity { values: Iterable>, ): Int = delete().whereRef(field, values).executeUpdate() - /** - * Deletes entities of type [E] matching the specified predicate. - * - * @param predicate Lambda to build the WHERE clause. - * @return the number of entities deleted. - */ - fun delete(predicate: PredicateBuilder): Int = delete().where(predicate).executeUpdate() - - /** - * Deletes entities of type [E] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val count = repository.delete { Entity_.active eq false } - * ``` - * - * @return the number of entities deleted. - */ - fun delete(predicate: () -> PredicateBuilder): Int = delete(predicate()) - /** * Returns a page of entities using offset-based pagination. * diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/ProjectionRepository.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/ProjectionRepository.kt index 9274b3722..2597b40d3 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/ProjectionRepository.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/ProjectionRepository.kt @@ -68,6 +68,28 @@ interface ProjectionRepository : Repository where P : Projection + /** + * Constructs a SELECT query using a block-based DSL. + * + * A [PredicateBuilder] returned as the block's last expression is automatically applied as a WHERE clause, + * so `select { path eq value }` is equivalent to `select { where(path eq value) }`. + * + * ```kotlin + * interface OwnerViewRepository : ProjectionRepository { + * fun findByCity(city: City): List = select { + * where(OwnerView_.city eq city) + * orderBy(OwnerView_.lastName) + * }.resultList + * } + * ``` + */ + @Suppress("UNCHECKED_CAST") + fun select(block: SqlScope.() -> Any?): QueryBuilder { + val scope = SqlScope(select()) + scope.applyResult(scope.block()) + return scope.builder + } + /** * Creates a new query builder for the projection type managed by this repository. * @@ -356,27 +378,6 @@ interface ProjectionRepository : Repository where P : Projection - /** * Retrieves a stream of projections based on their primary keys. * @@ -574,21 +575,6 @@ interface ProjectionRepository : Repository where P : Projection> = selectRef().resultList - /** - * Retrieves all entities of type [P] from the repository. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence containing all entities. - */ - fun selectAllRef(): Flow> = selectRef().resultFlow - /** * Retrieves an optional entity of type [P] based on a single field and its value. * Returns null if no matching entity is found. @@ -900,18 +886,6 @@ interface ProjectionRepository : Repository where P : Projection): List

= select().where(predicate).resultList - /** - * Retrieves projections of type [P] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val owners = ownerViews.findAll { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return a list of matching projections. - */ - fun findAll(predicate: () -> PredicateBuilder): List

= findAll(predicate()) - /** * Retrieves entities of type [P] matching the specified predicate. * @@ -919,18 +893,6 @@ interface ProjectionRepository : Repository where P : Projection): List> = selectRef().where(predicate).resultList - /** - * Retrieves projection references of type [P] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val ownerRefs = ownerViews.findAllRef { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return a list of matching projection references. - */ - fun findAllRef(predicate: () -> PredicateBuilder): List> = findAllRef(predicate()) - /** * Retrieves an optional entity of type [P] matching the specified predicate. * Returns null if no matching entity is found. @@ -941,19 +903,6 @@ interface ProjectionRepository : Repository where P : Projection, ): P? = select().where(predicate).optionalResult - /** - * Retrieves an optional projection of type [P] matching the specified predicate lambda. - * Returns null if no matching projection is found. - * - * Example: - * ```kotlin - * val owner = ownerViews.find { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return the matching projection, or null if none found. - */ - fun find(predicate: () -> PredicateBuilder): P? = find(predicate()) - /** * Retrieves an optional entity of type [P] matching the specified predicate. * Returns a ref with a null value if no matching entity is found. @@ -964,19 +913,6 @@ interface ProjectionRepository : Repository where P : Projection, ): Ref

? = selectRef().where(predicate).optionalResult - /** - * Retrieves an optional projection reference of type [P] matching the specified predicate lambda. - * Returns null if no matching projection is found. - * - * Example: - * ```kotlin - * val ownerRef = ownerViews.findRef { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return the matching projection reference, or null if none found. - */ - fun findRef(predicate: () -> PredicateBuilder): Ref

? = findRef(predicate()) - /** * Retrieves a single entity of type [P] matching the specified predicate. * Throws an exception if no entity or more than one entity is found. @@ -989,21 +925,6 @@ interface ProjectionRepository : Repository where P : Projection, ): P = select().where(predicate).singleResult - /** - * Retrieves a single projection of type [P] matching the specified predicate lambda. - * Throws an exception if no projection or more than one projection is found. - * - * Example: - * ```kotlin - * val owner = ownerViews.get { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return the matching projection. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ - fun get(predicate: () -> PredicateBuilder): P = get(predicate()) - /** * Retrieves a single entity of type [P] matching the specified predicate. * Throws an exception if no entity or more than one entity is found. @@ -1016,85 +937,6 @@ interface ProjectionRepository : Repository where P : Projection, ): Ref

= selectRef().where(predicate).singleResult - /** - * Retrieves a single projection reference of type [P] matching the specified predicate lambda. - * Throws an exception if no projection or more than one projection is found. - * - * Example: - * ```kotlin - * val ownerRef = ownerViews.getRef { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return the matching projection reference. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ - fun getRef(predicate: () -> PredicateBuilder): Ref

= getRef(predicate()) - - /** - * Retrieves entities of type [P] matching the specified predicate. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence of matching entities. - */ - fun select( - predicate: PredicateBuilder, - ): Flow

= select().where(predicate).resultFlow - - /** - * Retrieves projections of type [P] matching the specified predicate lambda as a flow. - * - * The resulting flow is lazily loaded, meaning that the projections are only retrieved from the database as they - * are consumed by the flow. - * - * Example: - * ```kotlin - * val owners = ownerViews.select { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return a flow of matching projections. - */ - fun select(predicate: () -> PredicateBuilder): Flow

= select(predicate()) - - /** - * Retrieves entities of type [P] matching the specified predicate. - * - * The resulting sequence is lazily loaded, meaning that the entities are only retrieved from the database as they - * are consumed by the sequence. This approach is efficient and minimizes the memory footprint, especially when - * dealing with large volumes of entities. - * - * 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 sequence holds resources open - * while in use, it must be closed after usage to prevent resource leaks. - * - * @return a sequence of matching entities. - */ - fun selectRef( - predicate: PredicateBuilder, - ): Flow> = selectRef().where(predicate).resultFlow - - /** - * Retrieves projection references of type [P] matching the specified predicate lambda as a flow. - * - * The resulting flow is lazily loaded, meaning that the projections are only retrieved from the database as they - * are consumed by the flow. - * - * Example: - * ```kotlin - * val ownerRefs = ownerViews.selectRef { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return a flow of matching projection references. - */ - fun selectRef(predicate: () -> PredicateBuilder): Flow> = selectRef(predicate()) - /** * Counts entities of type [P] matching the specified field and value. * @@ -1129,18 +971,6 @@ interface ProjectionRepository : Repository where P : Projection, ): Long = selectCount().where(predicate).singleResult - /** - * Counts projections of type [P] matching the specified predicate lambda. - * - * Example: - * ```kotlin - * val count = ownerViews.count { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return the count of matching projections. - */ - fun count(predicate: () -> PredicateBuilder): Long = count(predicate()) - /** * Checks if entities of type [P] matching the specified field and value exists. * @@ -1175,18 +1005,6 @@ interface ProjectionRepository : Repository where P : Projection, ): Boolean = selectCount().where(predicate).singleResult > 0 - /** - * Checks if projections of type [P] matching the specified predicate lambda exist. - * - * Example: - * ```kotlin - * val hasDavis = ownerViews.exists { OwnerView_.lastName eq "Davis" } - * ``` - * - * @return true if any matching projections exist, false otherwise. - */ - fun exists(predicate: () -> PredicateBuilder): Boolean = exists(predicate()) - /** * Returns a page of projections using offset-based pagination. * diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt index 26673c861..f012806eb 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/RepositoryLookup.kt @@ -130,20 +130,6 @@ inline fun RepositoryLookup.findAll(): List = if (T::class (projection(T::class as KClass>) as ProjectionRepository, *>).findAll() as List } -/** - * Retrieves all records of type [T] from the repository. - * - * [T] must be either an Entity or Projection type. - * - * @return stream containing all records. - */ -@Suppress("UNCHECKED_CAST") -inline fun RepositoryLookup.selectAll(): Flow = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectAll() as Flow -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectAll() as Flow -} - /** * Retrieves all records of type [T] from the repository. * @@ -158,20 +144,6 @@ inline fun RepositoryLookup.findAllRef(): List> = if ( (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().resultList as List> } -/** - * Retrieves all records of type [T] from the repository. - * - * [T] must be either an Entity or Projection type. - * - * @return stream containing all records. - */ -@Suppress("UNCHECKED_CAST") -inline fun RepositoryLookup.selectAllRef(): Flow> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().resultFlow as Flow> -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().resultFlow as Flow> -} - /** * Retrieves an optional record of type [T] based on a single field and its value. * Returns null if no matching record is found. @@ -578,14 +550,6 @@ inline fun RepositoryLookup.findAll(predicate: PredicateBuild (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).resultList as List } -/** - * Retrieves all records of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return list of matching records. - */ -inline fun RepositoryLookup.findAll(predicate: () -> PredicateBuilder): List = findAll(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -600,14 +564,6 @@ inline fun RepositoryLookup.findAllRef(predicate: PredicateBu (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).resultList as List> } -/** - * Retrieves all record references of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return list of matching record references. - */ -inline fun RepositoryLookup.findAllRef(predicate: () -> PredicateBuilder): List> = findAllRef(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -622,14 +578,6 @@ inline fun RepositoryLookup.find(predicate: PredicateBuilder< (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).optionalResult as T? } -/** - * Finds a single record of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the matching record, or null if none found. - */ -inline fun RepositoryLookup.find(predicate: () -> PredicateBuilder): T? = find(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -644,14 +592,6 @@ inline fun RepositoryLookup.findRef(predicate: PredicateBuild (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).optionalResult as Ref? } -/** - * Finds a single record reference of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the matching record reference, or null if none found. - */ -inline fun RepositoryLookup.findRef(predicate: () -> PredicateBuilder): Ref? = findRef(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -666,16 +606,6 @@ inline fun RepositoryLookup.get(predicate: PredicateBuilder>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).singleResult as T } -/** - * Retrieves exactly one record of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the matching record. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ -inline fun RepositoryLookup.get(predicate: () -> PredicateBuilder): T = get(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -690,16 +620,6 @@ inline fun RepositoryLookup.getRef(predicate: PredicateBuilde (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).singleResult as Ref } -/** - * Retrieves exactly one record reference of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the matching record reference. - * @throws st.orm.NoResultException if there is no result. - * @throws st.orm.NonUniqueResultException if more than one result. - */ -inline fun RepositoryLookup.getRef(predicate: () -> PredicateBuilder): Ref = getRef(predicate()) - /** * Creates a query builder to select records of type [T]. * @@ -708,55 +628,27 @@ inline fun RepositoryLookup.getRef(predicate: () -> Predicate * @return A [QueryBuilder] for selecting records of type [T]. */ @Suppress("UNCHECKED_CAST") -inline fun RepositoryLookup.select(predicate: PredicateBuilder): Flow = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select().where(predicate as PredicateBuilder, *, *>).resultFlow as Flow -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select().where(predicate as PredicateBuilder, *, *>).resultFlow as Flow -} - -/** - * Selects records of type [T] matching the specified predicate as a [Flow], using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return flow of matching records. - */ -inline fun RepositoryLookup.select(predicate: () -> PredicateBuilder): Flow = select(predicate()) - -/** - * Creates a query builder to select records of type [T]. - * - * [T] must be either an Entity or Projection type. - * - * @return A [QueryBuilder] for selecting records of type [T]. - */ -@Suppress("UNCHECKED_CAST") -inline fun RepositoryLookup.selectRef(predicate: PredicateBuilder): Flow> = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).resultFlow as Flow> +inline fun RepositoryLookup.select(): QueryBuilder = if (T::class.isSubclassOf(Entity::class)) { + (entity(T::class as KClass>) as EntityRepository, *>).select() as QueryBuilder } else { - (projection(T::class as KClass>) as ProjectionRepository, *>).selectRef().where(predicate as PredicateBuilder, *, *>).resultFlow as Flow> + (projection(T::class as KClass>) as ProjectionRepository, *>).select() as QueryBuilder } /** - * Selects record references of type [T] matching the specified predicate as a [Flow], using trailing lambda syntax. + * Creates a query builder to select records of type [T] matching the specified predicate. * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return flow of matching record references. - */ -inline fun RepositoryLookup.selectRef(predicate: () -> PredicateBuilder): Flow> = selectRef(predicate()) - -/** - * Creates a query builder to select records of type [T]. - * - * [T] must be either an Entity or Projection type. + * The entity type is inferred from the predicate: + * ```kotlin + * orm.select { User_.city eq city }.resultList + * orm.select { User_.city eq city }.page(0, 20) + * ``` * - * @return A [QueryBuilder] for selecting records of type [T]. + * @return A [QueryBuilder] with the predicate applied. */ @Suppress("UNCHECKED_CAST") -inline fun RepositoryLookup.select(): QueryBuilder = if (T::class.isSubclassOf(Entity::class)) { - (entity(T::class as KClass>) as EntityRepository, *>).select() as QueryBuilder -} else { - (projection(T::class as KClass>) as ProjectionRepository, *>).select() as QueryBuilder -} +inline fun RepositoryLookup.select( + predicate: PredicateBuilder, +): QueryBuilder = select().where(predicate) /** * Creates a query builder to select references of entity records of type [T]. @@ -823,14 +715,6 @@ inline fun RepositoryLookup.count(predicate: PredicateBuilder (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult } -/** - * Counts entities of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the count of matching entities. - */ -inline fun RepositoryLookup.count(predicate: () -> PredicateBuilder): Long = count(predicate()) - /** * Checks if entities of type [T] matching the specified field and value exists. * @@ -884,14 +768,6 @@ inline fun RepositoryLookup.exists(predicate: PredicateBuilde (projection(T::class as KClass>) as ProjectionRepository, *>).selectCount().where(predicate as PredicateBuilder, *, *>).singleResult > 0 } -/** - * Checks if entities of type [T] matching the specified predicate exist, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return true if any matching entities exist, false otherwise. - */ -inline fun RepositoryLookup.exists(predicate: () -> PredicateBuilder): Boolean = exists(predicate()) - /** * Inserts an entity of type [T] into the repository. * @@ -948,6 +824,20 @@ inline infix fun > RepositoryLookup.upsert(entities: Flow< inline fun RepositoryLookup.delete(): QueryBuilder where T : Data, T : Entity<*> = entity().delete() +/** + * Deletes entities of type [T] matching the specified predicate. + * + * The entity type is inferred from the predicate: + * ```kotlin + * orm.delete(User_.active eq false) + * ``` + * + * @return the number of entities deleted. + */ +inline fun > RepositoryLookup.delete( + predicate: PredicateBuilder, +): Int = entity().delete().where(predicate).executeUpdate() + /** * Deletes an entity of type [T] from the repository. * @@ -1079,25 +969,3 @@ inline fun RepositoryLookup.deleteAllByRef( field: Metamodel, values: Iterable>, ): Int where T : Entity<*>, V : Data = entity().delete().whereRef(field, values).executeUpdate() - -/** - * Deletes entities of type [T] matching the specified predicate. - * - * @param predicate Lambda to build the WHERE clause. - * @return the number of entities deleted. - */ -inline fun RepositoryLookup.delete( - predicate: PredicateBuilder, -): Int - where T : Entity<*> = entity().delete().whereBuilder { predicate }.executeUpdate() - -/** - * Deletes entities of type [T] matching the specified predicate, using trailing lambda syntax. - * - * @param predicate Lambda providing the predicate to build the WHERE clause. - * @return the number of entities deleted. - */ -inline fun RepositoryLookup.delete( - predicate: () -> PredicateBuilder, -): Int - where T : Entity<*> = delete(predicate()) diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/impl/EntityRepositoryImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/impl/EntityRepositoryImpl.kt index 9c0c24e72..6688d20da 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/impl/EntityRepositoryImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/impl/EntityRepositoryImpl.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.map -import kotlinx.coroutines.stream.consumeAsFlow import st.orm.Data import st.orm.Entity import st.orm.Metamodel @@ -154,8 +153,6 @@ class EntityRepositoryImpl( override fun deleteByRef(refs: Iterable>) = core.deleteByRef(refs) - override fun selectAll(): Flow = core.selectAll().consumeAsFlow() - override fun selectById(ids: Flow): Flow = ids.chunked(core.defaultChunkSize) .flatMapConcat { core.findAllById(it).asFlow() } diff --git a/storm-kotlin/src/main/kotlin/st/orm/repository/impl/ProjectionRepositoryImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/repository/impl/ProjectionRepositoryImpl.kt index e010b880a..650d534b0 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/repository/impl/ProjectionRepositoryImpl.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/repository/impl/ProjectionRepositoryImpl.kt @@ -19,7 +19,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.fold import kotlinx.coroutines.flow.map -import kotlinx.coroutines.stream.consumeAsFlow import st.orm.* import st.orm.repository.ProjectionRepository import st.orm.template.* @@ -91,8 +90,6 @@ class ProjectionRepositoryImpl( override fun findAllByRef(refs: Iterable>): List

= core.findAllByRef(refs) - override fun selectAll(): Flow

= core.selectAll().consumeAsFlow() - override fun selectById(ids: Flow): Flow

= ids.chunked(core.defaultChunkSize) .flatMapConcat { core.findAllById(it).asFlow() } diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt index f78aab426..6a72e11f4 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/ORMTemplate.kt @@ -15,7 +15,6 @@ */ package st.orm.template -import jakarta.persistence.EntityManager import st.orm.Data import st.orm.EntityCallback import st.orm.StormConfig @@ -36,7 +35,7 @@ import kotlin.reflect.KClass * operations originate. * * Instances can be created using the companion object factory methods [of], or via the Kotlin extension - * properties [EntityManager.orm], [DataSource.orm], and [Connection.orm]. + * properties [DataSource.orm] and [Connection.orm]. For JPA-based usage, see `JpaTemplate`. * * ## Example * ```kotlin @@ -92,7 +91,7 @@ interface ORMTemplate : * confirmation message and returns an empty list. * * This method requires a DataSource-backed template. Templates created from a raw - * [java.sql.Connection] or [EntityManager] do not support schema validation. + * [java.sql.Connection] or `EntityManager` do not support schema validation. * * @return the list of validation error messages (empty on success). * @throws st.orm.PersistenceException if the template does not support schema validation. @@ -107,7 +106,7 @@ interface ORMTemplate : * confirmation message and returns an empty list. * * This method requires a DataSource-backed template. Templates created from a raw - * [java.sql.Connection] or [EntityManager] do not support schema validation. + * [java.sql.Connection] or `EntityManager` do not support schema validation. * * @param types the entity and projection types to validate. * @return the list of validation error messages (empty on success). @@ -120,7 +119,7 @@ interface ORMTemplate : * Validates all discovered types and throws if any errors are found. * * This method requires a DataSource-backed template. Templates created from a raw - * [java.sql.Connection] or [EntityManager] do not support schema validation. + * [java.sql.Connection] or `EntityManager` do not support schema validation. * * @throws st.orm.PersistenceException if validation fails or the template does not support schema validation. * @since 1.9 @@ -131,7 +130,7 @@ interface ORMTemplate : * Validates the specified types and throws if any errors are found. * * This method requires a DataSource-backed template. Templates created from a raw - * [java.sql.Connection] or [EntityManager] do not support schema validation. + * [java.sql.Connection] or `EntityManager` do not support schema validation. * * @param types the entity and projection types to validate. * @throws st.orm.PersistenceException if validation fails or the template does not support schema validation. @@ -140,28 +139,6 @@ interface ORMTemplate : fun validateSchemaOrThrow(vararg types: KClass) companion object { - /** - * Returns an [ORMTemplate] for use with JPA. - * - * This method creates an ORM repository template using the provided [EntityManager]. - * It allows you to perform database operations using JPA in a type-safe manner. - * - * Example usage: - * ``` - * EntityManager entityManager = ...; - * ORMTemplate orm = ORMTemplate.of(entityManager); - * List otherTables = orm.query(RAW.""" - * SELECT \{MyTable.class} - * FROM \{MyTable.class} - * WHERE \{MyTable_.name} = \{"ABC"}""") - * .getResultList(MyTable.class); - * ``` - * - * @param entityManager the [EntityManager] to use for database operations; must not be `null`. - * @return an [ORMTemplate] configured for use with JPA. - */ - fun of(entityManager: EntityManager): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager)) - /** * Returns an [ORMTemplate] for use with JDBC. * @@ -209,21 +186,6 @@ interface ORMTemplate : */ fun of(connection: Connection): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(connection)) - /** - * Returns an [ORMTemplate] for use with JPA, with a custom template decorator. - * - * This method creates an ORM repository template using the provided [EntityManager] and applies - * the specified decorator to customize template processing behavior. - * - * @param entityManager the [EntityManager] to use for database operations. - * @param decorator a function that transforms the [TemplateDecorator] to customize template processing. - * @return an [ORMTemplate] configured for use with JPA. - */ - fun of( - entityManager: EntityManager, - decorator: (TemplateDecorator) -> TemplateDecorator, - ): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager, decorator)) - /** * Returns an [ORMTemplate] for use with JDBC, with a custom template decorator. * @@ -256,30 +218,6 @@ interface ORMTemplate : decorator: (TemplateDecorator) -> TemplateDecorator, ): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(connection, decorator)) - /** - * Returns an [ORMTemplate] for use with JPA, configured with the provided [StormConfig]. - * - * @param entityManager the [EntityManager] to use for database operations. - * @param config the Storm configuration to apply. - * @return an [ORMTemplate] configured for use with JPA. - */ - fun of(entityManager: EntityManager, config: StormConfig): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager, config)) - - /** - * Returns an [ORMTemplate] for use with JPA, configured with the provided [StormConfig] and a custom - * template decorator. - * - * @param entityManager the [EntityManager] to use for database operations. - * @param config the Storm configuration to apply. - * @param decorator a function that transforms the [TemplateDecorator] to customize template processing. - * @return an [ORMTemplate] configured for use with JPA. - */ - fun of( - entityManager: EntityManager, - config: StormConfig, - decorator: (TemplateDecorator) -> TemplateDecorator, - ): ORMTemplate = ORMTemplateImpl(st.orm.core.template.ORMTemplate.of(entityManager, config, decorator)) - /** * Returns an [ORMTemplate] for use with JDBC, configured with the provided [StormConfig]. * @@ -334,29 +272,66 @@ interface ORMTemplate : } } -val EntityManager.orm: ORMTemplate - get() = ORMTemplate.of(this) - +/** + * Creates an [ORMTemplate] from this [DataSource] with default configuration. + * + * ```kotlin + * val orm = dataSource.orm + * ``` + * + * Requires `import st.orm.template.orm`. + */ val DataSource.orm: ORMTemplate get() = ORMTemplate.of(this) +/** + * Creates an [ORMTemplate] from this [Connection] with default configuration. + * + * Requires `import st.orm.template.orm`. + */ val Connection.orm: ORMTemplate get() = ORMTemplate.of(this) -fun EntityManager.orm(decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, decorator) - +/** + * Creates an [ORMTemplate] from this [DataSource] with a custom [TemplateDecorator]. + * + * The decorator wraps the template pipeline, allowing cross-cutting concerns such as logging or SQL rewriting. + * + * Requires `import st.orm.template.orm`. + */ fun DataSource.orm(decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, decorator) +/** + * Creates an [ORMTemplate] from this [Connection] with a custom [TemplateDecorator]. + * + * Requires `import st.orm.template.orm`. + */ fun Connection.orm(decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, decorator) -fun EntityManager.orm(config: StormConfig): ORMTemplate = ORMTemplate.of(this, config) - +/** + * Creates an [ORMTemplate] from this [DataSource] with a custom [StormConfig]. + * + * Requires `import st.orm.template.orm`. + */ fun DataSource.orm(config: StormConfig): ORMTemplate = ORMTemplate.of(this, config) +/** + * Creates an [ORMTemplate] from this [Connection] with a custom [StormConfig]. + * + * Requires `import st.orm.template.orm`. + */ fun Connection.orm(config: StormConfig): ORMTemplate = ORMTemplate.of(this, config) -fun EntityManager.orm(config: StormConfig, decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, config, decorator) - +/** + * Creates an [ORMTemplate] from this [DataSource] with a custom [StormConfig] and [TemplateDecorator]. + * + * Requires `import st.orm.template.orm`. + */ fun DataSource.orm(config: StormConfig, decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, config, decorator) +/** + * Creates an [ORMTemplate] from this [Connection] with a custom [StormConfig] and [TemplateDecorator]. + * + * Requires `import st.orm.template.orm`. + */ fun Connection.orm(config: StormConfig, decorator: (TemplateDecorator) -> TemplateDecorator): ORMTemplate = ORMTemplate.of(this, config, decorator) diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt index 3e87a562a..e8a3c8704 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/QueryBuilder.kt @@ -1149,3 +1149,209 @@ fun Metamodel.isNull(): PredicateBuilder = create(t * Infix functions to create a predicate to check if a field is not null. */ fun Metamodel.isNotNull(): PredicateBuilder = create(this, IS_NOT_NULL, emptyList()) + +// Block-based query DSL + +/** + * A mutable scope for constructing queries using [QueryBuilder] methods in a block-based style, similar to + * [buildList] or [buildMap]. + * + * Each call inside the block (such as [where], [orderBy], [limit]) updates the internal builder state, removing + * the need to chain method calls or capture return values. + * + * ## Example + * ```kotlin + * orm.select { + * where(User_.name eq "Alice") + * orderBy(User_.email) + * limit(10) + * }.resultList + * ``` + * + * @param T the entity type being queried. + * @param R the result type of the query. + * @param ID the primary key type. + */ +@SqlDsl +class SqlScope @PublishedApi internal constructor( + @PublishedApi internal var builder: QueryBuilder, +) { + /** Adds a WHERE clause using a predicate built with metamodel infix operators (e.g., `User_.name eq "Alice"`). */ + fun where(predicate: PredicateBuilder) { + builder = builder.where(predicate) + } + + /** Adds a WHERE clause matching a metamodel path to value(s) using an [Operator]. */ + fun where(path: Metamodel, operator: Operator, vararg value: V) { + builder = builder.where(path, operator, *value) + } + + /** Adds a WHERE clause matching a metamodel path to a data record. */ + fun where(path: Metamodel, record: V) { + builder = builder.where(path, record) + } + + /** Adds a WHERE clause matching a metamodel path to a [Ref]. */ + fun where(path: Metamodel, ref: Ref) { + builder = builder.where(path, ref) + } + + /** Adds a WHERE clause matching a primary key. */ + fun where(id: ID) { + builder = builder.where(id) + } + + /** Adds a WHERE clause matching a [Ref]. */ + fun where(ref: Ref) { + builder = builder.where(ref) + } + + /** Adds a WHERE clause matching a record. */ + fun where(record: T) { + builder = builder.where(record) + } + + /** Adds a WHERE clause using a SQL template expression (e.g., `where { "${t(User_.score)} > ${t(100)}" }`). */ + fun where(template: TemplateBuilder) { + builder = builder.where(template) + } + + /** Adds a WHERE clause for any entity type (e.g., a joined entity). */ + fun whereAny(predicate: PredicateBuilder<*, *, *>) { + builder = builder.whereAny(predicate) + } + + /** Adds a WHERE clause using a [WhereBuilder] for compound predicates with `and`/`or`. */ + fun whereBuilder(predicate: WhereBuilder.() -> PredicateBuilder) { + builder = builder.whereBuilder(predicate) + } + + /** Adds a WHERE EXISTS clause with the given subquery. */ + fun whereExists(subquery: QueryBuilder<*, *, *>) { + builder = builder.whereExists(subquery) + } + + /** Adds a WHERE NOT EXISTS clause with the given subquery. */ + fun whereNotExists(subquery: QueryBuilder<*, *, *>) { + builder = builder.whereNotExists(subquery) + } + + /** Adds an INNER JOIN with automatic ON resolution between [relation] and [on]. */ + fun innerJoin(relation: KClass, on: KClass) { + builder = builder.innerJoin(relation).on(on) + } + + /** Adds a LEFT JOIN with automatic ON resolution between [relation] and [on]. */ + fun leftJoin(relation: KClass, on: KClass) { + builder = builder.leftJoin(relation).on(on) + } + + /** Adds a RIGHT JOIN with automatic ON resolution between [relation] and [on]. */ + fun rightJoin(relation: KClass, on: KClass) { + builder = builder.rightJoin(relation).on(on) + } + + /** Adds a CROSS JOIN for [relation]. */ + fun crossJoin(relation: KClass) { + builder = builder.crossJoin(relation) + } + + /** Adds a GROUP BY clause for the specified metamodel path(s). */ + fun groupBy(vararg path: Metamodel) { + builder = builder.groupBy(*path) + } + + /** Adds a GROUP BY clause using a SQL template expression. */ + fun groupBy(template: TemplateBuilder) { + builder = builder.groupBy(template) + } + + /** Adds a HAVING clause matching a metamodel path to value(s) using an [Operator]. */ + fun having(path: Metamodel, operator: Operator, vararg value: V) { + builder = builder.having(path, operator, *value) + } + + /** Adds a HAVING clause using a SQL template expression (e.g., `having { "COUNT(*) > ${t(5)}" }`). */ + fun having(template: TemplateBuilder) { + builder = builder.having(template) + } + + /** Adds an ORDER BY clause (ascending) for the specified metamodel path(s). */ + fun orderBy(vararg path: Metamodel) { + builder = builder.orderBy(*path) + } + + /** Adds an ORDER BY clause (descending) for the specified metamodel path(s). */ + fun orderByDescending(vararg path: Metamodel) { + builder = builder.orderByDescending(*path) + } + + /** Adds an ORDER BY clause (ascending) for any entity type (e.g., a joined entity). */ + fun orderByAny(vararg path: Metamodel<*, *>) { + builder = builder.orderByAny(*path) + } + + /** Adds an ORDER BY clause (descending) for any entity type (e.g., a joined entity). */ + fun orderByDescendingAny(vararg path: Metamodel<*, *>) { + builder = builder.orderByDescendingAny(*path) + } + + /** Adds an ORDER BY clause using a SQL template expression. */ + fun orderBy(template: TemplateBuilder) { + builder = builder.orderBy(template) + } + + /** Adds a LIMIT clause restricting the maximum number of results. */ + fun limit(limit: Int) { + builder = builder.limit(limit) + } + + /** Adds an OFFSET clause skipping the first [offset] results. */ + fun offset(offset: Int) { + builder = builder.offset(offset) + } + + /** Marks the query as SELECT DISTINCT. */ + fun distinct() { + builder = builder.distinct() + } + + /** Allows DELETE/UPDATE without a WHERE clause (Storm rejects this by default). */ + fun unsafe() { + builder = builder.unsafe() + } + + /** Locks the selected rows for reading (SELECT ... FOR SHARE). */ + fun forShare() { + builder = builder.forShare() + } + + /** Locks the selected rows for writing (SELECT ... FOR UPDATE). */ + fun forUpdate() { + builder = builder.forUpdate() + } + + /** Appends raw SQL to the query using a template expression. */ + fun append(template: TemplateBuilder) { + builder = builder.append(template) + } + + /** + * Handles the block's return value: a [PredicateBuilder] is automatically applied as a WHERE clause, + * while any other non-[Unit] value indicates a programming error. + * + * This allows the shorthand `select { path eq value }` as equivalent to `select { where(path eq value) }`. + */ + @Suppress("UNCHECKED_CAST") + @PublishedApi + internal fun applyResult(result: Any?) { + when (result) { + is PredicateBuilder<*, *, *> -> where(result as PredicateBuilder) + is Unit, null -> {} + else -> throw IllegalStateException( + "The query block returned a ${result::class.simpleName} value that was not applied. " + + "Did you forget to wrap it in a method call such as where()?", + ) + } + } +} diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Refs.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Refs.kt index 9ce54791f..08c4eecf8 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/Refs.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/Refs.kt @@ -15,6 +15,7 @@ */ package st.orm.template +import st.orm.Data import st.orm.Entity import st.orm.Ref @@ -22,8 +23,22 @@ import st.orm.Ref * Creates a [Ref] for the entity. * * Usage: - * ``` + * ```kotlin * val myEntityRef = myEntity.ref() * ``` + * + * Requires `import st.orm.template.ref`. */ fun > E.ref(): Ref = Ref.of(this) + +/** + * Creates a [Ref] from a type and primary key value, without needing an entity instance. + * + * Usage: + * ```kotlin + * val myEntityRef = refById(id) + * ``` + * + * Requires `import st.orm.template.refById`. + */ +inline fun refById(id: Any): Ref = Ref.of(T::class.java, id) diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/SqlDsl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/SqlDsl.kt new file mode 100644 index 000000000..5ea3b2f67 --- /dev/null +++ b/storm-kotlin/src/main/kotlin/st/orm/template/SqlDsl.kt @@ -0,0 +1,31 @@ +/* + * Copyright 2024 - 2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package st.orm.template + +/** + * Marker annotation for Storm's block-based SQL DSL. + * + * When two implicit receivers within the same block are annotated with this marker, the compiler hides the outer + * receiver inside the inner block, preventing accidental scope leaking. For example, inside a `whereBuilder { }` + * lambda nested within a `select { }` block, methods like `orderBy` or `limit` from the outer [SqlScope] are not + * accessible without an explicit `this@select` qualifier. + * + * Applied to: [SqlScope], [WhereBuilder], [SubqueryTemplate], [TemplateContext]. + * + * @see SqlScope + */ +@DslMarker +annotation class SqlDsl diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/SubqueryTemplate.kt b/storm-kotlin/src/main/kotlin/st/orm/template/SubqueryTemplate.kt index 2c72c3848..ae342efa1 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/SubqueryTemplate.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/SubqueryTemplate.kt @@ -31,6 +31,7 @@ import kotlin.reflect.KClass * @see QueryTemplate * @see WhereBuilder.exists */ +@SqlDsl interface SubqueryTemplate { /** * Create a subquery for the given table. diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/TemplateString.kt b/storm-kotlin/src/main/kotlin/st/orm/template/TemplateString.kt index 3661f1890..5bde8e5fe 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/TemplateString.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/TemplateString.kt @@ -82,6 +82,7 @@ typealias TemplateBuilder = TemplateContext.() -> String * @see TemplateBuilder * @see TemplateString */ +@SqlDsl interface TemplateContext { /** diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Templates.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Templates.kt index 038b32633..1401781eb 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/Templates.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/Templates.kt @@ -15,7 +15,6 @@ */ package st.orm.template -import jakarta.persistence.EntityManager import st.orm.* import st.orm.core.template.impl.Elements import st.orm.core.template.impl.Elements.ObjectExpression @@ -39,8 +38,8 @@ import kotlin.reflect.KClass * tables, aliases, and more. * * Additionally, the `Templates` interface provides methods to create [ORMTemplate] - * instances for use with different data sources like JPA's [EntityManager], JDBC's [DataSource], or - * [Connection]. + * instances for use with different data sources like JDBC's [DataSource] or [Connection], or + * JPA's `EntityManager` (see `JpaTemplate`). * * ## Using Templates * diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt index 4f46517f7..7bf9e085a 100644 --- a/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt +++ b/storm-kotlin/src/main/kotlin/st/orm/template/WhereBuilder.kt @@ -49,6 +49,7 @@ import st.orm.template.TemplateString.Companion.raw * @see QueryBuilder.where * @see PredicateBuilder */ +@SqlDsl interface WhereBuilder : SubqueryTemplate { /** diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt index f0535efa9..8db3d4284 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/EntityRepositoryTest.kt @@ -270,7 +270,7 @@ open class EntityRepositoryTest( fun `delete with whereBuilder predicate should delete matching entities`() { val repo = orm.entity(Vet::class) val firstNamePath = metamodel(repo.model, "first_name") - val deleted = repo.delete(firstNamePath eq "James") + val deleted = repo.delete().where(firstNamePath eq "James").executeUpdate() deleted shouldBe 1 repo.count() shouldBe 5 } @@ -354,7 +354,7 @@ open class EntityRepositoryTest( fun `select with PredicateBuilder should return flow`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val count = repo.select(namePath eq "Madison").count() + val count = repo.select().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -362,7 +362,7 @@ open class EntityRepositoryTest( fun `selectRef with PredicateBuilder should return flow of refs`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val count = repo.selectRef(namePath eq "Madison").count() + val count = repo.selectRef().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -370,101 +370,101 @@ open class EntityRepositoryTest( fun `delete with PredicateBuilder directly should delete matching`() { val repo = orm.entity(Vet::class) val firstNamePath = metamodel(repo.model, "first_name") - val deleted = repo.delete(firstNamePath eq "James") + val deleted = repo.delete().where(firstNamePath eq "James").executeUpdate() deleted shouldBe 1 } // EntityRepository: lambda predicate-based find/get/select/selectRef/count/exists/delete @Test - fun `findAll with lambda predicate should filter entities`() { + fun `findAll with predicate should filter entities`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val result = cities.findAll { namePath eq "Madison" } + val result = cities.findAll(namePath eq "Madison") result shouldHaveSize 1 } @Test - fun `find with lambda predicate should return matching entity`() { + fun `find with predicate should return matching entity`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val city = cities.find { namePath eq "Madison" } + val city = cities.find(namePath eq "Madison") city.shouldNotBeNull() city.name shouldBe "Madison" } @Test - fun `get with lambda predicate should return matching entity`() { + fun `get with predicate should return matching entity`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val city = cities.get { namePath eq "Madison" } + val city = cities.get(namePath eq "Madison") city.name shouldBe "Madison" } @Test - fun `findAllRef with lambda predicate should return refs`() { + fun `findAllRef with predicate should return refs`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val refs = cities.findAllRef { namePath eq "Madison" } + val refs = cities.findAllRef(namePath eq "Madison") refs shouldHaveSize 1 } @Test - fun `findRef with lambda predicate should return ref`() { + fun `findRef with predicate should return ref`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val ref = cities.findRef { namePath eq "Madison" } + val ref = cities.findRef(namePath eq "Madison") ref.shouldNotBeNull() } @Test - fun `getRef with lambda predicate should return ref`() { + fun `getRef with predicate should return ref`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val ref = cities.getRef { namePath eq "Madison" } + val ref = cities.getRef(namePath eq "Madison") ref.shouldNotBeNull() } @Test - fun `select with lambda predicate should return flow`(): Unit = runBlocking { + fun `select with where should return flow`(): Unit = runBlocking { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val count = cities.select { namePath eq "Madison" }.count() + val count = cities.select().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @Test - fun `selectRef with lambda predicate should return flow of refs`(): Unit = runBlocking { + fun `selectRef with where should return flow of refs`(): Unit = runBlocking { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val count = cities.selectRef { namePath eq "Madison" }.count() + val count = cities.selectRef().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @Test - fun `count with lambda predicate should count matching entities`() { + fun `count with predicate should count matching entities`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - val count = cities.count { namePath eq "Madison" } + val count = cities.count(namePath eq "Madison") count shouldBe 1 } @Test - fun `exists with lambda predicate should return true for match`() { + fun `exists with predicate should return true for match`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - cities.exists { namePath eq "Madison" } shouldBe true + cities.exists(namePath eq "Madison") shouldBe true } @Test - fun `exists with lambda predicate should return false for no match`() { + fun `exists with predicate should return false for no match`() { val cities = orm.entity(City::class) val namePath = metamodel(cities.model, "name") - cities.exists { namePath eq "NonExistent" } shouldBe false + cities.exists(namePath eq "NonExistent") shouldBe false } @Test - fun `delete with lambda predicate should delete matching`() { + fun `delete with block DSL predicate should delete matching`() { val vets = orm.entity(Vet::class) val firstNamePath = metamodel(vets.model, "first_name") val deleted = vets.delete { firstNamePath eq "James" } @@ -645,7 +645,7 @@ open class EntityRepositoryTest( @Test fun `orm delete reified with PredicateBuilder should delete matching`() { val firstNamePath = metamodel(orm.entity(Vet::class).model, "first_name") - val deleted = orm.delete(firstNamePath eq "James") + val deleted = orm.delete(firstNamePath eq "James") deleted shouldBe 1 } @@ -974,7 +974,7 @@ open class EntityRepositoryTest( @Test fun `selectAllRef should return all entity refs as flow`(): Unit = runBlocking { val repo = orm.entity(City::class) - val count = repo.selectAllRef().count() + val count = repo.selectRef().resultFlow.count() count shouldBe 6 } @@ -1251,7 +1251,7 @@ open class EntityRepositoryTest( @Test fun `selectAll should return all entities as flow`(): Unit = runBlocking { val repo = orm.entity(City::class) - val count = repo.selectAll().count() + val count = repo.select().resultFlow.count() count shouldBe 6 } @@ -1486,7 +1486,7 @@ open class EntityRepositoryTest( window.content shouldHaveSize 3 } - // EntityRepository: Scroll with lambda predicate + // EntityRepository: Scroll with predicate @Test fun `entity scroll with key and lambda predicate should filter results`() { @@ -1642,7 +1642,7 @@ open class EntityRepositoryTest( fun `delete with PredicateBuilder should delete matching entities`() { val repo = orm.entity(Visit::class) val idPath = metamodel(repo.model, "id") - val deleted = repo.delete(idPath eq 1) + val deleted = repo.delete().where(idPath eq 1).executeUpdate() deleted shouldBe 1 } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/FlowTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/FlowTest.kt index 10b0419bc..3bbd4a83a 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/FlowTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/FlowTest.kt @@ -9,7 +9,7 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.test.context.ContextConfiguration import org.springframework.test.context.jdbc.Sql import org.springframework.test.context.junit.jupiter.SpringExtension -import st.orm.repository.selectAll +import st.orm.repository.select import st.orm.template.model.Visit @ExtendWith(SpringExtension::class) @@ -24,14 +24,14 @@ open class FlowTest( @Test fun `selectAll should return all visits as flow`(): Unit = runBlocking { // data.sql inserts exactly 14 visits (ids 1-14). - orm.selectAll().count() shouldBe 14 + orm.select().resultFlow.count() shouldBe 14 } @Test fun `selectByRef should return all visits when given all refs`(): Unit = runBlocking { // Selecting all refs and then fetching by those refs should return the same 14 visits. val repository = orm.entity(Visit::class) - val refs = repository.selectAllRef() + val refs = repository.selectRef().resultFlow repository.selectByRef(refs).count() shouldBe 14 } @@ -39,7 +39,7 @@ open class FlowTest( fun `delete flow should remove all visits`(): Unit = runBlocking { // Deleting all entities via a flow should leave the table empty. val repository = orm.entity(Visit::class) - val entities = repository.selectAll() + val entities = repository.select().resultFlow repository.delete(entities) repository.count() shouldBe 0 } @@ -50,7 +50,7 @@ open class FlowTest( fun `selectAll within suspend transaction should return all visits`(): Unit = runBlocking { // Same as above but within a suspend transaction; data.sql inserts 14 visits. transaction { - orm.selectAll().count() shouldBe 14 + orm.select().resultFlow.count() shouldBe 14 } } @@ -59,7 +59,7 @@ open class FlowTest( // Round-trip ref fetch within a suspend transaction should return the same 14 visits. transaction { val repository = orm.entity(Visit::class) - val refs = repository.selectAllRef() + val refs = repository.selectRef().resultFlow repository.selectByRef(refs).count() shouldBe 14 } } @@ -69,7 +69,7 @@ open class FlowTest( // Deleting all entities via flow within a suspend transaction should leave the table empty. transaction { val repository = orm.entity(Visit::class) - val entities = repository.selectAll() + val entities = repository.select().resultFlow repository.delete(entities) repository.count() shouldBe 0 } 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 e87db35c4..721eb3275 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ORMTemplateTest.kt @@ -312,7 +312,7 @@ open class ORMTemplateTest( window.content shouldHaveSize 1 } - // EntityRepository: Scroll with lambda predicate + // EntityRepository: Scroll with predicate @Test fun `scroll with key and lambda predicate should return filtered first page`() { @@ -533,7 +533,7 @@ open class ORMTemplateTest( @Test fun `selectAll should return all entities as flow`(): Unit = runBlocking { val repo = orm.entity(City::class) - val count = repo.selectAll().count() + val count = repo.select().resultFlow.count() count shouldBe 6 } @@ -1043,72 +1043,65 @@ open class ORMTemplateTest( @Test fun `orm select reified with PredicateBuilder should return flow`(): Unit = runBlocking { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.select(namePath eq "Madison").count() + val count = orm.select().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @Test fun `orm selectRef reified with PredicateBuilder should return flow`(): Unit = runBlocking { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.selectRef(namePath eq "Madison").count() + val count = orm.selectRef().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } - // RepositoryLookup: reified methods with lambda predicate + // RepositoryLookup: reified methods with predicate @Test - fun `orm count reified with lambda predicate should count matching`() { + fun `orm count with predicate should count matching`() { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.count { namePath eq "Madison" } + val count = orm.count(namePath eq "Madison") count shouldBe 1 } @Test - fun `orm exists reified with lambda predicate should return true`() { + fun `orm exists with predicate should return true`() { val namePath = metamodel(orm.entity(City::class).model, "name") - orm.exists { namePath eq "Madison" } shouldBe true + orm.exists(namePath eq "Madison") shouldBe true } @Test - fun `orm find reified with lambda predicate should find entity`() { + fun `orm find with predicate should find entity`() { val namePath = metamodel(orm.entity(City::class).model, "name") - val city = orm.find { namePath eq "Madison" } + val city = orm.find(namePath eq "Madison") city.shouldNotBeNull() city.name shouldBe "Madison" } @Test - fun `orm get reified with lambda predicate should get entity`() { + fun `orm get with predicate should get entity`() { val namePath = metamodel(orm.entity(City::class).model, "name") - val city = orm.get { namePath eq "Madison" } + val city = orm.get(namePath eq "Madison") city.name shouldBe "Madison" } @Test - fun `orm findAll reified with lambda predicate should find entities`() { + fun `orm findAll with predicate should find entities`() { val namePath = metamodel(orm.entity(City::class).model, "name") - val cities = orm.findAll { namePath eq "Madison" } + val cities = orm.findAll(namePath eq "Madison") cities shouldHaveSize 1 } @Test - fun `orm select reified with lambda predicate should return flow`(): Unit = runBlocking { + fun `orm select with predicate should return results`() { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.select { namePath eq "Madison" }.count() + val count = orm.select(namePath eq "Madison").resultCount count shouldBe 1 } @Test - fun `orm selectRef reified with lambda predicate should return flow`(): Unit = runBlocking { - val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.selectRef { namePath eq "Madison" }.count() - count shouldBe 1 - } - - @Test - fun `orm delete reified with lambda predicate should delete matching`() { + fun `orm delete with predicate should delete matching`() { val firstNamePath = metamodel(orm.entity(Vet::class).model, "first_name") - val deleted = orm.delete { firstNamePath eq "James" } + val deleted = orm.delete(firstNamePath eq "James") deleted shouldBe 1 } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/ProjectionRepositoryTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/ProjectionRepositoryTest.kt index 984849792..bc93fb0fa 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/ProjectionRepositoryTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/ProjectionRepositoryTest.kt @@ -198,7 +198,7 @@ open class ProjectionRepositoryTest( @Test fun `selectAllRef should return flow of all refs`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) - val refs = repo.selectAllRef().toList() + val refs = repo.selectRef().resultFlow.toList() refs shouldHaveSize 10 } @@ -207,7 +207,7 @@ open class ProjectionRepositoryTest( @Test fun `selectAll should return flow of all owner views`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) - repo.selectAll().count() shouldBe 10 + repo.select().resultFlow.count() shouldBe 10 } @Test @@ -366,7 +366,7 @@ open class ProjectionRepositoryTest( fun `select with predicate should return flow of matching owner views`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val firstNamePath = metamodel(repo.model, "first_name") - val count = repo.select(firstNamePath eq "Betty").count() + val count = repo.select().where(firstNamePath eq "Betty").resultFlow.count() count shouldBe 1 } @@ -415,7 +415,7 @@ open class ProjectionRepositoryTest( fun `selectRef with predicate should return flow of refs`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val firstNamePath = metamodel(repo.model, "first_name") - val count = repo.selectRef(firstNamePath eq "Betty").count() + val count = repo.selectRef().where(firstNamePath eq "Betty").resultFlow.count() count shouldBe 1 } @@ -429,7 +429,7 @@ open class ProjectionRepositoryTest( @Test fun `orm selectAll reified should return all owner views as flow`(): Unit = runBlocking { - orm.selectAll().count() shouldBe 10 + orm.select().resultFlow.count() shouldBe 10 } @Test @@ -440,7 +440,7 @@ open class ProjectionRepositoryTest( @Test fun `orm selectAllRef reified should return all owner view refs as flow`(): Unit = runBlocking { - orm.selectAllRef().count() shouldBe 10 + orm.selectRef().resultFlow.count() shouldBe 10 } @Test @@ -515,7 +515,7 @@ open class ProjectionRepositoryTest( @Test fun `orm select with predicate should return matching flow`(): Unit = runBlocking { val firstNamePath = metamodel(orm.projection(OwnerView::class).model, "first_name") - val count = orm.select(firstNamePath eq "Betty").count() + val count = orm.select().where(firstNamePath eq "Betty").resultFlow.count() count shouldBe 1 } @@ -730,7 +730,7 @@ open class ProjectionRepositoryTest( fun `select with direct PredicateBuilder should return flow`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val firstNamePath = metamodel(repo.model, "first_name") - val count = repo.select(firstNamePath eq "Betty").count() + val count = repo.select().where(firstNamePath eq "Betty").resultFlow.count() count shouldBe 1 } @@ -738,7 +738,7 @@ open class ProjectionRepositoryTest( fun `selectRef with direct PredicateBuilder should return flow of refs`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val firstNamePath = metamodel(repo.model, "first_name") - val count = repo.selectRef(firstNamePath eq "Betty").count() + val count = repo.selectRef().where(firstNamePath eq "Betty").resultFlow.count() count shouldBe 1 } @@ -1255,7 +1255,7 @@ open class ProjectionRepositoryTest( fun `select with WhereBuilder predicate should return matching flow`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val lastNamePath = metamodel(repo.model, "last_name") - val count = repo.select(lastNamePath eq "Davis").count() + val count = repo.select().where(lastNamePath eq "Davis").resultFlow.count() count shouldBe 2 } @@ -1263,7 +1263,7 @@ open class ProjectionRepositoryTest( fun `selectRef with WhereBuilder predicate should return matching refs as flow`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) val lastNamePath = metamodel(repo.model, "last_name") - val refs = repo.selectRef(lastNamePath eq "Davis").toList() + val refs = repo.selectRef().where(lastNamePath eq "Davis").resultFlow.toList() refs shouldHaveSize 2 } @@ -1299,7 +1299,7 @@ open class ProjectionRepositoryTest( @Test fun `selectAllRef should return all projection refs as flow`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) - val count = repo.selectAllRef().count() + val count = repo.selectRef().resultFlow.count() count shouldBe 10 } @@ -1407,7 +1407,7 @@ open class ProjectionRepositoryTest( @Test fun `selectAll should return all projections as flow`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) - val count = repo.selectAll().count() + val count = repo.select().resultFlow.count() count shouldBe 10 } @@ -1544,7 +1544,7 @@ open class ProjectionRepositoryTest( val repo = orm.projection(OwnerView::class) val lastNamePath = metamodel(repo.model, "last_name") val predicate = lastNamePath eq "Davis" - val count = repo.select(predicate).count() + val count = repo.select().where(predicate).resultFlow.count() count shouldBe 2 } @@ -1553,7 +1553,7 @@ open class ProjectionRepositoryTest( val repo = orm.projection(OwnerView::class) val lastNamePath = metamodel(repo.model, "last_name") val predicate = lastNamePath eq "Davis" - val refs = repo.selectRef(predicate).toList() + val refs = repo.selectRef().where(predicate).resultFlow.toList() refs shouldHaveSize 2 } @@ -1789,7 +1789,7 @@ open class ProjectionRepositoryTest( @Test fun `selectAllRef flow should return all refs`(): Unit = runBlocking { val repo = orm.projection(OwnerView::class) - val refs = repo.selectAllRef().toList() + val refs = repo.selectRef().resultFlow.toList() refs shouldHaveSize 10 refs.forEach { it.shouldNotBeNull() } } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/RepositoryTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/RepositoryTest.kt index 89f986d21..e7efa360b 100644 --- a/storm-kotlin/src/test/kotlin/st/orm/template/RepositoryTest.kt +++ b/storm-kotlin/src/test/kotlin/st/orm/template/RepositoryTest.kt @@ -358,7 +358,7 @@ open class RepositoryTest( fun `select with predicate should return flow of matching cities`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val count = repo.select(namePath eq "Madison").count() + val count = repo.select().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -480,7 +480,7 @@ open class RepositoryTest( @Test fun `selectAll should return flow of all cities`(): Unit = runBlocking { val repo = orm.entity(City::class) - repo.selectAll().count() shouldBe 6 + repo.select().resultFlow.count() shouldBe 6 } // Flow operations: selectAllRef @@ -488,7 +488,7 @@ open class RepositoryTest( @Test fun `selectAllRef should return flow of all city refs`(): Unit = runBlocking { val repo = orm.entity(City::class) - repo.selectAllRef().count() shouldBe 6 + repo.selectRef().resultFlow.count() shouldBe 6 } // Flow operations: selectById @@ -679,7 +679,7 @@ open class RepositoryTest( @Test fun `orm selectAll reified should return all cities as flow`(): Unit = runBlocking { - orm.selectAll().count() shouldBe 6 + orm.select().resultFlow.count() shouldBe 6 } @Test @@ -771,7 +771,7 @@ open class RepositoryTest( @Test fun `orm select with predicate should return matching flow`(): Unit = runBlocking { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.select(namePath eq "Madison").count() + val count = orm.select().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -867,7 +867,7 @@ open class RepositoryTest( @Test fun `selectAll within suspend transaction should work`(): Unit = runBlocking { transaction { - orm.entity(City::class).selectAll().count() shouldBe 6 + orm.entity(City::class).select().resultFlow.count() shouldBe 6 } } @@ -875,7 +875,7 @@ open class RepositoryTest( fun `selectByRef within suspend transaction should work`(): Unit = runBlocking { transaction { val repo = orm.entity(City::class) - val refs = repo.selectAllRef() + val refs = repo.selectRef().resultFlow repo.selectByRef(refs).count() shouldBe 6 } } @@ -902,7 +902,7 @@ open class RepositoryTest( @Test fun `selectAllRef should be consumable as list`(): Unit = runBlocking { val repo = orm.entity(City::class) - val refs = repo.selectAllRef().toList() + val refs = repo.selectRef().resultFlow.toList() refs shouldHaveSize 6 } @@ -954,7 +954,7 @@ open class RepositoryTest( fun `visit selectAll flow should return 14`(): Unit = runBlocking { // data.sql inserts exactly 14 visits (ids 1-14). val repo = orm.entity(Visit::class) - repo.selectAll().count() shouldBe 14 + repo.select().resultFlow.count() shouldBe 14 } // Entity repository count and exists consistency @@ -1006,7 +1006,7 @@ open class RepositoryTest( fun `selectRef with predicate should return flow of matching refs`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val count = repo.selectRef(namePath eq "Madison").count() + val count = repo.selectRef().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -1044,7 +1044,7 @@ open class RepositoryTest( @Test fun `orm selectRef with predicate should return flow of matching refs`(): Unit = runBlocking { val namePath = metamodel(orm.entity(City::class).model, "name") - val count = orm.selectRef(namePath eq "Madison").count() + val count = orm.selectRef().where(namePath eq "Madison").resultFlow.count() count shouldBe 1 } @@ -1054,7 +1054,7 @@ open class RepositoryTest( fun `orm delete with predicate should remove matching cities`() { val city = orm insert City(name = "ToDeleteByPredicate") val namePath = metamodel(orm.entity(City::class).model, "name") - val deleted = orm.delete(namePath eq "ToDeleteByPredicate") + val deleted = orm.delete(namePath eq "ToDeleteByPredicate") deleted shouldBe 1 orm.entity(City::class).findById(city.id).shouldBeNull() } @@ -1073,7 +1073,7 @@ open class RepositoryTest( @Test fun `orm selectAllRef should return flow of all city refs`(): Unit = runBlocking { - orm.selectAllRef().count() shouldBe 6 + orm.selectRef().resultFlow.count() shouldBe 6 } @Test @@ -1317,7 +1317,7 @@ open class RepositoryTest( fun `select with direct PredicateBuilder should return flow`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val cities = repo.select(namePath eq "Madison").toList() + val cities = repo.select().where(namePath eq "Madison").resultFlow.toList() cities shouldHaveSize 1 cities.first().name shouldBe "Madison" } @@ -1326,7 +1326,7 @@ open class RepositoryTest( fun `selectRef with direct PredicateBuilder should return flow of refs`(): Unit = runBlocking { val repo = orm.entity(City::class) val namePath = metamodel(repo.model, "name") - val refs = repo.selectRef(namePath eq "Madison").toList() + val refs = repo.selectRef().where(namePath eq "Madison").resultFlow.toList() refs shouldHaveSize 1 } @@ -1349,7 +1349,7 @@ open class RepositoryTest( val repo = orm.entity(Vet::class) repo.insert(Vet(firstName = "Temp", lastName = "VetToDelete")) val firstNamePath = metamodel(repo.model, "first_name") - val deleted = repo.delete(firstNamePath eq "Temp") + val deleted = repo.delete().where(firstNamePath eq "Temp").executeUpdate() deleted shouldBe 1 } diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/SqlDslTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/SqlDslTest.kt new file mode 100644 index 000000000..fdfd65086 --- /dev/null +++ b/storm-kotlin/src/test/kotlin/st/orm/template/SqlDslTest.kt @@ -0,0 +1,195 @@ +package st.orm.template + +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.shouldBe +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.jdbc.Sql +import org.springframework.test.context.junit.jupiter.SpringExtension +import st.orm.Data +import st.orm.Metamodel +import st.orm.Operator.* +import st.orm.repository.delete +import st.orm.repository.select +import st.orm.template.model.City +import st.orm.template.model.OwnerView +import st.orm.template.model.Visit + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [IntegrationConfig::class]) +@Sql("/data.sql") +open class SqlDslTest( + @Autowired val orm: ORMTemplate, +) { + + @Suppress("UNCHECKED_CAST") + private fun metamodel(model: Model<*, *>, columnName: String): Metamodel = model.columns.first { it.name == columnName }.metamodel as Metamodel + + // ORMTemplate: predicate-based select/delete (RepositoryLookup extensions) + + @Test + fun `select with predicate via ORMTemplate`() { + val namePath = metamodel(orm.model(City::class), "name") + val cities = orm.select(namePath eq "Madison").resultList + cities shouldHaveSize 1 + cities[0].name shouldBe "Madison" + } + + @Test + fun `select predicate returns null when no match`() { + val idPath = metamodel(orm.model(City::class), "id") + val city = orm.select(idPath eq 999).optionalResult + city shouldBe null + } + + @Test + fun `select predicate singleResult`() { + val idPath = metamodel(orm.model(City::class), "id") + val city = orm.select(idPath eq 2).singleResult + city.name shouldBe "Madison" + } + + @Test + fun `delete with predicate via ORMTemplate`() { + val idPath = metamodel(orm.model(Visit::class), "id") + val affected = orm.delete(idPath eq 14) + affected shouldBe 1 + orm.entity(Visit::class).count() shouldBe 13 + } + + @Test + fun `delete predicate returns zero when no match`() { + val idPath = metamodel(orm.model(City::class), "id") + val affected = orm.delete(idPath eq 999) + affected shouldBe 0 + } + + @Test + fun `delete with gt predicate via ORMTemplate`() { + val idPath = metamodel(orm.model(Visit::class), "id") + val affected = orm.delete(idPath greater 13) + affected shouldBe 1 + } + + // EntityRepository: select { } / delete { } block DSL (default methods) + + @Test + fun `select all via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val cities = cityRepository.select {}.resultList + cities shouldHaveSize 6 + } + + @Test + fun `select with auto-applied predicate via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val idPath = metamodel(cityRepository.model, "id") + val city = cityRepository.select { idPath eq 1 }.singleResult + city.id shouldBe 1 + } + + @Test + fun `select with where and orderBy via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val namePath = metamodel(cityRepository.model, "name") + val cities = cityRepository.select { + where(namePath, IN, "Madison", "Windsor", "Monona") + orderBy(namePath) + }.resultList + cities shouldHaveSize 3 + cities[0].name shouldBe "Madison" + cities[1].name shouldBe "Monona" + cities[2].name shouldBe "Windsor" + } + + @Test + fun `select with limit and offset via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val idPath = metamodel(cityRepository.model, "id") + val cities = cityRepository.select { + orderBy(idPath) + limit(2) + offset(1) + }.resultList + cities shouldHaveSize 2 + cities[0].id shouldBe 2 + } + + @Test + fun `delete with auto-applied predicate via EntityRepository`() { + val visitRepository = orm.entity(Visit::class) + val idPath = metamodel(visitRepository.model, "id") + val affected = visitRepository.delete { idPath eq 14 } + affected shouldBe 1 + visitRepository.count() shouldBe 13 + } + + // ProjectionRepository: select { } block DSL (default method) + + @Test + fun `select all via ProjectionRepository`() { + val ownerViewRepository = orm.projection(OwnerView::class) + val views = ownerViewRepository.select {}.resultList + views shouldHaveSize 10 + } + + @Test + fun `select with auto-applied predicate via ProjectionRepository`() { + val ownerViewRepository = orm.projection(OwnerView::class) + val idPath = metamodel(ownerViewRepository.model, "id") + val view = ownerViewRepository.select { idPath eq 1 }.singleResult + view.id shouldBe 1 + view.firstName shouldBe "Betty" + } + + @Test + fun `select with orderBy and limit via ProjectionRepository`() { + val ownerViewRepository = orm.projection(OwnerView::class) + val lastNamePath = metamodel(ownerViewRepository.model, "last_name") + val views = ownerViewRepository.select { + orderBy(lastNamePath) + limit(3) + }.resultList + views shouldHaveSize 3 + views[0].lastName shouldBe "Black" + views[1].lastName shouldBe "Coleman" + views[2].lastName shouldBe "Davis" + } + + // SqlScope: whereAny, orderByAny, orderByDescendingAny + + @Test + fun `select with whereAny via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val namePath = metamodel(cityRepository.model, "name") + val cities = cityRepository.select { + whereAny(namePath eq "Madison") + }.resultList + cities shouldHaveSize 1 + cities[0].name shouldBe "Madison" + } + + @Test + fun `select with orderByAny via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val namePath = metamodel(cityRepository.model, "name") + val cities = cityRepository.select { + orderByAny(namePath) + }.resultList + cities shouldHaveSize 6 + cities[0].name shouldBe "Madison" + } + + @Test + fun `select with orderByDescendingAny via EntityRepository`() { + val cityRepository = orm.entity(City::class) + val namePath = metamodel(cityRepository.model, "name") + val cities = cityRepository.select { + orderByDescendingAny(namePath) + }.resultList + cities shouldHaveSize 6 + cities[0].name shouldBe "Windsor" + } +} diff --git a/storm-ktor/src/test/kotlin/st/orm/ktor/RepositoryTest.kt b/storm-ktor/src/test/kotlin/st/orm/ktor/RepositoryTest.kt index 29405e000..4c1213109 100644 --- a/storm-ktor/src/test/kotlin/st/orm/ktor/RepositoryTest.kt +++ b/storm-ktor/src/test/kotlin/st/orm/ktor/RepositoryTest.kt @@ -60,8 +60,32 @@ class RepositoryTest { } @Test - fun `register by package does not crash when no index exists`() { + fun `register no-arg discovers repositories from type index`() { val dataSource = createTestDataSource() + initializeSchema(dataSource) + try { + testApplication { + application { + install(Storm) { + this.dataSource = dataSource + } + stormRepositories { + register() + } + val petRepository = repository() + petRepository shouldNotBe null + petRepository.findAll().size shouldBe 3 + } + } + } finally { + dataSource.close() + } + } + + @Test + fun `register by package discovers repositories from type index`() { + val dataSource = createTestDataSource() + initializeSchema(dataSource) try { testApplication { application { @@ -71,6 +95,9 @@ class RepositoryTest { stormRepositories { register("st.orm.ktor.model") } + val petRepository = repository() + petRepository shouldNotBe null + petRepository.findAll().size shouldBe 3 } } } finally { diff --git a/storm-ktor/src/test/resources/META-INF/storm/st.orm.repository.Repository.idx b/storm-ktor/src/test/resources/META-INF/storm/st.orm.repository.Repository.idx new file mode 100644 index 000000000..ece139fbe --- /dev/null +++ b/storm-ktor/src/test/resources/META-INF/storm/st.orm.repository.Repository.idx @@ -0,0 +1 @@ +st.orm.ktor.model.PetRepository diff --git a/storm-test/src/main/java/st/orm/test/StormExtension.java b/storm-test/src/main/java/st/orm/test/StormExtension.java index 6c1a0853c..0bcf3b875 100644 --- a/storm-test/src/main/java/st/orm/test/StormExtension.java +++ b/storm-test/src/main/java/st/orm/test/StormExtension.java @@ -75,9 +75,10 @@ public void beforeAll(ExtensionContext context) throws Exception { dataSource = new SimpleDataSource(url, annotation.username(), annotation.password()); } if (annotation.scripts().length > 0) { + Class testClass = context.getRequiredTestClass(); try (Connection conn = dataSource.getConnection()) { for (String script : annotation.scripts()) { - String sql = readScript(script); + String sql = readScript(testClass, script); executeScript(conn, sql); } } @@ -125,8 +126,6 @@ private ExtensionContext.Store getStore(ExtensionContext context) { return context.getRoot().getStore(NAMESPACE); } - // --- DataSource factory method resolution --- - /** * Looks for a static {@code dataSource()} method on the test class. If found, invokes it and returns the result. * This also checks for a Kotlin companion object with a {@code dataSource()} method. @@ -157,8 +156,6 @@ private static DataSource findDataSourceFactory(Class testClass) throws Excep return null; } - // --- Factory method resolution --- - private static boolean hasFactoryMethod(Class type) { // Check for a Java static interface/class method. try { @@ -195,13 +192,32 @@ private static Object invokeFactoryMethod(Class type, DataSource dataSource) return of.invoke(companion, dataSource); } - // --- Script execution --- - - private static String readScript(String path) { - try (InputStream is = StormExtension.class.getResourceAsStream(path)) { - if (is == null) { - throw new IllegalArgumentException("Script not found on classpath: " + path); + /** + * Resolves and reads a script from the classpath. Script path resolution follows conventions similar to Spring's + * {@code @Sql} annotation: + *

    + *
  • Paths prefixed with {@code classpath:} are resolved from the classpath root.
  • + *
  • Absolute paths (starting with {@code /}) are resolved from the classpath root.
  • + *
  • Relative paths (no prefix, no leading {@code /}) are resolved relative to the test class.
  • + *
+ */ + private static String readScript(Class testClass, String path) { + InputStream is; + if (path.startsWith("classpath:")) { + String classpathPath = path.substring("classpath:".length()); + if (!classpathPath.startsWith("/")) { + classpathPath = "/" + classpathPath; } + is = testClass.getResourceAsStream(classpathPath); + } else { + // Absolute paths (starting with /) resolve from classpath root. + // Relative paths resolve relative to the test class package. + is = testClass.getResourceAsStream(path); + } + if (is == null) { + throw new IllegalArgumentException("Script not found on classpath: " + path); + } + try (is) { return new String(is.readAllBytes(), StandardCharsets.UTF_8); } catch (IOException e) { throw new UncheckedIOException(e); diff --git a/storm-test/src/main/java/st/orm/test/StormTest.java b/storm-test/src/main/java/st/orm/test/StormTest.java index 41a07d15d..7832b2c7a 100644 --- a/storm-test/src/main/java/st/orm/test/StormTest.java +++ b/storm-test/src/main/java/st/orm/test/StormTest.java @@ -50,6 +50,13 @@ /** * Classpath SQL scripts to execute before tests run. Scripts are executed once per test class. + * + *

Path resolution follows conventions similar to Spring's {@code @Sql}:

+ *
    + *
  • {@code "schema.sql"} — resolved relative to the test class package.
  • + *
  • {@code "/schema.sql"} — resolved from the classpath root.
  • + *
  • {@code "classpath:schema.sql"} — resolved from the classpath root.
  • + *
*/ String[] scripts() default {}; diff --git a/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java b/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java index ef9ed50ab..d7e350ee6 100644 --- a/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java +++ b/storm-test/src/test/java/st/orm/test/StormExtensionMissingScriptTest.java @@ -15,10 +15,10 @@ class StormExtensionMissingScriptTest { @Test void readScriptWithMissingPathShouldThrowIllegalArgumentException() throws Exception { // The readScript method is private static, so we use reflection to test it. - var method = StormExtension.class.getDeclaredMethod("readScript", String.class); + var method = StormExtension.class.getDeclaredMethod("readScript", Class.class, String.class); method.setAccessible(true); var exception = assertThrows(java.lang.reflect.InvocationTargetException.class, - () -> method.invoke(null, "/nonexistent-script.sql")); + () -> method.invoke(null, StormExtensionMissingScriptTest.class, "/nonexistent-script.sql")); assertTrue(exception.getCause() instanceof IllegalArgumentException); assertTrue(exception.getCause().getMessage().contains("Script not found on classpath")); } diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt index 3597d8aa0..97280ab3a 100644 --- a/website/static/llms-full.txt +++ b/website/static/llms-full.txt @@ -74,21 +74,20 @@ data class User( @FK val city: City ) : Entity -// DSL—query nested properties like city.name in one go -val users = orm.findAll { User_.city.name eq "Sunnyvale" } +// Type-safe predicates — query nested properties like city.name in one go +val users = orm.findAll(User_.city.name eq "Sunnyvale") -// Custom repository—inherits all CRUD operations, add your own queries +// Custom repository — inherits all CRUD operations, add your own queries interface UserRepository : EntityRepository { - fun findByCityName(name: String) = findAll { User_.city.name eq name } + fun findByCityName(name: String) = findAll(User_.city.name eq name) } val users = userRepository.findByCityName("Sunnyvale") -// Query Builder for more complex operations -val users = orm.entity(User::class) - .select() - .where(User_.city.name eq "Sunnyvale") - .orderBy(User_.name) - .resultList +// Block DSL — build queries with where, orderBy, joins, pagination +val users = userRepository.select { + where(User_.city.name eq "Sunnyvale") + orderBy(User_.name) +}.resultList // SQL Template for full control; parameterized by default, SQL injection safe val users = orm.query { """ @@ -102,7 +101,7 @@ Full coroutine support with `Flow` for streaming and programmatic transactions: ```kotlin // Streaming with Flow -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow users.collect { user -> println(user.name) } // Programmatic transactions @@ -612,7 +611,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`, `Connection`, or JPA `EntityManager`. +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`. [Kotlin] @@ -624,9 +623,6 @@ val orm = dataSource.orm // From a Connection val orm = connection.orm - -// From a JPA EntityManager -val orm = entityManager.orm ``` [Java] @@ -639,9 +635,6 @@ 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. @@ -695,7 +688,7 @@ The `insertAndFetch` method sends an INSERT statement, retrieves the auto-genera val user: User? = orm.entity(User::class).findById(userId) // Find by field value using the metamodel (requires storm-metamodel-processor) -val user: User? = orm.find { User_.email eq "alice@example.com" } +val user: User? = orm.find(User_.email eq "alice@example.com") ``` [Java] @@ -804,15 +797,15 @@ The simplest way to query is with predicate methods directly on the ORM template val users = orm.entity(User::class) // Find all users in a city -val usersInCity: List = users.findAll { User_.city eq city } +val usersInCity: List = users.findAll(User_.city eq city) // Find a single user by email -val user: User? = users.find { User_.email eq "alice@example.com" } +val user: User? = users.find(User_.email eq "alice@example.com") // Combine conditions with and / or -val results: List = users.findAll { +val results: List = users.findAll( (User_.city eq city) and (User_.name like "A%") -} +) // Check existence val exists: Boolean = users.existsById(userId) @@ -860,13 +853,13 @@ For domain-specific queries that you will reuse, define a custom repository inte interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByNameInCity(name: String, city: City): List = findAll((User_.city eq city) and (User_.name eq name)) fun streamByCity(city: City): Flow = - select { User_.city eq city } + select { User_.city eq city }.resultFlow } // Get the repository from the ORM template @@ -977,7 +970,7 @@ For large result sets, streaming avoids loading all rows into memory at once. Ro Kotlin uses `Flow`, which provides automatic resource management through structured concurrency: ```kotlin -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow // Process each row users.collect { user -> println(user.name) } @@ -991,7 +984,7 @@ val emails: List = users.map { it.email }.toList() Java uses `Stream`, which holds an open database cursor. Always close streams to release resources: ```java -try (Stream users = orm.entity(User.class).selectAll()) { +try (Stream users = orm.entity(User.class).select().getResultStream()) { List emails = users.map(User::email).toList(); } ``` @@ -1238,7 +1231,9 @@ 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 it with `@PK`. Storm treats all fields in the composite key class as part of the primary key. +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. [Kotlin] @@ -1249,9 +1244,9 @@ data class UserRolePk( ) data class UserRole( - @PK val userRolePk: UserRolePk, - @FK val user: User, - @FK val role: Role + @PK(generation = NONE) val id: UserRolePk, + @FK @Persist(insertable = false, updatable = false) val user: User, + @FK @Persist(insertable = false, updatable = false) val role: Role ) : Entity ``` @@ -1260,9 +1255,9 @@ data class UserRole( ```java record UserRolePk(int userId, int roleId) {} -record UserRole(@PK UserRolePk userRolePk, - @Nonnull @FK User user, - @Nonnull @FK Role role +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 ) implements Entity {} ``` --- @@ -1353,6 +1348,8 @@ 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: @@ -1958,7 +1955,7 @@ val exists = ownerViews.existsById(1) val allOwners = ownerViews.findAll() // Fetch all as a lazy stream -ownerViews.selectAll().forEach { owner -> +ownerViews.select().resultFlow.collect { owner -> println(owner.firstName) } ``` @@ -1982,7 +1979,7 @@ boolean exists = ownerViews.existsById(1); List allOwners = ownerViews.findAll(); // Fetch all as a stream (must close) -try (Stream owners = ownerViews.selectAll()) { +try (Stream owners = ownerViews.select().getResultStream()) { owners.forEach(o -> System.out.println(o.firstName())); } ``` @@ -2204,7 +2201,7 @@ Use `@DbColumn` to map fields to columns with different names. | `existsById(id)` | Check if projection exists | | `findAll()` | Fetch all as a list | | `findAllById(ids)` | Fetch multiple by IDs | -| `selectAll()` | Lazy Flow of all projections | +| `select().resultFlow` | Lazy Flow of all projections | | `selectById(ids)` | Lazy Flow by IDs | | `select()` | Query builder for filtering | | `selectCount()` | Query builder for counting | @@ -2350,7 +2347,7 @@ data class User( ) : Entity // Query with type-safe access to nested fields throughout the entire entity graph -val users = orm.findAll { User_.city.country.code eq "US" } +val users = orm.findAll(User_.city.country.code eq "US") // Result: fully populated User with City and Country included users.forEach { println("${it.name} lives in ${it.city.name}, ${it.city.country.name}") } @@ -2385,7 +2382,7 @@ data class User( When you query a `User`, the related `City` is automatically loaded: ```kotlin -val user = orm.find { User_.id eq userId } +val user = orm.find(User_.id eq userId) println(user?.city.name) // City is already loaded ``` @@ -2451,7 +2448,7 @@ Storm does not store collections on the "one" side of a relationship. Instead, q ```kotlin // Find all users in a city -val usersInCity: List = orm.findAll { User_.city eq city } +val usersInCity: List = orm.findAll(User_.city eq city) ``` [Java] @@ -2478,23 +2475,23 @@ data class UserRolePk( ) data class UserRole( - @PK val userRolePk: UserRolePk, + @PK(generation = NONE) val id: UserRolePk, @FK @Persist(insertable = false, updatable = false) val user: User, @FK @Persist(insertable = false, updatable = false) val role: Role ) : Entity ``` -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. +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. Query through the join entity: ```kotlin // Find all roles for a user -val userRoles: List = orm.findAll { UserRole_.user eq user } +val userRoles: List = orm.findAll(UserRole_.user eq user) val roles: List = userRoles.map { it.role } // Find all users with a specific role -val userRoles: List = orm.findAll { UserRole_.role eq role } +val userRoles: List = orm.findAll(UserRole_.role eq role) val users: List = userRoles.map { it.user } ``` @@ -2513,13 +2510,13 @@ val roles: List = orm.entity(Role::class) ```java record UserRolePk(int userId, int roleId) {} -record UserRole(@PK UserRolePk userRolePk, +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 ) implements Entity {} ``` -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. +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. Query through the join entity: @@ -2920,8 +2917,8 @@ val user = orm insert User( ) // Read -val found: User? = orm.find { User_.id eq user.id } -val all: List = orm.findAll { User_.city eq city } +val found: User? = orm.find(User_.id eq user.id) +val all: List = orm.findAll(User_.city eq city) // Update orm update user.copy(name = "Alice Johnson") @@ -2930,7 +2927,7 @@ orm update user.copy(name = "Alice Johnson") orm delete user // Delete by condition -orm.delete { User_.city eq city } +orm.delete(User_.city eq city) // Delete all orm.deleteAll() @@ -2986,7 +2983,7 @@ Storm uses dirty checking to determine which columns to include in the UPDATE st For result sets that may be large, streaming avoids loading all rows into memory at once. Kotlin's `Flow` provides automatic resource management through structured concurrency: the underlying database cursor and connection are released when the flow completes or is cancelled, without requiring explicit cleanup. ```kotlin -val users: Flow = userRepository.selectAll() +val users: Flow = userRepository.select().resultFlow val count = users.count() // Collect to list @@ -2998,7 +2995,7 @@ val userList: List = users.toList() Java streams over database results hold open a database cursor and connection. You must close the stream explicitly, either with try-with-resources or by calling `close()`. Failing to close the stream leaks database connections. ```java -try (Stream users = userRepository.selectAll()) { +try (Stream users = userRepository.select().getResultStream()) { List userIds = users.map(User::id).toList(); } ``` @@ -3250,7 +3247,7 @@ Refs are lightweight identifiers that carry only the record type and primary key ```kotlin // Select refs (lightweight identifiers) -val refs: Flow> = userRepository.selectAllRef() +val refs: Flow> = userRepository.selectRef().getResultStream() // Select by refs val users: Flow = userRepository.selectByRef(refs) @@ -3262,7 +3259,7 @@ Ref operations in Java return `Stream` objects that must be closed. Refs carry o ```java // Select refs (lightweight identifiers) -try (Stream> refs = userRepository.selectAllRef()) { +try (Stream> refs = userRepository.selectRef().getResultStream()) { // Process refs } @@ -3287,7 +3284,7 @@ interface UserRepository : EntityRepository { // Custom query method fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) // Custom query with multiple conditions fun findByNameInCity(name: String, city: City): List = @@ -3443,10 +3440,10 @@ For simple queries, use methods directly on the ORM template: ```kotlin // Find single entity with predicate -val user: User? = orm.find { User_.email eq email } +val user: User? = orm.find(User_.email eq email) // Find all matching -val users: List = orm.findAll { User_.city eq city } +val users: List = orm.findAll(User_.city eq city) // Find by field value val user: User? = orm.findBy(User_.email, email) @@ -3493,10 +3490,10 @@ val users = orm.entity(User::class) val user: User? = users.findById(userId) // Find with predicate -val user: User? = users.find { User_.email eq email } +val user: User? = users.find(User_.email eq email) // Find all matching -val usersInCity: List = users.findAll { User_.city eq city } +val usersInCity: List = users.findAll(User_.city eq city) // Count val count: Long = users.count() @@ -3541,14 +3538,14 @@ Combine conditions with `and` and `or`: ```kotlin // AND condition -val users = orm.findAll { +val users = orm.findAll( (User_.city eq city) and (User_.birthDate less LocalDate.of(2000, 1, 1)) -} +) // OR condition -val users = orm.findAll { +val users = orm.findAll( (User_.role eq adminRole) or (User_.role eq superUserRole) -} +) // Complex conditions val users = orm.entity(User::class) @@ -3579,9 +3576,9 @@ val users = orm.entity(User::class) | `notInList` | NOT IN (list) | ```kotlin -val users = orm.findAll { User_.email like "%@example.com" } -val users = orm.findAll { User_.deletedAt.isNull() } -val users = orm.findAll { User_.role inList listOf(adminRole, userRole) } +val users = orm.findAll(User_.email like "%@example.com") +val users = orm.findAll(User_.deletedAt.isNull()) +val users = orm.findAll(User_.role inList listOf(adminRole, userRole)) ``` [Java] @@ -3904,10 +3901,10 @@ List cities = orm.entity(User.class) [Kotlin] -For large result sets, use `selectAll()` or `select()` which return 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).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow // Process each users.collect { user -> process(user) } @@ -3924,7 +3921,7 @@ val count: Int = users.count() Java streams hold an open database cursor and JDBC resources. Unlike Kotlin's `Flow` (which handles cleanup automatically), Java `Stream` results must be explicitly closed. Always wrap them in a try-with-resources block to prevent connection leaks. ```java -try (Stream users = orm.entity(User.class).selectAll()) { +try (Stream users = orm.entity(User.class).select().getResultStream()) { List emails = users.map(User::email).toList(); } ``` @@ -4158,7 +4155,7 @@ When you expect at most one matching row, use `find` (Kotlin, returns `null` if [Kotlin] ```kotlin -val user: User? = orm.find { User_.email eq email } +val user: User? = orm.find(User_.email eq email) ``` [Java] @@ -4664,10 +4661,10 @@ Once the metamodel is generated, you use the `_` suffixed classes in place of st ```kotlin // Type-safe field reference -val users = orm.findAll { User_.email eq email } +val users = orm.findAll(User_.email eq email) // Type-safe access to nested fields throughout the entire entity graph -val users = orm.findAll { User_.city.country.code eq "US" } +val users = orm.findAll(User_.city.country.code eq "US") // Multiple conditions val users = orm.entity(User::class) @@ -5332,7 +5329,7 @@ When you need the full referenced entity, call `fetch()`. This triggers a databa [Kotlin] ```kotlin -val user = orm.get { User_.id eq userId } +val user = orm.get(User_.id eq userId) val city: City = user.city.fetch() // Loads from database ``` @@ -5584,7 +5581,7 @@ data class Report( ) : Entity // Later, when you need the author -val report = orm.find { Report_.id eq reportId } +val report = orm.find(Report_.id eq reportId) if (needsAuthorInfo) { val author = report?.author?.fetch() } @@ -6856,7 +6853,7 @@ template.setTimeout(30); // 30 seconds template.setReadOnly(true); List users = template.execute(status -> { - return orm.entity(User.class).selectAll().getResultList(); + return orm.entity(User.class).select().getResultList(); }); ``` @@ -6881,28 +6878,6 @@ 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 @@ -7073,7 +7048,7 @@ Define repositories: interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) } ``` @@ -7460,21 +7435,6 @@ 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. @@ -7797,9 +7757,14 @@ Add `storm-test` as a test dependency. testImplementation("st.orm:storm-test") ``` -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: +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): ```xml + + st.orm + storm-h2 + test + com.h2database h2 @@ -8722,7 +8687,7 @@ Query separately: val users = orm.findAll() // Batch fetch roles and group by user -val rolesByUser = orm.findAll { UserRole_.user inList users } +val rolesByUser = orm.findAll(UserRole_.user inList users) .groupBy({ it.user }, { it.role }) ``` @@ -10505,7 +10470,7 @@ data class Pet( @FK @Contextual val owner: Ref?, ) : Entity -val pet = orm.get { Pet_.id eq 1 } +val pet = orm.get(Pet_.id eq 1) val json = Json { serializersModule = StormSerializers } .encodeToString(pet) // {"id":1,"name":"Leo","owner":1} @@ -10536,7 +10501,7 @@ When the application calls `fetch()` on a ref before serialization, the referenc [Kotlin] ```kotlin -val pet = orm.get { Pet_.id eq 1 } +val pet = orm.get(Pet_.id eq 1) pet.owner?.fetch() // Load the owner into the ref val json = Json { serializersModule = StormSerializers } @@ -10579,7 +10544,7 @@ data class PetWithProjectionOwner( @FK @Contextual val owner: Ref?, ) : Entity -val pet = orm.get { PetWithProjectionOwner_.id eq 1 } +val pet = orm.get(PetWithProjectionOwner_.id eq 1) pet.owner?.fetch() val json = Json { serializersModule = StormSerializers } @@ -11112,7 +11077,7 @@ Streams returned by Storm must be closed after use. Use `.use {}` (Kotlin) or tr Kotlin uses `Flow` for streaming, which provides automatic resource cleanup through structured concurrency. When the Flow completes or the coroutine is cancelled, database cursors and connections are released without explicit cleanup code. ```kotlin -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow // Process one at a time -- only one row in memory users.collect { user -> @@ -11134,19 +11099,19 @@ Java uses `Stream` for streaming. Unlike Kotlin's Flow, Java streams do not have ```java // Process one at a time -try (Stream users = orm.entity(User.class).selectAll()) { +try (Stream users = orm.entity(User.class).select().getResultStream()) { users.forEach(user -> processUser(user)); } // Transform and collect -try (Stream users = orm.entity(User.class).selectAll()) { +try (Stream users = orm.entity(User.class).select().getResultStream()) { List emails = users .map(User::email) .toList(); } // Count without loading all entities -try (Stream users = orm.entity(User.class).selectAll()) { +try (Stream users = orm.entity(User.class).select().getResultStream()) { long count = users.count(); } ``` @@ -11179,7 +11144,7 @@ When you need to read and update rows as part of a single atomic operation, wrap ```kotlin transaction { - val users: Flow = orm.selectAll() + val users: Flow = orm.select().resultFlow users.collect { user -> // Process within the same transaction orm update user.copy(processed = true) @@ -11866,11 +11831,11 @@ Storm caches compiled templates to eliminate even this small overhead on repeate ```kotlin // First execution: full compilation + binding -userRepository.find { User_.email eq "alice@example.com" } +userRepository.find(User_.email eq "alice@example.com") // Subsequent executions: cache hit, binding only -userRepository.find { User_.email eq "bob@example.com" } -userRepository.find { User_.email eq "charlie@example.com" } +userRepository.find(User_.email eq "bob@example.com") +userRepository.find(User_.email eq "charlie@example.com") ``` This applies to all Storm operations. Repository methods like `findAll()`, `insert()`, and `update()` benefit from the same caching mechanism. Once a query pattern has been compiled, repeated use across your application reuses the cached compilation. @@ -12561,7 +12526,7 @@ When a cache hit occurs, Storm skips the entire construction process for that en ```kotlin // Query returns 1000 users, but only 50 unique cities -val users = userRepository.findAll { User_.city eq city } +val users = userRepository.findAll(User_.city eq city) ``` Without early lookup, Storm would construct 1000 `City` objects and then deduplicate. With early lookup: @@ -12587,7 +12552,7 @@ The interner only retains entities while your code uses them. Once released, the ```kotlin // Safe for large result sets - processed entities don't accumulate -orderRepository.selectAll().collect { order -> +orderRepository.select().resultFlow.collect { order -> process(order) // order can be cleaned up after this iteration } @@ -12852,7 +12817,7 @@ The selected update mode controls: In `OFF` mode, Storm bypasses dirty checking entirely. Every call to `update()` generates a full UPDATE statement that writes all columns, regardless of whether values have actually changed. ```kotlin -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) val updatedUser = user.copy(name = "New Name") // Always generates: UPDATE user SET email=?, name=?, city_id=? WHERE id=? @@ -12895,7 +12860,7 @@ userRepository.update(users.map { processUser(it) }) - If **any field changed**: A full-row UPDATE is executed (all columns are written) ```kotlin -val user = orm.get { User_.id eq 1 } // Storm observes: {id=1, email="a@b.com", name="Alice"} +val user = orm.get(User_.id eq 1) // Storm observes: {id=1, email="a@b.com", name="Alice"} // Scenario 1: No changes orm update user // No SQL executed - entity unchanged @@ -12914,7 +12879,7 @@ When multiple entities of the same type are updated in a transaction, JDBC can b ```kotlin // All updates have identical SQL shape - JDBC batches them -val users = userRepository.findAll { User_.city eq city } +val users = userRepository.findAll(User_.city eq city) userRepository.update(users.map { it.copy(lastLogin = now()) }) ``` @@ -12925,7 +12890,7 @@ userRepository.update(users.map { it.copy(lastLogin = now()) }) **Read-modify-write patterns:** When you load an entity and pass it back to update without modifications, ENTITY mode skips the UPDATE entirely. ```kotlin -val user = orm.get { User_.id eq userId } +val user = orm.get(User_.id eq userId) // No changes made - UPDATE is skipped orm update user @@ -12952,7 +12917,7 @@ orm update updated `FIELD` mode provides the most granular control. Storm compares each field individually and generates UPDATE statements that include only the columns that actually changed. Like ENTITY mode, if no fields changed, Storm skips the UPDATE entirely. ```kotlin -val user = orm.get { User_.id eq 1 } // {id=1, email="a@b.com", name="Alice", bio="...", settings="..."} +val user = orm.get(User_.id eq 1) // {id=1, email="a@b.com", name="Alice", bio="...", settings="..."} // Only name changed val updated = user.copy(name = "Bob") @@ -12972,7 +12937,7 @@ orm update updated2 ```kotlin // Article has 20 columns including large 'content' field // But we're only updating the view count -val article = orm.find { Article_.id eq articleId } +val article = orm.find(Article_.id eq articleId) orm update article.copy(viewCount = article.viewCount + 1) // UPDATE article SET view_count=? WHERE id=? // The large 'content' column is NOT written @@ -13306,7 +13271,7 @@ Dirty checking determines *what to update*. Optimistic locking determines *wheth ```kotlin // This does NOT prevent lost updates: -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) // ... another transaction modifies the same user ... orm update user.copy(name = "New Name") // May overwrite other transaction's changes! @@ -13327,8 +13292,8 @@ orm update user.copy(name = "New Name") Storm tracks which entity types are affected by each mutation so it can selectively invalidate observed state. For template-based updates (using `orm update entity`), Storm knows the entity type and only clears observed state of that type. However, when you execute raw SQL mutations without entity type information, Storm cannot determine which entities may have been affected. Rather than risk stale comparisons that could silently skip a necessary UPDATE, Storm clears all observed state in the current transaction: ```kotlin -val user = orm.get { User_.id eq 1 } // Storm observes User state -val city = orm.get { City_.id eq 100 } // Storm observes City state +val user = orm.get(User_.id eq 1) // Storm observes User state +val city = orm.get(City_.id eq 100) // Storm observes City state // Raw SQL mutation - Storm clears all observed state orm.execute("UPDATE user SET name = 'Changed' WHERE id = 1") @@ -13353,7 +13318,7 @@ data class User( @Embedded val address: Address ) : Entity -val user = orm.find { User_.id eq 1 } +val user = orm.find(User_.id eq 1) // With FIELD mode: only changed columns in Address are updated orm update user.copy(address = user.address.copy(city = "New City")) // UPDATE user SET city=? WHERE id=? @@ -13773,7 +13738,7 @@ Even without transaction-level caching, Storm preserves entity identity within a ```kotlin // Even at READ_COMMITTED in a read-write transaction: -val orders = orderRepository.findAll { Order_.status eq "pending" } +val orders = orderRepository.findAll(Order_.status eq "pending") // If order1 and order2 have the same customer, they share the instance val customer1 = orders[0].customer.fetch() @@ -13822,7 +13787,7 @@ When navigating relationships, `Ref.fetch()` automatically uses the cache: ```kotlin transaction { // Load orders with their users - val orders = orderRepository.findAll { Order_.status eq "pending" } + val orders = orderRepository.findAll(Order_.status eq "pending") // If multiple orders share the same user, only one query per unique user orders.forEach { order -> @@ -14713,10 +14678,10 @@ The simplest way to enable SQL logging is to annotate the repository interface i interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findActiveUsers(): List = - findAll { User_.active eq true } + findAll(User_.active eq true) } ``` @@ -14759,12 +14724,12 @@ interface OrderRepository : EntityRepository { // No logging fun findById(id: Int): Order? = - find { Order_.id eq id } + find(Order_.id eq id) // Logged @SqlLog fun findExpiredOrders(cutoff: LocalDate): List = - findAll { Order_.expiresAt lt cutoff } + findAll(Order_.expiresAt lt cutoff) } ``` @@ -14802,7 +14767,7 @@ Setting `inlineParameters = true` replaces the `?` placeholders with the actual interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) } ``` @@ -15097,7 +15062,7 @@ The most important security property of Storm is that **all values are parameter ```kotlin // The 'email' value is sent as a JDBC parameter, not interpolated into SQL. -val user = userRepository.find { User_.email eq email } +val user = userRepository.find(User_.email eq email) ``` Generated SQL: @@ -16290,7 +16255,7 @@ data class OrderWithItems( fun findOrderWithItems(orderId: Long): OrderWithItems? { val order = orm.entity(Order::class).findById(orderId) ?: return null - val lineItems = orm.entity(LineItem::class).findAll { LineItem_.order eq order } + val lineItems = orm.entity(LineItem::class).findAll(LineItem_.order eq order) return OrderWithItems(order, lineItems) } ``` @@ -16452,11 +16417,11 @@ interface CustomerRepository : EntityRepository { /** Find only non-deleted customers. */ fun findActive(): List = - findAll { Customer_.deletedAt.isNull() } + findAll(Customer_.deletedAt.isNull()) /** Find a non-deleted customer by ID. */ fun findActiveOrNull(customerId: Int): Customer? = - find { (Customer_.id eq customerId) and Customer_.deletedAt.isNull() } + find((Customer_.id eq customerId) and Customer_.deletedAt.isNull()) /** Soft-delete a customer by setting the deletedAt timestamp. */ fun softDelete(customer: Customer): Customer { @@ -17540,7 +17505,7 @@ Storm does not store collections on entities. This is intentional: collection fi ```kotlin // Instead of user.orders (not supported) -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ### Why doesn't Storm support lazy loading? @@ -17607,7 +17572,7 @@ Yes. Storm adds minimal overhead on top of JDBC. There are no runtime proxies, n Loading millions of rows into a `List` consumes proportional memory and delays processing until the entire result set is fetched. Streaming processes rows one at a time as the database returns them, keeping memory usage constant regardless of result set size. In Kotlin, Storm exposes streams as `Flow`, which integrates naturally with coroutines. ```kotlin -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow users.collect { processUser(it) } ``` @@ -17689,14 +17654,14 @@ Storm's Java streams are backed by a JDBC `ResultSet`, which is tied to the data ```java // Wrong: stream closed before consumption Stream getUsers() { - try (var users = orm.entity(User.class).selectAll()) { + try (var users = orm.entity(User.class).select().getResultStream()) { return users; // Stream is closed when method returns } } // Right: consume within the block List getUsers() { - try (var users = orm.entity(User.class).selectAll()) { + try (var users = orm.entity(User.class).select().getResultStream()) { return users.toList(); } } @@ -17865,7 +17830,7 @@ public interface UserRepository extends JpaRepository { interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByCity(city: City): List = select() @@ -17874,7 +17839,7 @@ interface UserRepository : EntityRepository { .resultList fun findRecentUsers(since: LocalDateTime): List = - findAll { User_.createdAt gt since } + findAll(User_.createdAt gt since) } ``` @@ -17941,10 +17906,10 @@ public interface UserRepository extends JpaRepository { interface UserRepository : EntityRepository { fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) fun findByCity(city: City): List = - findAll { User_.city eq city } + findAll(User_.city eq city) } ``` @@ -17983,7 +17948,7 @@ Optional findByEmail(@Param("email") String email); ```kotlin // Storm fun findByEmail(email: String): User? = - find { User_.email eq email } + find(User_.email eq email) ``` [Java] @@ -18015,7 +17980,7 @@ return em.createQuery(cq).getResultList(); ```kotlin // Storm -orm.findAll { User_.city eq city } +orm.findAll(User_.city eq city) ``` [Java] @@ -18084,7 +18049,7 @@ private List orders; Storm approach (query the "many" side): ```kotlin -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ## Transaction Migration @@ -18226,7 +18191,7 @@ data class CustomerProfile( // Storm repository (new) interface CustomerProfileRepository : EntityRepository { fun findByCustomerId(customerId: Long): CustomerProfile? = - find { CustomerProfile_.customerId eq customerId } + find(CustomerProfile_.customerId eq customerId) } // Service that uses both @@ -18359,7 +18324,7 @@ Storm intentionally does not support collection fields on entities. This is a de val orders = user.orders // Not supported // Right approach -val orders = orm.findAll { Order_.user eq user } +val orders = orm.findAll(Order_.user eq user) ``` ## Schema Validation @@ -18421,7 +18386,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`, `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). +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). **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). @@ -18651,7 +18616,7 @@ Storm's Kotlin API provides first-class coroutine support. Query results can be ```kotlin // Streaming with Flow -val users: Flow = orm.entity(User::class).selectAll() +val users: Flow = orm.entity(User::class).select().resultFlow users.collect { processUser(it) } // Suspending transaction diff --git a/website/static/llms.txt b/website/static/llms.txt index d8ae33334..6e18770f6 100644 --- a/website/static/llms.txt +++ b/website/static/llms.txt @@ -110,6 +110,31 @@ record User(@PK Integer id, - `@DbColumn("name")` - Override the default column name. - `@Inline` - Embeds fields of a nested type directly into the parent table. +### Composite Primary Keys + +For join/junction tables, wrap key columns in a separate data class with raw types only — never `@FK`, entity types, or `Ref` inside the PK class. Declare `@FK` fields on the entity with `@Persist(insertable = false, updatable = false)`. + +```kotlin +data class UserRolePk(val userId: Int, val roleId: Int) + +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 +) : Entity +``` + +### Primary Key as Foreign Key + +For dependent one-to-one relationships or extension tables, use both `@PK` and `@FK` on the same field: + +```kotlin +data class UserProfile( + @PK(generation = NONE) @FK val user: User, + val bio: String? +) : Entity +``` + ### CRUD Operations Kotlin: @@ -129,7 +154,7 @@ orm update city.copy(population = 160_000) orm delete city // Find with predicate -val users: List = orm.findAll { User_.city.name eq "Sunnyvale" } +val users: List = orm.findAll(User_.city.name eq "Sunnyvale") ``` Java: @@ -160,7 +185,7 @@ List users = orm.entity(User.class).select() Kotlin: ```kotlin interface UserRepository : EntityRepository { - fun findByCityName(name: String) = findAll { User_.city.name eq name } + fun findByCityName(name: String) = findAll(User_.city.name eq name) } val userRepository = orm.repository() ``` @@ -293,7 +318,7 @@ JSON: storm-jackson2, storm-jackson3, storm-kotlinx-serialization Dialects: storm-postgresql, storm-mysql, storm-mariadb, storm-oracle, storm-mssqlserver Build: storm-bom, storm-compiler-plugin-2.0, storm-compiler-plugin-2.1 Metamodel: storm-metamodel-processor (Java), storm-metamodel-ksp (Kotlin) -Testing: storm-test +Testing: storm-test, storm-h2 (test runtime), com.h2database:h2 (test runtime — required, storm-h2 declares it as provided) Validation: storm-kotlin-validator ## Critical Gotchas @@ -318,14 +343,14 @@ Validation: storm-kotlin-validator fields. Relationships are modeled from the child side using @FK. To get a user's orders, query from Order with a filter on User. -4. **Close Java Streams from selectAll().** In Java, `selectAll()` returns a +4. **Close Java Streams.** In Java, `select().getResultStream()` returns a Stream backed by a database cursor. Always use try-with-resources: ```java - try (Stream stream = users.selectAll().getResultStream()) { + try (Stream stream = users.select().getResultStream()) { stream.forEach(System.out::println); } ``` - In Kotlin, `selectAll()` returns a Flow which handles cleanup automatically. + In Kotlin, `select().resultFlow` returns a Flow which handles cleanup automatically. 5. **DELETE/UPDATE without WHERE throws by default.** To prevent accidental bulk operations, Storm throws if you omit a WHERE clause. Use `unsafe()` if you diff --git a/website/static/skills/index-demo.json b/website/static/skills/index-demo.json new file mode 100644 index 000000000..9f8b5f12f --- /dev/null +++ b/website/static/skills/index-demo.json @@ -0,0 +1,5 @@ +{ + "skills": [ + "storm-demo" + ] +} diff --git a/website/static/skills/storm-demo.md b/website/static/skills/storm-demo.md new file mode 100644 index 000000000..5c4eca225 --- /dev/null +++ b/website/static/skills/storm-demo.md @@ -0,0 +1,159 @@ +# Storm Demo + +Build a web application with Storm ORM using the public IMDB dataset. The purpose of this demo is to introduce Storm ORM and show how database development works with Storm: write entities, validate against the schema, write queries in repositories, verify with SqlCapture. + +This project uses Storm ORM exclusively — there is no JPA in this project. Do not use JPA annotations (`@Entity`, `@Table`, `@Column`, `@Id`, `@ManyToOne`), do not use `EntityManager`, do not add `jakarta.persistence-api` or Hibernate as dependencies. Storm works directly with JDBC `DataSource` and has its own annotations (`@PK`, `@FK`, `@DbTable`, `@DbColumn`, etc.). + +## Tone + +This demo is fully non-interactive. After the user picks their platform and workflow in Step 1, proceed through every step autonomously without asking for confirmation or feedback. Do not stop to ask "should I continue?" or "does this look right?" — just build, verify, explain, and move on. + +Be vocal about the **development workflow** — how you use the MCP server to inspect the database, how you reason about the schema when designing entities, how `validateSchema()` proves the entities match the database, how `SqlCapture` proves the queries match the intent. The user should understand how Storm's tooling drives the development process: MCP for schema awareness, `@StormTest` for verification, and how the two close the loop. + +Don't spend time explaining Storm API details like `Ref`, `@PK`, or QueryBuilder syntax — the Storm skills already cover that. Focus your commentary on the workflow: what you're checking, why you're checking it, and what the result means. + +Keep non-Storm scaffolding (project setup, Docker, build config, HTML templates) brief. Set it up quickly, explain minimally, and move on. The demo is about Storm, not about Maven or Thymeleaf. + +## Code Quality + +All code must be clean and readable. Use logical, descriptive variable names everywhere — in Kotlin code, entity fields, and database column names. No abbreviations (`val movieRepository`, not `val movieRepo`; `primaryTitle`, not `primTitle`; `birth_year`, not `b_year`). This applies equally to entities, repositories, services, controllers, SQL migrations, and HTML templates. + +## Step 1: Introduce and Choose + +Introduce yourself: + +> This training program demonstrates Storm Fu, an AI workflow for database development with full-circle validation. I will build a complete web application from scratch using real IMDB data. Storm's MCP server gives me direct access to the live database schema, and built-in verification ensures the generated code is correct. I'm your AI agent, Smith. You choose the platform and workflow, and I take it from there. + +Then ask the user to choose. Do NOT elaborate on the options — just list the names exactly as shown: + +- **Platform:** Spring Boot or Ktor +- **Workflow:** Schema-first (start from database) or Code-first (start from entities) + +After listing the options, ask the user to choose. Wait for the user's answers before doing any work. If the user chooses Spring Boot, follow up asking: 3.x or 4.x. + +## Step 2: Project Scaffolding + +Set up the project structure quickly and without much commentary: +- Kotlin/Gradle project with Storm ORM dependencies (`storm-kotlin-spring-boot-starter` for Spring Boot, `storm-ktor` for Ktor) +- Database driver and HikariCP connection pool +- Docker Compose for the database (see below) +- The `storm-test` dependency for verification (`st.orm:storm-test` as test scope) + +**Ktor repository registration (required):** After `install(Storm)`, register all repository interfaces using `stormRepositories { }`. This must be called before `routing { }`. Without it, `call.repository()` throws at runtime. Use package-based registration to auto-discover all repositories: +```kotlin +install(Storm) { this.dataSource = dataSource } + +stormRepositories { + register() // auto-discovers all repositories from the compile-time index +} + +routing { ... } +``` + +Use the Storm skills (/storm-setup) for correct dependency configuration. + +### Database + +Use the database configured by the Storm CLI (check the MCP server connection settings). Set up a Docker Compose file matching that database, with database name `imdb`, user `storm`, password `storm`, and the default port. Start the container as part of the setup. Adjust the driver dependency, Docker Compose configuration, and schema SQL dialect accordingly. + +## Step 3 & 4: Schema and Entities + +Use Flyway for schema management. Add the Flyway dependency. The migration goes in `src/main/resources/db/migration/V1__create_schema.sql`. The same SQL file should also be copied to `src/test/resources/schema.sql` for use by `@StormTest`. + +The data model is based on the public IMDB TSV data files (https://datasets.imdbws.com/). Study the file formats (`title.basics.tsv.gz`, `name.basics.tsv.gz`, `title.principals.tsv.gz`, `title.ratings.tsv.gz`) and design tables that map naturally to the data. Also add a table for tracking which movies the user has viewed (clicked), so recently viewed movies can be shown on the home page. + +Use the /storm-entity-kotlin skill for entity patterns and the /storm-migration skill for DDL conventions. + +**Entity loading strategy:** Use the full table graph (direct entity FK fields like `@FK val city: City`) by default — this loads the complete entity graph in a single query with automatic JOINs. Only use `Ref` in the one place where it makes most sense (e.g., a self-reference, a very wide graph, or a circular dependency). + +**FK naming convention in composite PKs:** When designing composite PK data classes for junction tables, use the `_id` postfix for FK columns in both the database schema and the PK data class fields (e.g., `titleId`, `genreId`). This way Storm's default camelCase-to-snake_case naming convention produces the correct column names (`title_id`, `genre_id`) and `@DbColumn` overrides are not needed. + +### If schema-first: + +1. **Write the Flyway migration first.** Design the tables, columns, primary keys, foreign keys, NOT NULL constraints, and indexes. Explain your DDL decisions to the user. +2. **Start the database** (Docker Compose) and let Flyway apply the migration. +3. **Use the local MCP server** to inspect the live schema — call `list_tables` and `describe_table` for each table. Narrate what you see: table structure, column types, constraints, foreign key relationships. Tell the user you're using MCP to read the schema. +4. **Generate entity classes** from the MCP schema metadata. Explain how each column maps to an entity field, how you decide between natural and generated keys, and where `Ref` is appropriate. Use the /storm-entity-from-schema skill. + +### If code-first: + +1. **Write the entity classes first.** Design the domain model as Kotlin data classes implementing `Entity`. Explain your modeling decisions. +2. **Use the local MCP server** to reason about what DDL is needed. If there's an existing schema, compare against it; if not, derive the migration from the entity definitions. Tell the user you're using MCP to check the current database state and determine what schema changes are required. +3. **Write the Flyway migration** to create the tables that match your entities. Explain how each entity maps to a table, how FK fields become foreign key constraints, how naming conventions translate. + +### Both workflows converge here: + +After creating the entities, remind the user to rebuild for metamodel generation. + +## Step 5: Validate Entities Against Schema + +Write a `@StormTest` that validates all entities you created against the schema using `validateSchema()`. Pass every entity class to the validation call. + +Run the test. Explain what you're verifying and what the result means: does the entity model agree with the database? If validation fails, explain what the mismatch tells you, fix the entity or migration, and re-run. Narrate how this closes the loop between schema and code — regardless of which direction you started from, the validation is the same. + +## Step 6: Repositories + +Create repository interfaces with the query methods the web application needs. All queries must be written in repositories using the Kotlin DSL — do not scatter query logic across controllers or services. Use the /storm-repository-kotlin and /storm-query-kotlin skills for correct patterns and API usage. Write the actual queries based on the entity classes you created — the queries should follow naturally from the entities and the features the web application needs. + +Demonstrate different query levels as appropriate for the features: +- `find`/`findAll` for simple lookups +- `select().where()` with QueryBuilder for filtered queries +- Metamodel navigation for traversing relationships +- Pagination with `.page()` for search results +- Keyset scrolling with `.scroll()` for browsing large result sets + +## Step 7: Verify Queries with SqlCapture + +For each repository method, write a `@StormTest` that captures the SQL and verifies that schema, SQL, and intent are aligned. Write these tests based on the actual repository methods you created. + +Run the tests. Show the captured SQL and explain what it proves: does the query hit the right tables, filter the right columns, join correctly? Narrate how `SqlCapture` lets you verify that the ORM logic matches what you intended when you wrote the repository method. + +## Step 8: Projections + +Create projections where the web pages only need a subset of columns. Use the Storm skills for correct patterns. + +Design projections based on what each page actually needs. Verify projections with SqlCapture too — show that the SELECT only includes the projected columns. + +## Step 9: Data Import + +The data import runs once at application startup — check whether data already exists and skip if so. + +1. Download TSV.gz files from https://datasets.imdbws.com/ (cache locally so restarts don't re-download) +2. Stream, decompress, parse TSV (handle `\N` as null) +3. Convert each row into an entity instance and use Storm's batch insert (`orm insert listOf(...)` or `orm.entity(...).insert(flow, chunkSize)`) to bulk-load the data. This demonstrates Storm's write path with real volume. +4. Import order: titles → persons → principals → ratings +5. Filter: only movies (`titleType = 'movie'`) with at least 1000 votes to keep the dataset manageable + +**Import filtering:** The ratings file identifies titles with 1000+ votes across ALL title types. After importing only movies from title.basics, use the set of **actually imported title IDs** (not the full qualifying set) when filtering principals and persons. Otherwise, principals referencing non-movie titles will violate FK constraints. + +## Step 10: Web Application + +Build these pages. The UI must be polished — clean typography, consistent spacing, hover states, smooth transitions, and a professional visual design. No raw unstyled HTML. + +1. **Home** — The main page features The Matrix as a hero/featured movie section with a prominent backdrop. Below that: a search bar with auto-complete, recently viewed movies (from the view tracking table), and top 10 movies by rating. +2. **Search results** — Movie list using keyset scrolling (`.scroll()`) with infinite scroll (see UI requirements below). +3. **Browse** — All movies in a genre using keyset scrolling (`.scroll()`) with infinite scroll for efficient navigation through large result sets. +4. **Movie detail** — Full info + cast. When a user opens this page, insert a view record to track the visit. +5. **Person detail** — Filmography via repository query. +6. **Top movies** — Filterable by genre, sortable (demonstrate query composition). + +### UI Requirements + +**Infinite scroll:** On pages that use keyset scrolling (`.scroll()`), implement automatic infinite scrolling. When the user scrolls to the bottom, the next window is requested using the serialized cursor from the previous result. Show a loading spinner while the next batch is being fetched. Continue loading until no more results are available. + +**Search auto-complete:** The search bar must implement auto-complete with debounce (300ms) and abort logic — when the user types a new character before the previous request completes, abort the in-flight request before sending the next one. Show suggestions in a dropdown as the user types. + +**Movie posters:** Create a `/api/poster/{id}` endpoint that fetches the poster URL from IMDB's public suggestion API (`https://v3.sg.media-imdb.com/suggestion/x/{tconst}.json`), extracts the `imageUrl` for the matching tconst from the `d` array, resizes it by replacing `._V1_.jpg` with `._V1_SX400.jpg`, and caches the result in a `ConcurrentHashMap`. The endpoint returns a 302 redirect to the CDN URL (or 404 if no poster exists). Templates use `` with an `onerror` handler that hides the image and shows a gradient placeholder underneath. This approach avoids CORS issues, IMDB's WAF, and works for all titles in the dataset. For the featured movie on the home page, display the poster large and prominent. + +When wiring pages to repositories, explain how Storm's explicit queries make the data flow visible: every SQL statement is intentional and verifiable. + +## Step 11: Run and Verify End-to-End + +1. Start PostgreSQL via Docker Compose +2. Run the full test suite — all SqlCapture tests should pass +3. Start the application (data import runs automatically on first startup) +4. Walk the user through the pages, pointing out where each Storm feature is at work + +After everything is done — tests pass, the app is running, and you've told the user where to find the website — the very last thing you say is: + +> I know Storm Fu. diff --git a/website/static/skills/storm-docs.md b/website/static/skills/storm-docs.md index 0931a826d..083f32925 100644 --- a/website/static/skills/storm-docs.md +++ b/website/static/skills/storm-docs.md @@ -1,4 +1,4 @@ -Fetch the Storm ORM documentation from https://orm.st/llms-full.txt and use it as context for the current conversation. +Load the Storm ORM documentation and use it as context for the current conversation. The Storm skills already contain the core API reference, patterns, and conventions needed for development. After loading, briefly confirm what was loaded and offer to help with Storm development tasks such as: - Defining entities and relationships diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md index d5d1ca4d8..7657bef51 100644 --- a/website/static/skills/storm-entity-java.md +++ b/website/static/skills/storm-entity-java.md @@ -1,6 +1,17 @@ Help the user create Storm entities using Java. -Fetch https://orm.st/llms-full.txt for complete reference. +**Important:** Storm can run on top of JPA, but when generating entities, always use Storm's own annotations from the `st.orm` package — not JPA annotations (`@Id`, `@Entity`, `@Table`, `@Column`, `@ManyToOne`, `@GeneratedValue`): +- `st.orm.Entity` — marker interface for entity records +- `st.orm.Data` — base marker (entities and projections extend this) +- `st.orm.PK` — primary key annotation +- `st.orm.FK` — foreign key annotation +- `st.orm.UK` — unique key annotation +- `st.orm.DbTable` — custom table name +- `st.orm.DbColumn` — custom column name +- `st.orm.Version` — optimistic locking +- `st.orm.Inline` — embedded component +- `st.orm.Ref` — lazy-loaded reference +- `st.orm.GenerationStrategy` — PK generation: `IDENTITY`, `SEQUENCE`, `NONE` Ask the user to describe their domain model: tables, columns, types, constraints, and relationships. @@ -42,14 +53,60 @@ 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. -8. Unique keys, embedded components, enums, optimistic locking: same rules as Kotlin. +8. Composite primary keys (join/junction tables): + - Wrap key columns in a separate record. Use raw column types (e.g., `int`, `String`), **never** `@FK` or entity/Ref types inside the PK record. + - Annotate the PK field with `@PK(generation = NONE)`. The PK record is implicitly `@Inline`. + - Place `@FK` fields on the **entity itself** with `@Persist(insertable = false, updatable = false)` to load related entities without duplicating columns on insert/update. + ```java + record UserRolePk(int userId, int roleId) {} -9. Java records are immutable. Consider Lombok \`@Builder(toBuilder = true)\` for copy-with-modification. + 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 + ) implements Entity {} + ``` -10. Use descriptive variable names, never abbreviated. +9. Primary key as foreign key (dependent one-to-one, extension tables): + - Use both `@PK(generation = NONE)` and `@FK` on the same field. The entity's type parameter is the related entity type. + ```java + record UserProfile(@PK(generation = NONE) @FK User user, + @Nullable String bio, + @Nullable String avatarUrl + ) implements Entity {} + ``` -11. **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. +10. Unique keys, embedded components, enums, optimistic locking: same rules as Kotlin. + +11. Java records are immutable. Consider Lombok \`@Builder(toBuilder = true)\` for copy-with-modification. + +12. Use descriptive variable names, never abbreviated. + +13. **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. After generating, remind the user to rebuild for metamodel generation. +## Verification + +After creating or modifying entities, write a \`@StormTest\` to validate them against the database schema using \`validateSchema()\`. + +Tell the user what you are doing and why: explain that \`validateSchema()\` checks entities against the database at the JDBC level — catching type mismatches, nullability disagreements, missing columns, unmapped NOT NULL columns, and FK inconsistencies before anything reaches production. This is Storm's verify-then-trust pattern. + +\`\`\`java +@StormTest(scripts = {"/schema.sql"}) +class EntitySchemaTest { + @Test + void validateEntities(ORMTemplate orm) { + var errors = orm.validateSchema(List.of( + User.class, City.class, Order.class + )); + assertTrue(errors.isEmpty(), () -> "Schema validation errors: " + errors); + } +} +\`\`\` + +Run the test. Show the user the result and explain what it proves. If validation fails, explain the errors and fix the entities. If a validation result is ambiguous or involves a trade-off (e.g., a nullable column mapped to a non-null field intentionally), ask the user for guidance before changing anything. + + +The test can be temporary — verify and remove, or keep as a regression test. Ask the user which they prefer. + Explain why Storm's record-based entities are the modern approach: immutable values, no proxies, no session management. AI-friendly, stable, performant. diff --git a/website/static/skills/storm-entity-kotlin.md b/website/static/skills/storm-entity-kotlin.md index 2568a5f55..7edf98706 100644 --- a/website/static/skills/storm-entity-kotlin.md +++ b/website/static/skills/storm-entity-kotlin.md @@ -1,6 +1,16 @@ Help the user create Storm entities using Kotlin. - -Fetch https://orm.st/llms-full.txt for complete reference. +**Important:** Storm can run on top of JPA, but when generating entities, always use Storm's own annotations from the `st.orm` package — not JPA annotations (`@Id`, `@Entity`, `@Table`, `@Column`, `@ManyToOne`, `@GeneratedValue`): +- `st.orm.Entity` — marker interface for entity data classes +- `st.orm.Data` — base marker (entities and projections extend this) +- `st.orm.PK` — primary key annotation +- `st.orm.FK` — foreign key annotation +- `st.orm.UK` — unique key annotation +- `st.orm.DbTable` — custom table name +- `st.orm.DbColumn` — custom column name +- `st.orm.Version` — optimistic locking +- `st.orm.Inline` — embedded component +- `st.orm.Ref` — lazy-loaded reference +- `st.orm.GenerationStrategy` — PK generation: `IDENTITY`, `SEQUENCE`, `NONE` Ask the user to describe their domain model: tables, columns, types, constraints, and relationships between entities. @@ -17,6 +27,7 @@ Generation rules: - IDENTITY (default): \`val id: Int = 0\`. Storm omits PK on insert, retrieves generated value. - SEQUENCE: \`@PK(generation = SEQUENCE, sequence = "seq_name") val id: Long = 0\` - NONE: \`@PK(generation = NONE) val code: String\` for natural keys. + - Import `GenerationStrategy` values from the top-level enum: `import st.orm.GenerationStrategy.NONE` (not `st.orm.PK.GenerationStrategy.NONE`). `GenerationStrategy` is a top-level enum in `st.orm`, not nested inside `PK`. 3. Foreign keys (\`@FK\`): - Non-nullable \`@FK val city: City\` produces INNER JOIN. @@ -26,13 +37,53 @@ Generation rules: 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?\` -5. NO COLLECTION FIELDS. No \`List\` on entities. Query the child side instead: \`orm.findAll { Order_.user eq user }\`. +5. NO COLLECTION FIELDS. No \`List\` on entities. Query the child side instead: \`orm.findAll(Order_.user eq user)\`. 6. Unique keys: \`@UK val email: String\` for type-safe lookups. -7. Embedded components: Separate data class (no @PK, no Entity interface). Fields become parent table columns. - -8. Naming: camelCase to snake_case automatically. FK appends _id. +7. Embedded components: Separate data class (no @PK, no Entity interface). Fields become parent table columns. Inlining is implicit — `@Inline` never needs to be specified explicitly. When `@Inline` is used, the field must be an inline (embedded) type, not a scalar or entity. + +8. Composite primary keys (join/junction tables): + - Wrap key columns in a separate data class. Use raw column types (e.g., `Int`, `String`), **never** `@FK` or entity/Ref types inside the PK class. + - Annotate the PK field with `@PK(generation = NONE)`. The PK class is implicitly `@Inline`. + - Place `@FK` fields on the **entity itself** to load related entities via JOINs. **Only** add `@Persist(insertable = false, updatable = false)` to FK fields whose column is already in the PK data class — these duplicate a PK column, so they must not be inserted/updated twice. FK fields for columns NOT in the PK must remain insertable (no `@Persist`). + ```kotlin + // Simple case: all FK columns are in the PK + data class UserRolePk( + val userId: Int, + val roleId: Int + ) + + data class UserRole( + @PK(generation = NONE) val id: UserRolePk, + @FK @Persist(insertable = false, updatable = false) val user: User, // userId is in PK + @FK @Persist(insertable = false, updatable = false) val role: Role // roleId is in PK + ) : Entity + + // Mixed case: some FK columns are in the PK, some are not + data class OrderItemPk( + val orderId: Int, + val lineNumber: Int + ) + + data class OrderItem( + @PK(generation = NONE) val id: OrderItemPk, + @FK @Persist(insertable = false, updatable = false) val order: Order, // orderId is in PK → non-insertable + @FK val product: Product // productId is NOT in PK → must be insertable + ) : Entity + ``` + +9. Primary key as foreign key (dependent one-to-one, extension tables): + - Use both `@PK(generation = NONE)` and `@FK` on the same field. The entity's type parameter is the related entity type. + ```kotlin + data class UserProfile( + @PK(generation = NONE) @FK val user: User, + val bio: String?, + val avatarUrl: String? + ) : Entity + ``` + +10. Naming: camelCase to snake_case automatically. FK appends _id. - For individual overrides: \`@DbTable("custom_name")\` / \`@DbColumn("custom_name")\`. - 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 @@ -45,14 +96,38 @@ 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. -9. Enums: String by default. \`@DbEnum(ORDINAL)\` for integer. +11. Enums: String by default. \`@DbEnum(ORDINAL)\` for integer. -10. Optimistic locking: \`@Version val version: Int\`. +12. Optimistic locking: \`@Version val version: Int\`. -11. Use descriptive variable names, never abbreviated. +13. Use descriptive variable names, never abbreviated. -12. **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. +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. After generating, remind the user to rebuild for metamodel generation (e.g., \`City_\`). +## Verification + +After creating or modifying entities, write a \`@StormTest\` to validate them against the database schema using \`validateSchema()\`. + +Tell the user what you are doing and why: explain that \`validateSchema()\` checks entities against the database at the JDBC level — catching type mismatches, nullability disagreements, missing columns, unmapped NOT NULL columns, and FK inconsistencies before anything reaches production. This is Storm's verify-then-trust pattern. + +\`\`\`kotlin +@StormTest(scripts = ["/schema.sql"]) +class EntitySchemaTest { + @Test + fun validateEntities(orm: ORMTemplate) { + val errors = orm.validateSchema( + User::class, City::class, Order::class + ) + assertTrue(errors.isEmpty()) { "Schema validation errors: \$errors" } + } +} +\`\`\` + +Run the test. Show the user the result and explain what it proves. If validation fails, explain the errors and fix the entities. If a validation result is ambiguous or involves a trade-off (e.g., a nullable column mapped to a non-null field intentionally), ask the user for guidance before changing anything. + + +The test can be temporary — verify and remove, or keep as a regression test. Ask the user which they prefer. + Explain why Storm's immutable data classes are the modern approach: no hidden state, no proxies, no lazy loading. Freely cacheable, serializable, comparable by value, thread-safe. AI tools generate correct code because there is no invisible magic. diff --git a/website/static/skills/storm-json-java.md b/website/static/skills/storm-json-java.md index 9f64888dc..dd3b557a7 100644 --- a/website/static/skills/storm-json-java.md +++ b/website/static/skills/storm-json-java.md @@ -1,7 +1,4 @@ Help the user work with JSON columns in Storm entities using Java. - -Fetch https://orm.st/llms-full.txt for complete reference. - Ask: what data they want to store as JSON and whether they need JSON aggregation. ## JSON Columns diff --git a/website/static/skills/storm-json-kotlin.md b/website/static/skills/storm-json-kotlin.md index 719c609d7..d3aa4fd66 100644 --- a/website/static/skills/storm-json-kotlin.md +++ b/website/static/skills/storm-json-kotlin.md @@ -1,7 +1,4 @@ Help the user work with JSON columns in Storm entities using Kotlin. - -Fetch https://orm.st/llms-full.txt for complete reference. - Ask: what data they want to store as JSON, which serialization library (Jackson or kotlinx.serialization), and whether they need JSON aggregation. ## JSON Columns diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md index 445320c00..0017861de 100644 --- a/website/static/skills/storm-query-java.md +++ b/website/static/skills/storm-query-java.md @@ -1,6 +1,22 @@ Help the user write Storm queries using Java. -Fetch https://orm.st/llms-full.txt for complete reference. +**Important:** Storm can run on top of JPA, but when writing queries, always use Storm's own QueryBuilder and operator-based predicates — not JPQL, `CriteriaBuilder`, or `EntityManager.createQuery()`. + +## Key Imports + +```java +import st.orm.core.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.) +import st.orm.Ref; // Lazy-loaded reference +import st.orm.Page; // Offset-based pagination result +import st.orm.Pageable; // Pagination request +import st.orm.Scrollable; // Keyset scrolling cursor +import st.orm.Window; // Keyset scrolling result +``` + +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`. Ask what data they need, filters, ordering, or pagination. @@ -28,11 +44,13 @@ List result = users.select() .getResultList(); ``` +Entity comparison: `.where(User_.city, EQUALS, city)` compares by FK — pass the entity directly, don't extract the ID. Nested paths: `User_.city.country.code` with appropriate operator Ordering: `.orderBy(User_.name)`, `.orderByDescending(User_.createdAt)` +Limit/Offset: `.limit(10)`, `.offset(20)` Pagination: `.page(0, 20)` or `.page(Pageable.ofSize(20).sortBy(User_.name))` Scrolling (keyset): `.scroll(User_.id, 20)` -Explicit joins: `.innerJoin(UserRole.class).on(Role.class).where(UserRole_.user, EQUALS, user)` +Explicit joins: `.innerJoin(Entity.class).on(OtherEntity.class)`, `.leftJoin(Entity.class).on(OtherEntity.class)`, `.rightJoin(Entity.class).on(OtherEntity.class)` Projection: `.select(ProjectionType.class)` Operators: EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUAL, GREATER_THAN, GREATER_THAN_OR_EQUAL, LIKE, NOT_LIKE, IS_NULL, IS_NOT_NULL, IN, NOT_IN @@ -96,6 +114,35 @@ List> refs = orm.entity(User.class).selectRef() .getResultList(); ``` +## Subqueries (EXISTS / NOT EXISTS) + +```java +// WHERE EXISTS — filter entities that have related data +List ownersWithPets = orm.entity(Owner.class) + .select() + .whereExists(it -> it.subquery(Pet.class)) + .getResultList(); + +// WHERE NOT EXISTS +List ownersWithoutPets = orm.entity(Owner.class) + .select() + .whereNotExists(it -> it.subquery(Pet.class)) + .getResultList(); +``` + +## Compound Predicates (where with WhereBuilder) + +For complex WHERE clauses with AND/OR grouping: + +```java +List users = orm.entity(User.class) + .select() + .where(it -> it.where(User_.active, EQUALS, true) + .and(it.where(User_.email, IS_NOT_NULL)) + .or(it.where(User_.role, EQUALS, "admin"))) + .getResultList(); +``` + ## Bulk DELETE/UPDATE ```java @@ -106,14 +153,37 @@ orm.entity(User.class).delete().where(User_.active, EQUALS, false).executeUpdate orm.entity(User.class).delete().unsafe().executeUpdate(); ``` +**Always prefer entity/metamodel-based QueryBuilder methods over SQL template strings.** Only fall back to template strings when QueryBuilder cannot express the query (e.g., database-specific functions). When you do use template strings, use `RAW."""..."""` (Java string templates with `--enable-preview`) — never use `TemplateString.raw()`. + +Operators: `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` + +The `EQUALS` operator accepts both entities and `Ref`. When you have an entity, use it directly — no need to convert to a `Ref` first. + +## Result Retrieval + +QueryBuilder terminals: +- `.getResultList()` → `List` +- `.getSingleResult()` → `R` (throws `NoResultException` if empty, `NonUniqueResultException` if multiple) +- `.getOptionalResult()` → `Optional` +- `.getResultCount()` → `long` +- `.getResultStream()` → `Stream` (lazy, **must** close with try-with-resources) +- `.page(pageNumber, pageSize)` → `Page` (offset-based pagination) +- `.scroll(size)` → `MappedWindow` (keyset scrolling, better for large tables) +- `.executeUpdate()` → `int` (for DELETE/UPDATE) + Critical rules: - QueryBuilder is IMMUTABLE. Every method returns a new instance. Always use the return value. -- DELETE/UPDATE without WHERE throws. Use unsafe(). -- Streaming: selectAll() returns a Stream. ALWAYS use try-with-resources to avoid connection leaks. +- DELETE/UPDATE without WHERE throws. Use `unsafe()`. +- Streaming: `select().getResultStream()` returns a `Stream`. ALWAYS use try-with-resources to avoid connection leaks. - **Metamodel navigation depth**: Multiple levels of navigation are allowed on the root entity. Joined (non-root) entities can only navigate one level deep. For deeper navigation, explicitly join the intermediate entity. - **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. -After writing queries, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +## Verification + +After writing queries, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"}) class UserQueryTest { @@ -125,10 +195,15 @@ class UserQueryTest { .where(User_.city, EQUALS, city) .orderBy(User_.name) .getResultList()); - String sql = capture.statements().getFirst().statement(); - assertTrue(sql.contains("WHERE")); - assertTrue(sql.contains("ORDER BY")); + // Verify intent: single query, only active users in the given city, ordered by name. + assertEquals(1, capture.count(Operation.SELECT)); assertFalse(users.isEmpty()); + assertTrue(users.stream().allMatch(u -> u.city().equals(city) && u.active())); } } ``` + +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. + + +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-query-kotlin.md b/website/static/skills/storm-query-kotlin.md index 22a580e3a..7952cf4da 100644 --- a/website/static/skills/storm-query-kotlin.md +++ b/website/static/skills/storm-query-kotlin.md @@ -1,9 +1,60 @@ Help the user write Storm queries using Kotlin. +**Important:** Storm can run on top of JPA, but when writing queries, always use Storm's own QueryBuilder and infix predicate operators — not JPQL, `CriteriaBuilder`, or `EntityManager.createQuery()`. -Fetch https://orm.st/llms-full.txt for complete reference. +## Key Imports + +```kotlin +import st.orm.template.* // QueryBuilder, eq, neq, like, ref, orm, etc. +import st.orm.Operator.* // EQUALS, NOT_EQUALS, LIKE, IN, IS_NULL, etc. +import st.orm.Ref // Lazy-loaded reference +import st.orm.Page // Offset-based pagination result +import st.orm.Pageable // Pagination request +import st.orm.Scrollable // Keyset scrolling cursor +import st.orm.MappedWindow // Keyset scrolling result +import org.junit.jupiter.api.Assertions.* // assertEquals, assertTrue, assertFalse +``` + +Use `import st.orm.template.*` to get all infix operators and the `ref()` / `orm` extensions in one import. The `select { }` / `delete { }` block DSL and `and` / `or` combinators are member functions — no import needed. + +All infix predicate operators (`eq`, `neq`, `like`, `greater`, `less`, `inList`, `isNull`, `isNotNull`, `isTrue`, `isFalse`, `between`, etc.) are extension functions on `Metamodel` defined in `st.orm.template` (in QueryBuilder.kt). Ask what data they need, filters, ordering, or pagination. +## Kotlin Infix Predicate Operators + +All operators are extension functions on `Metamodel` (generated metamodel fields like `User_.name`): + +```kotlin +User_.name eq "Alice" // EQUALS +User_.name neq "Bob" // NOT_EQUALS +User_.age greater 18 // GREATER_THAN +User_.age greaterEq 21 // GREATER_THAN_OR_EQUAL +User_.age less 65 // LESS_THAN +User_.age lessEq 30 // LESS_THAN_OR_EQUAL +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_.age.between(18, 65) // BETWEEN +User_.active.isTrue() // IS_TRUE +User_.archived.isFalse() // IS_FALSE +User_.email.isNull() // IS_NULL +User_.email.isNotNull() // IS_NOT_NULL +``` + +Combine with `and`/`or`: +```kotlin +(User_.active eq true) and (User_.email isNotNull()) +(User_.role eq "admin") or (User_.role eq "superadmin") +``` + +The `eq` operator accepts both entities and `Ref`. When you have an entity, use it directly — no need to extract the ID or convert to a `Ref`: +```kotlin +User_.city eq city // ✅ entity directly — compares by FK +User_.city eq city.ref() // also works, but unnecessary when you have the entity +Order_.user eq user // ✅ same for any FK field — don't use Order_.id.userId eq user.id +``` + Three query levels (suggest the simplest that works): | Approach | Best for | |----------|----------| @@ -13,14 +64,14 @@ Three query levels (suggest the simplest that works): Quick queries: ```kotlin -val user = orm.find { User_.email eq email } -val users = orm.findAll { User_.city eq city } +val user = orm.find(User_.email eq email) +val users = orm.findAll(User_.city eq city) val exists = orm.existsBy(User_.email, email) ``` -QueryBuilder: +QueryBuilder (use `orm.entity()` — reified, `import st.orm.repository.entity`): ```kotlin -val users = orm.entity(User::class) +val users = orm.entity() .select() .where((User_.city eq city) and (User_.birthDate less LocalDate.of(2000, 1, 1))) .orderBy(User_.name) @@ -31,11 +82,13 @@ 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))` -Scrolling (keyset, better for large tables): `.scroll(User_.id, 20)` -Explicit joins: `.innerJoin(UserRole::class).on(Role::class).whereAny(UserRole_.user eq user)` -Projection: `.select(ProjectionType::class)` to return lighter types +Scrolling (keyset, better for large tables): `.scroll(User_.id, 20)` or `Scrollable.fromCursor(User_.id, cursorString)` to resume from a serialized cursor +Explicit joins — two syntax forms depending on context: +- **Block DSL** (inside `select { }`): `innerJoin(UserRole::class, Role::class)` — two-arg, no `.on()` +- **Chained API**: `.innerJoin(UserRole::class).on(Role::class)` — returns builder, chain `.whereAny()` etc. +Select result type: `.select(ResultType::class)` to return a different type than the root entity -Operators: eq, notEq, less, lessOrEquals, greater, greaterOrEquals, like, notLike, isNull, isNotNull, inList, notInList +**Always prefer entity/metamodel-based QueryBuilder methods over SQL template strings.** Only fall back to template lambdas when the QueryBuilder cannot express the query (e.g., database-specific functions). When you do use template lambdas, use `${}` interpolation (the compiler plugin handles parameter binding automatically) — never use `TemplateString.raw()` or `${t(...)}` manually. ## Aggregation @@ -98,6 +151,59 @@ val refs = orm.entity(User::class) .resultList ``` +## Subqueries (EXISTS / NOT EXISTS) + +```kotlin +// WHERE EXISTS — filter entities that have related data +val ownersWithPets = orm.entity(Owner::class) + .select() + .whereExists { subquery(Pet::class) } + .resultList + +// WHERE NOT EXISTS +val ownersWithoutPets = orm.entity(Owner::class) + .select() + .whereNotExists { subquery(Pet::class) } + .resultList +``` + +The `whereExists { }` / `whereNotExists { }` lambdas receive a `SubqueryTemplate` that provides the `subquery()` method. The subquery is automatically correlated with the outer query. + +## Compound Predicates (whereBuilder) + +For complex WHERE clauses that need AND/OR grouping beyond what infix operators provide: + +```kotlin +val users = orm.entity(User::class) + .select() + .whereBuilder { + where(User_.active, EQUALS, true) + .and(where(User_.email, IS_NOT_NULL)) + .or(where(User_.role, EQUALS, "admin")) + } + .resultList +``` + +The `whereBuilder { }` lambda receives a `WhereBuilder` that provides `where()`, `exists()`, `notExists()` methods returning `PredicateBuilder` instances composable with `.and()` / `.or()`. + +## Joined-Entity Predicates and Ordering + +The `where()` and `orderBy()` methods in the block DSL are typed to the root entity (`T`). To filter or order by a joined entity's field, use the `Any` variants: + +- `whereAny(predicate)` — accepts `PredicateBuilder<*, *, *>` (any entity type) +- `orderByAny(path)` — accepts `Metamodel<*, *>` (any entity type) +- `orderByDescendingAny(path)` — same, descending + +```kotlin +select { + innerJoin(UserRole::class, User::class) + whereAny(UserRole_.role eq role) + orderByAny(User_.name) +}.resultList +``` + +These are also available on the chained QueryBuilder API: `.whereAny(...)`, `.orderByAny(...)`, `.orderByDescendingAny(...)`. + ## Bulk DELETE/UPDATE ```kotlin @@ -108,11 +214,53 @@ orm.entity(User::class).delete().where(User_.active eq false).executeUpdate() orm.entity(User::class).delete().unsafe().executeUpdate() ``` +## Block-Based Query DSL + +Use `select { }` / `delete { }` to build queries without chaining. A `PredicateBuilder` returned as the block's last expression is automatically applied as a WHERE clause, so `select { path eq value }` is equivalent to `select { where(path eq value) }`: + +```kotlin +// Predicate shorthand (auto-applied as WHERE) +orm.select { User_.active eq true }.resultList + +// Explicit where — equivalent, use when combining with other clauses +orm.select { + where(User_.active eq true) + orderBy(User_.name) + limit(10) +}.resultList + +// On EntityRepository — same syntax +select { User_.city eq city }.resultList +select { + where(User_.city eq city) + orderByDescending(User_.createdAt) +}.scroll(20) +``` + +Available in the block: `where`, `whereAny`, `orderBy`, `orderByAny`, `orderByDescending`, `orderByDescendingAny`, `groupBy`, `having`, `limit`, `offset`, `distinct`, `forUpdate`, `forShare`, `innerJoin`, `leftJoin`, `rightJoin`, `crossJoin`, `append`. + +The block DSL is typed to the root entity. To select a different result type, use the chained API: `repository.select(ResultType::class).where(...).resultList`. + +**Important:** `select(ResultType::class)` changes the **output columns**, not the table being queried. The query always runs against the repository's root entity table. The result type must map to columns available in the query (from the root table or joined tables). It does NOT support selecting a column subset of the root entity — use a `ProjectionRepository` for that. + +## Result Retrieval + +QueryBuilder terminals: +- `.resultList` → `List` +- `.singleResult` → `R` (throws `NoResultException` if empty, `NonUniqueResultException` if multiple) +- `.optionalResult` → `R?` (null if empty, throws if multiple) +- `.resultCount` → `Long` +- `.resultFlow` → `Flow` (lazy, coroutines-based) +- `.resultStream` → `Stream` (lazy, must close after use) +- `.page(pageNumber, pageSize)` → `Page` (offset-based pagination) +- `.scroll(size)` → `MappedWindow` (keyset scrolling, better for large tables) +- `.executeUpdate()` → `Int` (for DELETE/UPDATE) + Critical rules: -- QueryBuilder is IMMUTABLE. Every method returns a new instance. Always use the return value. +- QueryBuilder is IMMUTABLE. Every method returns a new instance. Always use the return value (or use the `select { }` DSL which handles this automatically). - Multiple .where() calls are AND-combined. - DELETE/UPDATE without WHERE throws. Use unsafe(). -- Streaming: selectAll() returns a Flow with automatic cleanup. +- Streaming: `select().resultFlow` returns a Flow with automatic cleanup. - **Metamodel navigation depth**: Multiple levels of navigation are allowed on the root entity. Joined (non-root) entities can only navigate one level deep. For deeper navigation, explicitly join the intermediate entity. - **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key: ```kotlin @@ -120,7 +268,12 @@ Critical rules: countMap.getOrPut(candidate.cell.ref()) { mutableMapOf() } ``` -After writing queries, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +## Verification + +After writing queries, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"]) class UserQueryTest { @@ -133,10 +286,16 @@ class UserQueryTest { .orderBy(User_.name) .resultList } - val sql = capture.statements().first().statement() - assertContains(sql, "WHERE") - assertContains(sql, "ORDER BY") + // Verify intent: single query, only active users in the given city, ordered by name. + assertEquals(1, capture.count(Operation.SELECT)) assertFalse(users.isEmpty()) + assertTrue(users.all { it.city == city && it.active }) + assertEquals(users.sortedBy { it.name }, users) } } ``` + +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. + + +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-java.md b/website/static/skills/storm-repository-java.md index 4a272962f..c1c422dc4 100644 --- a/website/static/skills/storm-repository-java.md +++ b/website/static/skills/storm-repository-java.md @@ -1,10 +1,38 @@ Help the user write a Storm repository using Java. -Fetch https://orm.st/llms-full.txt for complete reference. +**Important:** Storm can run on top of JPA, but when generating repository code, always use Storm's own `EntityRepository` API with JDBC `DataSource` — not `EntityManager`, `@PersistenceContext`, or Spring Data JPA repositories. + +## 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.Operator; // EQUALS, NOT_EQUALS, IN, etc. +import static st.orm.Operator.*; // Static import for operator constants +import st.orm.Ref; // Lazy-loaded reference +import st.orm.Page; // Offset-based pagination result +import st.orm.Pageable; // Pagination request +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 +``` Ask: which entity, what custom queries? -Detect the project's framework from its build file (pom.xml or build.gradle.kts): 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. +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. + +## Getting a Repository + +```java +// Generic entity access (no custom interface needed) +var users = orm.entity(User.class); // EntityRepository + +// Custom repository (interface with explicit default method bodies) +var userRepository = orm.repository(UserRepository.class); +``` ```java interface UserRepository extends EntityRepository { @@ -15,65 +43,88 @@ interface UserRepository extends EntityRepository { return select().where(User_.city, EQUALS, city).getResultList(); } } - -// Obtain the repository -UserRepository userRepository = orm.repository(UserRepository.class); - -// Or use the generic entity repository for simple CRUD -var users = orm.entity(User.class); ``` Key rules: 1. ALL query methods have EXPLICIT BODIES with `default` keyword. Storm does NOT derive queries from method names. -2. Inherited CRUD: insert, insertAndFetch, update, delete, findById, getById, findBy(Key), count, existsById, selectAll, page, scroll. +2. Inherited CRUD: insert, insertAndFetch, update, delete, findById, getById, findBy(Key), count, existsById, page, scroll. 3. Descriptive variable names: `var users = orm.entity(User.class)`, not `var repo`. 4. QueryBuilder is IMMUTABLE. Always chain or capture the return value. -5. Streaming: `selectAll()` returns a `Stream`. ALWAYS use try-with-resources to avoid connection leaks. +5. Streaming: `select().getResultStream()` returns a `Stream`. ALWAYS use try-with-resources to avoid connection leaks. 6. DELETE/UPDATE without WHERE throws. Use `unsafe()` for intentional bulk ops. -7. Spring Boot: define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` to auto-register repos as beans. -8. Pagination: `page(0, 20)` for offset-based. `scroll(User_.id, 20)` for keyset on large tables. -9. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. When a projection already returns `Ref`, use it directly without calling `.ref()` again. +7. Pagination: `page(0, 20)` for offset-based. `scroll(scrollable)` for keyset on large tables. +8. **Prefer entity/metamodel-based methods over templates.** Use `.innerJoin(Entity.class).on(OtherEntity.class)` for joins unless it cannot be expressed with entity classes. Only fall back to template lambdas when QueryBuilder cannot express the query. +9. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. ## CRUD Operations ```java // Insert +users.insert(new User(null, "alice@example.com", "Alice", city)); + +// Insert with fetch (returns entity with generated PK and DB defaults) User user = users.insertAndFetch(new User(null, "alice@example.com", "Alice", city)); +int id = users.insertAndFetchId(new User(null, "alice@example.com", "Alice", city)); // Read Optional found = users.findById(user.id()); // nullable via Optional User fetched = users.getById(user.id()); // throws NoResultException - -// Read by predicate -Optional alice = users.select().where(User_.email, EQUALS, "alice@example.com").getOptionalResult(); +Optional found = users.findByRef(userRef); // by Ref +User fetched = users.getByRef(userRef); // throws if not found // Update users.update(new User(user.id(), user.email(), "Alice Johnson", user.city())); +User updated = users.updateAndFetch(new User(user.id(), user.email(), "Alice Johnson", user.city())); + +// Upsert (insert or update) +users.upsert(new User(1, "alice@example.com", "Alice", city)); +User upserted = users.upsertAndFetch(new User(1, "alice@example.com", "Alice", city)); +int id = users.upsertAndFetchId(new User(1, "alice@example.com", "Alice", city)); // Delete users.delete(user); users.deleteById(user.id()); +users.deleteByRef(userRef); +users.deleteAll(); ``` Java records are immutable. For convenient copy-with-modification, consider Lombok `@Builder(toBuilder = true)` or define a `with` method. -## Upsert (insert or update) +## Field-Based Lookups + +Query by a specific metamodel key without writing a full QueryBuilder chain: ```java -users.upsert(new User(1, "alice@example.com", "Alice", city)); -User upserted = users.upsertAndFetch(new User(1, "alice@example.com", "Alice", city)); +// Unique key lookups +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); ``` ## Ref-Based Operations ```java -Ref ref = user.ref(); +// Create a Ref from an entity (attached — can fetch from DB) +Ref ref = users.ref(user); +Ref ref = users.ref(userId); // from ID only + +// Unload an entity to a lightweight Ref (discards entity data, keeps PK) +Ref ref = users.unload(user); + +// Lookup by Ref Optional found = users.findByRef(ref); User fetched = users.getByRef(ref); users.deleteByRef(ref); + +// Batch Ref operations +users.deleteByRef(List.of(ref1, ref2, ref3)); +List entities = users.findAllByRef(List.of(ref1, ref2)); ``` -## Batch and Streaming +## Batch Operations ```java // Batch insert/update/delete with iterables @@ -81,29 +132,129 @@ users.insert(List.of(user1, user2, user3)); users.update(List.of(user1, user2)); users.delete(List.of(user1, user2)); -// Stream-based operations (ALWAYS use try-with-resources) -try (var stream = users.selectAll()) { +// With fetch (returns inserted/updated entities with generated values) +List inserted = users.insertAndFetch(List.of(user1, user2)); +List updated = users.updateAndFetch(List.of(user1, user2)); +List ids = users.insertAndFetchIds(List.of(user1, user2)); + +// Upsert batch +users.upsert(List.of(user1, user2)); +List upserted = users.upsertAndFetch(List.of(user1, user2)); +List ids = users.upsertAndFetchIds(List.of(user1, user2)); + +// Batch by IDs/Refs +List found = users.findAllById(List.of(1, 2, 3)); +List found = users.findAllByRef(List.of(ref1, ref2)); +``` + +## Stream-Based Operations + +Use Java `Stream` for memory-efficient processing of large datasets. **ALWAYS use try-with-resources** to avoid connection leaks: + +```java +// Stream all entities lazily +try (Stream stream = users.select().getResultStream()) { stream.forEach(System.out::println); } -// Stream operations with batch size -users.insert(userStream, 100); -users.update(userStream, 100); +// Stream by IDs or Refs +try (Stream stream = users.selectById(idStream)) { ... } +try (Stream stream = users.selectByRef(refStream)) { ... } +try (Stream stream = users.selectById(idStream, chunkSize)) { ... } + +// Count via Stream +long count = users.countById(idStream); +long count = users.countByRef(refStream, chunkSize); + +// Batch insert/update/delete via Stream +users.insert(userStream); +users.insert(userStream, batchSize); +users.update(userStream); +users.update(userStream, batchSize); +users.delete(userStream); +users.delete(userStream, batchSize); +users.upsert(userStream); +users.upsert(userStream, batchSize); +users.deleteByRef(refStream); +users.deleteByRef(refStream, batchSize); ``` -## Count, Exists, Unique Key Lookups +Stream operations are lazy — entities are retrieved/processed as consumed. Use `batchSize`/`chunkSize` to control how many items are sent to the database per batch. + +## Count, Exists, Delete ```java long count = users.count(); +boolean exists = users.exists(); boolean exists = users.existsById(userId); -boolean existsByRef = users.existsByRef(userRef); +boolean exists = users.existsByRef(userRef); +users.deleteById(userId); +users.deleteByRef(userRef); +users.deleteAll(); +``` -// Unique key lookups -Optional user = users.findBy(User_.email, "alice@example.com"); -User fetched = users.getBy(User_.email, "alice@example.com"); // throws if not found +## Pagination and Scrolling + +```java +// Offset-based pagination (executes count + select) +Page page = users.page(0, 20); +Page page = users.page(Pageable.ofSize(20).sortBy(User_.name)); +Page next = users.page(page.nextPageable()); + +// Ref-based pagination +Page> refPage = users.pageRef(0, 20); + +// Keyset scrolling (better for large tables — no COUNT, cursor-based) +Window window = users.scroll(scrollable); +``` + +## Framework-Specific Repository Registration + +### Spring Boot +Define a `RepositoryBeanFactoryPostProcessor` with `repositoryBasePackages` to auto-register repos as beans: +```java +@Service +public class UserService { + private final UserRepository userRepository; + public UserService(UserRepository userRepository) { this.userRepository = userRepository; } +} ``` -After writing repository methods, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +### Standalone +Create repositories directly from the `ORMTemplate`: +```java +UserRepository userRepository = orm.repository(UserRepository.class); +``` + +## Transactions + +### Spring Boot +Use `@Transactional` on service methods (standard Spring): +```java +@Service +public class UserService { + @Transactional + public User createUser(String email, City city) { + return userRepository.insertAndFetch(new User(null, email, "Alice", city)); + } +} +``` + +### 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. +}); +``` + +## Verification + +After writing repository methods, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"}) class UserRepositoryTest { @@ -112,9 +263,14 @@ class UserRepositoryTest { var userRepository = orm.repository(UserRepository.class); City city = orm.entity(City.class).findById(1).orElseThrow(); List users = capture.execute(() -> userRepository.findByCity(city)); - String sql = capture.statements().getFirst().statement(); - assertTrue(sql.contains("WHERE")); + // Verify intent: single query, filtered by city, returns expected data. + assertEquals(1, capture.count(Operation.SELECT)); assertFalse(users.isEmpty()); + assertTrue(users.stream().allMatch(u -> u.city().equals(city))); } } ``` + +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. + +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 f065b6fcc..7d9860c97 100644 --- a/website/static/skills/storm-repository-kotlin.md +++ b/website/static/skills/storm-repository-kotlin.md @@ -1,15 +1,45 @@ Help the user write a Storm repository using Kotlin. +**Important:** Storm can run on top of JPA, but when generating repository code, always use Storm's own `EntityRepository` API with JDBC `DataSource` — not `EntityManager`, `@PersistenceContext`, or Spring Data JPA repositories. -Fetch https://orm.st/llms-full.txt for complete reference. +## Key Imports + +```kotlin +import st.orm.repository.EntityRepository // Repository base interface +import st.orm.repository.entity // Reified: orm.entity() +import st.orm.repository.repository // Reified: orm.repository() +import st.orm.repository.insert // Infix: orm insert entity +import st.orm.template.* // ORMTemplate, QueryBuilder, orm, ref, eq, neq, etc. +import st.orm.Operator.* // EQUALS, NOT_EQUALS, IN, etc. +import st.orm.Ref // Lazy-loaded reference +import st.orm.Page // Offset-based pagination result +import st.orm.Pageable // Pagination request +import st.orm.Scrollable // Keyset scrolling cursor +import st.orm.MappedWindow // 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 org.junit.jupiter.api.Assertions.* // assertEquals, assertTrue, assertFalse +``` Ask: which entity, what custom queries? Detect the project's framework from its build file (pom.xml or build.gradle.kts): look for `storm-kotlin-spring-boot-starter` or `spring-boot-starter` (Spring Boot), `storm-ktor` or `ktor-server-core` (Ktor), or neither (standalone). Use the detected framework to suggest the appropriate repository registration pattern below. +## Getting a Repository + +```kotlin +// Generic entity access (no custom interface needed) +val users = orm.entity() // preferred — reified, import st.orm.repository.entity +val users = orm.entity(User::class) // also works, no import needed + +// Custom repository (interface with explicit query method bodies) +val userRepository = orm.repository() // import st.orm.repository.repository +``` + ```kotlin interface UserRepository : EntityRepository { - fun findByEmail(email: String): User? = find { User_.email eq email } - fun findByCity(city: City): List = findAll { User_.city eq city } + fun findByEmail(email: String): User? = find(User_.email eq email) + fun findByCity(city: City): List = findAll(User_.city eq city) fun findActiveInCity(city: City): List = findAll((User_.city eq city) and (User_.active eq true)) } @@ -23,13 +53,14 @@ val users = orm.entity(User::class) Key rules: 1. ALL query methods have EXPLICIT BODIES. Storm does NOT derive queries from method names. -2. Inherited CRUD: insert, update, delete, findById, findBy(Key), count, existsById, selectAll, page, scroll. +2. Inherited CRUD: insert, update, delete, findById, findBy(Key), count, existsById, page, scroll. 3. Descriptive variable names: `val users = orm.entity(User::class)`, not `val repo`. -4. QueryBuilder is IMMUTABLE. Always chain or capture the return value. -5. Streaming: `selectAll()` returns a `Flow` with automatic resource cleanup. +4. QueryBuilder is IMMUTABLE. Always chain or capture the return value (or use the `select { }` DSL which handles this automatically). +5. Streaming: `select().resultFlow` returns a `Flow` with automatic resource cleanup. 6. DELETE/UPDATE without WHERE throws. Use `unsafe()` for intentional bulk ops. 7. Pagination: `page(0, 20)` for offset-based. `scroll(User_.id, 20)` for keyset on large tables. -8. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. When a projection already returns `Ref`, use it directly without calling `.ref()` again. +8. **Prefer entity/metamodel-based methods over templates.** For joins, use `innerJoin(Entity::class, OnEntity::class)` in the block DSL, or `.innerJoin(Entity::class).on(OnEntity::class)` in the chained API. Only fall back to template lambdas when QueryBuilder cannot express the query. +9. **Use `Ref` for map keys and set membership**: Prefer `Ref` (via `.ref()`) for map keys, set membership, and identity-based lookups. `Ref` provides identity-based `equals`/`hashCode` on the primary key. When a projection already returns `Ref`, use it directly without calling `.ref()` again. ## CRUD Operations @@ -37,37 +68,140 @@ Key rules: // Insert (infix, returns inserted entity with generated ID) val user = orm insert User(email = "alice@example.com", name = "Alice", city = city) +// Insert with fetch (returns entity with generated PK and DB defaults) +val user: User = users.insertAndFetch(User(email = "alice@example.com", name = "Alice", city = city)) +val id: Int = users.insertAndFetchId(User(email = "alice@example.com", name = "Alice", city = city)) + // Read -val found: User? = orm.entity().findById(user.id) // nullable -val fetched: User = orm.entity().getById(user.id) // throws NoResultException -val alice: User? = orm.find { User_.name eq "Alice" } // by predicate -val all: List = orm.findAll { User_.city eq city } // list by predicate +val found: User? = users.findById(user.id) // nullable +val fetched: User = users.getById(user.id) // throws NoResultException +val found: User? = users.findByRef(userRef) // by Ref +val fetched: User = users.getByRef(userRef) // throws if not found -// Update (infix) +// Update (infix, entities are immutable — use copy()) orm update user.copy(name = "Alice Johnson") +val updated: User = users.updateAndFetch(user.copy(name = "Alice Johnson")) + +// Upsert (insert or update) +orm upsert User(id = 1, email = "alice@example.com", name = "Alice", city = city) +val upserted: User = users.upsertAndFetch(User(id = 1, email = "alice@example.com", name = "Alice", city = city)) // Delete orm delete user -orm.delete { User_.city eq city } +users.deleteById(userId) +users.deleteByRef(userRef) ``` -## Upsert (insert or update) +## ORMTemplate Convenience Functions + +`ORMTemplate` (via `RepositoryLookup`) provides reified extension functions for quick access without creating a repository first: ```kotlin -orm upsert User(id = 1, email = "alice@example.com", name = "Alice", city = city) +// Read shortcuts (reified — type inferred from predicate) +val alice: User? = orm.find(User_.name eq "Alice") +val all: List = orm.findAll(User_.city eq city) +val all: List = orm.findAll() + +// Select with predicate (returns QueryBuilder for chaining) +val users: List = orm.select(User_.city eq city).resultList + +// Field-based lookups +val user: User? = orm.findBy(User_.email, "alice@example.com") +val user: User = orm.getBy(User_.email, "alice@example.com") +val cityUsers: List = orm.findAllBy(User_.city, city) + +// Streaming +val allFlow: Flow = orm.select().resultFlow +val cityFlow: Flow = orm.selectBy(User_.city, city) + +// Ref variants +val refs: List> = orm.findAllRef() + +// Delete with predicate +orm.delete(User_.city eq city) ``` ## Ref-Based Operations ```kotlin -val ref: Ref = user.ref() +// Create a Ref directly from an entity (no repository needed) +val ref: Ref = user.ref() // import st.orm.template.ref + +// Create a Ref from a type and ID (no entity instance needed) +val ref: Ref = refById<Title>(tconst) // import st.orm.template.refById + +// Or via repository +val ref: Ref<User> = users.ref(user) +val ref: Ref<User> = users.ref(userId) // from ID only + +// Unload an entity to a lightweight Ref (discards entity data, keeps PK) +val ref: Ref<User> = users.unload(user) + +// Lookup by Ref val found: User? = users.findByRef(ref) val fetched: User = users.getByRef(ref) users.deleteByRef(ref) orm deleteByRef ref // infix + +// Batch Ref operations +users.deleteByRef(listOf(ref1, ref2, ref3)) +val entities: List<User> = users.findAllByRef(listOf(ref1, ref2)) ``` -## Batch and Streaming +## Predicate-Based Queries + +Use predicate lambdas for quick lookups without building a full QueryBuilder chain: + +```kotlin +// Single result (nullable) +val alice: User? = users.find(User_.email eq "alice@example.com") + +// Single result (throws NoResultException if not found) +val alice: User = users.get(User_.email eq "alice@example.com") + +// List of results +val activeUsers: List<User> = users.findAll(User_.active eq true) + +// Compare by entity — use the FK field directly, don't extract the ID +val orders: List<Order> = orders.findAll(Order_.user eq user) + +// Ref variants (return Ref<User> instead of User — lightweight, only loads PK) +val ref: Ref<User>? = users.findRef(User_.email eq "alice@example.com") +val refs: List<Ref<User>> = users.findAllRef(User_.active eq true) + +// Count by predicate +val activeCount: Long = users.count(User_.active eq true) + +// Exists by predicate +val hasActive: Boolean = users.exists(User_.active eq true) + +``` + +These accept a `PredicateBuilder` built with infix operators. Use parentheses — not braces — for predicates. Braces are reserved for the block DSL (see below). + +## Field-Based Lookups + +Query by a specific metamodel field without writing a full predicate: + +```kotlin +// Find by field value +val user: User? = users.findBy(User_.email, "alice@example.com") +val user: User = users.getBy(User_.email, "alice@example.com") // throws if not found + +// Find all by field value +val cityUsers: List<User> = users.findAllBy(User_.city, city) + +// Count / Exists by field +val count: Long = users.countBy(User_.city, city) +val exists: Boolean = users.existsBy(User_.email, "alice@example.com") + +// Delete by field +val deleted: Int = users.deleteAllBy(User_.city, city) +``` + +All field-based methods also accept `Ref<V>` as the value parameter for FK lookups. + +## Batch Operations ```kotlin // Batch insert/update/delete with iterables @@ -75,29 +209,79 @@ orm insert listOf(user1, user2, user3) orm update listOf(user1, user2) orm delete listOf(user1, user2) -// Flow-based streaming (suspending, with automatic resource cleanup) -val allUsers: Flow<User> = users.selectAll() +// With fetch (returns inserted/updated entities with generated values) +val inserted: List<User> = users.insertAndFetch(listOf(user1, user2)) +val updated: List<User> = users.updateAndFetch(listOf(user1, user2)) +val ids: List<Int> = users.insertAndFetchIds(listOf(user1, user2)) + +// Upsert (insert or update) +users.upsert(listOf(user1, user2)) +val upserted: List<User> = users.upsertAndFetch(listOf(user1, user2)) +``` + +## Flow-Based Streaming -// Batch operations on Flow with chunk size -users.insert(userFlow, chunkSize = 100) -users.update(userFlow, chunkSize = 100) -users.delete(userFlow, chunkSize = 100) +Use Kotlin `Flow` for memory-efficient processing of large datasets: + +```kotlin +// Stream all entities lazily +val allUsers: Flow<User> = users.select().resultFlow + +// Stream by IDs or Refs +val selected: Flow<User> = users.selectById(idFlow) +val selected: Flow<User> = users.selectByRef(refFlow) +val selected: Flow<User> = users.selectById(idFlow, chunkSize = 500) + +// Count via Flow +val count: Long = users.countById(idFlow) +val count: Long = users.countByRef(refFlow, chunkSize = 500) + +// Batch insert/update/delete via Flow (suspending) +users.insert(userFlow, batchSize = 100) +users.update(userFlow, batchSize = 100) +users.delete(userFlow, batchSize = 100) + +// Insert via Flow with fetch (returns Flow of results) +val insertedFlow: Flow<User> = users.insertAndFetch(userFlow) +val idFlow: Flow<Int> = users.insertAndFetchIds(userFlow, batchSize = 500) ``` -## Count, Exists, Delete by ID +Flow operations are lazy — entities are retrieved/processed as consumed. Use `batchSize`/`chunkSize` to control how many items are sent to the database per batch. Default batch size is used when omitted. + +## Count, Exists, Delete ```kotlin val count: Long = users.count() val exists: Boolean = users.existsById(userId) +val existsByRef: Boolean = users.existsByRef(userRef) users.deleteById(userId) +users.deleteByRef(userRef) users.deleteAll() // deletes all entities ``` -## Unique Key Lookups +## Pagination and Scrolling ```kotlin -val user: User? = users.findBy(User_.email, "alice@example.com") -val user: User = users.getBy(User_.email, "alice@example.com") // throws if not found +// Offset-based pagination (executes count + select) +// Page numbers are 0-based — page 0 is the first page. +// When accepting 1-based page numbers from a URL (e.g., ?page=1), pass page - 1. +val page: Page<User> = users.page(0, 20) +val page: Page<User> = users.page(Pageable.ofSize(20).sortBy(User_.name)) +val nextPage = users.page(page.nextPageable) + +// Keyset scrolling (better for large tables — no COUNT, cursor-based) +val window = users.scroll(Scrollable.of(User_.id, 20)) + +// With custom sort order (sort column in addition to key) +val window = users.scroll(Scrollable.of(User_.id, User_.name, 20)) + +// Resume from a serialized cursor (e.g., from a REST API request) +val window = users.scroll(Scrollable.fromCursor(User_.id, cursorString)) + +// MappedWindow API +// window.content — List<User> of results +// window.hasNext / window.hasPrevious — bounds checking +// window.nextCursor() / window.previousCursor() — serialized cursors for REST APIs ``` ## Framework-Specific Repository Registration @@ -137,7 +321,75 @@ Create repositories directly from the `ORMTemplate`: val userRepository = orm.repository<UserRepository>() ``` -After writing repository methods, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +## Transactions + +### Spring Boot +Use `@Transactional` on service methods (standard Spring): +```kotlin +@Service +class UserService(private val userRepository: UserRepository) { + @Transactional + fun createUser(email: String, city: City): User = + userRepository.insertAndFetch(User(email = email, city = city)) +} +``` + +### Ktor +Use `transaction { }` blocks: +```kotlin +get("/users") { + call.orm.transaction { + val users = entity(User::class).findAll() + call.respond(users) + } +} +``` + +### 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. +} +``` + +## Block-Based Query DSL + +Repository methods can use the `select { }` / `delete { }` DSL for building queries. A `PredicateBuilder` returned as the block's last expression is automatically applied as a WHERE clause, so `select { path eq value }` is equivalent to `select { where(path eq value) }`: + +```kotlin +interface UserRepository : EntityRepository<User, Int> { + // Predicate shorthand (auto-applied as WHERE) + fun findActive(): List<User> = select { User_.active eq true }.resultList + + // Explicit where — equivalent, use when combining with other clauses + fun findActiveByCity(city: City): List<User> = select { + where((User_.active eq true) and (User_.address.city eq city)) + orderBy(User_.name) + }.resultList + + fun deleteInactive(): Int = delete { User_.active eq false } +} +``` + +The `select { }` block returns a `QueryBuilder`, so you pick the terminal: `.resultList`, `.singleResult`, `.optionalResult`, `.scroll(20)`, `.page(0, 20)`, `.resultFlow`, `.resultCount`. + +Standalone usage on `ORMTemplate`: +```kotlin +val users = orm.select<User> { + where(User_.name eq "Alice") + orderBy(User_.email) + limit(10) +}.resultList +``` + +## Verification + +After writing repository methods, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"]) class UserRepositoryTest { @@ -146,9 +398,15 @@ class UserRepositoryTest { val userRepository = orm.repository<UserRepository>() val city = orm.entity<City>().getById(1) val users = capture.execute { userRepository.findByCity(city) } - val sql = capture.statements().first().statement() - assertContains(sql, "WHERE") + // Verify intent: single query, filtered by city, returns expected data. + assertEquals(1, capture.count(Operation.SELECT)) assertFalse(users.isEmpty()) + assertTrue(users.all { it.city == city }) } } ``` + +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. + + +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-rules.md b/website/static/skills/storm-rules.md index c2108876f..ccccc6838 100644 --- a/website/static/skills/storm-rules.md +++ b/website/static/skills/storm-rules.md @@ -4,6 +4,20 @@ This project uses the [Storm ORM framework](https://orm.st) for database access. Storm is a modern SQL Template and ORM for Kotlin 2.0+ and Java 21+, built around immutable data classes and records instead of proxied entities. +### Storm Annotations and API + +Storm can run on top of JPA, but when generating code, always use Storm's own annotations and JDBC-based API: +- Use `@PK`, not `@Id` or `@GeneratedValue` +- Use `@FK`, not `@ManyToOne` or `@JoinColumn` +- Use `@DbTable`, not `@Table` or `@Entity` +- Use `@DbColumn`, not `@Column` +- Use `@UK`, not `@UniqueConstraint` +- Use `@Version` from `st.orm`, not from `jakarta.persistence` +- Use `DataSource.orm` or `ORMTemplate.of(dataSource)`, not `EntityManager` +- Do not add `jakarta.persistence-api`, Hibernate, or any JPA implementation unless the project already uses them + +Storm works directly with JDBC `DataSource`. There is no persistence context, no session, no lazy proxy objects. + ### Framework Detection Before suggesting dependencies, patterns, or configuration, detect which framework the project uses by examining the build file and existing dependencies: @@ -17,6 +31,14 @@ Adapt your suggestions to the detected framework: - **Ktor**: use `install(Storm)` plugin, `transaction { }` blocks, `application.conf` (HOCON) for config, `call.orm` for route access. - **Standalone**: use `DataSource.orm` or `ORMTemplate.of(dataSource)`, programmatic `transaction { }` blocks. +### Query and Template Rules + +- **Always prefer QueryBuilder and metamodel-based methods** for joins, where clauses, ordering, etc. Only fall back to SQL template lambdas when QueryBuilder cannot express the query. +- **Joins**: use `.innerJoin(Entity::class).on(OtherEntity::class)` unless it cannot be expressed with entity classes. +- **Template lambdas**: when you must use a template expression, write it as a lambda (`{ "..." }`) — never use `TemplateString.raw()`. +- **Compiler plugin interpolation**: with the Storm compiler plugin (which Kotlin projects should always use), standard `${}` interpolation inside template lambdas is automatically processed. Do not call `t()` manually — it exists only as a fallback for projects without the compiler plugin. +- **Metamodel in templates**: even inside template lambdas, use metamodel references (`${User_.email}`) instead of hardcoded column names wherever possible. + If the project does not yet have Storm dependencies in its build file (pom.xml, build.gradle.kts), use /storm-setup to help the user configure their project. Detect the project's Kotlin or Java version from the build file to recommend the @@ -34,6 +56,3 @@ Available Storm skills: - /storm-migration - Write Flyway/Liquibase migration SQL When the user asks about Storm topics, suggest the relevant skill if they need detailed guidance. - -Quick reference: https://orm.st/llms.txt -Full documentation: https://orm.st/llms-full.txt diff --git a/website/static/skills/storm-serialization-java.md b/website/static/skills/storm-serialization-java.md index 63cdfe4da..125c3cf66 100644 --- a/website/static/skills/storm-serialization-java.md +++ b/website/static/skills/storm-serialization-java.md @@ -1,7 +1,4 @@ Help the user serialize Storm entities to JSON for REST APIs using Java. - -Fetch https://orm.st/llms-full.txt for complete reference. - This is about serializing entities for API responses (Jackson), not about JSON database columns (use /storm-json-java for that). Ask: whether entities have `Ref<T>` fields. diff --git a/website/static/skills/storm-serialization-kotlin.md b/website/static/skills/storm-serialization-kotlin.md index 26dcd377b..8020133ee 100644 --- a/website/static/skills/storm-serialization-kotlin.md +++ b/website/static/skills/storm-serialization-kotlin.md @@ -1,7 +1,4 @@ Help the user serialize Storm entities to JSON for REST APIs using Kotlin. - -Fetch https://orm.st/llms-full.txt for complete reference. - This is about serializing entities for API responses or caching (Jackson, kotlinx.serialization), not about JSON database columns (use /storm-json-kotlin for that). Ask: which serialization library (Jackson or kotlinx.serialization), whether entities have `Ref<T>` fields. diff --git a/website/static/skills/storm-setup.md b/website/static/skills/storm-setup.md index 1f3a68ed2..57386c02c 100644 --- a/website/static/skills/storm-setup.md +++ b/website/static/skills/storm-setup.md @@ -1,47 +1,105 @@ Help the user set up Storm ORM in their project. - -Fetch https://orm.st/llms-full.txt for complete reference (see the Installation section). +**Important:** Storm can run on top of JPA, but when setting up a new project, use Storm's JDBC-based API with `DataSource`. Do not add JPA/Hibernate dependencies unless the project already uses them. Storm has its own annotations (`@PK`, `@FK`, `@DbTable`, etc.) — use those instead of JPA annotations. Before suggesting dependencies, read the project's build file (pom.xml, build.gradle.kts, or build.gradle) to detect: - Build tool (Maven or Gradle) - Language and version (Kotlin version from kotlin plugin, Java version from sourceCompatibility/release) - Existing dependencies (Spring Boot, Ktor, database driver, etc.) -- Fetch the latest Storm version from https://repo1.maven.org/maven2/st/orm/storm-bom/maven-metadata.xml +- If no Storm version is specified in the project, use version `1.11.0` +- If no Kotlin version is specified in the project, use Kotlin `2.3.0` (the current stable release) +- If no Spring Boot version is specified, use Spring Boot `3.5.6` + +## Core Dependencies + +### Kotlin (Gradle) + +**Important:** The KSP plugin version must match the project's Kotlin version. Declare it in `plugins { }`: +```kotlin +plugins { + kotlin("jvm") version "<kotlin-version>" + id("com.google.devtools.ksp") version "<kotlin-version>-<ksp-patch>" // e.g., 2.1.20-1.0.32 +} +``` -Core dependencies: +In Gradle, a `platform()` BOM only applies to the configuration where it's declared. The `ksp` and `kotlinCompilerPluginClasspath` configurations are separate — they do NOT inherit the BOM from `implementation`. You must apply the BOM to each configuration that needs it: -Kotlin (Gradle): -- `implementation(platform("st.orm:storm-bom:<version>"))` -- `implementation("st.orm:storm-kotlin")` -- `runtimeOnly("st.orm:storm-core")` -- `ksp("st.orm:storm-metamodel-ksp")` -- `kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-<kotlin-major.minor>")` - Match the 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. +```kotlin +dependencies { + implementation(platform("st.orm:storm-bom:<version>")) + ksp(platform("st.orm:storm-bom:<version>")) + kotlinCompilerPluginClasspath(platform("st.orm:storm-bom:<version>")) -Java (Maven): + implementation("st.orm:storm-kotlin") + runtimeOnly("st.orm:storm-core") + ksp("st.orm:storm-metamodel-ksp") // version from BOM + kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-<kotlin-major.minor>") // version from BOM +} +``` + +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. + +### Kotlin (Maven) +- Import `st.orm:storm-bom` in dependencyManagement +- `st.orm:storm-kotlin` +- `st.orm:storm-core` (runtime scope) +- `st.orm:storm-metamodel-ksp` with `com.dyescape:kotlin-maven-symbol-processing` execution +- `st.orm:storm-compiler-plugin-<kotlin-major.minor>` as a dependency of `kotlin-maven-plugin` +- The compiler plugin must be listed under `<dependencies>` of the `kotlin-maven-plugin` configuration +- Use `build-helper-maven-plugin` to add the KSP generated sources directory (`target/generated-sources/ksp`) as a source folder + +### Java (Maven) - Import `st.orm:storm-bom` in dependencyManagement - `st.orm:storm-java21` - `st.orm:storm-core` (runtime scope) - `st.orm:storm-metamodel-processor` (provided scope) - Requires `--enable-preview` in maven-compiler-plugin and maven-surefire-plugin -Spring Boot: -- Kotlin: `st.orm:storm-kotlin-spring-boot-starter` -- Java: `st.orm:storm-spring-boot-starter` -- These replace the core module and include auto-configuration -- See https://orm.st/docs/spring-integration for full setup +### Spring Boot +- 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` -Ktor: +### 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) -- See https://orm.st/docs/ktor-integration for full setup +- Install with `install(Storm)`, access ORM via `call.orm` in routes +- Register repositories via `stormRepositories { register(UserRepository::class) }` + +## Getting ORMTemplate + +```kotlin +// Extension property (most common) +val orm = dataSource.orm + +// With custom decorator (e.g., name resolvers) +val orm = dataSource.orm { decorator -> + decorator.withTableNameResolver(TableNameResolver.toUpperCase(TableNameResolver.DEFAULT)) +} + +// Factory method +val orm = ORMTemplate.of(dataSource) + +// Spring Boot: injected automatically +@Service +class UserService(private val orm: ORMTemplate) +``` Serialization (pick one if needed): - `st.orm:storm-kotlinx-serialization` for kotlinx-serialization - `st.orm:storm-jackson2` for Jackson 2 (Spring Boot 3.x) - `st.orm:storm-jackson3` for Jackson 3 (Spring Boot 4+) +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` (test runtime scope) — the H2 JDBC driver itself (required — `storm-h2` declares it as `provided`) +- 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 +- Schema SQL files go in `src/test/resources/` + Database dialects (add as runtime dependency): - `st.orm:storm-postgresql` - `st.orm:storm-mysql` @@ -51,4 +109,4 @@ Database dialects (add as runtime dependency): After configuring dependencies, remind the user to rebuild so the metamodel classes are generated. -Do not hardcode versions. Always fetch the latest from Maven Central or use the version already in the project's BOM. +Use the version already in the project's BOM, or `1.11.0` for new projects. diff --git a/website/static/skills/storm-sql-java.md b/website/static/skills/storm-sql-java.md index 5ac33936b..6710163d3 100644 --- a/website/static/skills/storm-sql-java.md +++ b/website/static/skills/storm-sql-java.md @@ -1,7 +1,4 @@ Help the user write Storm SQL Templates using Java. - -Fetch https://orm.st/llms-full.txt for complete reference. - Ask what query they need and why QueryBuilder does not suffice. When to use SQL Templates: complex joins, subqueries, CTEs, window functions, DB-specific syntax, UNION/INTERSECT. @@ -41,7 +38,12 @@ Critical rules: Close any ResultStream from custom queries. Use try-with-resources for getResultStream(). -After writing SQL templates, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +## Verification + +After writing SQL templates, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"}) class CityCountQueryTest { @@ -54,11 +56,14 @@ class CityCountQueryTest { LEFT JOIN \{User.class} ON \{User_.city} = \{City_.id} GROUP BY \{City_.id}""") .getResultList(CityCount.class)); - // Verify the SQL structure matches the intent. - String sql = capture.statements().getFirst().statement(); - assertTrue(sql.contains("LEFT JOIN")); - assertTrue(sql.contains("GROUP BY")); + // Verify intent: one row per city, each with a user count. assertFalse(results.isEmpty()); + assertTrue(results.stream().allMatch(r -> r.count() >= 0)); } } ``` + +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. + + +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-sql-kotlin.md b/website/static/skills/storm-sql-kotlin.md index cddf12f65..7e81ad246 100644 --- a/website/static/skills/storm-sql-kotlin.md +++ b/website/static/skills/storm-sql-kotlin.md @@ -1,71 +1,68 @@ Help the user write Storm SQL Templates using Kotlin. - -Fetch https://orm.st/llms-full.txt for complete reference. - Ask what query they need and why QueryBuilder does not suffice. -When to use SQL Templates: complex joins, subqueries, CTEs, window functions, DB-specific syntax, UNION/INTERSECT. -When to use QueryBuilder (/storm-query-kotlin): simple CRUD, filtering, ordering, pagination. +**Always prefer the QueryBuilder and metamodel-based API first** (/storm-query-kotlin). SQL Templates are a fallback for queries that QueryBuilder cannot express: complex joins, subqueries, CTEs, window functions, DB-specific syntax, UNION/INTERSECT. -Requires the Storm compiler plugin. Kotlin uses \`\${}\` interpolation inside a lambda: +Even inside SQL Templates, **use metamodel references wherever possible** (e.g., `${User_.email}` instead of hardcoding column names). This keeps queries type-safe and refactor-proof. -\`\`\`kotlin +Requires the Storm compiler plugin. With the compiler plugin, use standard Kotlin `${}` interpolation inside a lambda — the plugin automatically wraps all interpolations for safe parameter binding: + +```kotlin val users = orm.query { """ - SELECT \${User::class} - FROM \${User::class} - WHERE \${User_.email} = \$email - AND \${User_.city.country.code} = \$countryCode + SELECT ${User::class} + FROM ${User::class} + WHERE ${User_.email} = $email + AND ${User_.city.country.code} = $countryCode """ }.resultList<User>() -\`\`\` +``` Template elements: -- \`\${User::class}\` in SELECT: full column list with aliases -- \`\${User::class}\` in FROM: table + auto-JOINs for all @FK fields -- \`\${User_.email}\`: column reference with correct alias -- \`\$email\`: parameterized bind variable (SQL injection safe) -- \`\${from(User::class, autoJoin = false)}\`: FROM without auto-joins -- \`\${table(User::class)}\`: table name only (for subqueries) -- \`\${select(User::class, SelectMode.PK)}\`: only PK columns -- \`\${column(User_.email)}\`: explicit column with alias -- \`\${unsafe("raw sql")}\`: raw SQL (use with caution) +- `${User::class}` in SELECT: full column list with aliases +- `${User::class}` in FROM: table + auto-JOINs for all @FK fields +- `${User_.email}`: column reference with correct alias +- `$email`: parameterized bind variable (SQL injection safe) +- `${from(User::class, autoJoin = false)}`: FROM without auto-joins +- `${table(User::class)}`: table name only (for subqueries) +- `${select(User::class, SelectMode.PK)}`: only PK columns +- `${column(User_.email)}`: explicit column with alias +- `${unsafe("raw sql")}`: raw SQL (use with caution) The Data interface marks types for SQL generation without CRUD: -\`\`\`kotlin +```kotlin data class CityCount(val city: City, val count: Long) : Data -\`\`\` +``` All interpolated values become bind parameters. SQL injection safe by design. Critical rules: -- **`t()` for parameter binding** (requires the Storm compiler plugin): Inside `.having {}` and other lambda expressions, use `t()` to ensure proper parameter binding and SQL injection protection: - ```kotlin - // ✅ Correct - t() ensures proper parameter binding - .having { "COUNT(DISTINCT ${t(column)}) = ${t(numTypes)}" } - - // ❌ Wrong - raw interpolation bypasses parameter binding - .having { "COUNT(DISTINCT $column) = $numTypes" } - ``` +- **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. +- **`${}` interpolation with the compiler plugin**: The Storm compiler plugin automatically wraps all `${}` interpolations inside template lambdas. You do NOT need to call `t()` manually — just use standard Kotlin string interpolation. The `t()` function only exists as a fallback for projects without the compiler plugin. - **Metamodel navigation depth**: Multiple levels of navigation are allowed on the root entity. However, joined (non-root) entities can only navigate one level deep. If you need deeper navigation from a joined entity, explicitly join the intermediate entity: ```kotlin // ❌ Wrong - two levels from joined entity orm.selectFrom(DemographicSet::class, ...) .innerJoin(DemographicDemographicSet::class).on(DemographicSet::class) - .having { "${t(DemographicDemographicSet_.demographic.demographicType)}" } + .having { "${DemographicDemographicSet_.demographic.demographicType}" } // ✅ Correct - explicitly join intermediate, then one level orm.selectFrom(DemographicSet::class, ...) .innerJoin(DemographicDemographicSet::class).on(DemographicSet::class) .innerJoin(Demographic::class).on(DemographicDemographicSet::class) - .having { "${t(Demographic_.demographicType)}" } + .having { "${Demographic_.demographicType}" } ``` Advanced patterns: -- Subqueries: use \`table()\` for inner FROM, \`column()\` for inner columns -- Correlated subqueries: use \`column(field, INNER)\` / \`column(field, OUTER)\` for alias scoping +- Subqueries: use `table()` for inner FROM, `column()` for inner columns +- Correlated subqueries: use `column(field, INNER)` / `column(field, OUTER)` for alias scoping - CTEs: write standard WITH clause, reference types normally - UNION: combine multiple query blocks -After writing SQL templates, offer to write a test using `SqlCapture` to verify the generated SQL matches the user's intent: +## Verification + +After writing SQL templates, write a test using `@StormTest` and `SqlCapture` to verify that schema, generated SQL, and intent are aligned. + +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"]) class CityCountQueryTest { @@ -79,11 +76,14 @@ class CityCountQueryTest { GROUP BY ${City_.id} """ }.resultList<CityCount>() } - // Verify the SQL structure matches the intent. - val sql = capture.statements().first().statement() - assertContains(sql, "LEFT JOIN") - assertContains(sql, "GROUP BY") + // Verify intent: one row per city, each with a user count. + assertEquals(1, capture.count(Operation.SELECT)) assertFalse(results.isEmpty()) + assertTrue(results.all { it.count >= 0 }) } } ``` + +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. + +The test can be temporary — verify and remove, or keep as a regression test. Ask the user which they prefer.