metamodel = MetamodelFactory.of(EntityWithInline.class, "address");
+
+ // No unique constraint restricts the inline record's NULL values, so nothing makes it nullable.
+ assertFalse(MetamodelFactory.isNullable(metamodel));
+ }
}
diff --git a/storm-core/src/test/resources/data.sql b/storm-core/src/test/resources/data.sql
index f88ead5e0..af5216a72 100644
--- a/storm-core/src/test/resources/data.sql
+++ b/storm-core/src/test/resources/data.sql
@@ -23,7 +23,7 @@ drop table if exists country CASCADE;
drop table if exists country_city CASCADE;
create table city (id integer auto_increment, name varchar(255) not null, primary key (id));
-create table owner (id integer auto_increment, first_name varchar(255) not null, last_name varchar(255) not null, address varchar(255) not null, city_id integer, telephone varchar(255), primary key (id), version integer default 0 not null, last_name_folded varchar(255) generated always as (lower(last_name)) not null);
+create table owner (id integer auto_increment, first_name varchar(255) not null, last_name varchar(255) not null, address varchar(255) not null, city_id integer, telephone varchar(255), primary key (id), version integer default 0 not null, last_name_folded varchar(255) generated always as (lower(last_name)) not null, last_name_upper varchar(255) generated always as (upper(last_name)) not null);
create table pet (id integer auto_increment, name varchar(255) not null, birth_date date not null, owner_id integer, type_id integer not null, primary key (id));
create table pet_type (id integer, name varchar(255) not null, primary key (id));
create table specialty (id integer auto_increment, name varchar(255) not null, primary key (id));
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumn.java b/storm-foundation/src/main/java/st/orm/QueryColumn.java
index d96bc6360..ec6a6d760 100644
--- a/storm-foundation/src/main/java/st/orm/QueryColumn.java
+++ b/storm-foundation/src/main/java/st/orm/QueryColumn.java
@@ -30,7 +30,7 @@
* value to give it.
*
* {@code
- * @QueryColumn(field = "titleFolded", type = String.class)
+ * @QueryColumn(field = "titleFolded", type = String.class, function = "fold_accents")
* record Movie(@PK Integer id, String title, Integer year) implements Entity {}
*
* orm.entity(Movie.class).select()
@@ -38,6 +38,9 @@
* .getResultList();
* }
*
+ * The search compares {@code LOWER(m.title_folded) LIKE LOWER(fold_accents(?))}: the column as stored, the input
+ * folded by the database through the {@link #function()} the column names.
+ *
* The generated {@code Movie_.titleFolded} is a {@code Metamodel} and goes wherever a column does:
* {@code where}, {@code orderBy}, {@code orderFirst}, {@code search} and {@code suggest}, a {@link SqlFunction}, a
* keyset sort and a template placeholder. It is reached through paths like any other field, so a query column of a
@@ -80,4 +83,17 @@
* keyset sort field, as a nullable field is.
*/
boolean nullable() default false;
+
+ /**
+ * The SQL function whose result the column stores, such as {@code fold_accents} for a generated column holding
+ * {@code fold_accents(title)}. A value the column is compared with passes through it, on the value side only:
+ * {@code title_folded = fold_accents(?)}. The column is read as stored, which is what its index holds, and the
+ * database computes the value in the same statement, so a caller passes the input as it is. It applies wherever
+ * the column is compared with a value: the comparison operators, {@code IN}, {@code BETWEEN}, the text operators,
+ * {@code search}, {@code suggest} and {@code orderFirst}; a pattern passes through it too, so the function must
+ * leave the wildcards and the escape character as they are. A position of a keyset scroll holds the stored value
+ * and is compared as it is. A SQL identifier, optionally qualified by a schema, as {@link SqlFunction} takes.
+ * Empty for a column compared with a value as it is.
+ */
+ String function() default "";
}
diff --git a/storm-java21/src/main/java/st/orm/template/Column.java b/storm-java21/src/main/java/st/orm/template/Column.java
index 5bbc5364e..a8d25c474 100644
--- a/storm-java21/src/main/java/st/orm/template/Column.java
+++ b/storm-java21/src/main/java/st/orm/template/Column.java
@@ -117,6 +117,16 @@ public interface Column {
*/
boolean ref();
+ /**
+ * Returns the SQL function whose result a query column stores, which a value compared with the column passes
+ * through on the value side: {@code title_folded = fold_accents(?)}. Empty for a column compared with a value as
+ * it is, which every column a field maps is.
+ *
+ * @return the function's name, or an empty string.
+ * @since 1.16
+ */
+ String function();
+
/**
* Gets the metamodel of the column.
*
diff --git a/storm-java21/src/main/java/st/orm/template/impl/ColumnImpl.java b/storm-java21/src/main/java/st/orm/template/impl/ColumnImpl.java
index ba2c0cfab..bd4f151b8 100644
--- a/storm-java21/src/main/java/st/orm/template/impl/ColumnImpl.java
+++ b/storm-java21/src/main/java/st/orm/template/impl/ColumnImpl.java
@@ -52,6 +52,7 @@ public record ColumnImpl(
boolean updatable,
boolean version,
boolean ref,
+ String function,
Metamodel metamodel
) implements Column {
@@ -70,6 +71,7 @@ public ColumnImpl(st.orm.core.template.Column column) {
column.updatable(),
column.version(),
column.ref(),
+ column.function(),
column.metamodel()
);
}
diff --git a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
index 3c1a6741b..3922962a6 100644
--- a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
+++ b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
@@ -1346,5 +1346,9 @@ public void testQueryColumnIsNamedByAQueryAndCarriedByNoRow() {
assertEquals(List.of("last_name_folded"), model.queryColumns().stream().map(Column::name).toList());
assertTrue(model.columns().stream().noneMatch(column -> column.name().equals("last_name_folded")));
assertThrows(UnsupportedOperationException.class, () -> Owner_.lastNameFolded.getValue(owners.getFirst()));
+ // The query column names lower as the function that computed it, so the value side is lowered by the database.
+ assertEquals("lower", model.queryColumns().getFirst().function());
+ List upperCased = orm.entity(Owner.class).select().where(Owner_.lastNameFolded, EQUALS, "DAVIS").getResultList();
+ assertEquals(List.of("Betty", "Harold"), upperCased.stream().map(Owner::firstName).sorted().toList());
}
}
diff --git a/storm-java21/src/test/java/st/orm/template/model/Owner.java b/storm-java21/src/test/java/st/orm/template/model/Owner.java
index 7b96f1964..b42bfe300 100644
--- a/storm-java21/src/test/java/st/orm/template/model/Owner.java
+++ b/storm-java21/src/test/java/st/orm/template/model/Owner.java
@@ -6,7 +6,7 @@
import st.orm.QueryColumn;
import st.orm.Version;
-@QueryColumn(field = "lastNameFolded", type = String.class)
+@QueryColumn(field = "lastNameFolded", type = String.class, function = "lower")
public record Owner(
@PK Integer id,
String firstName,
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt
index ec54291d9..946499f94 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt
@@ -117,6 +117,15 @@ public interface Column {
*/
public val ref: Boolean
+ /**
+ * The SQL function whose result a query column stores, which a value compared with the column passes through on
+ * the value side: `title_folded = fold_accents(?)`. Null for a column compared with a value as it is, which every
+ * column a field maps is.
+ *
+ * @since 1.16
+ */
+ public val function: String?
+
/**
* Gets the metamodel of the column.
*
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ColumnImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ColumnImpl.kt
index 363f40aa4..4800b98c8 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ColumnImpl.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ColumnImpl.kt
@@ -36,5 +36,6 @@ internal class ColumnImpl(
override val updatable: Boolean get() = core.updatable()
override val version: Boolean get() = core.version()
override val ref: Boolean get() = core.ref()
+ override val function: String? get() = core.function().ifEmpty { null }
override val metamodel: Metamodel get() = core.metamodel()
}
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
index 1236a13f0..7d1c1a2eb 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
@@ -2293,6 +2293,10 @@ internal open class QueryBuilderTest(
model.queryColumns.map { it.name } shouldBe listOf("last_name_folded")
model.columns.none { it.name == "last_name_folded" } shouldBe true
shouldThrow { lastNameFolded.getValue(owners.first()) }
+ // The query column names lower as the function that computed it, so the value side is lowered by the database.
+ model.queryColumns.first().function shouldBe "lower"
+ orm.entity(Owner::class).select().where(lastNameFolded eq "DAVIS").resultList
+ .map { it.firstName }.sorted() shouldBe listOf("Betty", "Harold")
}
@Test
diff --git a/storm-kotlin/src/test/kotlin/st/orm/template/model/Owner.kt b/storm-kotlin/src/test/kotlin/st/orm/template/model/Owner.kt
index 2d4a68319..1b9d8506a 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/model/Owner.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/model/Owner.kt
@@ -24,7 +24,7 @@ import st.orm.Version
* Simple domain object representing an owner.
*
*/
-@QueryColumn(field = "lastNameFolded", type = String::class)
+@QueryColumn(field = "lastNameFolded", type = String::class, function = "lower")
internal data class Owner(
@PK val id: Int = 0,
override val firstName: String,
diff --git a/website/static/skills/storm-entity-from-schema.md b/website/static/skills/storm-entity-from-schema.md
index f4df3a448..52c1bf796 100644
--- a/website/static/skills/storm-entity-from-schema.md
+++ b/website/static/skills/storm-entity-from-schema.md
@@ -61,7 +61,7 @@ Generation/update rules:
- **Composite unique constraints** (only when needed): composite unique constraints do NOT need to be modeled unless the user explicitly needs a composite `Metamodel.Key` (for keyset pagination or type-safe lookups). When needed, use an inline record + `@UK @Persist(insertable = false, updatable = false)` — ask the user if they need this.
- `describe_table` reports unique constraints (top-level `uniqueConstraints` array with constraint name and column list) and FK cascade rules (onDelete/onUpdate). Both are exposed for context only. **Storm does not model cascade behavior or enforce uniqueness** — these are database-level concerns. Do not generate annotations or code for cascade rules.
- @Version for version columns (confirm with user).
-- Generated or computed columns that no caller reads, such as a folded or normalized copy of another column that a search index is built on: declare them as query columns on the type, `@QueryColumn(field = "titleFolded", type = String::class)` (Java: `String.class`), not as fields, so they are never selected or written. Map one as a `@Persist(insertable = false, updatable = false)` field only when the application reads its value (confirm with user).
+- Generated or computed columns that no caller reads, such as a folded or normalized copy of another column that a search index is built on: declare them as query columns on the type, `@QueryColumn(field = "titleFolded", type = String::class, function = "fold_accents")` (Java: `String.class`), not as fields, so they are never selected or written; `function` names the function in the column's generation expression, so a compared value passes through it, and is left out for a column compared as it is. Map one as a `@Persist(insertable = false, updatable = false)` field only when the application reads its value (confirm with user).
- When updating, preserve existing field order and custom annotations. Only add/modify what changed.
- Use `@DbIgnore` on fields or types that should be excluded from schema validation. Note: ad-hoc query result types (aggregation DTOs) should not need this — write them as plain data classes/records without the `Data` marker instead of declaring `Data` and then suppressing validation with `@DbIgnore`.
- Use `@PK(constraint = false)` if the table intentionally has no PK constraint in the database.
diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md
index 6d59fea57..50fc0009a 100644
--- a/website/static/skills/storm-entity-java.md
+++ b/website/static/skills/storm-entity-java.md
@@ -146,7 +146,7 @@ Generation rules:
```
Use `insertable = false` alone for columns set by the database only on INSERT, or `updatable = false` alone for columns written once and never modified.
-11c. Query columns: a column a query names but no row needs to carry, such as a generated column storing a folded title for a search index, a `tsvector`, a computed sort key or a large column a filter names, is declared on the type, not as a field: `@QueryColumn(field = "titleFolded", type = String.class)`, repeated per column; `column = "..."` names the column where the resolver does not, and `nullable = true` declares a nullable one. It generates `Movie_.titleFolded` with no record component, so the column is never selected, inserted or updated, while `where`, `orderBy`, `orderFirst`, `search`, `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it, through paths as well (`Credit_.movie.titleFolded`). Map such a column as a `@Persist(insertable = false, updatable = false)` field only when a caller reads its value. On an entity the database must fill a query column, since every insert leaves it out: generated, identity, with a default, or nullable. Schema validation reports one the database does not fill as an unmapped column.
+11c. Query columns: a column a query names but no row needs to carry, such as a generated column storing a folded title for a search index, a `tsvector`, a computed sort key or a large column a filter names, is declared on the type, not as a field: `@QueryColumn(field = "titleFolded", type = String.class)`, repeated per column; `column = "..."` names the column where the resolver does not, `nullable = true` declares a nullable one, and `function = "fold_accents"` names the function the column stores the result of: a value compared with the column then passes through it on the value side (`title_folded = fold_accents(?)`), in `search`, `suggest`, `orderFirst`, `IN` and the operators alike, so the caller passes the input as it is and the column is read as stored. It generates `Movie_.titleFolded` with no record component, so the column is never selected, inserted or updated, while `where`, `orderBy`, `orderFirst`, `search`, `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it, through paths as well (`Credit_.movie.titleFolded`). Map such a column as a `@Persist(insertable = false, updatable = false)` field only when a caller reads its value. On an entity the database must fill a query column, since every insert leaves it out: generated, identity, with a default, or nullable. Schema validation reports one the database does not fill as an unmapped column.
11c. Update tuning: `@DynamicUpdate` sets how the entity's UPDATE statements are generated. The default mode (`ENTITY`) skips the UPDATE entirely when nothing changed and writes the full row when anything did. `@DynamicUpdate(FIELD)` writes only the changed columns, at the cost of one SQL shape per distinct change pattern (which splits batches); `@DynamicUpdate(OFF)` always writes all columns without comparison. `dirtyCheck = VALUE` switches change detection from reference identity to `equals()`. Semantics, trade-offs, and the foreign-key comparison rule: /storm-repository-java (Dirty Checking and Update Suppression).
diff --git a/website/static/skills/storm-entity-kotlin.md b/website/static/skills/storm-entity-kotlin.md
index b2cc9320e..58c3ab6da 100644
--- a/website/static/skills/storm-entity-kotlin.md
+++ b/website/static/skills/storm-entity-kotlin.md
@@ -166,7 +166,7 @@ Generation rules:
```
Use `insertable = false` alone for columns set by the database only on INSERT, or `updatable = false` alone for columns that are written once and never modified.
-12c. Query columns: a column a query names but no row needs to carry, such as a generated column storing a folded title for a search index, a `tsvector`, a computed sort key or a large column a filter names, is declared on the type, not as a property: `@QueryColumn(field = "titleFolded", type = String::class)`, repeated per column; `column = "..."` names the column where the resolver does not, and `nullable = true` declares a nullable one. It generates `Movie_.titleFolded` with no constructor parameter, so the column is never selected, inserted or updated, while `where`, `orderBy`, `orderFirst`, `search`, `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it, through paths as well (`Credit_.movie.titleFolded`). Map such a column as a `@Persist(insertable = false, updatable = false)` property only when a caller reads its value. On an entity the database must fill a query column, since every insert leaves it out: generated, identity, with a default, or nullable. Schema validation reports one the database does not fill as an unmapped column.
+12c. Query columns: a column a query names but no row needs to carry, such as a generated column storing a folded title for a search index, a `tsvector`, a computed sort key or a large column a filter names, is declared on the type, not as a property: `@QueryColumn(field = "titleFolded", type = String::class)`, repeated per column; `column = "..."` names the column where the resolver does not, `nullable = true` declares a nullable one, and `function = "fold_accents"` names the function the column stores the result of: a value compared with the column then passes through it on the value side (`title_folded = fold_accents(?)`), in `search`, `suggest`, `orderFirst`, `IN` and the operators alike, so the caller passes the input as it is and the column is read as stored. It generates `Movie_.titleFolded` with no constructor parameter, so the column is never selected, inserted or updated, while `where`, `orderBy`, `orderFirst`, `search`, `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it, through paths as well (`Credit_.movie.titleFolded`). Map such a column as a `@Persist(insertable = false, updatable = false)` property only when a caller reads its value. On an entity the database must fill a query column, since every insert leaves it out: generated, identity, with a default, or nullable. Schema validation reports one the database does not fill as an unmapped column.
12c. Update tuning: `@DynamicUpdate` sets how the entity's UPDATE statements are generated. The default mode (`ENTITY`) skips the UPDATE entirely when nothing changed and writes the full row when anything did. `@DynamicUpdate(FIELD)` writes only the changed columns, at the cost of one SQL shape per distinct change pattern (which splits batches); `@DynamicUpdate(OFF)` always writes all columns without comparison. `dirtyCheck = VALUE` switches change detection from reference identity to `equals()`. Semantics, trade-offs, and the foreign-key comparison rule: /storm-repository-kotlin (Dirty Checking and Update Suppression).
diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md
index caf178474..02599fb94 100644
--- a/website/static/skills/storm-query-java.md
+++ b/website/static/skills/storm-query-java.md
@@ -139,7 +139,7 @@ Operators: EQUALS, NOT_EQUALS, LESS_THAN, LESS_THAN_OR_EQUAL, GREATER_THAN, GREA
**Rank search results with `orderFirst`, never with a `CASE` template.** `search` and `suggest` decide which rows match, not which come first. `orderFirst(predicate)` puts the rows matching the predicate first, appending a sort key as `orderBy` does, so the calls apply in order: `.orderFirst(it -> it.where(Movie_.title, EQUALS_IGNORE_CASE, input)).orderFirst(it -> it.where(Movie_.title, STARTS_WITH_IGNORE_CASE, input)).orderByDescending(Movie_.votes)` lists an exact title, then titles starting with the input, then the most popular. The operators match the input literally and ignoring case, as in `where`; combine columns with `.or(...)`. Rank only by columns of the queried or joined tables: a semi-joined table's columns are refused, as it adds no columns. A keyset `scroll` follows `orderFirst` (its keys lead the `Scrollable`'s sort fields and key, and the cursor carries the ranks), and a `Scrollable` may sort by a field of a joined table, such as the votes of a joined rating; `orderFirst` cannot order a `distinct` query. No index serves the sort, which is fine after a search's few matches.
-**A search needs an index built for it, created in the migration with the query.** `search` and the `IGNORE_CASE` text operators compare `LOWER(column) LIKE LOWER('%word%')`: a lowered expression with a leading wildcard, which no B-tree index serves. Without an index for it, each search reads and lowers every row of every table it names, which goes unnoticed on test data and takes seconds on a production-sized table. Where a searched table can grow beyond a few thousand rows, add the target database's substring index (a trigram or n-gram index) to the migration, over exactly `LOWER(column)`, one per searched column, including the columns of a semi-joined table or of a table reached through a path. An index on the plain column does not match the lowered expression. A word shorter than the index's gram (usually three characters) cannot use it: `search` tests it only on the rows its longer words found, and a search of short words alone still reads every row; the slow log (`st.orm.sql.slow`) adds advice to such a statement's line. Confirm in the database's query plan that each table is read through its index, for the statement as the application sends it: with the word as a bind parameter, after the driver has prepared it, not with the word written into the SQL. A prepared statement may be planned once for every value; that plan cannot see the word, and a search ordered by an indexed column with a small limit, such as a list of suggestions, then walks that column's index testing every row. Where that happens, configure the driver or database to plan each execution for its values. After creating the index on a table that holds data, refresh the table's statistics; where a rare word is still estimated to match many rows, raise the statistics sample of the indexed expression. Never speed a search up by changing its predicate: a case-sensitive operator reads every row as well and changes what matches, and an `ILIKE` or a `LOWER(...)` template reads every row just the same. Where the database has no substring index, say so, and narrow the rows with a selective predicate instead. Whether accents count is the schema's decision, never the application's: the search follows the column's collation, which ignores accents on MySQL and MariaDB by default and compares them elsewhere. Where a search must ignore them on such a database, have the database remove them through a function an index is built on, and name that function in the query with `SqlFunction`: the migration wraps the database's accent function in one marked immutable where it is not (on PostgreSQL, `CREATE FUNCTION fold_accents(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT RETURN public.unaccent('public.unaccent'::regdictionary, $1)`) and indexes `LOWER(fold_accents(column))`; the repository declares `SqlFunction foldAccents = SqlFunction.of("fold_accents")` once and searches `it.search(input, foldAccents.apply(Movie_.title))`, ranking with `orderFirst(it -> it.where(foldAccents.apply(Movie_.title), EQUALS_IGNORE_CASE, input))`. A comparison applies the function to the column and to the input, so the database folds both and the application folds neither: never remove accents from the input in application code, never add an entity field holding the folded text, and never wrap the column in an accent function inside a template, which the search operators cannot use. Where the recheck of the accent function on every candidate row dominates, which shows on a column where a common word matches many thousands of rows, store the folded text in a generated column (`title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL`), index `LOWER(title_folded)`, and declare it as a query column on the entity, `@QueryColumn(field = "titleFolded", type = String.class)`, so no row carries it (see the entity skill). Fold the input once in the database, `SELECT fold_accents(?)`, and search the column with the result, `it.search(folded, Movie_.titleFolded)`, so the column and the input are folded by the same function. Name exactly the function the index is built on; another expression is not served by it. A `Scrollable` refuses a function call as key or sort field.
+**A search needs an index built for it, created in the migration with the query.** `search` and the `IGNORE_CASE` text operators compare `LOWER(column) LIKE LOWER('%word%')`: a lowered expression with a leading wildcard, which no B-tree index serves. Without an index for it, each search reads and lowers every row of every table it names, which goes unnoticed on test data and takes seconds on a production-sized table. Where a searched table can grow beyond a few thousand rows, add the target database's substring index (a trigram or n-gram index) to the migration, over exactly `LOWER(column)`, one per searched column, including the columns of a semi-joined table or of a table reached through a path. An index on the plain column does not match the lowered expression. A word shorter than the index's gram (usually three characters) cannot use it: `search` tests it only on the rows its longer words found, and a search of short words alone still reads every row; the slow log (`st.orm.sql.slow`) adds advice to such a statement's line. Confirm in the database's query plan that each table is read through its index, for the statement as the application sends it: with the word as a bind parameter, after the driver has prepared it, not with the word written into the SQL. A prepared statement may be planned once for every value; that plan cannot see the word, and a search ordered by an indexed column with a small limit, such as a list of suggestions, then walks that column's index testing every row. Where that happens, configure the driver or database to plan each execution for its values. After creating the index on a table that holds data, refresh the table's statistics; where a rare word is still estimated to match many rows, raise the statistics sample of the indexed expression. Never speed a search up by changing its predicate: a case-sensitive operator reads every row as well and changes what matches, and an `ILIKE` or a `LOWER(...)` template reads every row just the same. Where the database has no substring index, say so, and narrow the rows with a selective predicate instead. Whether accents count is the schema's decision, never the application's: the search follows the column's collation, which ignores accents on MySQL and MariaDB by default and compares them elsewhere. Where a search must ignore them on such a database, have the database remove them through a function an index is built on, and name that function in the query with `SqlFunction`: the migration wraps the database's accent function in one marked immutable where it is not (on PostgreSQL, `CREATE FUNCTION fold_accents(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT RETURN public.unaccent('public.unaccent'::regdictionary, $1)`) and indexes `LOWER(fold_accents(column))`; the repository declares `SqlFunction foldAccents = SqlFunction.of("fold_accents")` once and searches `it.search(input, foldAccents.apply(Movie_.title))`, ranking with `orderFirst(it -> it.where(foldAccents.apply(Movie_.title), EQUALS_IGNORE_CASE, input))`. A comparison applies the function to the column and to the input, so the database folds both and the application folds neither: never remove accents from the input in application code, never add an entity field holding the folded text, and never wrap the column in an accent function inside a template, which the search operators cannot use. Where the recheck of the accent function on every candidate row dominates, which shows on a column where a common word matches many thousands of rows, store the folded text in a generated column (`title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL`), index `LOWER(title_folded)`, and declare it as a query column on the entity that names the function, `@QueryColumn(field = "titleFolded", type = String.class, function = "fold_accents")`, so no row carries it (see the entity skill) and a value compared with it passes through the function on the value side: `it.search(input, Movie_.titleFolded)` renders `LOWER(m.title_folded) LIKE LOWER(fold_accents(?))`, the column as stored and the input folded by the database in the same statement. Never fold the input in application code or in a query of its own. Name exactly the function the index is built on; another expression is not served by it. A `Scrollable` refuses a function call as key or sort field.
### Naming a foreign key column
diff --git a/website/static/skills/storm-query-kotlin.md b/website/static/skills/storm-query-kotlin.md
index 16b545d9e..6911b5a43 100644
--- a/website/static/skills/storm-query-kotlin.md
+++ b/website/static/skills/storm-query-kotlin.md
@@ -104,7 +104,7 @@ User_.email.isNotNull() // IS_NOT_NULL
**Rank search results with `orderFirst`, never with a `CASE` template.** `search` and `suggest` decide which rows match, not which come first. `orderFirst(predicate)` puts the rows matching the predicate first, appending a sort key as `orderBy` does, so the calls apply in order: `.orderFirst(Movie_.title eqIgnoreCase input).orderFirst(Movie_.title startsWithIgnoreCase input).orderByDescending(Movie_.votes)` lists an exact title, then titles starting with the input, then the most popular. The operators match the input literally and ignoring case, as in `where`; combine columns with `or`. Rank only by columns of the queried or joined tables: a semi-joined table's columns are refused, as it adds no columns. A keyset `scroll` follows `orderFirst` (its keys lead the `Scrollable`'s sort fields and key, and the cursor carries the ranks), and a `Scrollable` may sort by a field of a joined table, such as the votes of a joined rating; `orderFirst` cannot order a `distinct` query. No index serves the sort, which is fine after a search's few matches.
-**A search needs an index built for it, created in the migration with the query.** `search` and the `IgnoreCase` text operators compare `LOWER(column) LIKE LOWER('%word%')`: a lowered expression with a leading wildcard, which no B-tree index serves. Without an index for it, each search reads and lowers every row of every table it names, which goes unnoticed on test data and takes seconds on a production-sized table. Where a searched table can grow beyond a few thousand rows, add the target database's substring index (a trigram or n-gram index) to the migration, over exactly `LOWER(column)`, one per searched column, including the columns of a semi-joined table or of a table reached through a path. An index on the plain column does not match the lowered expression. A word shorter than the index's gram (usually three characters) cannot use it: `search` tests it only on the rows its longer words found, and a search of short words alone still reads every row; the slow log (`st.orm.sql.slow`) adds advice to such a statement's line. Confirm in the database's query plan that each table is read through its index, for the statement as the application sends it: with the word as a bind parameter, after the driver has prepared it, not with the word written into the SQL. A prepared statement may be planned once for every value; that plan cannot see the word, and a search ordered by an indexed column with a small limit, such as a list of suggestions, then walks that column's index testing every row. Where that happens, configure the driver or database to plan each execution for its values. After creating the index on a table that holds data, refresh the table's statistics; where a rare word is still estimated to match many rows, raise the statistics sample of the indexed expression. Never speed a search up by changing its predicate: a case-sensitive operator reads every row as well and changes what matches, and an `ILIKE` or a `LOWER(...)` template reads every row just the same. Where the database has no substring index, say so, and narrow the rows with a selective predicate instead. Whether accents count is the schema's decision, never the application's: the search follows the column's collation, which ignores accents on MySQL and MariaDB by default and compares them elsewhere. Where a search must ignore them on such a database, have the database remove them through a function an index is built on, and name that function in the query with `SqlFunction`: the migration wraps the database's accent function in one marked immutable where it is not (on PostgreSQL, `CREATE FUNCTION fold_accents(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT RETURN public.unaccent('public.unaccent'::regdictionary, $1)`) and indexes `LOWER(fold_accents(column))`; the repository declares `val foldAccents = SqlFunction.of("fold_accents")` once, with an extension property that reads as a field of the column, `val Navigable.folded: Metamodel get() = foldAccents(this)`, and searches `search(input, Movie_.title.folded)`, ranking with `orderFirst(Movie_.title.folded eqIgnoreCase input)`. A comparison applies the function to the column and to the input, so the database folds both and the application folds neither: never remove accents from the input in application code, never add an entity field holding the folded text, and never wrap the column in an accent function inside a template, which the search operators cannot use. Where the recheck of the accent function on every candidate row dominates, which shows on a column where a common word matches many thousands of rows, store the folded text in a generated column (`title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL`), index `LOWER(title_folded)`, and declare it as a query column on the entity, `@QueryColumn(field = "titleFolded", type = String::class)`, so no row carries it (see the entity skill). Fold the input once in the database, `SELECT fold_accents(?)`, and search the column with the result, `search(folded, Movie_.titleFolded)`, so the column and the input are folded by the same function. Name exactly the function the index is built on; another expression is not served by it. A `Scrollable` refuses a function call as key or sort field.
+**A search needs an index built for it, created in the migration with the query.** `search` and the `IgnoreCase` text operators compare `LOWER(column) LIKE LOWER('%word%')`: a lowered expression with a leading wildcard, which no B-tree index serves. Without an index for it, each search reads and lowers every row of every table it names, which goes unnoticed on test data and takes seconds on a production-sized table. Where a searched table can grow beyond a few thousand rows, add the target database's substring index (a trigram or n-gram index) to the migration, over exactly `LOWER(column)`, one per searched column, including the columns of a semi-joined table or of a table reached through a path. An index on the plain column does not match the lowered expression. A word shorter than the index's gram (usually three characters) cannot use it: `search` tests it only on the rows its longer words found, and a search of short words alone still reads every row; the slow log (`st.orm.sql.slow`) adds advice to such a statement's line. Confirm in the database's query plan that each table is read through its index, for the statement as the application sends it: with the word as a bind parameter, after the driver has prepared it, not with the word written into the SQL. A prepared statement may be planned once for every value; that plan cannot see the word, and a search ordered by an indexed column with a small limit, such as a list of suggestions, then walks that column's index testing every row. Where that happens, configure the driver or database to plan each execution for its values. After creating the index on a table that holds data, refresh the table's statistics; where a rare word is still estimated to match many rows, raise the statistics sample of the indexed expression. Never speed a search up by changing its predicate: a case-sensitive operator reads every row as well and changes what matches, and an `ILIKE` or a `LOWER(...)` template reads every row just the same. Where the database has no substring index, say so, and narrow the rows with a selective predicate instead. Whether accents count is the schema's decision, never the application's: the search follows the column's collation, which ignores accents on MySQL and MariaDB by default and compares them elsewhere. Where a search must ignore them on such a database, have the database remove them through a function an index is built on, and name that function in the query with `SqlFunction`: the migration wraps the database's accent function in one marked immutable where it is not (on PostgreSQL, `CREATE FUNCTION fold_accents(text) RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT RETURN public.unaccent('public.unaccent'::regdictionary, $1)`) and indexes `LOWER(fold_accents(column))`; the repository declares `val foldAccents = SqlFunction.of("fold_accents")` once, with an extension property that reads as a field of the column, `val Navigable.folded: Metamodel get() = foldAccents(this)`, and searches `search(input, Movie_.title.folded)`, ranking with `orderFirst(Movie_.title.folded eqIgnoreCase input)`. A comparison applies the function to the column and to the input, so the database folds both and the application folds neither: never remove accents from the input in application code, never add an entity field holding the folded text, and never wrap the column in an accent function inside a template, which the search operators cannot use. Where the recheck of the accent function on every candidate row dominates, which shows on a column where a common word matches many thousands of rows, store the folded text in a generated column (`title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL`), index `LOWER(title_folded)`, and declare it as a query column on the entity that names the function, `@QueryColumn(field = "titleFolded", type = String::class, function = "fold_accents")`, so no row carries it (see the entity skill) and a value compared with it passes through the function on the value side: `search(input, Movie_.titleFolded)` renders `LOWER(m.title_folded) LIKE LOWER(fold_accents(?))`, the column as stored and the input folded by the database in the same statement. Never fold the input in application code or in a query of its own. Name exactly the function the index is built on; another expression is not served by it. A `Scrollable` refuses a function call as key or sort field.
Combine with `and`/`or`:
```kotlin