diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index c1fd32d52..727394134 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -103,10 +103,8 @@ jobs:
fi
done
- - name: Generate llms-full.txt
- run: bash scripts/generate-llms-full.sh
- working-directory: website
-
+ # llms-full.txt is regenerated by the prebuild script that npm runs ahead of
+ # the build, so it needs no step of its own here.
- name: Build Docusaurus
run: npm run build
working-directory: website
diff --git a/.gitignore b/.gitignore
index ca9523227..50f908fc0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,6 +49,9 @@ website/node_modules/
website/build/
website/.docusaurus/
website/static/api/
+# Generated from docs/ by website/scripts/generate-llms-full.sh, which runs as the
+# prebuild step of every site build. Committing it only lets it drift.
+website/static/llms-full.txt
# Working analysis doc — not for check-in
ADOPTION_ANALYSIS.md
diff --git a/README.md b/README.md
index caf6bd9c6..ed3c10c5a 100644
--- a/README.md
+++ b/README.md
@@ -203,6 +203,7 @@ Everything you need to build applications with Storm. Start with Getting Started
| [Queries](docs/queries.md) | Select, filter, aggregate, order (8 min) |
| [Metamodel](docs/metamodel.md) | Compile-time type safety (10 min) |
| [Refs](docs/refs.md) | Lazy loading and optimized references (7 min) |
+| [Entity Design](docs/entity-design.md) | Choosing between inlined foreign keys and Refs (9 min) |
| [Batch & Streaming](docs/batch-streaming.md) | Bulk operations and Flow/Stream (5 min) |
| [Upserts](docs/upserts.md) | Insert-or-update operations (6 min) |
| [Write Sets](docs/write-sets.md) | Dependency-ordered writes of mixed-type entity graphs (7 min) |
diff --git a/docs/entities.md b/docs/entities.md
index 245aec69c..3ed76770a 100644
--- a/docs/entities.md
+++ b/docs/entities.md
@@ -767,6 +767,8 @@ Nullability affects how relationships are loaded:
- **Non-nullable FK:** INNER JOIN (referenced entity must exist)
- **Nullable FK:** LEFT JOIN (referenced entity may be null)
+Foreign keys are joined transitively, so an entity's reads carry the columns of everything its graph reaches. Declaring a foreign key as [`Ref`](refs.md) stores the key and leaves the join to the queries that ask for it. See [Entity Design](entity-design.md) for when that is worth doing.
+
---
## Suppressing Schema Validation
diff --git a/docs/entity-design.md b/docs/entity-design.md
new file mode 100644
index 000000000..daa27bb5f
--- /dev/null
+++ b/docs/entity-design.md
@@ -0,0 +1,251 @@
+# Entity Design
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+Every foreign key in your schema becomes one of two things in your model: a direct type, which Storm joins on every read, or a [`Ref`](refs.md), which stores the key and leaves the join to the queries that ask for it. That single choice, repeated across a schema, decides what your reads cost.
+
+This page describes how to make it, and how Storm's schema-first generation makes it for you.
+
+---
+
+## Where the Decision Lives
+
+Any framework that returns objects from a relational schema has to settle one question: when a query returns an entity, how much of the surrounding data comes back with it. Storm answers that question in the entity class. The relationships a class declares as direct types are loaded alongside it, on every read, in every query, for as long as they stay declared that way. The rest of this page follows from that answer, so it is worth setting out what the choice costs and what it buys before arriving at the rule itself.
+
+The alternative is to answer the question at each query instead. That approach is more flexible, since any given read can ask for exactly the data it needs and nothing more. What it asks in return is that every read state its requirements, and that those requirements stay correct as the surrounding code changes. When a read omits something it turns out to need, the shortfall is usually made up at runtime, one additional query per row returned, and that is a cost which stays invisible until the data grows.
+
+Storm takes the other position. The decision is made once, where the model is defined, and applies uniformly to every read of that entity. That uniformity is the cost: a query interested in two columns still pays for the relationships the class declares. It is also the benefit, because it makes the cost of a read a property of the model rather than of whichever code happens to be calling it. You can establish what an entity costs to read by looking at its declaration, without running anything, and tooling can check that cost against a budget before the code ships.
+
+Where a particular query genuinely needs more than the class declares, [`fetch(...)`](refs.md#resolving-a-ref-as-part-of-the-query) resolves the additional records as part of the same statement, so the class sets the default rather than an upper bound. What a class cannot do is prompt you to reconsider the decision later, because nothing at the call sites refers back to it. That is precisely why the reasoning behind it is worth writing down.
+
+---
+
+## Start by Not Optimizing
+
+The default is to model every foreign key as a direct type and take the joins.
+
+Storm joins on a primary key with an equality predicate, which is the cheapest thing a relational database does. It is an index lookup per row against a structure the database keeps hot. A query that joins six tables to return one fully populated entity graph is not a query in trouble. It is a query doing what the database is built for, and it beats six round trips to assemble the same object by a margin that does not depend on your schema.
+
+So `Ref` is not the careful choice, and a direct type is not the lazy one. Reaching for `Ref` before you have a reason gives up nothing in query capability, but it does move work into your application: a `fetch()` at the call site, a second round trip, and a loading decision that every caller now has to get right. Pay that when something is buying it.
+
+Concretely: build the graph, model the foreign keys as direct types, and move on. Most schemas never need anything else.
+
+---
+
+## What Actually Costs You
+
+When a graph does start to hurt, it helps to be precise about which part hurts. There are three costs, and they are not close to equal.
+
+**The join.** Nearly free, as above. This is the cost people optimize for, and it is the one that matters least.
+
+**The width.** Every inlined table adds its columns to every row that every ancestor selects. A join to a 40-column table does not cost you a join, it costs you 40 columns times however many rows come back, over the wire and through the row mapper, on every read of every entity that reaches it. Width is the real bill.
+
+**Growth you did not ask for.** This is the one worth designing against.
+
+Consider a `Visit` that references `Pet`:
+
+
+
+
+```kotlin
+data class Visit(
+ @PK val id: Int = 0,
+ val visitDate: LocalDate,
+ val description: String,
+ @FK val pet: Pet
+) : Entity
+```
+
+
+
+
+```java
+record Visit(@PK Integer id,
+ LocalDate visitDate,
+ String description,
+ @FK Pet pet
+) implements Entity {}
+```
+
+
+
+
+Later, someone adds a clinic reference to `Pet`. `Clinic` has its own foreign keys to `Address` and `Organization`, and `Address` reaches `City`, which reaches `Country`. Every query that selects a `Visit` now joins five more tables and carries their columns, and nothing about `Visit` changed. The developer who edited `Pet` had no way to see it.
+
+That asymmetry is what justifies a boundary. Not the join. A direct type has an unbounded blast radius that grows with your schema, and it grows in a place nobody is looking. A `Ref` has a blast radius of one edge, and it is written down at the edge.
+
+---
+
+## What a Ref Does Not Cost
+
+Before turning to where a cut belongs, it is worth being precise about what a cut gives up, which is less than it appears. Declaring a foreign key as a `Ref` narrows what a read returns by default. It does not narrow what a query is permitted to ask, and that distinction is what makes the choices in the next section a question of cost rather than of capability.
+
+A reference does not terminate the metamodel; paths continue straight through it. A column on the far side of a reference, however many hops beyond it, is named exactly as it would be on a directly-referenced entity. Storm joins the tables the path crosses, for the query that asks and for no other:
+
+
+
+
+```kotlin
+// city is a Ref, and the filter still reaches through it into country.
+orm.entity()
+ .select()
+ .where(User_.city.country.name eq "Netherlands")
+ .resultList
+```
+
+
+
+
+```java
+// city is a Ref, and the filter still reaches through it into country.
+List users = orm.entity(User.class)
+ .select()
+ .where(User_.city.country.name, EQUALS, "Netherlands")
+ .getResultList();
+```
+
+
+
+
+The same paths are available inside a select template, so a query is free to return data from beyond a reference even though the entity itself does not carry it:
+
+
+
+
+```kotlin
+// Selects a column two hops past the reference, joining city and country for this query alone.
+orm.entity()
+ .select { "${User_.city.country.name}, COUNT(*)" }
+ .groupBy(User_.city.country.name)
+ .resultList
+```
+
+
+
+
+```java
+// Selects a column two hops past the reference, joining city and country for this query alone.
+orm.entity(User.class)
+ .select(UsersPerCountry.class, RAW."\{User_.city.country.name}, COUNT(*)")
+ .groupBy(User_.city.country.name)
+ .getResultList();
+```
+
+
+
+
+Both work anywhere a query names a column: `where`, `orderBy`, `groupBy`, `having`, and custom select templates. Naming the reference field on its own, `User_.city`, reads the foreign key column and adds no join at all, which is what makes it an inexpensive grouping key. Every other read of `User` is left alone throughout: the records still come back carrying an unloaded `Ref`. See [Querying Through Refs](refs.md#querying-through-refs) and [Navigating Through Refs](metamodel.md#navigating-through-refs).
+
+---
+
+## Where to Draw the Line
+
+When a graph does need cutting, the useful question is not "is this join expensive." It is "how far does this edge let the graph grow, and who inherits that."
+
+**Cut at the deepest edge, not the nearest one.** If `Visit` to `Pet` to `Clinic` to `Organization` is too much, the edge to cut is the one from `Clinic` to `Organization`. Cutting close to the root severs a relationship people read through constantly. Cutting close to the leaf trims a tail that almost nobody reaches. It also localizes the fix: cut the deep edge and every entity above it gets narrower at once, without any of them changing.
+
+**Cut the wide before the narrow.** Between two candidates, the one that drags in more columns is the one worth cutting. Cutting a narrow table saves you almost nothing and costs a `fetch()` at every call site.
+
+**Optional relationships are the natural first cut.** A nullable foreign key is a `LEFT JOIN` that hydrates a null sub-object on the rows where the reference is absent. When the reference is usually absent, you are paying width for nothing.
+
+Two edges to leave alone:
+
+**Identifying foreign keys.** When the foreign key column is part of the referenced row's own primary key, the row cannot exist without its parent. That is composition, not association, and splitting it produces a model where you routinely hold half an object.
+
+**Foreign-key-free targets.** A table with no outgoing foreign keys can never grow a subtree. Its cost is bounded by its own width, permanently, and no future schema change can make it worse. Lookup tables like `Country`, `PetType`, and `Currency` fall here, and there is rarely a reason to make one a `Ref`.
+
+None of these cuts costs you the relationship, as above. The choice is about the SELECT you get by default, not about what you are allowed to ask.
+
+---
+
+## Circular References
+
+Cycles are not a judgment call. Two entities that reference each other cannot both use direct types, so one side must be a `Ref`, and a self-reference is always a `Ref`.
+
+
+
+
+```kotlin
+data class Category(
+ @PK val id: Int = 0,
+ val name: String,
+ @FK val parent: Ref?
+) : Entity
+```
+
+
+
+
+```java
+record Category(@PK Integer id,
+ String name,
+ @Nullable @FK Ref parent
+) implements Entity {}
+```
+
+
+
+
+The self-reference stays navigable: `Category_.parent.name` joins the table to itself. See [Cyclic References](metamodel.md#cyclic-references).
+
+---
+
+## How the Generator Decides
+
+Everything above is reasoning for a developer, who knows which paths their application actually reads through. A generator working from a schema does not know that, so it applies a fixed rule instead, and the same schema always produces the same entities.
+
+The rule is a guard rail, not an optimization pass. It is calibrated so that ordinary schemas trip nothing at all and every foreign key comes out as a direct type. It engages only where a graph would otherwise run away.
+
+### The Budget
+
+Storm's schema-first generation holds this invariant for every table, treated as a potential root:
+
+- The table's inline closure adds at most **10 joins**.
+- The table's inline closure adds at most **96 columns**, excluding the table's own.
+
+A table's *inline closure* is the set of tables reachable from it through direct-type foreign keys. Define `columns(T)` as the table's own column count, counting [inline record](entities.md#embedded-components) components, which live in the same table and cost no join. Then `cost(T) = columns(T) + Σ cost(X)` across every edge from `T` that is inlined, and `joins(T)` is the number of joins that closure produces.
+
+### The Algorithm
+
+1. **Break cycles.** Every cycle gets one edge cut, and self-references are cut. Tables are visited in alphabetical order so the choice is reproducible.
+2. **Order the remaining graph topologically**, leaves first.
+3. **For each table, rank its outgoing foreign keys** into four tiers, breaking ties on the foreign key column name:
+ 1. Identifying foreign keys, where the column participates in the target's primary key.
+ 2. Non-null foreign keys to targets that have no foreign keys of their own and are at most 32 columns wide.
+ 3. Remaining non-null foreign keys, narrowest target first.
+ 4. Nullable foreign keys, narrowest target first.
+4. **Inline down that ranking while the budget holds.** The first edge that would exceed it becomes a `Ref`, and so does every edge below it.
+
+Working bottom-up is what makes the result stable. A new foreign key on a leaf table is cut at the leaf, so the entities above it keep the shape they already had. Cutting top-down would put the cut near whichever root happened to be processed first, and since a table has one class, roots would disagree about the same edge.
+
+Ranking by cost is what removes the need for naming heuristics. `Country`, at two columns with no foreign keys, wins a budget in every schema it appears in. A transactional table that arrives with its own closure attached does not. The lookup-versus-aggregate distinction falls out of the schema instead of out of a list of table-name patterns.
+
+Nothing is exempt from the budget, including tiers 1 and 2. The ranking gives those edges first claim, which in any realistic schema is enough for them to always survive, and the invariant holds without exceptions.
+
+### A Worked Example
+
+Take a veterinary schema: `country(2 columns)`, `pet_type(2)`, `city(3, references country)`, `owner(6, references city)`, `pet(5, references owner and pet_type)`, `visit(4, references pet)`.
+
+Working leaves first: `cost(country) = 2` and `cost(pet_type) = 2`, both with no joins. `cost(city) = 3 + 2 = 5` across one join. `cost(owner) = 6 + 5 = 11` across two. `cost(pet) = 5 + 11 + 2 = 18` across four. `cost(visit) = 4 + 18 = 22` across five.
+
+The widest closure in the schema belongs to `visit`, at 5 joins and 18 columns. Both budgets are untouched, so nothing is cut and every foreign key is generated as a direct type.
+
+Now give `pet` a reference to a 40-column `clinic` table that carries its own foreign keys to `address` and `organization`. The closure under `visit` roughly doubles in joins and passes 96 columns, so the budget fires. The cut lands on `clinic`'s widest edge, `organization`, which is both the deepest edge and the one carrying the most width. The edge from `visit` to `pet`, and the edge from `pet` to `owner`, are untouched.
+
+### Existing Entities
+
+When generation runs against entities that already exist, a direct type that now exceeds the budget is **reported, never flipped**. You get a diagnostic naming the table and the overrun, and the code is left as written.
+
+An entity that diverges from the rule is assumed to be doing so deliberately. The budget describes what generation produces from a schema, and it does not describe what your model is permitted to be. If you know a path is read constantly, inline past the budget. If you know one is read almost never, cut inside it.
+
+---
+
+## Tips
+
+1. **Model foreign keys as direct types until something says otherwise.** Joins on primary keys are cheap, and one query beats several.
+2. **Design against inherited growth, not against joins.** The cost that bites is the subtree an edge lets in later, in an entity nobody was editing.
+3. **Cut deep, not shallow.** Trimming the tail of a graph narrows every entity above it at once.
+4. **Never cut a lookup table.** A table with no foreign keys of its own has a cost that cannot grow.
+5. **Cut optional relationships first.** A nullable foreign key is a `LEFT JOIN` whose width buys you nulls on the rows that lack the reference.
+6. **A `Ref` is not a smaller relationship.** It stays filterable, orderable, and selectable through the metamodel. See [Refs](refs.md).
diff --git a/docs/faq.md b/docs/faq.md
index 0c8e9beb2..289c5d19e 100644
--- a/docs/faq.md
+++ b/docs/faq.md
@@ -608,6 +608,45 @@ Every executed statement is logged, whichever repository, query builder or templ
See [SQL Logging](sql-logging.md) for the full guide.
+### The Kotlin compiler crashes with "Unexpected FirPlaceholderProjectionImpl"
+
+This is a Kotlin 2.0.x compiler bug, fixed in **Kotlin 2.1.0**. It reports an unresolved call as an internal error
+rather than a diagnostic, whenever the call uses `_` type-argument placeholders:
+
+```
+org.jetbrains.kotlin.util.FileAnalysisException: While analysing Foo.kt:12:9:
+ Unexpected FirPlaceholderProjectionImpl
+```
+
+On 2.1.0 and later the same code reports `Unresolved reference 'select'`, which names the real problem. Upgrading
+the Kotlin version is the fix; nothing about Storm changes.
+
+If you are on 2.0.x, read the crash as "something in this call does not resolve". It names nothing you wrote, so it
+can stand in for any resolution failure at that call site. Two causes account for most of them.
+
+**A dependency compiled by a newer Kotlin.** A 2.0.x compiler cannot read metadata from Kotlin 2.2 or later, so every
+call into that library becomes unresolved. Look for this line above the crash, which is the one that actually explains
+it:
+
+```
+Module was compiled with an incompatible version of Kotlin.
+The binary version of its metadata is 2.2.0, expected version is 2.0.0.
+```
+
+A 2.1-built dependency is fine; 2.2 is where it breaks.
+
+**A missing import**, because repository operations are top-level extension functions:
+
+```kotlin
+import st.orm.repository.*
+```
+
+`import st.orm.template.*` does not bring them in, since they live in `st.orm.repository`. Import both packages with
+wildcards rather than naming functions individually. Without it, a call such as `select { ... }`
+has no applicable candidate at all: the extension is out of scope, and no `select` member matches a call that passes
+a template lambda and three type arguments. Replacing the placeholders with explicit type arguments turns the crash
+back into a readable error on 2.0.x, which is a quick way to find the cause without upgrading.
+
### Schema validation reports type narrowing warnings for my Integer columns
Some databases (notably Oracle) use a single numeric type for all integer columns. For example, Oracle's `NUMBER` maps to `java.sql.Types.NUMERIC`, which Storm considers a "narrowing" conversion for `Integer` fields. These are logged as warnings because the mapping works at runtime but may involve precision differences.
diff --git a/docs/index.md b/docs/index.md
index 842b3c46f..664b0f108 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -206,6 +206,7 @@ If you are new to Storm, follow these guides in order to build a solid foundatio
5. [Queries](queries.md) -- the full query DSL and builder reference
6. [Repositories](repositories.md) -- the repository pattern and custom query methods
7. [Relationships](relationships.md) -- foreign keys, entity graphs, and many-to-many
+8. [Entity Design](entity-design.md) -- when to inline a foreign key and when to reach for a Ref
### Migrating from JPA
diff --git a/docs/refs.md b/docs/refs.md
index e4a408a62..4b24b182f 100644
--- a/docs/refs.md
+++ b/docs/refs.md
@@ -7,6 +7,8 @@ Refs are lightweight identifiers for entities, projections, and other data types
Unlike a typical lazy reference, a `Ref` never trades away query capability. Filter, order, group, and select through it with the metamodel exactly as you would through a directly-referenced entity, and Storm adds the join only for the query that actually needs it. When a query already knows it needs the referenced record, it can resolve the `Ref` as part of that same statement instead of paying for a separate fetch. Choosing `Ref` over the entity type is a decision about the SELECT you get by default, not a capability you give up.
+That said, it is a decision worth making sparingly. Joins on primary keys are cheap, so the default is to model foreign keys as direct types and reach for a `Ref` when an edge lets the graph grow beyond what your reads should carry. [Entity Design](entity-design.md) covers where that line sits and how Storm's schema-first generation draws it.
+
---
## Using Refs in Entities
@@ -545,7 +547,7 @@ Use `Ref.of(entity)` when you already have the entity in memory and want to wrap
## Aggregation with Refs
-Refs are particularly useful in aggregation queries where you group by a foreign key. Instead of loading the full related entity for each group, you can select only the primary key as a Ref. This keeps the query lightweight while still giving you a typed identifier to use in subsequent lookups if needed.
+Refs are particularly useful in aggregation queries where you group by a foreign key. Instead of loading the full related entity for each group, name the foreign key path and select it as a Ref. This keeps the query lightweight while still giving you a typed identifier to use in subsequent lookups if needed.
@@ -557,7 +559,7 @@ data class GroupedByCity(
)
val counts: Map[, Long> = orm.entity()
- .select { "${select(City::class, SelectMode.PK)}, COUNT(*)" }
+ .select { "${User_.city}, COUNT(*)" }
.groupBy(User_.city)
.resultList
.associate { it.city to it.count }
@@ -570,7 +572,7 @@ val counts: Map][, Long> = orm.entity()
record GroupedByCity(Ref city, long count) {}
Map][, Long> counts = orm.entity(User.class)
- .select(GroupedByCity.class, RAW."\{select(City.class, SelectMode.PK)}, COUNT(*)")
+ .select(GroupedByCity.class, RAW."\{User_.city}, COUNT(*)")
.groupBy(User_.city)
.getResultList().stream()
.collect(toMap(GroupedByCity::city, GroupedByCity::count));
@@ -580,7 +582,7 @@ Using SQL Templates:
```java
Map][, Long> counts = orm.query(RAW."""
- SELECT \{select(City.class, SelectMode.PK)}, COUNT(*)
+ SELECT \{User_.city}, COUNT(*)
FROM \{User.class}
GROUP BY \{User_.city}""")
.getResultList(GroupedByCity.class).stream()
@@ -592,6 +594,16 @@ Map][, Long> counts = orm.query(RAW."""
The database does the aggregating, so one row per city comes back and the reference carries the key you group by. Fetch the cities themselves with `findAllByRef(counts.keys)` when a later step needs them.
+Naming the foreign key path selects the key column on the user table itself, so the referenced table is never joined:
+
+```sql
+SELECT u.city_id, COUNT(*) FROM user u GROUP BY u.city_id
+```
+
+The key does not have to be a Ref. Selecting the entity itself, `${City::class}` in place of `${User_.city}` with a `City` component instead of `Ref`, produces a `Map` whose keys carry their data, with no later fetch. That form pays for the join, for the city columns on every row, and for a `GROUP BY` over all of them, and its keys hash across every field rather than the primary key alone. Prefer the Ref form unless you need the records and would have fetched them anyway.
+
+This is not the same as [`resultGroupedBy`](queries.md#grouped-results). That variant does not change the SQL: it runs the same select and groups the hydrated rows, returning each city with the users that belong to it. Reach for it when you want the records themselves, and aggregate in the database, as above, when you only want a number per group.
+
---
## Use Cases
@@ -652,7 +664,7 @@ Understanding how `fetch()` resolves its target helps you predict performance an
## Tips
-1. **Use Refs for optional relationships.** Avoid loading data you might not need.
+1. **Reach for a Ref when an edge lets the graph grow.** A join on its own is not a reason, and neither is an optional relationship. Optional edges are simply the natural first cut once a graph does need trimming. See [Entity Design](entity-design.md).
2. **Use Refs for self-references.** Prevent circular loading in hierarchical data.
3. **Use Refs in aggregations.** Get counts by FK without loading full entities.
4. **Refs are reliable map keys.** They provide lightweight, identity-based comparison.
diff --git a/docs/relationships.md b/docs/relationships.md
index b503a4b82..35de53fbc 100644
--- a/docs/relationships.md
+++ b/docs/relationships.md
@@ -642,7 +642,7 @@ Storm's approach:
### Managing Graph Depth
-For deep or circular relationships, use `Ref` to break the loading chain:
+Circular relationships force a `Ref`, and a graph that has grown genuinely wide can use one to break the loading chain:
```kotlin
data class Category(
@@ -656,9 +656,11 @@ A `Ref` removes the join from every read but keeps the relationship queryable: y
The self-reference above is navigable too: `Category_.parent.name` joins the category table to itself, so it filters on the parent's name. The typed metamodel navigates a cycle two hops deep; deeper cyclic paths are named as strings. See [Refs](refs.md), [Querying Through Refs](refs.md#querying-through-refs) and [Cyclic References](metamodel.md#cyclic-references) for details.
+Deciding which edges to cut, and which to leave inlined, is a design question in its own right. See [Entity Design](entity-design.md).
+
## Tips
-1. **Keep entity graphs shallow.** Deep graphs mean large JOINs. Use `Ref` for optional or deep relationships.
+1. **Model foreign keys as direct types by default.** A join on a primary key is cheap, and one query beats several. Reach for `Ref` when an edge lets the graph grow, not because a join exists. See [Entity Design](entity-design.md).
2. **Query the "many" side.** For one-to-many, query the child entity with a filter on the parent.
3. **Use join entities for many-to-many.** Explicit join tables give you control over the relationship.
4. **Match nullability to your schema.** Use nullable FKs only when the database column allows NULL.
diff --git a/website/package.json b/website/package.json
index b66b6e19f..cf5f878e0 100644
--- a/website/package.json
+++ b/website/package.json
@@ -5,6 +5,7 @@
"scripts": {
"docusaurus": "docusaurus",
"start": "docusaurus start",
+ "prebuild": "bash scripts/generate-llms-full.sh",
"build": "docusaurus build",
"serve": "docusaurus serve",
"clear": "docusaurus clear"
diff --git a/website/scripts/generate-llms-full.sh b/website/scripts/generate-llms-full.sh
index d305287c4..106bad4d0 100755
--- a/website/scripts/generate-llms-full.sh
+++ b/website/scripts/generate-llms-full.sh
@@ -30,6 +30,7 @@ DOCS=(
pagination-and-scrolling.md
metamodel.md
refs.md
+ entity-design.md
transactions.md
spring-integration.md
dialects.md
diff --git a/website/sidebars.ts b/website/sidebars.ts
index d0c7d1d50..879f5faef 100644
--- a/website/sidebars.ts
+++ b/website/sidebars.ts
@@ -16,6 +16,7 @@ const sidebars: SidebarsConfig = {
'pagination-and-scrolling',
'metamodel',
'refs',
+ 'entity-design',
'transactions',
'spring-integration',
'ktor-integration',
diff --git a/website/static/llms-full.txt b/website/static/llms-full.txt
deleted file mode 100644
index 809e85bc4..000000000
--- a/website/static/llms-full.txt
+++ /dev/null
@@ -1,20586 +0,0 @@
-# Storm Framework - Complete Documentation
-
-> Storm is an AI-first ORM framework for Kotlin 2.0+ and Java 21+, the gold
-> standard for AI-assisted database development.
->
-> It uses immutable data classes and records instead of proxied entities,
-> providing type-safe queries, predictable performance, and zero hidden magic.
-> Storm works perfectly standalone, but its design and tooling make it uniquely
-> suited for AI-assisted development: immutable entities produce stable code,
-> the CLI installs per-tool skills, and a locally running MCP server exposes
-> only schema metadata (table definitions, column types, constraints) while
-> shielding your database credentials and data from the LLM. Built-in
-> verification (validateSchema(), SqlCapture) lets the AI validate its own work
-> before anything is committed.
->
-> Get started: `npx @storm-orm/cli`
-> Website: https://orm.st
-> GitHub: https://github.com/storm-orm/storm-framework
-> License: Apache 2.0
-
-# Generated: 2026-07-29T21:33:50Z
-
-========================================
-## Source: index.md
-========================================
-
-# Storm
-
-**Storm** is a modern, high-performance ORM for Kotlin 2.0+ and Java 21+, built around a powerful SQL template engine. It focuses on simplicity, type safety, and predictable performance through immutable models and compile-time metadata.
-
-**Key benefits:**
-
-- **Minimal code**: Define entities with simple records/data classes and query with concise, readable syntax, no boilerplate.
-- **Parameterized by default**: String interpolations are automatically converted to bind variables, making queries SQL injection safe by design.
-- **Close to SQL**: Storm embraces SQL rather than abstracting it away, keeping you in control of your database operations.
-- **Type-safe**: Storm's DSL mirrors SQL, providing a type-safe, intuitive experience that makes queries easy to write and read while reducing the risk of runtime errors.
-- **Direct Database Interaction**: Storm translates method calls directly into database operations, offering a transparent and straightforward experience. It eliminates inefficiencies like the N+1 query problem for predictable and efficient interactions.
-- **Stateless**: Avoids hidden complexities and "magic" with stateless, record-based entities, ensuring simplicity and eliminating lazy initialization and transaction issues downstream.
-- **Performance**: Template caching, transaction-scoped entity caching, and zero-overhead dirty checking (thanks to immutability) ensure efficient database interactions. Batch processing, lazy streams, and upserts are built in.
-- **Universal Database Compatibility**: Fully compatible with all SQL databases, it offers flexibility and broad applicability across various database systems.
-
-## Why Storm?
-
-Storm draws inspiration from established ORMs such as Hibernate, but is built from scratch around a clear design philosophy: capture intent using the minimum amount of code, optimized for Kotlin and modern Java.
-
-**Storm's mission:** Make database development productive and enjoyable, with full developer control and high performance.
-
-Storm embraces SQL rather than abstracting it away. It simplifies database interactions while remaining transparent, and scales from prototypes to enterprise systems.
-
-| Traditional ORM Pain | Storm Solution |
-|----------------------|----------------|
-| N+1 queries from lazy loading | Entity graphs load in a single query |
-| Hidden magic (proxies, implicit flush, cascades) | Stateless records; explicit, predictable behavior |
-| Entity state confusion (managed/detached/transient) | Immutable records; no state to manage |
-| Entities tied to session/context | Stateless records easily cached and shared across layers |
-| Dirty checking via bytecode manipulation | Lightning-fast dirty checking thanks to immutability |
-| Complex mapping configuration | Convention over configuration |
-| Runtime query errors | Compile-time type-safe DSL |
-| SQL hidden behind abstraction layers | SQL-first design; stay close to the database |
-
-**Storm is ideal for** developers who understand that the best solutions emerge when object model and database model work in harmony. If you value a database-first approach where records naturally mirror your schema, Storm is built for you. Custom mappings are supported when needed, but the real elegance comes from alignment, not abstraction.
-
-## Choose Your Language
-
-Both Kotlin and Java support SQL Templates for powerful query composition. Kotlin additionally provides a type-safe DSL with infix operators for a more idiomatic experience.
-
-[Kotlin]
-
-```kotlin
-// Define an entity
-data class User(
- @PK val id: Int = 0,
- val email: String,
- val name: String,
- @FK val city: City
-) : Entity
-
-// 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
-interface UserRepository : EntityRepository {
- fun findByCityName(name: String) = findAll(User_.city.name eq name)
-}
-
-// 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 { """
- SELECT ${User::class}
- FROM ${User::class}
- WHERE ${User_.city.name} = $cityName"""
- }.resultList()
-```
-
-Full coroutine support with `Flow` for streaming and programmatic transactions:
-
-```kotlin
-// Streaming with Flow
-val users: Flow = orm.entity().select().resultFlow
-users.collect { user -> println(user.name) }
-
-// Programmatic transactions
-transaction {
- val city = orm insert City(name = "Sunnyvale", population = 161_884)
- val user = orm insert User(email = "bob@example.com", name = "Bob", city = city)
-}
-```
-
-[Java]
-
-```java
-// Define an entity
-record User(@PK Integer id,
- String email,
- String name,
- @FK City city
-) implements Entity {}
-
-// Custom repository—inherits all CRUD operations, add your own queries
-interface UserRepository extends EntityRepository {
- default List findByCityName(String name) {
- return select().where(User_.city.name, EQUALS, name).getResultList();
- }
-}
-
-// Query Builder for more complex operations
-List users = orm.entity(User.class)
- .select()
- .where(User_.city.name, EQUALS, "Sunnyvale")
- .orderBy(User_.name)
- .getResultList();
-
-// SQL Template for full control; parameterized by default, SQL injection safe
-List users = orm.query(RAW."""
- SELECT \{User.class}
- FROM \{User.class}
- WHERE \{User_.city.name} = \{cityName}
- """).getResultList(User.class);
-```
-## AI Assisted Development
-
-Storm is the ORM that AI coding assistants get right. Its stateless, immutable entities mean what you see in the source code is exactly what exists at runtime: no hidden proxies, no lazy loading surprises, no persistence context rules that trip up AI-generated code. When you ask your AI tool to write a query, define an entity, or build a repository, the output is straightforward data classes and explicit SQL, the same code a senior developer would write by hand.
-
-**Get started in seconds:**
-
-```bash
-npx @storm-orm/cli
-```
-
-This configures your AI tool (Claude Code, Cursor, Copilot, Windsurf, or Codex) with Storm's patterns, conventions, and slash commands. See [more on ai](ai.md) for details.
-
-## Quick Start
-
-The Storm Gradle plugin sets up a Kotlin project in one line: it imports the BOM, adds the core dependencies, and wires the metamodel processor and Kotlin compiler plugin. Maven users import the BOM once and omit version numbers from individual Storm dependencies.
-
-[Kotlin (Gradle)]
-
-```kotlin
-plugins {
- kotlin("jvm") version "2.4.0"
- id("com.google.devtools.ksp") version "2.3.10"
- id("st.orm") version "@@STORM_VERSION@@"
-}
-```
-
-[Java (Maven)]
-
-```xml
-
-
-
- st.orm
- storm-bom
- @@STORM_VERSION@@
- pom
- import
-
-
-
-
-
-
- st.orm
- storm-java21
-
-
- st.orm
- storm-core
- runtime
-
-
-```
-Ready to get started? Head to the [Getting Started](getting-started.md) guide.
-
-## Learning Paths
-
-Not sure where to begin? Pick the path that fits your situation.
-
-### New to Storm
-
-If you are new to Storm, follow these guides in order to build a solid foundation:
-
-1. [Installation](installation.md) -- add Storm to your project
-2. [First Entity](first-entity.md) -- define entities, insert and fetch records
-3. [First Query](first-query.md) -- filtering, repositories, and streaming
-4. [Entities](entities.md) -- annotations, nullability, naming conventions
-5. [Queries](queries.md) -- the full query DSL and builder reference
-6. [Repositories](repositories.md) -- the repository pattern and custom query methods
-7. [Relationships](relationships.md) -- foreign keys, entity graphs, and many-to-many
-
-### Migrating from JPA
-
-If you are coming from JPA or Hibernate, these pages explain the key differences and how to transition:
-
-1. [Migration from JPA](migration-from-jpa.md) -- annotation mapping, concept translation, coexistence strategy
-2. [Storm vs Other Frameworks](comparison.md) -- feature comparison with JPA, jOOQ, MyBatis, and others
-3. [Entities](entities.md) -- how Storm entities differ from JPA entities
-4. [Repositories](repositories.md) -- Storm repositories vs. Spring Data repositories
-5. [Transactions](transactions.md) -- transaction management without an EntityManager
-6. [Spring Integration](spring-integration.md) -- Spring Boot Starter and auto-configuration
-
-### Evaluating for Production
-
-If you are a tech lead or architect evaluating Storm for a production system, these pages cover the areas that matter most:
-
-1. [Storm vs Other Frameworks](comparison.md) -- feature-level comparison across frameworks
-2. [Spring Integration](spring-integration.md) -- Spring Boot auto-configuration, repository scanning, DI
-3. [Ktor Integration](ktor-integration.md) -- Ktor plugin, HOCON configuration, coroutine-native transactions
-4. [Batch Processing and Streaming](batch-streaming.md) -- bulk operations and large dataset handling
-4. [Testing](testing.md) -- JUnit 5 integration, statement capture, and test isolation
-5. [Configuration](configuration.md) -- runtime tuning, dirty checking modes, cache retention
-6. [Database Dialects](dialects.md) -- database-specific optimizations
-
-## What Storm Does Not Do
-
-Storm is focused on being a great ORM and SQL template engine. It intentionally does not include:
-
-- **Schema migration or DDL generation.** Storm does not automatically create, alter, or drop tables at runtime. With Storm's [AI integration](ai.md), your coding assistant can read your database schema and generate Flyway or Liquibase migration scripts on demand. For schema versioning, use [Flyway](https://flywaydb.org/) or [Liquibase](https://www.liquibase.com/).
-- **Second-level cache.** Storm's entity cache is transaction-scoped and cleared on commit. For cross-transaction caching, use Spring's `@Cacheable` or a dedicated cache layer like Caffeine or Redis.
-- **Lazy loading proxies.** Entities are plain records with no proxies. Related entities are loaded eagerly in a single query via JOINs. For deferred loading, use [Refs](refs.md) to explicitly control when related data is fetched.
-
-## Database Support
-
-Storm works with any JDBC-compatible database. Dialect packages provide optimized support for:
-
-      
-
-See [Database Dialects](dialects.md) for installation and configuration details.
-
-## Requirements
-
-- Kotlin 2.0+ or Java 21+
-- Maven 3.9+ or Gradle 8+
-
-## Glossary
-
-New to Storm's terminology? See the [Glossary](glossary.md) for definitions of key terms like Entity, Projection, Metamodel, Ref, Hydration, and more.
-
-## License
-
-Storm is released under the [Apache 2.0 License](https://github.com/storm-repo/storm-framework/blob/main/LICENSE).
-
-
-========================================
-## Source: getting-started.md
-========================================
-
-# Get Started
-
-Storm is a modern SQL Template and ORM framework for Kotlin 2.0+ and Java 21+. It uses immutable data classes and records instead of proxied entities, giving you predictable behavior, type-safe queries, and high performance.
-
-## Choose Your Path
-
-> **Fastest start:** each example app is a GitHub template. Click **Use this template** to generate a runnable project, then replace the sample entities with your own: [Kotlin + Ktor](https://github.com/storm-orm/storm-example-kotlin-ktor/generate) · [Kotlin + Spring Boot](https://github.com/storm-orm/storm-example-kotlin-spring-boot-4/generate) · [Java + Spring Boot](https://github.com/storm-orm/storm-example-java-spring-boot-4/generate).
-
-Two ways to get started, and both reach the same working setup: follow the guides by hand, or let your AI coding tool do it. Pick whichever fits your workflow.
-[Manual]
-
-### Manual Setup
-
-Follow these three steps in order for the fastest path from zero to a working application.
-
-**1. Installation**
-
-Set up your project with the right dependencies, build flags, and optional modules.
-
-**[Go to Installation](installation.md)**
-
-**2. First Entity**
-
-Define your first entity, create an ORM template, and perform insert, read, update, and remove operations.
-
-**[Go to First Entity](first-entity.md)**
-
-**3. First Query**
-
-Write custom queries, build repositories, stream results, and use the type-safe metamodel.
-
-**[Go to First Query](first-query.md)**
-
-[AI-Assisted]
-
-### AI-Assisted Setup
-
-If you use an AI coding tool (Claude Code, Cursor, GitHub Copilot, Windsurf, or Codex), Storm provides rules, skills, and an optional database-aware MCP server that give the AI deep knowledge of Storm's conventions. The AI can generate entities from your schema, write queries, and verify its own work against a real database.
-
-**1. Install the Storm CLI and run it in your project:**
-
-```bash
-npx @storm-orm/cli init
-```
-
-The interactive setup configures your AI tool with Storm's rules and skills, and optionally connects it to your development database for schema-aware code generation.
-
-**2. Ask your AI tool to set up Storm:**
-
-Once `storm init` has configured your tool, you can ask it to add the right dependencies, create entities from your database tables, and write queries. The AI has access to Storm's full documentation and your database schema.
-
-For example:
-- "Add Storm to this project with Spring Boot and PostgreSQL"
-- "Set up Storm with Ktor and PostgreSQL"
-- "Create entities for the users and orders tables"
-- "Write a repository method that finds orders by status with pagination"
-
-**3. Verify:**
-
-Storm's AI workflow includes built-in verification. The AI can run `ORMTemplate.validateSchema()` to prove entities match the database and `SqlCapture` to inspect generated SQL, all in an isolated H2 test database before anything touches production.
-
-See [AI-Assisted Development](ai.md) for the full setup guide, available skills, and MCP server configuration.
----
-
-## What's Next
-
-Once you have completed the steps above, explore the features that match your needs:
-
-**Core Concepts:**
-- [Entities](entities.md) -- annotations, nullability, naming conventions
-- [Queries](queries.md) -- query DSL, filtering, joins, aggregation
-- [Relationships](relationships.md) -- one-to-one, many-to-one, many-to-many
-- [Repositories](repositories.md) -- custom repository pattern
-
-**Operations:**
-- [Transactions](transactions.md) -- transaction management and propagation
-- [Upserts](upserts.md) -- insert-or-update operations
-- [Batch Processing & Streaming](batch-streaming.md) -- bulk operations and large datasets
-- [Dirty Checking](dirty-checking.md) -- automatic change detection on update
-
-**Integration:**
-- [Spring Integration](spring-integration.md) -- Spring Boot Starter, auto-configuration, and DI
-- [Testing](testing.md) -- JUnit 5 integration and statement capture
-- [Database Dialects](dialects.md) -- database-specific features
-
-**Advanced:**
-- [Refs](refs.md) -- lightweight entity references for deferred loading
-- [Projections](projections.md) -- read-only views of entities
-- [SQL Templates](sql-templates.md) -- raw SQL with type safety
-- [Metamodel](metamodel.md) -- compile-time type-safe field references
-- [JSON Support](json.md) -- JSON columns and aggregation
-- [Entity Serialization](serialization.md) -- JSON serialization with Ref support
-
-**Migration:**
-- [Migration from JPA](migration-from-jpa.md) -- step-by-step guide
-- [Storm vs Other Frameworks](comparison.md) -- feature comparison
-
-
-========================================
-## Source: installation.md
-========================================
-
-# Installation
-This page covers everything you need to add Storm to your project: prerequisites, dependency setup, and optional modules.
-
-## Prerequisites
-
-| Requirement | Version |
-|-------------|---------|
-| JDK | 21 or later |
-| Kotlin (if using Kotlin) | 2.0 or later |
-| Build tool | Maven 3.9+ or Gradle 8+ |
-| Database | Any JDBC-compatible database |
-
-Kotlin users do not need any preview flags. Java users must enable `--enable-preview` in their compiler configuration because the Java API uses String Templates (JEP 430).
-
-## Gradle Plugin (Recommended)
-
-The Storm Gradle plugin collapses the whole setup into one plugin application. It imports the BOM, adds the core dependencies for your language, wires the metamodel processor, selects the Kotlin compiler-plugin variant matching your Kotlin version, and configures the Java preview flags. Requires Gradle 8.5+.
-
-[Kotlin]
-
-```kotlin
-plugins {
- kotlin("jvm") version "2.4.0"
- id("com.google.devtools.ksp") version "2.3.10"
- id("st.orm") version "@@STORM_VERSION@@"
-}
-```
-
-That is the entire Storm setup. KSP stays in your plugins block because its version is paired to your Kotlin version; when it is missing, the build fails with the exact line to add.
-
-[Java]
-
-```kotlin
-plugins {
- java
- id("st.orm") version "@@STORM_VERSION@@"
-}
-```
-
-That is the entire Storm setup, including the `--enable-preview` flags on compilation, tests, and execution that storm-java21's String Templates (JEP 430) require. Use a JDK 21 toolchain: preview class files are version-locked, so storm-java21 runs on JDK 21 exactly.
-The plugin configures, per language path:
-
-| | Kotlin | Java |
-|---|--------|------|
-| BOM | `storm-bom` imported as a platform | `storm-bom` imported as a platform |
-| API | `storm-kotlin` | `storm-java21` |
-| Engine | `storm-core` (runtime only) | `storm-core` (runtime only) |
-| Metamodel | `storm-metamodel-ksp` on `ksp` | `storm-metamodel-processor` on `annotationProcessor` |
-| Compiler plugin | `storm-compiler-plugin-` matching the Kotlin version | — |
-| Compiler flags | — | `--enable-preview` on compile, test, and exec tasks |
-
-All Storm coordinates use the plugin's own version; the plugin and the artifacts are released together. Because the BOM is imported, optional modules stay version-less: `runtimeOnly("st.orm:storm-postgresql")` just works.
-
-The `storm { }` extension covers the cases where the defaults do not fit:
-
-```kotlin
-storm {
- metamodel.set(true) // metamodel generation (default true)
- compilerPlugin.set(true) // Kotlin: Storm compiler plugin (default true)
- compilerPluginVariant.set("2.4") // pin the variant, e.g. for a newer Kotlin than the plugin knows
- javaPreview.set(true) // Java: --enable-preview flags (default true)
-}
-```
-
-In mixed Kotlin/Java projects the Kotlin path wins: KSP processes Java declarations too. If you specifically need the Java annotation processor as well, add `annotationProcessor("st.orm:storm-metamodel-processor")` manually.
-
-Maven users and Gradle users who prefer explicit configuration continue with the manual setup below.
-
-## Add the BOM
-
-Storm provides a Bill of Materials (BOM) for centralized version management. Import the BOM once, then omit version numbers from individual Storm dependencies. This prevents version mismatches between modules.
-
-[Kotlin]
-
-```kotlin
-dependencies {
- implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@"))
-}
-```
-
-[Java]
-
-**Maven:**
-
-```xml
-
-
-
- st.orm
- storm-bom
- @@STORM_VERSION@@
- pom
- import
-
-
-
-```
-
-**Gradle (Kotlin DSL):**
-
-```kotlin
-dependencies {
- implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@"))
-}
-```
-## Add the Core Dependencies
-
-[Kotlin]
-
-```kotlin
-plugins {
- id("com.google.devtools.ksp") version "2.0.21-1.0.28"
-}
-
-dependencies {
- implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@"))
-
- implementation("st.orm:storm-kotlin")
- runtimeOnly("st.orm:storm-core")
- ksp("st.orm:storm-metamodel-ksp")
- kotlinCompilerPluginClasspath("st.orm:storm-compiler-plugin-2.0")
-}
-```
-
-The `storm-metamodel-ksp` dependency generates type-safe metamodel classes (e.g., `User_`, `City_`) at compile time. See [Metamodel](metamodel.md) for details. The `storm-compiler-plugin` automatically wraps string interpolations inside SQL template lambdas, making queries injection-safe by default. The `2.0` suffix matches the Kotlin major.minor version used in your project (e.g., `storm-compiler-plugin-2.1` for Kotlin 2.1.x). See [String Templates](string-templates.md) for details.
-
-[Java]
-
-**Gradle (Kotlin DSL):**
-
-```kotlin
-dependencies {
- implementation(platform("st.orm:storm-bom:@@STORM_VERSION@@"))
-
- implementation("st.orm:storm-java21")
- runtimeOnly("st.orm:storm-core")
- annotationProcessor("st.orm:storm-metamodel-processor")
-}
-
-tasks.withType {
- options.compilerArgs.add("--enable-preview")
-}
-
-tasks.withType {
- jvmArgs("--enable-preview")
-}
-```
-
-**Maven:**
-
-```xml
-
-
- st.orm
- storm-java21
-
-
- st.orm
- storm-core
- runtime
-
-
- st.orm
- storm-metamodel-processor
- provided
-
-
-```
-
-Enable preview features for String Templates (JEP 430):
-
-```xml
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
- 21
-
- --enable-preview
-
-
-
-```
-The metamodel processor generates type-safe metamodel classes (e.g., `User_`, `City_`) at compile time. See [Metamodel](metamodel.md) for details.
-
-## Optional Modules
-
-Storm is modular. Add only what you need.
-
-### Database Dialects
-
-Storm works with any JDBC-compatible database out of the box. Dialect modules provide database-specific optimizations (e.g., native upsert syntax, tuple comparisons). Add the one that matches your database as a runtime dependency:
-
-| Module | Database |
-|--------|----------|
-| `storm-oracle` | Oracle |
-| `storm-mssqlserver` | SQL Server |
-| `storm-postgresql` | PostgreSQL |
-| `storm-mysql` | MySQL |
-| `storm-mariadb` | MariaDB |
-| `storm-sqlite` | SQLite |
-| `storm-h2` | H2 |
-
-```kotlin
-runtimeOnly("st.orm:storm-postgresql")
-```
-
-See [Database Dialects](dialects.md) for what each dialect provides.
-
-### Spring Boot Integration
-
-For Spring Boot applications, use the starter modules instead of the base modules. The starters auto-configure the `ORMTemplate` bean, enable repository scanning, and integrate with Spring's transaction management. See [Spring Integration](spring-integration.md) for full setup details.
-
-[Kotlin]
-
-```kotlin
-implementation("st.orm:storm-kotlin-spring-boot-starter")
-```
-
-[Java]
-
-```xml
-
- st.orm
- storm-spring-boot-starter
-
-```
-### Ktor Integration
-
-For Ktor applications, add the Ktor plugin module. It provides a `Storm` plugin that manages the DataSource lifecycle, reads HOCON configuration, and exposes the `ORMTemplate` through extension properties on `Application`, `ApplicationCall`, and `RoutingContext`. See [Ktor Integration](ktor-integration.md) for full setup details.
-
-```kotlin
-implementation("st.orm:storm-ktor")
-```
-
-For testing:
-
-```kotlin
-testImplementation("st.orm:storm-ktor-test")
-```
-
-### JSON Support
-
-Storm supports storing and reading JSON-typed columns. Pick the module that matches your serialization library:
-
-| Module | Library |
-|--------|---------|
-| `storm-jackson2` | Jackson 2.17+ (Spring Boot 3.x) |
-| `storm-jackson3` | Jackson 3.0+ (Spring Boot 4+) |
-| `storm-kotlinx-serialization` | Kotlinx Serialization |
-
-See [JSON Support](json.md) for usage details.
-
-### Observability
-
-| Module | Provides |
-|--------|----------|
-| `storm-micrometer` | Micrometer Observations for queries and transactions (`storm.query`, `storm.transaction`), the OpenTelemetry database semantic conventions, and trace-context SQL comments |
-
-The Spring Boot starters include `storm-micrometer`; Ktor applications add it explicitly. See the observability sections of [Spring Integration](spring-integration.md#observability) and [Ktor Integration](ktor-integration.md#observability).
-
-### Testing
-
-| Module | Provides |
-|--------|----------|
-| `storm-test` | `@StormTest` JUnit 5 extension and `SqlCapture`, framework-free |
-| `storm-spring-boot-test-autoconfigure` | The `@DataStormTest` Spring Boot test slice (test scope) |
-
-See [Testing](testing.md) and [Testing with @DataStormTest](spring-integration.md#testing-with-datastormtest).
-
-## Module Overview
-
-The following diagram shows how Storm's modules relate to each other. You only need the modules relevant to your language and integration choices.
-
-```
-storm-foundation (base interfaces)
-└── storm-kotlin / storm-java21 (your primary dependency)
- ├── storm-kotlin-spring / storm-spring (Spring Framework)
- │ └── storm-kotlin-spring-boot-starter / storm-spring-boot-starter
- ├── storm-ktor (Ktor)
- │ └── storm-ktor-test (testing support)
- ├── dialect modules (postgresql, mysql, mariadb, oracle, mssqlserver, sqlite, h2)
- └── JSON modules (jackson2, jackson3, kotlinx-serialization)
-```
-
-## Next Steps
-
-With Storm installed, you are ready to define your first entity and run your first query:
-
-- [First Entity](first-entity.md) -- define an entity, create an ORM template, insert and fetch a record
-- [First Query](first-query.md) -- custom queries, repositories, and type-safe filtering
-
-
-========================================
-## Source: first-entity.md
-========================================
-
-# First Entity
-This guide walks you through defining your first Storm entity, creating an ORM template, and performing basic CRUD operations. By the end, you will have inserted a record into the database and read it back.
-
-## Define an Entity
-
-Storm entities are plain data classes (Kotlin) or records (Java) that implement the `Entity` interface. Annotate the primary key with `@PK` and foreign keys with `@FK`. Storm maps field names to column names automatically using camelCase-to-snake_case conversion, so no XML or additional configuration is needed.
-
-[Kotlin]
-
-```kotlin
-data class City(
- @PK val id: Int = 0,
- val name: String,
- val population: Long
-) : Entity
-
-data class User(
- @PK val id: Int = 0,
- val email: String,
- val name: String,
- @FK val city: City
-) : Entity
-```
-
-Non-nullable fields (like `city: City`) produce `INNER JOIN` queries. Nullable fields (like `city: City?`) produce `LEFT JOIN` queries. Kotlin's type system maps directly to Storm's null handling.
-
-[Java]
-
-```java
-@Builder(toBuilder = true)
-record City(@PK Integer id,
- String name,
- long population
-) implements Entity {}
-
-@Builder(toBuilder = true)
-record User(@PK Integer id,
- String email,
- String name,
- @FK City city
-) implements Entity {}
-```
-
-In Java, record components are non-null by default, exactly like Kotlin. Mark nullable fields with `@Nullable` (JSpecify's `org.jspecify.annotations.Nullable` or `jakarta.annotation.Nullable`); see [Defining Entities](entities.md) for the full nullability rules.
-
-The `@Builder` annotation is from [Lombok](https://projectlombok.org/) and is optional. It generates a builder that lets you construct entities without specifying the primary key, and creates modified copies via `toBuilder()`. Without Lombok, you can pass `null` as the primary key (e.g., `new City(null, "Sunnyvale", 161_884)`) or define a convenience constructor that omits it. See [Modifying Entities](entities.md#modifying-entities) for details.
-These entities map to the following database tables:
-
-| Table | Columns |
-|-------|---------|
-| `city` | `id`, `name`, `population` |
-| `user` | `id`, `email`, `name`, `city_id` |
-
-Storm automatically appends `_id` to foreign key column names. See [Entities](entities.md) for the full set of annotations, naming conventions, and customization options.
-
-## 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`.
-
-[Kotlin]
-
-Kotlin provides extension properties for concise creation:
-
-```kotlin
-// From a DataSource (most common)
-val orm = dataSource.orm
-
-// From a Connection
-val orm = connection.orm
-
-// From a JPA EntityManager
-val orm = entityManager.orm
-```
-
-[Java]
-
-Use the `ORMTemplate.of(...)` factory methods:
-
-```java
-// From a DataSource (most common)
-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.
-
-## Insert a Record
-
-[Kotlin]
-
-Storm's Kotlin API provides infix operators for a concise syntax:
-
-```kotlin
-// Insert a city -- the returned object has the database-generated ID
-val city = orm insert City(name = "Sunnyvale", population = 161_884)
-
-// Insert a user that references the city
-val user = orm insert User(
- email = "alice@example.com",
- name = "Alice",
- city = city
-)
-```
-
-The `insert` operator sends an INSERT statement, retrieves the auto-generated primary key, and returns a new instance with the key populated. You do not need to set the `id` field yourself when using `IDENTITY` generation (the default).
-
-[Java]
-
-```java
-var cities = orm.entity(City.class);
-var users = orm.entity(User.class);
-
-// Insert a city -- the returned object has the database-generated ID
-City city = cities.insertAndFetch(City.builder()
- .name("Sunnyvale")
- .population(161_884)
- .build());
-
-// Insert a user that references the city
-User user = users.insertAndFetch(User.builder()
- .email("alice@example.com")
- .name("Alice")
- .city(city)
- .build());
-```
-
-The `insertAndFetch` method sends an INSERT statement, retrieves the auto-generated primary key, and returns a new record with the key populated.
-## Read a Record
-
-[Kotlin]
-
-```kotlin
-// Find by ID
-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")
-```
-
-[Java]
-
-```java
-// Find by ID
-Optional user = orm.entity(User.class).findById(userId);
-
-// Find by field value using the metamodel (requires storm-metamodel-processor)
-Optional user = orm.entity(User.class)
- .select()
- .where(User_.email, EQUALS, "alice@example.com")
- .getOptionalResult();
-```
-When Storm loads a `User`, it automatically joins the `City` table (because `city` is marked with `@FK`) and populates the full `City` object in a single query. There is no N+1 problem.
-
-## Update a Record
-
-Since entities are immutable, you create a new instance with the changed fields and pass it to the update operation.
-
-[Kotlin]
-
-```kotlin
-val updatedUser = orm update user.copy(name = "Alice Johnson")
-```
-
-[Java]
-
-```java
-users.update(new User(user.id(), user.email(), "Alice Johnson", user.city()));
-```
-## Remove a Record
-
-[Kotlin]
-
-```kotlin
-orm remove user
-```
-
-[Java]
-
-```java
-users.remove(user);
-```
-## Transactions
-
-Wrap multiple operations in a transaction to ensure they succeed or fail together.
-
-[Kotlin]
-
-Storm provides a `transaction` block that commits on success and rolls back on exception:
-
-```kotlin
-transaction {
- val city = orm insert City(name = "Sunnyvale", population = 161_884)
- val user = orm insert User(email = "bob@example.com", name = "Bob", city = city)
-}
-```
-
-[Java]
-
-With Spring's `@Transactional`:
-
-```java
-@Transactional
-public User createUser(String email, String name, City city) {
- return orm.entity(User.class)
- .insertAndFetch(User.builder()
- .email(email)
- .name(name)
- .city(city)
- .build());
-}
-```
-See [Transactions](transactions.md) for programmatic transaction control, propagation modes, and savepoints.
-
-## Summary
-
-You have now seen the core workflow:
-
-1. Define entities as data classes or records with `@PK` and `@FK` annotations
-2. Create an `ORMTemplate` from a `DataSource`
-3. Use `insert`, `findById`, `update`, and `remove` for basic CRUD
-
-## Next Steps
-
-- [First Query](first-query.md) -- custom queries, repositories, filtering, and streaming
-- [Entities](entities.md) -- enumerations, versioning, composite keys, and naming conventions
-- [Spring Integration](spring-integration.md) -- auto-configuration and dependency injection
-
-
-========================================
-## Source: first-query.md
-========================================
-
-# First Query
-Once you can insert and fetch records (see [First Entity](first-entity.md)), the next step is querying. This page covers the query patterns you will use most often: filtering with predicates, using repositories, streaming results, and writing type-safe queries with the metamodel.
-
-## Filtering with Predicates
-
-The simplest way to query is with predicate methods directly on the ORM template or entity repository.
-
-[Kotlin]
-
-```kotlin
-val users = orm.entity()
-
-// Find all users in a 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")
-
-// Combine conditions with and / or
-val results: List = users.findAll(
- (User_.city eq city) and (User_.name like "A%")
-)
-
-// Check existence
-val exists: Boolean = users.existsById(userId)
-
-// Count
-val count: Long = users.count()
-```
-
-[Java]
-
-```java
-var users = orm.entity(User.class);
-
-// Find all users in a city
-List usersInCity = users.select()
- .where(User_.city, EQUALS, city)
- .getResultList();
-
-// Find a single user by email
-Optional user = users.select()
- .where(User_.email, EQUALS, "alice@example.com")
- .getOptionalResult();
-
-// Combine conditions with and / or
-List results = users.select()
- .where(it -> it.where(User_.city, EQUALS, city)
- .and(it.where(User_.name, LIKE, "A%")))
- .getResultList();
-
-// Check existence
-boolean exists = users.existsById(userId);
-
-// Count
-long count = users.count();
-```
-These predicate methods use the [Static Metamodel](metamodel.md) (`User_`, `City_`), which is generated at compile time. The compiler catches typos and type mismatches in field references before your code runs.
-
-## Custom Repositories
-
-For domain-specific queries that you will reuse, define a custom repository interface. This keeps query logic in a single place and makes it testable through interface substitution.
-
-[Kotlin]
-
-```kotlin
-interface UserRepository : EntityRepository {
-
- fun findByEmail(email: String): User? =
- 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).resultFlow
-}
-
-// Get the repository from the ORM template
-val userRepository = orm.repository()
-
-// Use it
-val user = userRepository.findByEmail("alice@example.com")
-val usersInCity = userRepository.findByNameInCity("Alice", city)
-```
-
-Custom repositories inherit all built-in CRUD operations (`insert`, `findById`, `update`, `remove`, etc.) from `EntityRepository`. You only add methods for domain-specific queries.
-
-[Java]
-
-```java
-interface UserRepository extends EntityRepository {
-
- default Optional findByEmail(String email) {
- return select()
- .where(User_.email, EQUALS, email)
- .getOptionalResult();
- }
-
- default List findByNameInCity(String name, City city) {
- return select()
- .where(it -> it.where(User_.city, EQUALS, city)
- .and(it.where(User_.name, EQUALS, name)))
- .getResultList();
- }
-}
-
-// Get the repository from the ORM template
-UserRepository userRepository = orm.repository(UserRepository.class);
-
-// Use it
-Optional user = userRepository.findByEmail("alice@example.com");
-```
-
-Custom repositories inherit all built-in CRUD operations from `EntityRepository`. You only add `default` methods for domain-specific queries.
-See [Repositories](repositories.md) for the full repository pattern, Spring integration, and scrolling.
-
-## Query Builder
-
-For queries that need ordering, pagination, joins, or aggregation, use the fluent query builder.
-
-[Kotlin]
-
-```kotlin
-val users = orm.entity()
-
-// Ordering and pagination
-val page = users.select()
- .where(User_.city eq city)
- .orderBy(User_.name)
- .limit(10)
- .resultList
-
-// Joins (for entities not directly referenced by @FK)
-val roles = orm.entity()
- .select()
- .innerJoin().on()
- .whereAny(UserRole_.user eq user)
- .resultList
-
-// Aggregation
-data class CityCount(val city: City, val count: Long)
-
-val counts = users.select { "${City::class}, COUNT(*)" }
- .groupBy(User_.city)
- .resultList
-```
-
-[Java]
-
-```java
-var users = orm.entity(User.class);
-
-// Ordering and pagination
-List page = users.select()
- .where(User_.city, EQUALS, city)
- .orderBy(User_.name)
- .limit(10)
- .getResultList();
-
-// Joins (for entities not directly referenced by @FK)
-List roles = orm.entity(Role.class)
- .select()
- .innerJoin(UserRole.class).on(Role.class)
- .where(UserRole_.user, EQUALS, user)
- .getResultList();
-
-// Aggregation
-record CityCount(City city, long count) {}
-
-List counts = users
- .select(CityCount.class, RAW."\{City.class}, COUNT(*)")
- .groupBy(User_.city)
- .getResultList();
-```
-See [Queries](queries.md) for the full query reference, including scrolling, distinct results, and compound field handling.
-
-## Streaming
-
-For large result sets, streaming avoids loading all rows into memory at once. Rows are fetched lazily from the database as you consume them.
-
-[Kotlin]
-
-Kotlin uses `Flow`, which provides automatic resource management through structured concurrency:
-
-```kotlin
-val users: Flow = orm.entity().select().resultFlow
-
-// Process each row
-users.collect { user -> println(user.name) }
-
-// Transform and collect
-val emails: List = users.map { it.email }.toList()
-```
-
-[Java]
-
-Java uses `Stream`, which holds an open database cursor. Always close streams to release resources:
-
-```java
-try (Stream users = orm.entity(User.class).select().getResultStream()) {
- List emails = users.map(User::email).toList();
-}
-```
-See [Batch Processing and Streaming](batch-streaming.md) for bulk operations and advanced streaming patterns.
-
-## SQL Templates
-
-When the query builder does not cover your use case (for example, CTEs, window functions, or database-specific syntax), SQL Templates give you full control over the SQL while retaining type safety and parameterized values.
-
-[Kotlin]
-
-```kotlin
-val users = orm.query {
- """SELECT ${User::class}
- FROM ${User::class}
- WHERE ${User_.city} = $city
- ORDER BY ${User_.name}"""
-}.resultList()
-```
-
-With the [Storm compiler plugin](string-templates.md), interpolated expressions are automatically processed by the template engine: entity types expand to column lists, metamodel fields resolve to column names, and values become parameterized placeholders.
-
-[Java]
-
-```java
-List users = orm.query(RAW."""
- SELECT \{User.class}
- FROM \{User.class}
- WHERE \{User_.city} = \{city}
- ORDER BY \{User_.name}""")
- .getResultList(User.class);
-```
-
-Java uses String Templates (JEP 430) with the `RAW` processor. Entity types expand to column lists, metamodel fields to column names, and values to parameterized placeholders.
-See [SQL Templates](sql-templates.md) for the full template reference.
-
-## Summary
-
-Storm provides multiple query styles that you can mix freely:
-
-| Style | Best for |
-|-------|----------|
-| Predicate methods (`find`, `findAll`) | Simple single-entity lookups |
-| Custom repositories | Reusable domain-specific queries |
-| Query builder | Ordering, pagination, joins, aggregation |
-| SQL Templates | Complex SQL, CTEs, window functions |
-
-Start with the simplest approach that fits your query. Move to a more powerful style only when needed.
-
-## Next Steps
-
-- [Queries](queries.md) -- full query reference
-- [Repositories](repositories.md) -- repository pattern and Spring integration
-- [Entities](entities.md) -- annotations, nullability, and naming conventions
-- [Relationships](relationships.md) -- one-to-one, many-to-one, many-to-many
-- [Metamodel](metamodel.md) -- compile-time type-safe field references
-
-
-========================================
-## Source: entities.md
-========================================
-
-# Entities
-Storm entities are simple data classes that map to database tables. By default, Storm applies sensible naming conventions to map entity fields to database columns automatically.
-
----
-
-## Defining Entities
-
-[Kotlin]
-
-Use Kotlin data classes with the `Entity` interface:
-
-```kotlin
-data class City(
- @PK val id: Int = 0,
- val name: String,
- val population: Long
-) : Entity
-
-data class User(
- @PK val id: Int = 0,
- val email: String,
- val birthDate: LocalDate,
- val street: String,
- val postalCode: String?,
- @FK val city: City
-) : Entity
-```
-
-[Java]
-
-Use Java records with the `Entity` interface:
-
-```java
-record City(@PK Integer id,
- String name,
- long population
-) implements Entity {}
-
-record User(@PK Integer id,
- String email,
- LocalDate birthDate,
- String street,
- @Nullable String postalCode,
- @FK City city
-) implements Entity {}
-```
----
-
-## Entity Interface
-
-Implementing the `Entity` interface is optional but required for using `EntityRepository` with built-in CRUD operations. The type parameter specifies the primary key type. Without this interface, you can still use Storm's SQL template features and query builder, but you lose the convenience methods like `findById`, `insert`, `update`, and `remove`. If you only need read access, consider using `Projection` instead (see [Projections](projections.md)).
-
-Storm also supports polymorphic entity hierarchies using sealed interfaces. A sealed interface extending `Entity` can define multiple record subtypes, enabling Single-Table or Joined Table inheritance with compile-time exhaustive pattern matching. See [Polymorphism](polymorphism.md) for details.
-
----
-
-## Nullability
-
-[Kotlin]
-
-Kotlin's type system maps directly to Storm's null handling. A non-nullable field produces an `INNER JOIN` for foreign keys and a `NOT NULL` expectation for columns. A nullable field produces a `LEFT JOIN` for foreign keys and allows `NULL` values from the database. This means your entity definition fully describes the expected schema constraints.
-
-Use nullable types (`?`) to indicate nullable fields:
-
-```kotlin
-data class User(
- @PK val id: Int = 0,
- val email: String, // Non-nullable
- val birthDate: LocalDate, // Non-nullable
- val postalCode: String?, // Nullable
- @FK val city: City? // Nullable (results in LEFT JOIN)
-) : Entity
-```
-
-[Java]
-
-In Java, record components are non-null by default, exactly like Kotlin: `String` means a value is always present, and nullable is the marked case. Mark nullable fields with `@Nullable` — Storm recognizes JSpecify's `org.jspecify.annotations.Nullable` (as a type-use annotation), `jakarta.annotation.Nullable`, and `javax.annotation.Nullable`. This matches JSpecify's `@NullMarked` semantics: annotating your model package `@NullMarked` is welcome documentation for static checkers, and `@NullUnmarked` on a class or package opts back into lenient, nullable-by-default components for that scope. As with Kotlin, nullability determines JOIN behavior: a non-nullable `@FK` field — including a bare, unannotated one — produces an `INNER JOIN`, while a `@Nullable @FK` field produces a `LEFT JOIN`. If the FK column allows `NULL` in the database, annotate the field `@Nullable`, or rows without the reference are silently filtered by the inner join.
-
-```java
-record User(@PK Integer id,
- String email, // Non-nullable (default)
- LocalDate birthDate, // Non-nullable (default)
- @Nullable String postalCode, // Nullable
- @Nullable @FK City city // Nullable (results in LEFT JOIN)
-) implements Entity {}
-```
----
-
-## Primary Key Generation
-
-The `@PK` annotation supports a `generation` parameter that controls how primary key values are generated:
-
-| Strategy | Description |
-|----------|-------------|
-| `IDENTITY` | Database generates the key using an identity/auto-increment column (default) |
-| `SEQUENCE` | Database generates the key using a named sequence |
-| `NONE` | No generation; the caller must provide the key value |
-
-[Kotlin]
-
-**IDENTITY (default):**
-
-```kotlin
-data class User(
- @PK val id: Int = 0, // Database generates via auto-increment
- val name: String
-) : Entity
-```
-
-When inserting, Storm omits the PK column and retrieves the generated value:
-
-```kotlin
-val user = User(name = "Alice")
-val inserted = orm.insert(user) // Returns User with generated id
-```
-
-**SEQUENCE:**
-
-```kotlin
-data class Order(
- @PK(generation = SEQUENCE, sequence = "order_seq") val id: Long = 0,
- val total: BigDecimal
-) : Entity
-```
-
-Storm fetches the next value from the sequence before inserting.
-
-**NONE:**
-
-```kotlin
-data class Country(
- @PK(generation = NONE) val code: String, // Caller provides the value
- val name: String
-) : Entity
-```
-
-Use `NONE` when:
-- The key is a natural key (like country codes or UUIDs)
-- The key comes from an external source
-- The primary key is also a foreign key (see [Primary Key as Foreign Key](relationships.md#primary-key-as-foreign-key))
-
-[Java]
-
-**IDENTITY (default):**
-
-```java
-record User(@PK Integer id, // Database generates via auto-increment
- String name
-) implements Entity {}
-```
-
-When inserting, Storm omits the PK column and retrieves the generated value:
-
-```java
-var user = new User(null, "Alice");
-var inserted = orm.entity(User.class).insert(user); // Returns User with generated id
-```
-
-**SEQUENCE:**
-
-```java
-record Order(@PK(generation = SEQUENCE, sequence = "order_seq") Long id,
- BigDecimal total
-) implements Entity {}
-```
-
-Storm fetches the next value from the sequence before inserting.
-
-**NONE:**
-
-```java
-record Country(@PK(generation = NONE) String code, // Caller provides the value
- String name
-) implements Entity {}
-```
-
-Use `NONE` when:
-- The key is a natural key (like country codes or UUIDs)
-- The key comes from an external source
-- The primary key is also a foreign key (see [Primary Key as Foreign Key](relationships.md#primary-key-as-foreign-key))
----
-
-## 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.
-
-[Kotlin]
-
-```kotlin
-data class UserRolePk(
- val userId: Int,
- val roleId: Int
-)
-
-data class UserRole(
- @PK val userRolePk: UserRolePk,
- @FK val user: User,
- @FK val role: Role
-) : Entity
-```
-
-[Java]
-
-```java
-record UserRolePk(int userId, int roleId) {}
-
-record UserRole(@PK UserRolePk userRolePk,
- @FK User user,
- @FK Role role
-) implements Entity {}
-```
----
-
-## Foreign Keys
-
-The `@FK` annotation marks a field as a foreign key reference to another table-backed type (entity, projection, or data class with a `@PK`). Storm uses these annotations to automatically generate JOINs when querying and to derive column names (by default, appending `_id` to the field name).
-
-[Kotlin]
-
-```kotlin
-data class User(
- @PK val id: Int = 0,
- val email: String,
- @FK val city: City // Always loaded via INNER JOIN
-) : Entity
-```
-
-[Java]
-
-```java
-record User(@PK Integer id,
- String email,
- @FK City city // Always loaded via INNER JOIN
-) implements Entity {}
-```
-> **Tip:**
-Use the full entity type (e.g., `@FK val city: City`) when you always want the related entity loaded. Use `Ref` (e.g., `@FK val city: Ref`) when you only sometimes need the related entity, when the relationship is optional, or to prevent circular dependencies. See [Refs](refs.md) for details.
-
----
-
-## Unique Keys
-
-Use `@UK` on fields that have a unique constraint in the database. The `@PK` annotation implies `@UK`, so primary key fields are automatically unique. Annotating a field with `@UK` tells Storm that the column contains unique values, which enables several framework features:
-
-1. **Type-safe lookups.** `findBy(Key, value)` and `getBy(Key, value)` return a single result without requiring a predicate. The metamodel processor generates `Metamodel.Key` instances for `@UK` fields. See [Metamodel](metamodel.md#unique-keys-uk-and-metamodelkey) for details.
-2. **Scrolling.** `@UK` fields can serve as cursor columns for `scroll(Scrollable)`. Because the values are unique, the cursor position is always unambiguous. See [Scrolling](pagination-and-scrolling.md#scrolling).
-3. **Schema validation.** When [schema validation](validation.md) is enabled, Storm checks that the database actually has a matching unique constraint for each `@UK` field and reports a warning if it is missing.
-
-[Kotlin]
-
-```kotlin
-data class User(
- @PK val id: Int = 0,
- @UK val email: String,
- val name: String
-) : Entity
-```
-
-[Java]
-
-```java
-record User(@PK Integer id,
- @UK String email,
- String name
-) implements Entity {}
-```
-### Compound Unique Keys
-
-For compound unique constraints that need a metamodel key (e.g., for keyset pagination or type-safe lookups), use an inline record annotated with `@UK`. When the compound key columns overlap with other fields on the entity, use `@Persist(insertable = false, updatable = false)` to prevent duplicate persistence:
-
-[Kotlin]
-
-```kotlin
-data class UserEmailUk(val userId: Int, val email: String)
-
-data class SomeEntity(
- @PK val id: Int = 0,
- @FK val user: User,
- val email: String,
- @UK @Persist(insertable = false, updatable = false) val uniqueKey: UserEmailUk
-) : Entity
-```
-
-[Java]
-
-```java
-record UserEmailUk(int userId, String email) {}
-
-record SomeEntity(@PK Integer id,
- @FK User user,
- String email,
- @UK @Persist(insertable = false, updatable = false) UserEmailUk uniqueKey
-) implements Entity {}
-```
-Compound unique constraints that do not require a metamodel key do not need to be modeled in the entity. Schema validation does not warn about unmodeled compound constraints.
-
-Use `@UK(constraint = false)` when the unique constraint does not exist in the database — for example, when uniqueness is enforced at the application level.
-
-When a column is not annotated with `@UK` but becomes unique in a specific query context (for example, a GROUP BY column produces unique values in the result set), wrap the metamodel with `.key()` (Kotlin) or `Metamodel.key()` (Java) to indicate it can serve as a scrolling cursor. See [Manual Key Wrapping](metamodel.md#manual-key-wrapping) for details.
-
----
-
-## Embedded Components
-
-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.
-
-[Kotlin]
-
-Use data classes for embedded components:
-
-```kotlin
-data class Address(
- val street: String? = null,
- @FK val city: City? = null
-)
-
-data class Owner(
- @PK val id: Int = 0,
- val firstName: String,
- val lastName: String,
- val address: Address,
- val telephone: String?
-) : Entity
-```
-
-[Java]
-
-Use records for embedded components:
-
-```java
-record Address(@Nullable String street,
- @Nullable @FK City city) {}
-
-record Owner(@PK Integer id,
- String firstName,
- String lastName,
- Address address,
- @Nullable String telephone
-) implements Entity {}
-```
-### `@Persist` Propagation on Embedded Components
-
-When `@Persist` is placed on an embedded component field, it propagates to all child fields within that component. This is useful when the embedded component's columns overlap with other fields on the entity and should not be persisted separately. Child fields can override the inherited `@Persist` with their own annotation.
-
-[Kotlin]
-
-```kotlin
-data class OwnerCityKey(val ownerId: Int, val cityId: Int)
-
-data class Pet(
- @PK val id: Int = 0,
- val name: String,
- @FK val owner: Owner,
- @FK val city: City,
- @Persist(insertable = false, updatable = false) val ownerCityKey: OwnerCityKey
-) : Entity
-```
-
-In this example, the `owner` and `city` foreign keys define the actual persisted columns. The `ownerCityKey` inline record maps to the same underlying columns but is excluded from INSERT and UPDATE statements because its child fields inherit `@Persist(insertable = false, updatable = false)` from the parent field.
-
-[Java]
-
-```java
-record OwnerCityKey(int ownerId, int cityId) {}
-
-record Pet(@PK Integer id,
- String name,
- @FK Owner owner,
- @FK City city,
- @Persist(insertable = false, updatable = false) OwnerCityKey ownerCityKey
-) implements Entity {}
-```
-
-In this example, the `owner` and `city` foreign keys define the actual persisted columns. The `ownerCityKey` inline record maps to the same underlying columns but is excluded from INSERT and UPDATE statements because its child fields inherit `@Persist(insertable = false, updatable = false)` from the parent field.
----
-
-## Enumerations
-
-Storm persists enum values as their `name()` string by default, which is readable and resilient to reordering. If storage efficiency is a priority or your schema uses integer columns for enums, you can switch to ordinal storage with `@DbEnum(ORDINAL)`. Be aware that ordinal storage is sensitive to the order of enum constants: adding or reordering values will break existing data.
-
-[Kotlin]
-
-Enums are stored by their name by default:
-
-```kotlin
-enum class RoleType {
- USER,
- ADMIN
-}
-
-data class Role(
- @PK val id: Int = 0,
- val name: String,
- val type: RoleType // Stored as "USER" or "ADMIN"
-) : Entity
-```
-
-To store by ordinal:
-
-```kotlin
-data class Role(
- @PK val id: Int = 0,
- val name: String,
- @DbEnum(ORDINAL) val type: RoleType // Stored as 0 or 1
-) : Entity
-```
-
-[Java]
-
-Enums are stored by their name by default:
-
-```java
-enum RoleType {
- USER,
- ADMIN
-}
-
-record Role(@PK Integer id,
- String name,
- RoleType type // Stored as "USER" or "ADMIN"
-) implements Entity {}
-```
-
-To store by ordinal:
-
-```java
-record Role(@PK Integer id,
- String name,
- @DbEnum(ORDINAL) RoleType type // Stored as 0 or 1
-) implements Entity {}
-```
----
-
-## Converters
-
-When an entity field uses a type that is not directly supported by the JDBC driver, use `@Convert` to specify a converter that transforms between your domain type and a JDBC-compatible column type. Storm also supports auto-apply converters via `@DefaultConverter`, which automatically apply to all matching field types without requiring explicit annotations.
-
-[Kotlin]
-
-```kotlin
-data class Money(val amount: BigDecimal)
-
-@DbTable("product")
-data class Product(
- @PK val id: Int = 0,
- val name: String,
- @Convert(converter = MoneyConverter::class) val price: Money
-) : Entity
-```
-
-[Java]
-
-```java
-record Money(BigDecimal amount) {}
-
-@DbTable("product")
-record Product(@PK Integer id,
- String name,
- @Convert(converter = MoneyConverter.class) Money price
-) implements Entity {}
-```
-See [Converters](converters.md) for the full `Converter` interface, auto-apply with `@DefaultConverter`, resolution order, and practical examples.
-
----
-
-## Versioning (Optimistic Locking)
-
-Optimistic locking prevents lost updates when multiple users or threads modify the same record concurrently. Storm checks the version value during updates: if another transaction has already changed the row, the update fails with an exception rather than silently overwriting the other change. You can use either an integer counter or a timestamp.
-
-[Kotlin]
-
-Use `@Version` for optimistic locking:
-
-```kotlin
-data class Owner(
- @PK val id: Int = 0,
- val firstName: String,
- val lastName: String,
- @Version val version: Int
-) : Entity
-```
-
-Timestamps are also supported:
-
-```kotlin
-data class Visit(
- @PK val id: Int = 0,
- val visitDate: LocalDate,
- val description: String? = null,
- @FK val pet: Pet,
- @Version val timestamp: Instant?
-) : Entity
-```
-
-[Java]
-
-Use `@Version` for optimistic locking:
-
-```java
-record Owner(@PK Integer id,
- String firstName,
- String lastName,
- @Version int version
-) implements Entity {}
-```
-
-Timestamps are also supported:
-
-```java
-record Visit(@PK Integer id,
- LocalDate visitDate,
- @Nullable String description,
- @FK Pet pet,
- @Nullable @Version Instant timestamp
-) implements Entity {}
-```
----
-
-## Non-Updatable Fields
-
-Some fields should be set once at creation and never changed by the application, such as creation timestamps, entity types, or references that define an object's identity. Marking a field with `@Persist(updatable = false)` tells Storm to include it in INSERT statements but exclude it from UPDATE statements.
-
-[Kotlin]
-
-Use `@Persist(updatable = false)` for fields that should only be set on insert:
-
-```kotlin
-data class Pet(
- @PK val id: Int = 0,
- val name: String,
- @Persist(updatable = false) val birthDate: LocalDate,
- @FK @Persist(updatable = false) val type: PetType,
- @FK val owner: Owner? = null
-) : Entity
-```
-
-[Java]
-
-Use `@Persist(updatable = false)` for fields that should only be set on insert:
-
-```java
-record Pet(@PK Integer id,
- String name,
- @Persist(updatable = false) LocalDate birthDate,
- @FK @Persist(updatable = false) PetType type,
- @Nullable @FK Owner owner
-) implements Entity {}
-```
----
-
-## Modifying Entities
-
-Since Storm entities are immutable, updating a field means creating a new instance with the changed value. Kotlin data classes have a built-in `copy()` method for this. Java records do not provide an equivalent, but Lombok's `@Builder(toBuilder = true)` annotation generates a builder that copies all fields from an existing instance:
-
-```java
-@Builder(toBuilder = true)
-record User(@PK Integer id,
- String email,
- String name,
- @FK City city
-) implements Entity {}
-```
-
-This enables `user.toBuilder().email("new@example.com").build()` to create a modified copy. See the [FAQ](faq.md#how-do-i-modify-a-java-record-entity) for alternative approaches and upcoming Java language features.
-
----
-
-## Naming Conventions
-
-Storm uses pluggable name resolvers to convert Kotlin/Java names to database identifiers. By default, camelCase names are converted to snake_case, and foreign key fields append `_id`.
-
-### Default Conversion: CamelCase to Snake_Case
-
-The default resolver converts camelCase to snake_case:
-
-1. Convert the first character to lowercase
-2. Insert an underscore before each uppercase letter and convert it to lowercase
-
-| Field/Class | Resolved Name |
-|-------------|---------------|
-| `id` | `id` |
-| `email` | `email` |
-| `birthDate` | `birth_date` |
-| `postalCode` | `postal_code` |
-| `firstName` | `first_name` |
-| `UserRole` | `user_role` |
-
-For foreign keys, `_id` is appended after the conversion:
-
-| FK Field | Resolved Column |
-|----------|-----------------|
-| `city` | `city_id` |
-| `petType` | `pet_type_id` |
-| `homeAddress` | `home_address_id` |
-
-For details on customizing name resolution (uppercase conversion, custom resolvers, composable wrappers), see [Naming Conventions](configuration.md#naming-conventions).
-
-### Per-Entity and Per-Field Overrides
-
-Annotation overrides (`@DbTable`, `@DbColumn`, and the string parameters on `@PK` and `@FK`) always take precedence over configured resolvers. See [Custom Table and Column Names](#custom-table-and-column-names) for details and examples.
-
-### Identifier Escaping
-
-Storm automatically escapes identifiers that are SQL reserved words or contain special characters. Force escaping with the `escape` parameter:
-
-[Kotlin]
-
-```kotlin
-@DbTable("order", escape = true) // "order" is a reserved word
-data class Order(
- @PK val id: Int = 0,
- @DbColumn("select", escape = true) val select: String // "select" is reserved
-) : Entity
-```
-
-[Java]
-
-```java
-@DbTable(value = "order", escape = true) // "order" is a reserved word
-record Order(@PK Integer id,
- @DbColumn(value = "select", escape = true) String select // "select" is reserved
-) implements Entity {}
-```
----
-
-## Custom Table and Column Names
-
-When the database schema does not follow Storm's default camelCase-to-snake_case convention, use annotations to specify the exact names. `@DbTable` overrides the table name, `@DbColumn` overrides a column name, and the string parameter on `@PK` or `@FK` overrides their respective column names. These annotations take precedence over any configured name resolver.
-
-[Kotlin]
-
-```kotlin
-@DbTable("app_users")
-data class User(
- @PK("user_id") val id: Int = 0,
- @DbColumn("email_address") val email: String,
- @FK("home_city_id") val city: City
-) : Entity
-```
-
-[Java]
-
-```java
-@DbTable("app_users")
-record User(@PK("user_id") Integer id,
- @DbColumn("email_address") String email,
- @FK("home_city_id") City city
-) implements Entity {}
-```
----
-
-## Column Mapping
-
-Storm automatically maps fields to columns using these conventions:
-
-| Entity Field | Database Column |
-|--------------|-----------------|
-| `id` | `id` |
-| `email` | `email` |
-| `birthDate` | `birth_date` |
-| `postalCode` | `postal_code` |
-| `city` (FK) | `city_id` |
-
-CamelCase field names are converted to snake_case column names. Foreign keys automatically append `_id` and reference the primary key of the related entity.
-
----
-
-## Join Behavior
-
-Nullability affects how relationships are loaded:
-
-- **Non-nullable FK:** INNER JOIN (referenced entity must exist)
-- **Nullable FK:** LEFT JOIN (referenced entity may be null)
-
----
-
-## Suppressing Schema Validation
-
-To suppress constraint-specific warnings (missing primary key, foreign key, or unique constraint), use the `constraint` attribute on `@PK`, `@FK`, or `@UK`. This is more targeted than `@DbIgnore` because it only suppresses the constraint check while preserving all other validation (column existence, type compatibility, nullability). See [Constraint Validation](validation.md#constraint-validation) for details and examples.
-
-Use `@DbIgnore` to suppress [schema validation](configuration.md#schema-validation) for an entity or a specific field entirely. This is useful for legacy tables, columns handled by [custom converters](converters.md), or known type mismatches that are safe at runtime.
-
-[Kotlin]
-
-```kotlin
-// Suppress all schema validation for a legacy entity.
-@DbIgnore
-data class LegacyUser(
- @PK val id: Int = 0,
- val name: String
-) : Entity
-
-// Suppress schema validation for a specific field.
-data class User(
- @PK val id: Int = 0,
- val name: String,
- @DbIgnore("DB uses FLOAT, but column only stores whole numbers")
- val age: Int
-) : Entity
-```
-
-[Java]
-
-```java
-// Suppress all schema validation for a legacy entity.
-@DbIgnore
-record LegacyUser(@PK Integer id,
- String name
-) implements Entity {}
-
-// Suppress schema validation for a specific field.
-record User(@PK Integer id,
- String name,
- @DbIgnore("DB uses FLOAT, but column only stores whole numbers")
- Integer age
-) implements Entity {}
-```
-The optional `value` parameter documents why the mismatch is acceptable. When placed on an embedded component field, `@DbIgnore` suppresses validation for all columns within that component.
-
-
-========================================
-## Source: projections.md
-========================================
-
-# Projections
-## What Are Projections?
-
-Projections are **read-only** data structures for the query side of your application. A projection can map a database view, a subset of a table's columns, or the result of a custom SQL query defined with `@ProjectionQuery`. Like entities, they are plain Kotlin data classes or Java records with no proxies and no bytecode manipulation, so you get purpose-built read models without writing a DTO mapping layer by hand. Unlike entities, projections support only read operations: no insert, update, or remove.
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ Entity vs Projection │
-├─────────────────────────────────────────────────────────────────────┤
-│ │
-│ Entity Projection │
-│ ─────────── ────────────── │
-│ - Full CRUD operations - Read-only operations │
-│ - Represents a database table - Represents a query result │
-│ - Primary key required - Primary key optional │
-│ - Dirty checking supported - No dirty checking needed │
-│ │
-└─────────────────────────────────────────────────────────────────────┘
-```
-
-## When to Use Projections
-
-**Database views:** Represent database views or materialized views as first-class types in your application.
-
-**Lightweight table reads:** Map a subset of a table's columns for list views, dropdowns, and search results without loading full entities.
-
-**Complex reusable queries:** Use `@ProjectionQuery` to define projections backed by complex SQL involving joins, aggregations, or subqueries that you want to reuse across your application.
-
-For simple ad-hoc queries or one-off aggregations, prefer using a plain data class. Projections are best suited for reusable, view-like structures. See [SQL Templates](sql-templates.md) for details.
-
----
-
-## Defining a Projection
-
-A projection is a data class (Kotlin) or record (Java) that implements `Projection`, where `ID` is the type of the primary key. Use `Projection` when the projection has no primary key.
-
-### Basic Projection with Primary Key
-
-[Kotlin]
-
-```kotlin
-@DbTable("owner")
-data class OwnerView(
- @PK val id: Int,
- val firstName: String,
- val lastName: String,
- val telephone: String?
-) : Projection
-```
-
-[Java]
-
-```java
-@DbTable("owner")
-record OwnerView(
- @PK Integer id,
- String firstName,
- String lastName,
- @Nullable String telephone
-) implements Projection {}
-```
-By default, Storm derives the table name from the class name using camelCase to snake_case conversion, so `OwnerView` would map to `owner_view`. The `@DbTable` annotation points the projection at the `owner` table instead, so it reads a subset of that table's columns. Leave the annotation out when the class name already matches the view or table you are mapping.
-
-### Projection Without Primary Key
-
-When a projection doesn't need a primary key (e.g., aggregation results), use `Projection`:
-
-[Kotlin]
-
-```kotlin
-data class VisitSummary(
- val visitDate: LocalDate,
- val description: String?,
- val petName: String
-) : Projection
-```
-
-[Java]
-
-```java
-record VisitSummary(
- LocalDate visitDate,
- @Nullable String description,
- String petName
-) implements Projection {}
-```
-This projection reads from a `visit_summary` view, following the default class name to table name conversion.
-
-### Projection with Foreign Keys
-
-Projections can reference entities or other projections using `@FK`:
-
-[Kotlin]
-
-```kotlin
-@DbTable("pet")
-data class PetView(
- @PK val id: Int,
- val name: String,
- @FK val owner: OwnerView // References another projection
-) : Projection
-```
-
-[Java]
-
-```java
-@DbTable("pet")
-record PetView(@PK Integer id,
- String name,
- @FK OwnerView owner // References another projection
-) implements Projection {}
-```
-Storm automatically joins the related table and populates the nested projection.
-
-### Projection with Custom SQL
-
-Use `@ProjectionQuery` to define a projection backed by custom SQL:
-
-[Kotlin]
-
-```kotlin
-@ProjectionQuery("""
- SELECT b.id, COUNT(*) AS item_count, SUM(i.price) AS total_price
- FROM basket b
- JOIN basket_item bi ON bi.basket_id = b.id
- JOIN item i ON bi.item_id = i.id
- GROUP BY b.id
-""")
-data class BasketSummary(
- @PK val id: Int,
- val itemCount: Int,
- val totalPrice: BigDecimal
-) : Projection
-```
-
-[Java]
-
-```java
-@ProjectionQuery("""
- SELECT b.id, COUNT(*) AS item_count, SUM(i.price) AS total_price
- FROM basket b
- JOIN basket_item bi ON bi.basket_id = b.id
- JOIN item i ON bi.item_id = i.id
- GROUP BY b.id
- """)
-record BasketSummary(
- @PK Integer id,
- int itemCount,
- BigDecimal totalPrice
-) implements Projection {}
-```
-This is useful for aggregations, complex joins, or mapping database views.
-
----
-
-## Querying Projections
-
-### Getting a ProjectionRepository
-
-Obtain a `ProjectionRepository` from the ORM template. This is the read-only counterpart to `EntityRepository`. It provides find, select, count, and existence-check operations, but no insert, update, or remove.
-
-[Kotlin]
-
-```kotlin
-val ownerViews = orm.projection()
-```
-
-[Java]
-
-```java
-ProjectionRepository ownerViews = orm.projection(OwnerView.class);
-```
-### Basic Operations
-
-The `ProjectionRepository` supports the same query patterns as `EntityRepository`, minus write operations. Results are plain data objects with no proxy behavior or session attachment.
-
-[Kotlin]
-
-```kotlin
-// Count all
-val count = ownerViews.count()
-
-// Find by primary key (returns null if not found)
-val foundOwner = ownerViews.findById(1)
-
-// Get by primary key (throws if not found)
-val owner = ownerViews.getById(1)
-
-// Check existence
-val exists = ownerViews.existsById(1)
-
-// Fetch all as a list
-val allOwners = ownerViews.findAll()
-
-// Fetch all as a lazy Flow (collect from a coroutine)
-ownerViews.select().resultFlow.collect { owner ->
- println(owner.firstName)
-}
-```
-
-[Java]
-
-```java
-// Count all
-long count = ownerViews.count();
-
-// Find by primary key (empty Optional if not found)
-Optional foundOwner = ownerViews.findById(1);
-
-// Get by primary key (throws if not found)
-OwnerView owner = ownerViews.getById(1);
-
-// Check existence
-boolean exists = ownerViews.existsById(1);
-
-// Fetch all as a list
-List allOwners = ownerViews.findAll();
-
-// Fetch all as a stream (must close)
-try (Stream owners = ownerViews.select().getResultStream()) {
- owners.forEach(o -> System.out.println(o.firstName()));
-}
-```
-### Query Builder
-
-Use the `select()` method for type-safe queries with the generated metamodel:
-
-[Kotlin]
-
-```kotlin
-// Filter by field value
-val owners = ownerViews.select()
- .where(OwnerView_.lastName, EQUALS, "Smith")
- .resultList
-
-// Filter with comparison operators
-val recentVisits = orm.projection().select()
- .where(VisitView_.visitDate, GREATER_THAN, LocalDate.of(2024, 1, 1))
- .resultList
-
-// Filter by nested foreign key
-val ownerPets = orm.projection().select()
- .where(PetView_.owner.id, EQUALS, 1)
- .resultList
-
-// Count with filter
-val count = ownerViews.selectCount()
- .where(OwnerView_.lastName, EQUALS, "Smith")
- .singleResult
-```
-
-[Java]
-
-```java
-// Filter by field value
-List owners = ownerViews.select()
- .where(OwnerView_.lastName, EQUALS, "Smith")
- .getResultList();
-
-// Filter with comparison operators
-List recentVisits = orm.projection(VisitView.class).select()
- .where(VisitView_.visitDate, GREATER_THAN, LocalDate.of(2024, 1, 1))
- .getResultList();
-
-// Filter by nested foreign key
-List ownerPets = orm.projection(PetView.class).select()
- .where(PetView_.owner.id, EQUALS, 1)
- .getResultList();
-```
-### Batch Operations
-
-Efficiently fetch multiple projections by ID:
-
-[Kotlin]
-
-```kotlin
-// Fetch multiple by IDs
-val ids = listOf(1, 2, 3)
-val owners = ownerViews.findAllById(ids)
-
-// Flow-based fetching (lazy evaluation, collect from a coroutine)
-ownerViews.select()
- .where(OwnerView_.id, IN, ids)
- .resultFlow
- .collect { owner ->
- // Process each owner
- }
-```
-
-[Java]
-
-```java
-// Fetch multiple by IDs
-List ids = List.of(1, 2, 3);
-List owners = ownerViews.findAllById(ids);
-
-// Stream-based batch fetching (must close)
-try (Stream stream = ownerViews.selectById(ids.stream())) {
- stream.forEach(owner -> {
- // Process each owner
- });
-}
-```
----
-
-## Choosing Between Entities and Projections
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ When to Use What │
-├─────────────────────────────────────────────────────────────────────┤
-│ │
-│ Use Entity when you need to: │
-│ • Create, update, or delete records │
-│ • Work with the full row including all columns │
-│ • Use dirty checking and optimistic locking │
-│ • Maintain referential integrity through the ORM │
-│ │
-│ Use Projection when you need to: │
-│ • Map database views or materialized views │
-│ • Read a subset of a table's columns for lists and search results │
-│ • Define reusable complex queries via @ProjectionQuery │
-│ │
-└─────────────────────────────────────────────────────────────────────┘
-```
-
-### Example: Same Table, Different Views
-
-[Kotlin]
-
-```kotlin
-// Full entity for writes
-data class Owner(
- @PK val id: Int = 0,
- val firstName: String,
- val lastName: String,
- val address: String,
- val city: String,
- val telephone: String?,
- @Version val version: Int = 0
-) : Entity
-
-// Lightweight projection for list views
-@DbTable("owner")
-data class OwnerListItem(
- @PK val id: Int,
- val firstName: String,
- val lastName: String
-) : Projection
-
-// Detailed projection for detail views
-@DbTable("owner")
-data class OwnerDetail(
- @PK val id: Int,
- val firstName: String,
- val lastName: String,
- val address: String,
- val city: String,
- val telephone: String?
-) : Projection
-```
-
-[Java]
-
-```java
-// Full entity for writes
-record Owner(@PK Integer id,
- String firstName,
- String lastName,
- String address,
- String city,
- @Nullable String telephone,
- @Version int version
-) implements Entity {}
-
-// Lightweight projection for list views
-@DbTable("owner")
-record OwnerListItem(@PK Integer id,
- String firstName,
- String lastName
-) implements Projection {}
-
-// Detailed projection for detail views
-@DbTable("owner")
-record OwnerDetail(@PK Integer id,
- String firstName,
- String lastName,
- String address,
- String city,
- @Nullable String telephone
-) implements Projection {}
-```
-Use `Owner` when creating or updating owners. Use `OwnerListItem` for displaying a list (fewer columns, faster queries). Use `OwnerDetail` for read-only detail views.
-
----
-
-## Working with Refs
-
-When a projection references another entity or projection but you do not need the full related object in every query, use `Ref` to store only the foreign key value. This avoids the cost of an additional JOIN when you only need the key. You can resolve the reference later by fetching the full object on demand.
-
-```kotlin
-@DbTable("pet")
-data class PetListItem(
- @PK val id: Int,
- val name: String,
- @FK val owner: Ref // Lightweight reference
-) : Projection
-```
-
-The `Ref` contains only the foreign key value. You can resolve it later if needed:
-
-```kotlin
-val pet = orm.projection().getById(1)
-
-// Access the foreign key without loading the owner
-val ownerId = pet.owner.projectionId() // import st.orm.template.projectionId
-
-// Load the full owner when needed
-val owner = pet.owner.fetch()
-```
-
-See [Refs](refs.md) for the full lifecycle, including detached refs and fetch semantics.
-
----
-
-## Mapping to Custom Tables
-
-By default, Storm derives the table name from the projection class name using camelCase to snake_case conversion, so `OwnerSummary` maps to `owner_summary`. Override this with `@DbTable`:
-
-```kotlin
-@DbTable("owner")
-data class OwnerSummary(
- @PK val id: Int,
- @DbColumn("first_name") val name: String
-) : Projection
-```
-
-Use `@DbColumn` to map fields to columns with different names.
-
----
-
-## ProjectionRepository Methods
-
-| Method | Description |
-|--------|-------------|
-| `count()` | Count all projections |
-| `findById(id)` | Find by primary key; returns null (Kotlin) or an empty `Optional` (Java) if not found |
-| `getById(id)` | Get by primary key, throws if not found |
-| `existsById(id)` | Check if projection exists |
-| `findAll()` | Fetch all as a list |
-| `findAllById(ids)` | Fetch multiple by IDs |
-| `select().resultFlow` | Lazy Flow of all projections (Kotlin) |
-| `select().getResultStream()` | Lazy Stream of all projections (Java) |
-| `selectById(ids)` | Lazy Stream by IDs (Java) |
-| `select()` | Query builder for filtering |
-| `selectCount()` | Query builder for counting |
-
-Note: Unlike `EntityRepository`, there are no `insert`, `update`, `remove`, or `upsert` methods. Projections are read-only.
-
----
-
-## Best Practices
-
-### 1. Keep Projections Focused
-
-Design projections for specific use cases rather than trying to reuse one projection everywhere:
-
-```kotlin
-// Good: Purpose-built projections
-@ProjectionQuery("""
- SELECT id, first_name || ' ' || last_name AS display_name
- FROM owner
-""")
-data class OwnerDropdownItem(
- @PK val id: Int,
- val displayName: String
-) : Projection
-
-@DbTable("owner")
-data class OwnerSearchResult(
- @PK val id: Int,
- val firstName: String,
- val lastName: String,
- val city: String
-) : Projection
-
-// Avoid: One projection trying to serve all purposes
-@DbTable("owner")
-data class OwnerProjection(
- @PK val id: Int,
- val firstName: String,
- val lastName: String,
- val address: String?, // Sometimes null, sometimes not
- val city: String?,
- val telephone: String?,
- val petCount: Int? // Only populated in some queries
-) : Projection
-```
-
-### 2. Use @ProjectionQuery for Complex Queries
-
-When your projection involves joins, aggregations, or subqueries, define the SQL explicitly:
-
-```kotlin
-@ProjectionQuery("""
- SELECT
- o.id,
- o.first_name,
- o.last_name,
- COUNT(p.id) AS pet_count
- FROM owner o
- LEFT JOIN pet p ON p.owner_id = o.id
- GROUP BY o.id, o.first_name, o.last_name
-""")
-data class OwnerWithPetCount(
- @PK val id: Int,
- val firstName: String,
- val lastName: String,
- val petCount: Int
-) : Projection
-```
-
-### 3. Prefer Projections for Read-Heavy Paths
-
-In read-heavy scenarios (dashboards, lists, search results), projections reduce database load:
-
-```kotlin
-// Instead of loading full entities
-val owners = orm.entity().findAll() // Loads all columns
-
-// Load only what you need
-val owners = orm.projection().findAll() // Loads 3 columns
-```
-
-### 4. Use Void for Keyless Results
-
-Aggregations and analytics often don't have a natural primary key:
-
-```kotlin
-@ProjectionQuery("""
- SELECT
- CAST(DATE_TRUNC('month', visit_date) AS DATE) AS month,
- COUNT(*) AS visit_count,
- COUNT(DISTINCT pet_id) AS unique_pets
- FROM visit
- GROUP BY DATE_TRUNC('month', visit_date)
-""")
-data class MonthlyVisitStats(
- val month: LocalDate,
- val visitCount: Int,
- val uniquePets: Int
-) : Projection // No primary key
-```
-
-### 5. Combine with Entity Graphs
-
-For complex object graphs, you can mix projections with entity relationships:
-
-```kotlin
-@DbTable("pet")
-data class PetWithOwnerSummary(
- @PK val id: Int,
- val name: String,
- val birthDate: LocalDate?,
- @FK val owner: OwnerListItem // Projection, not full entity
-) : Projection
-```
-
-This fetches pet details with a lightweight owner summary in a single query.
-
-
-========================================
-## Source: relationships.md
-========================================
-
-# Relationships
-Automatic relationship loading is a core part of Storm's design. The database owns your data model, and your entities capture it as immutable classes. When you define a foreign key, Storm automatically joins the related entity and returns complete, fully populated records in a single query.
-
-This design enables:
-
-- **Single-query loading.** One query returns the complete entity graph, so the declared relationships never cost a query each, and nothing loads behind your back.
-- **Type-safe path expressions.** Filter on joined fields with full IDE support, including auto-completion across relationships: `User_.city.name eq "Sunnyvale"`
-- **Concise syntax.** No manual joins, no fetch configuration, no lazy loading surprises.
-- **Predictable behavior.** What you define is what you get. The entity structure *is* the query structure.
-
-```kotlin
-// Define the relationships once
-data class Country(
- @PK val code: String,
- val name: String
-) : Entity
-
-data class City(
- @PK val id: Int = 0,
- val name: String,
- @FK val country: Country
-) : Entity
-
-data class User(
- @PK val id: Int = 0,
- val name: String,
- @FK val city: City // Auto-joins City, Country, and all nested relationships
-) : Entity
-
-// Query with type-safe access to nested fields throughout the entire entity graph
-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}") }
-```
-
-All relationship types are supported through the `@FK` annotation.
-
----
-
-## One-to-One / Many-to-One
-
-The most common relationship type. A foreign key field on one entity points to the primary key of another. Storm automatically generates a JOIN when querying and populates the referenced entity in the result.
-
-[Kotlin]
-
-Use `@FK` to reference another entity:
-
-```kotlin
-data class City(
- @PK val id: Int = 0,
- val name: String,
- val population: Long
-) : Entity
-
-data class User(
- @PK val id: Int = 0,
- val email: String,
- @FK val city: City // Many users belong to one city
-) : Entity
-```
-
-When you query a `User`, the related `City` is automatically loaded:
-
-```kotlin
-val user = orm.get(User_.id eq userId)
-println(user.city.name) // City is already loaded
-```
-
-[Java]
-
-Use `@FK` to reference another entity:
-
-```java
-record City(@PK Integer id,
- String name,
- long population
-) implements Entity {}
-
-record User(@PK Integer id,
- String email,
- @FK City city // Many users belong to one city
-) implements Entity {}
-```
-
-When you query a `User`, the related `City` is automatically loaded:
-
-```java
-Optional user = orm.entity(User.class)
- .select()
- .where(User_.id, EQUALS, userId)
- .getOptionalResult();
-
-user.ifPresent(u -> System.out.println(u.city().name())); // City is already loaded
-```
----
-
-## Nullable Relationships
-
-[Kotlin]
-
-When a foreign key can be null (the referenced entity is optional), Storm uses a LEFT JOIN instead of an INNER JOIN. This ensures that parent rows are still returned even when the referenced entity does not exist.
-
-```kotlin
-data class User(
- @PK val id: Int = 0,
- val email: String,
- @FK val city: City? // Nullable = LEFT JOIN
-) : Entity
-```
-
-[Java]
-
-In Java, use `@Nullable` on foreign key fields to indicate that the referenced entity is optional. Storm switches from INNER JOIN to LEFT JOIN for nullable foreign keys.
-
-```java
-record User(@PK Integer id,
- String email,
- @Nullable @FK City city // Nullable = LEFT JOIN
-) implements Entity {}
-```
----
-
-## One-to-Many
-
-Storm does not store collections on the "one" side of a relationship. Instead, query the "many" side and filter by the parent entity. This keeps entities stateless and avoids the lazy-loading pitfalls found in traditional ORMs.
-
-[Kotlin]
-
-```kotlin
-// Find all users in a city
-val usersInCity: List = orm.findAll(User_.city eq city)
-```
-
-To load parents with their children, group the query by the parent path. The same select is executed; the
-results are grouped during hydration:
-
-```kotlin
-// Load cities with their users in one query
-val usersByCity: Map> = orm.entity()
- .select()
- .orderBy(User_.city)
- .resultGroupedBy(User_.city)
-```
-
-[Java]
-
-```java
-// Find all users in a city
-List usersInCity = orm.entity(User.class)
- .select()
- .where(User_.city, EQUALS, city)
- .getResultList();
-```
-
-To load parents with their children, group the query by the parent path. The same select is executed; the
-results are grouped during hydration:
-
-```java
-// Load cities with their users in one query
-Map> usersByCity = orm.entity(User.class)
- .select()
- .orderBy(User_.city)
- .getResultGroupedBy(User_.city);
-```
-The grouped terminal returns an unmodifiable, insertion-ordered map: parents appear in the order
-their first row is encountered, children in row order within each parent. Because duplicate entities within a
-result set are guaranteed to share the same instance, each child's reference to its parent is the map key itself, and repeated
-parents are materialized once rather than once per row. The path must resolve to a non-null record for every
-result; narrow queries over nullable foreign keys with a `where()` clause first. This replaces the manual
-pattern of querying the many side and grouping in memory, and it loads the whole graph in a single query,
-without the N+1 queries or the join duplication handling that collection-based ORMs need.
-
----
-
-## Many-to-Many
-
-Use a join entity with composite primary key:
-
-[Kotlin]
-
-```kotlin
-data class UserRolePk(
- val userId: Int,
- val roleId: Int
-)
-
-data class UserRole(
- @PK val userRolePk: 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. [Write sets](write-sets.md#junction-tables) recognize this shape: a junction row referencing an unsaved entity is inserted after its parent, with the generated key propagated into the composite PK.
-
-Query through the join entity:
-
-```kotlin
-// Find all roles for a 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 users: List = userRoles.map { it.user }
-
-// Find roles for many users at once, grouped per user
-val rolesByUser: Map> = orm.entity()
- .select()
- .where(UserRole_.user inList users)
- .resultGroupedBy(UserRole_.user)
- .mapValues { (_, userRoles) -> userRoles.map { it.role } }
-```
-
-For more control, use explicit join queries:
-
-```kotlin
-val roles: List = orm.entity()
- .select()
- .innerJoin().on()
- .whereAny(UserRole_.user eq user)
- .resultList
-```
-
-[Java]
-
-```java
-record UserRolePk(int userId, int roleId) {}
-
-record UserRole(@PK UserRolePk userRolePk,
- @FK @Persist(insertable = false, updatable = false) User user,
- @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.
-
-Query through the join entity:
-
-```java
-// Find all roles for a user
-List userRoles = orm.entity(UserRole.class)
- .select()
- .where(UserRole_.user, EQUALS, user)
- .getResultList();
-
-List roles = userRoles.stream()
- .map(UserRole::role)
- .toList();
-
-// Find roles for many users at once, grouped per user
-Map> rolesByUser = orm.entity(UserRole.class)
- .select()
- .where(UserRole_.user, IN, users)
- .getResultGroupedBy(UserRole_.user);
-```
-
-For more control, use explicit join queries:
-
-```java
-List roles = orm.entity(Role.class)
- .select()
- .innerJoin(UserRole.class).on(Role.class)
- .where(UserRole_.user, EQUALS, user)
- .getResultList();
-```
----
-
-## Composite Foreign Keys
-
-When referencing an entity with a composite primary key, Storm automatically generates multi-column join conditions:
-
-[Kotlin]
-
-```kotlin
-// Entity with composite PK
-data class UserRolePk(
- val userId: Int,
- val roleId: Int
-)
-
-data class UserRole(
- @PK val pk: UserRolePk,
- @FK val user: User,
- @FK val role: Role,
- val grantedAt: Instant
-) : Entity
-
-// Entity referencing the composite PK entity
-data class AuditLog(
- @PK val id: Int = 0,
- val action: String,
- @FK val userRole: UserRole? // References entity with composite PK
-) : Entity
-```
-
-Storm generates a multi-column join condition:
-
-```sql
-LEFT JOIN user_role ur
- ON al.user_id = ur.user_id
- AND al.role_id = ur.role_id
-```
-
-**Custom column names:** Use `@DbColumn` annotations to specify custom FK column names:
-
-```kotlin
-data class AuditLog(
- @PK val id: Int = 0,
- val action: String,
- @FK @DbColumn("audit_user_id") @DbColumn("audit_role_id") val userRole: UserRole?
-) : Entity
-```
-
-[Java]
-
-```java
-// Entity with composite PK
-record UserRolePk(int userId, int roleId) {}
-
-record UserRole(@PK UserRolePk pk,
- @FK User user,
- @FK Role role,
- Instant grantedAt
-) implements Entity {}
-
-// Entity referencing the composite PK entity
-record AuditLog(@PK Integer id,
- String action,
- @Nullable @FK UserRole userRole // References entity with composite PK
-) implements Entity {}
-```
-
-Storm generates a multi-column join condition:
-
-```sql
-LEFT JOIN user_role ur
- ON al.user_id = ur.user_id
- AND al.role_id = ur.role_id
-```
-
-**Custom column names:** Use `@DbColumn` annotations to specify custom FK column names:
-
-```java
-record AuditLog(@PK Integer id,
- String action,
- @Nullable @FK @DbColumn("audit_user_id") @DbColumn("audit_role_id")
- UserRole userRole
-) implements Entity {}
-```
----
-
-## Self-Referential Relationships
-
-When an entity references itself (e.g., employees with managers, categories with parents), eager loading would recurse infinitely. Use `Ref` to break the cycle. `Ref` stores only the foreign key value without loading the referenced entity, so Storm stops the JOIN chain at that point.
-
-[Kotlin]
-
-```kotlin
-data class Employee(
- @PK val id: Int = 0,
- val name: String,
- @FK val manager: Ref? // Self-reference with Ref
-) : Entity
-```
-
-[Java]
-
-```java
-record Employee(@PK Integer id,
- String name,
- @Nullable @FK Ref manager // Self-reference with Ref
-) implements Entity {}
-```
----
-
-## Primary Key as Foreign Key
-
-Sometimes a table's primary key is also a foreign key to another entity. This is common for:
-
-- **Dependent one-to-one relationships** where a child entity cannot exist without its parent
-- **Extension tables** that add optional data to an existing entity
-- **Specialized subtypes** in a table-per-subtype inheritance strategy (see [Polymorphism](polymorphism.md))
-
-Use both `@PK` and `@FK` annotations on the same field, with `generation = NONE` since the key value comes from the related entity rather than being auto-generated:
-
-[Kotlin]
-
-```kotlin
-data class UserProfile(
- @PK(generation = NONE) @FK val user: User, // PK is also FK to User
- val bio: String?,
- val avatarUrl: String?,
- val theme: Theme?
-) : Entity
-```
-
-The `generation = NONE` tells Storm that the primary key is not auto-generated; the value must be provided when inserting. This is necessary because the key comes from the related `User` entity.
-
-**Column name resolution:** When both `@PK` and `@FK` are present, Storm resolves the column name in this order:
-
-1. Explicit name in `@PK` (e.g., `@PK("user_profile_id")`)
-2. Explicit name in `@DbColumn`
-3. Foreign key naming convention (default)
-
-For a field named `user`, the FK convention produces `user_id`. To override this, specify the name explicitly:
-
-```kotlin
-@PK("user_profile_id", generation = NONE) @FK val user: User // Uses "user_profile_id"
-```
-
-The entity's type parameter is the related entity type (`User`), not a primitive key type. This reflects that the `UserProfile` is uniquely identified by its associated `User`.
-
-When inserting, provide the related entity:
-
-```kotlin
-val profile = UserProfile(
- user = existingUser,
- bio = "Software developer",
- avatarUrl = null,
- theme = Theme.DARK
-)
-orm.insert(profile)
-```
-
-Storm extracts the primary key from the `User` entity and uses it as the value for the `user_id` column.
-
-[Java]
-
-```java
-record UserProfile(@PK(generation = NONE) @FK User user, // PK is also FK to User
- @Nullable String bio,
- @Nullable String avatarUrl,
- @Nullable Theme theme
-) implements Entity {}
-```
-
-The `generation = NONE` tells Storm that the primary key is not auto-generated; the value must be provided when inserting. This is necessary because the key comes from the related `User` entity.
-
-**Column name resolution:** When both `@PK` and `@FK` are present, Storm resolves the column name in this order:
-
-1. Explicit name in `@PK` (e.g., `@PK("user_profile_id")`)
-2. Explicit name in `@DbColumn`
-3. Foreign key naming convention (default)
-
-For a field named `user`, the FK convention produces `user_id`. To override this, specify the name explicitly:
-
-```java
-@PK(value = "user_profile_id", generation = NONE) @FK User user // Uses "user_profile_id"
-```
-
-The entity's type parameter is the related entity type (`User`), not a primitive key type. This reflects that the `UserProfile` is uniquely identified by its associated `User`.
-
-When inserting, provide the related entity:
-
-```java
-var profile = new UserProfile(existingUser, "Software developer", null, Theme.DARK);
-orm.entity(UserProfile.class).insert(profile);
-```
-
-Storm extracts the primary key from the `User` entity and uses it as the value for the `user_id` column.
-### Key Chains
-
-The referenced entity's primary key may itself be a foreign key — or a compound key record. Storm follows this *key chain* to its terminal columns. A dependent one-to-one on an entity that is itself a dependent one-to-one works the same way as the single-level case:
-
-[Kotlin]
-
-```kotlin
-data class ProfileAudit(
- @PK(generation = NONE) @FK val profile: UserProfile, // UserProfile's own PK is the FK to User
- val remark: String
-) : Entity
-```
-
-[Java]
-
-```java
-record ProfileAudit(@PK(generation = NONE) @FK UserProfile profile, // UserProfile's own PK is the FK to User
- String remark
-) implements Entity {}
-```
-The foreign key spans the same columns as the terminal key of the chain: a single-column chain resolves to one column named by the FK convention (`profile_id` here), and a compound key contributes the referenced key's column names. Circular key chains are rejected at model construction with a clear error.
-
----
-
-## Relationship Loading Behavior
-
-Storm loads the complete reachable entity graph in a single query using JOINs, unless a relationship is explicitly broken with `Ref`:
-
-```kotlin
-data class Order(
- @PK val id: Int = 0,
- @FK val customer: Customer,
- @FK val shippingAddress: Address
-) : Entity
-
-data class Customer(
- @PK val id: Int = 0,
- val name: String,
- @FK val defaultAddress: Address
-) : Entity
-```
-
-When you query `Order`:
-1. `Order` is loaded
-2. `Customer` is loaded (via JOIN)
-3. `Address` for shipping is loaded (via JOIN)
-4. `Address` for customer default is loaded (via JOIN)
-
-All in **one SQL query**. No lazy loading surprises, and no query per relationship to avoid.
-
-### How It Works
-
-Storm generates a single SELECT with all necessary JOINs:
-
-```
-┌─────────────────────────────────────────────────────────────────────┐
-│ SELECT o.id, o.customer_id, o.shipping_address_id, │
-│ c.id, c.name, c.default_address_id, │
-│ a1.id, a1.street, a1.city, │
-│ a2.id, a2.street, a2.city │
-│ FROM order o │
-│ INNER JOIN customer c ON o.customer_id = c.id │
-│ INNER JOIN address a1 ON o.shipping_address_id = a1.id │
-│ INNER JOIN address a2 ON c.default_address_id = a2.id │
-│ WHERE o.id = ? │
-└─────────────────────────────────────────────────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────────────────────────────────┐
-│ Result: Single row with all columns from all joined tables │
-│ │
-│ Storm automatically: │
-│ 1. Parses columns back into their respective entity types │
-│ 2. Constructs the complete object graph │
-│ 3. Returns a fully populated Order with nested entities │
-└─────────────────────────────────────────────────────────────────────┘
-```
-
-Storm always uses explicit column names (never `SELECT *`), ensuring predictable results even when table schemas change.
-
-### Entity Graph to JOIN Mapping
-
-Storm traverses the entity graph and generates JOINs based on FK nullability:
-
-```
-Entity Graph Generated JOINs
-───────────── ───────────────
-
-┌─────────┐ FROM order o
-│ Order │
-└────┬────┘
- │
- ├──── @FK customer ──────────────► INNER JOIN customer c
- │ (non-null) ON o.customer_id = c.id
- │ │
- │ └─ @FK defaultAddress ► INNER JOIN address a2
- │ (non-null) ON c.default_address_id = a2.id
- │
- └──── @FK shippingAddress? ──────► LEFT JOIN address a1
- (nullable) ON o.shipping_address_id = a1.id
-```
-
-**Join type is determined by nullability:**
-- Non-nullable FK -> INNER JOIN (referenced entity must exist)
-- Nullable FK -> LEFT JOIN (referenced entity may be null)
-
-**Nested FKs are joined transitively.** Storm follows the entire entity graph, joining each FK it encounters.
-
-### Why Eager Loading?
-
-Traditional ORMs use lazy loading, which causes:
-
-| Problem | Description |
-|---------|-------------|
-| **N+1 queries** | Accessing a collection triggers N additional queries |
-| **LazyInitializationException** | Accessing data outside transaction scope fails |
-| **Unpredictable performance** | Same code has different DB load depending on access patterns |
-| **Hidden complexity** | Proxied entities mask when database access occurs |
-
-Storm's approach:
-
-| Benefit | Description |
-|---------|-------------|
-| **Predictable queries** | One query per `find`/`select` operation |
-| **No session required** | Entities work anywhere, no transaction scope needed |
-| **Transparent behavior** | What you query is what you get |
-| **Simple debugging** | Easy to trace and optimize SQL |
-
-### Managing Graph Depth
-
-For deep or circular relationships, use `Ref` to break the loading chain:
-
-```kotlin
-data class Category(
- @PK val id: Int = 0,
- val name: String,
- @FK val parent: Ref? // Stops here, loads only the ID
-) : Entity
-```
-
-A `Ref` removes the join from every read but keeps the relationship queryable: you can still filter, order, and select through it with the metamodel (`Product_.category.name`, where `category` is a `Ref`), and Storm adds the join only for the query that navigates beyond the foreign key. This makes `Ref` the primary tool for keeping wide or deep graphs' reads narrow without giving up type-safe traversal.
-
-The self-reference above is navigable too: `Category_.parent.name` joins the category table to itself, so it filters on the parent's name. The typed metamodel navigates a cycle two hops deep; deeper cyclic paths are named as strings. See [Refs](refs.md), [Querying Through Refs](refs.md#querying-through-refs) and [Cyclic References](metamodel.md#cyclic-references) for details.
-
-## Tips
-
-1. **Keep entity graphs shallow.** Deep graphs mean large JOINs. Use `Ref` for optional or deep relationships.
-2. **Query the "many" side.** For one-to-many, query the child entity with a filter on the parent.
-3. **Use join entities for many-to-many.** Explicit join tables give you control over the relationship.
-4. **Match nullability to your schema.** Use nullable FKs only when the database column allows NULL.
-5. **Use Ref for circular references.** Prevents infinite recursion in self-referential entities.
-
-
-========================================
-## Source: repositories.md
-========================================
-
-# Repositories
-Entity repositories provide a high-level abstraction for managing entities in the database. They offer methods for creating, reading, updating, and deleting entities, as well as querying and filtering based on specific criteria.
-
----
-
-## Getting a Repository
-
-[Kotlin]
-
-Storm provides two ways to obtain a repository. The generic `entity()` method returns a built-in repository with standard CRUD operations. For custom query methods, define your own interface extending `EntityRepository` and retrieve it with `repository()` (covered below in Custom Repositories).
-
-```kotlin
-val orm = ORMTemplate.of(dataSource)
-
-// Generic entity repository (reified extension function, preferred)
-val userRepository = orm.entity()
-
-// Or passing the class explicitly
-val userRepository = orm.entity(User::class)
-```
-
-[Java]
-
-The Java API follows the same pattern as Kotlin. The generic `entity()` method provides standard CRUD operations; custom interfaces use `repository()`.
-
-```java
-var orm = ORMTemplate.of(dataSource);
-
-// Generic entity repository
-EntityRepository userRepository = orm.entity(User.class);
-```
----
-
-## Basic CRUD Operations
-
-[Kotlin]
-
-All CRUD operations use the entity's primary key (marked with `@PK`) for identity. Insert returns the entity with any database-generated fields populated (such as auto-increment IDs). Update and remove match by primary key. Query methods accept metamodel-based filter expressions that compile to parameterized WHERE clauses.
-
-```kotlin
-// Create
-val user = orm insert User(
- email = "alice@example.com",
- name = "Alice",
- birthDate = LocalDate.of(1990, 5, 15)
-)
-
-// 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)
-
-// Update
-orm update user.copy(name = "Alice Johnson")
-
-// Remove
-orm remove user
-
-// Remove by condition
-orm.removeBy(User_.city, city)
-
-// Remove by predicate
-orm.removeAll(User_.active eq false)
-
-// Remove all
-orm.removeAll()
-
-// Delete all (builder approach, requires unsafe() to confirm intent)
-orm.entity().delete().unsafe().executeUpdate()
-```
-
-[Java]
-
-Java CRUD operations use the fluent builder pattern. Since Java records are immutable, updates require constructing a new record instance with the changed field values.
-
-```java
-// Insert
-User user = userRepository.insertAndFetch(new User(
- null, "alice@example.com", "Alice", LocalDate.of(1990, 5, 15), city
-));
-
-// Read
-Optional found = userRepository.select()
- .where(User_.id, EQUALS, user.id())
- .getOptionalResult();
-
-List all = userRepository.select()
- .where(User_.city, EQUALS, city)
- .getResultList();
-
-// Update
-userRepository.update(new User(
- user.id(), "alice@example.com", "Alice Johnson", user.birthDate(), user.city()
-));
-
-// Remove
-userRepository.remove(user);
-
-// Remove all
-userRepository.removeAll();
-
-// Delete all (builder approach, requires unsafe() to confirm intent)
-userRepository.delete().unsafe().executeUpdate();
-```
-> **Warning:**
-Storm rejects DELETE and UPDATE queries that have no WHERE clause, throwing a `PersistenceException`. This prevents accidental bulk deletions, which is especially important because `QueryBuilder` is immutable and a lost `where()` return value would silently drop the filter. Call `unsafe()` to opt out of this check when you intentionally want to affect all rows. The `removeAll()` convenience method calls `unsafe()` internally.
-
-Storm uses dirty checking to determine which columns to include in the UPDATE statement. See [Dirty Checking](dirty-checking.md) for configuration details.
-
----
-
-## Streaming
-
-[Kotlin]
-
-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.select().resultFlow
-val count = users.count()
-
-// Collect to list
-val userList: List = users.toList()
-```
-
-[Java]
-
-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.select().getResultStream()) {
- List userIds = users.map(User::id).toList();
-}
-```
----
-
-## Unique Key Lookups
-
-When a field is annotated with `@UK`, the metamodel generates a `Metamodel.Key` instance that enables type-safe single-result lookups:
-
-[Kotlin]
-
-```kotlin
-val user: User? = userRepository.findBy(User_.email, "alice@example.com")
-val user: User = userRepository.getBy(User_.email, "alice@example.com") // throws if not found
-```
-
-[Java]
-
-```java
-Optional user = userRepository.findBy(User_.email, "alice@example.com");
-User user = userRepository.getBy(User_.email, "alice@example.com"); // throws if not found
-```
-Since `@PK` implies `@UK`, primary key fields also work with `findBy` and `getBy`.
-
-Entities loaded within a transaction are cached. See [Entity Cache](entity-cache.md) for details.
-
----
-
-## Offset-Based Pagination
-
-Storm provides built-in `Page` and `Pageable` types for offset-based pagination. These eliminate the need to write manual `LIMIT`/`OFFSET` queries or define your own page wrapper. The repository handles the count query and result slicing automatically. For query-builder-level pagination (manual offset/limit, Page with query builder), see [Pagination and Scrolling: Pagination](pagination-and-scrolling.md#pagination).
-
-### Page and Pageable
-
-A `Pageable` describes a pagination request: which page to fetch, how many results per page, and an optional sort order. A `Page` holds the results along with metadata such as the total number of matching results, the total number of pages, and navigation helpers.
-
-| `Page` field / method | Description |
-|---|---|
-| `content` | The list of results for this page |
-| `totalCount` | Total number of matching rows across all pages |
-| `pageNumber()` | Zero-based index of the current page |
-| `pageSize()` | Maximum number of elements per page |
-| `totalPages()` | Total number of pages |
-| `hasNext()` | Whether a next page exists |
-| `hasPrevious()` | Whether a previous page exists |
-| `nextPageable()` | Returns a `Pageable` for the next page (preserves sort orders) |
-| `previousPageable()` | Returns a `Pageable` for the previous page (preserves sort orders) |
-
-Create a `Pageable` using one of the factory methods:
-
-- `Pageable.ofSize(pageSize)` creates a request for the first page (page 0) with the given size.
-- `Pageable.of(pageNumber, pageSize)` creates a request for a specific page.
-- Chain `.sortBy(field)` or `.sortByDescending(field)` to add sort orders.
-
-### Basic Usage
-
-The simplest way to paginate is to call `page(pageNumber, pageSize)` on a repository. For more control over sorting, construct a `Pageable` and pass it to `page(pageable)`.
-
-[Kotlin]
-
-```kotlin
-// First page of 20 users
-val page1: Page = userRepository.page(0, 20)
-
-// Using Pageable with sort order
-val pageable = Pageable.ofSize(20).sortBy(User_.name)
-val page: Page = userRepository.page(pageable)
-
-// Navigate to next page
-if (page.hasNext()) {
- val nextPage = userRepository.page(page.nextPageable())
-}
-```
-
-[Java]
-
-```java
-// First page of 20 users
-Page page1 = userRepository.page(0, 20);
-
-// Using Pageable with sort order
-Pageable pageable = Pageable.ofSize(20).sortBy(User_.name);
-Page page = userRepository.page(pageable);
-
-// Navigate to next page
-if (page.hasNext()) {
- Page nextPage = userRepository.page(page.nextPageable());
-}
-```
-### Ref Variants
-
-Use `pageRef` to load only primary keys instead of full entities, returning a `Page][>`. This is useful when you need identifiers for a subsequent batch operation without the overhead of fetching full entity data.
-
-[Kotlin]
-
-```kotlin
-val refPage: Page][> = userRepository.pageRef(0, 20)
-```
-
-[Java]
-
-```java
-Page][> refPage = userRepository.pageRef(0, 20);
-```
----
-
-## Scrolling
-
-Repositories provide convenience methods for scrolling through result sets, where a unique column value (typically the primary key) acts as a cursor. This approach avoids the performance issues of `OFFSET` on large tables, because the database can seek directly to the cursor position using an index rather than scanning and discarding skipped rows.
-
-The key parameter must be a `Metamodel.Key`, which is generated for fields annotated with `@UK` or `@PK`. See [Metamodel](metamodel.md#unique-keys-uk-and-metamodelkey) for details.
-
-The `scroll` method accepts a `Scrollable` that captures the cursor state (key, page size, direction, and cursor values) and returns a `Window` containing the page content, informational `hasNext`/`hasPrevious` flags, and `Scrollable` navigation tokens for fetching the adjacent window. Navigation tokens (`next()`, `previous()`) are always present when the window has content; they are only `null` when the window is empty. The `hasNext` and `hasPrevious` flags indicate whether more results existed at query time, but they do not gate access to the navigation tokens. Since new data may appear after the query, the developer decides whether to follow a cursor.
-
-Create a `Scrollable` using the factory methods, then use the navigation tokens on the returned `Window` to move forward or backward:
-
-[Kotlin]
-
-```kotlin
-// First page of 20 users ordered by ID
-val window: Window = userRepository.scroll(Scrollable.of(User_.id, 20))
-
-// Next page (next() is non-null whenever the window has content)
-val next: Window = userRepository.scroll(window.next())
-
-// Previous page
-val previous: Window = userRepository.scroll(window.previous())
-
-// Optionally check hasNext/hasPrevious to decide whether to follow the cursor.
-// These flags reflect a snapshot at query time; new data may appear afterward.
-if (window.hasNext()) {
- // more results existed when the query ran
-}
-```
-
-To scroll through a filtered subset, use the query builder with `scroll` as a terminal operation. The filter and cursor conditions are combined with AND.
-
-```kotlin
-val activeWindow = userRepository.select()
- .where(User_.active, EQUALS, true)
- .scroll(Scrollable.of(User_.id, 20))
-val nextActive = userRepository.select()
- .where(User_.active, EQUALS, true)
- .scroll(activeWindow.next())
-```
-
-For backward scrolling (starting from the end of the result set), use `.backward()`:
-
-```kotlin
-val lastWindow: Window = userRepository.scroll(Scrollable.of(User_.id, 20).backward())
-```
-
-The scroll methods handle ordering internally and reject explicit `orderBy()` calls. Backward scrolling returns results in descending key order; reverse the list if you need ascending order for display. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling) for full details on ordering constraints.
-
-[Java]
-
-The same scrolling methods described in the Kotlin section are available on Java repositories. The `scroll` method accepts a `Scrollable` and returns a `Window` containing the page `content()`, informational `hasNext()`/`hasPrevious()` flags, and `Scrollable` navigation tokens (`next()`, `previous()`) that are always present when the window has content.
-
-```java
-// First page of 20 users ordered by ID
-Window window = userRepository.scroll(Scrollable.of(User_.id, 20));
-
-// Next page (next() is non-null whenever the window has content)
-Window next = userRepository.scroll(window.next());
-
-// Previous page
-Window previous = userRepository.scroll(window.previous());
-
-// Optionally check hasNext/hasPrevious to decide whether to follow the cursor.
-// These flags reflect a snapshot at query time; new data may appear afterward.
-if (window.hasNext()) {
- // more results existed when the query ran
-}
-```
-
-For filtered results, use the query builder and call `scroll` as a terminal operation. The filter and cursor conditions are combined with AND.
-
-```java
-Window activeWindow = userRepository.select()
- .where(User_.active, EQUALS, true)
- .scroll(Scrollable.of(User_.id, 20));
-```
-
-For backward scrolling (starting from the end of the result set), use `.backward()`:
-
-```java
-Window lastWindow = userRepository.scroll(Scrollable.of(User_.id, 20).backward());
-```
-
-As with Kotlin, the scroll methods handle ordering internally and reject explicit `orderBy()` calls. Backward scrolling returns results in descending key order. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling) for full details.
-### Scrolling with Sort
-
-When you need to sort by a non-unique column (for example, a date or status), use the `Scrollable.of` overload that accepts a separate sort column. This accepts a `key` column (typically the primary key) as a unique tiebreaker, and a `sort` column for the primary sort order, to guarantee deterministic paging even when `sort` values repeat.
-
-[Kotlin]
-
-```kotlin
-// First page sorted by creation date, with ID as tiebreaker
-val window: Window = postRepository.scroll(Scrollable.of(Post_.id, Post_.createdAt, 20))
-
-// Next page
-val next: Window = postRepository.scroll(window.next())
-
-// With filter (use query builder)
-val activeWindow = postRepository.select()
- .where(Post_.active, EQUALS, true)
- .scroll(Scrollable.of(Post_.id, Post_.createdAt, 20))
-```
-
-[Java]
-
-```java
-// First page sorted by creation date, with ID as tiebreaker
-Window window = postRepository.scroll(Scrollable.of(Post_.id, Post_.createdAt, 20));
-
-// Next page
-Window next = postRepository.scroll(window.next());
-```
-The `Window` carries navigation tokens (`next()`, `previous()`) that encode the cursor values internally, so the client does not need to extract cursor values manually. These tokens are always non-null when the window contains content. For REST APIs, `nextCursor()` and `previousCursor()` provide a convenient serialized form: `nextCursor()` returns `null` when `hasNext` is false, and `previousCursor()` returns `null` when `hasPrevious` is false.
-
-For queries that need joins, projections, or more complex filtering, use the query builder and call `scroll` as a terminal operation. See [Pagination and Scrolling: Scrolling](pagination-and-scrolling.md#scrolling) for full details on how scrolling composes with WHERE and ORDER BY clauses, including indexing recommendations.
-
-## Pagination vs. Scrolling
-
-Storm supports two strategies for traversing large result sets. The table below summarizes the trade-offs to help you choose.
-
-| Factor | Pagination (`page`) | Scrolling (`scroll`) |
-|---|---|---|
-| Request type | `Pageable` | `Scrollable` |
-| Result type | `Page` | `Window` |
-| Navigation | page number | cursor |
-| Count query | yes | no |
-| Random access | yes | no |
-| Performance at page 1 | Good | Good |
-| Performance at page 1,000 | Degrades (database must skip rows) | Consistent (index seek) |
-| Handles concurrent inserts | Rows may shift between pages | Stable cursor |
-| Navigate forward | `page.nextPageable()` | `window.next()` |
-| Navigate backward | `page.previousPageable()` | `window.previous()` |
-
-Use pagination when you need random page access or a total count (for example, displaying "Page 3 of 12" in a UI). Use scrolling when you need consistent performance over deep result sets or when the data changes frequently between requests.
-
----
-
-## Refs
-
-Refs are lightweight identifiers that carry only the record type and primary key. Selecting refs instead of full entities reduces memory usage and network bandwidth when you only need IDs for subsequent operations, such as batch lookups or filtering. See [Refs](refs.md) for a detailed discussion.
-
-[Kotlin]
-
-```kotlin
-// Select refs (lightweight identifiers)
-val refs: Flow]