From bd9d1080bc84fda6b5e324af9d0a624ae8aada72 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 07:45:52 +0100
Subject: [PATCH 1/8] Declare query columns an entity does not carry (#743)
A query may name a column that the entity record does not hold: a stored
generated column such as a folded title, or a trigger-filled column that
serves a search or a sort. Entities declare it with @QueryColumn, which
gives the metamodel a field for it while keeping it out of every SELECT,
INSERT and row mapping.
Model lists the query columns apart from its columns. The metamodel
processors (APT and KSP) emit a field per declaration, so Owner_.lastNameFolded
works like any other field in search, where and orderBy. The schema
validator checks that the database holds the column and fills it on its
own, reporting QUERY_COLUMN_NOT_FILLED otherwise; validate = false opts a
column out for triggers the metadata cannot see. DatabaseSchema reads the
generated flag defensively since not every driver reports it.
Docs cover the annotation in the entities page, the validation table and
the search page, where the stored folded column is the optional step for
a search the expression index leaves slow. Skills carry the same guidance.
Fixes #743
---
CHANGELOG.md | 1 +
docs/entities.md | 67 +++++
docs/metamodel.md | 2 +-
docs/search.md | 50 ++++
docs/validation.md | 2 +
.../java/st/orm/core/template/Column.java | 3 +-
.../main/java/st/orm/core/template/Model.java | 14 +
.../core/template/impl/DatabaseSchema.java | 23 +-
.../core/template/impl/MetamodelFactory.java | 125 ++++++++-
.../orm/core/template/impl/ModelFactory.java | 77 +++++-
.../st/orm/core/template/impl/ModelImpl.java | 28 +-
.../core/template/impl/RecordReflection.java | 37 +++
.../template/impl/SchemaValidationError.java | 7 +-
.../core/template/impl/SchemaValidator.java | 102 ++++---
.../orm/core/QueryColumnIntegrationTest.java | 194 ++++++++++++++
.../test/java/st/orm/core/model/Owner.java | 2 +
.../core/template/SchemaValidatorTest.java | 126 +++++++++
.../template/impl/DatabaseSchemaTest.java | 15 ++
storm-core/src/test/resources/data.sql | 2 +-
...bstractSchemaValidatorConformanceTest.java | 43 +++
.../src/main/java/st/orm/QueryColumn.java | 88 ++++++
.../src/main/java/st/orm/QueryColumns.java | 38 +++
.../h2/H2SchemaValidatorConformanceTest.java | 5 +
.../src/main/java/st/orm/template/Column.java | 3 +-
.../src/main/java/st/orm/template/Model.java | 11 +
.../java/st/orm/template/impl/ModelImpl.java | 11 +-
.../src/main/kotlin/st/orm/template/Column.kt | 3 +-
.../src/main/kotlin/st/orm/template/Model.kt | 11 +
.../kotlin/st/orm/template/impl/ModelImpl.kt | 1 +
.../st/orm/template/QueryBuilderTest.kt | 19 ++
.../kotlin/st/orm/template/model/Owner.kt | 2 +
storm-kotlin/src/test/resources/data.sql | 2 +-
...MariaDBSchemaValidatorConformanceTest.java | 5 +
.../st/orm/metamodel/MetamodelProcessor.kt | 160 +++++++++++
.../orm/metamodel/MetamodelProcessorTest.kt | 191 +++++++++++++
.../st/orm/metamodel/MetamodelProcessor.java | 251 +++++++++++++++++-
.../orm/metamodel/MetamodelProcessorTest.java | 156 +++++++++++
...LServerSchemaValidatorConformanceTest.java | 5 +
.../MySQLSchemaValidatorConformanceTest.java | 5 +
.../OracleSchemaValidatorConformanceTest.java | 5 +
...tgreSQLSchemaValidatorConformanceTest.java | 5 +
.../SQLiteSchemaValidatorConformanceTest.java | 5 +
.../static/skills/storm-entity-from-schema.md | 1 +
website/static/skills/storm-entity-java.md | 2 +
website/static/skills/storm-entity-kotlin.md | 2 +
website/static/skills/storm-query-java.md | 2 +-
website/static/skills/storm-query-kotlin.md | 2 +-
website/static/skills/storm-validate.md | 2 +-
48 files changed, 1851 insertions(+), 62 deletions(-)
create mode 100644 storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
create mode 100644 storm-foundation/src/main/java/st/orm/QueryColumn.java
create mode 100644 storm-foundation/src/main/java/st/orm/QueryColumns.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6a47c7f92..a344b7771 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,7 @@ for the CLI, to [npm](https://www.npmjs.com/package/@storm-orm/cli)
- `delete()` and `update()` refuse to run without a condition unless `unsafe()` is called, take explicit joins on every database, and refuse a joined inheritance hierarchy (#712). `SqlDialect.writeJoinSyntax()` replaces `supportsDeleteAlias()`.
- Added `semiJoin` and `antiJoin`, which filter rows by a related table without adding its rows, so no `distinct` is needed (#690). `semiJoin().on()` keeps the owners with a pet matching the conditions that name `Pet`, `antiJoin` the owners without one. They filter `select()`, `delete()` and `update()`, and nest to filter through two to-many relations: `semiJoin().on().semiJoin().on()` (#717). A search over a row and its related rows reads each table through its own indexes: 1.3 s to a few milliseconds over 980,000 credits on PostgreSQL.
- A typed join takes a condition: `on { Pet_.name eq "Leo" }` in Kotlin, `on(Owner.class, it -> ...)` in Java (#690). On a left join it keeps the rows without a match, which the same condition in `where` drops.
+- Added query columns, the columns a query names but the entity does not carry (#743): `@QueryColumn(field = "titleFolded", type = String::class)` on an entity or projection generates `Movie_.titleFolded` as a metamodel field 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. A generated column that stores a folded title for a search index is the typical case. Schema validation checks the column exists with a compatible type and, on an entity, that the database fills it, since every insert leaves it out (`QUERY_COLUMN_NOT_FILLED`); `validate = false` skips a column a trigger fills. Schema validation reads whether a column is generated, and no longer reports a generated `NOT NULL` column no field maps as unmapped. `Model.queryColumns()` lists them.
- Added `suggest`, the `search` for input typed as you go: it ignores words shorter than the database's substring index serves and matches nothing when no word is left, so it never reads every row. `search` tests such short words only on the rows its longer words found: "de niro" over 980,000 credits went from 2.3 s to 19 ms on PostgreSQL. The length comes from `SqlDialect.minimumSearchTermLength()`, 3 on PostgreSQL and 0 elsewhere, and `storm.search.min_term_length` overrides it. `suggest` also takes the words a search box ignores, such as `the` in a catalog of titles, which the index serves but which narrow nothing: `suggest(input, setOf("the", "a", "of"), Movie_.title)` treats them as short words, compared ignoring case (#741).
- Added `orderFirst`, which sorts the rows matching a predicate first: `orderFirst(Movie_.title eqIgnoreCase input)` in Kotlin, `orderFirst(it -> it.where(Movie_.title, EQUALS_IGNORE_CASE, input))` in Java, rendered as `CASE WHEN ... THEN 0 ELSE 1 END` on every database. A keyset `scroll` follows it, and `Scrollable.sortBy` and `sortByDescending` take a field of any table in the query.
- Added `SqlFunction`, a one-argument SQL function applied to a column wherever a query takes one, so a query can match the expression an index is built on (#694). `foldAccents(Movie_.title)` in Kotlin, `foldAccents.apply(Movie_.title)` in Java; a comparison applies it to the column and the value alike, so an accent-insensitive search on PostgreSQL uses an index on `LOWER(fold_accents(title))`.
diff --git a/docs/entities.md b/docs/entities.md
index 8a3fb0b59..8f88a6fa9 100644
--- a/docs/entities.md
+++ b/docs/entities.md
@@ -689,6 +689,73 @@ record Pet(@PK Integer id,
---
+## Query Columns
+
+A query can only name a column that a field maps, and every field is selected, inserted and updated. Some columns exist only to be queried: a generated column that stores a derived form of another column, which a search matches and an index is built on, but which no caller reads and the database computes. Mapped as a field it sits in every SELECT, needs `@Persist(insertable = false, updatable = false)`, and makes every caller that constructs the entity supply a value the database overwrites. The same holds for a `tsvector` column for full-text search, a normalized or hashed copy of a column, a computed sort key, and a large column a query filters on but should not load.
+
+A query column is a column a query names but the entity does not carry. `@QueryColumn` on the entity or projection declares it with the name of its metamodel field and the type of its values. The processors generate the field as they generate one for a record component, and the column follows from the field name through the column name resolver; a `column` attribute names it where the resolver does not.
+
+
+
+
+```kotlin
+@DbTable("movie")
+@QueryColumn(field = "titleFolded", type = String::class)
+data class Movie(
+ @PK val id: Int = 0,
+ val title: String,
+ val year: Int
+) : Entity
+```
+
+The annotation repeats for each query column. `Movie_.titleFolded` is a `Metamodel` and goes wherever a column does:
+
+```kotlin
+orm.entity(Movie::class)
+ .select()
+ .where(search(input, Movie_.titleFolded))
+ .orderBy(Movie_.titleFolded)
+ .resultList
+```
+
+
+
+
+```java
+@DbTable("movie")
+@QueryColumn(field = "titleFolded", type = String.class)
+record Movie(@PK Integer id, String title, Integer year) implements Entity {}
+```
+
+The annotation repeats for each query column. `Movie_.titleFolded` is a `Metamodel` and goes wherever a column does:
+
+```java
+orm.entity(Movie.class)
+ .select()
+ .where(it -> it.search(input, Movie_.titleFolded))
+ .orderBy(Movie_.titleFolded)
+ .getResultList();
+```
+
+
+
+
+The migration creates the column, here generated from the title by the accent-folding function of [Storing the Folded Text](search.md#storing-the-folded-text), and the index on it:
+
+```sql
+ALTER TABLE movie ADD COLUMN title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL;
+CREATE INDEX idx_movie_title_folded ON movie USING gin (LOWER(title_folded) gin_trgm_ops);
+```
+
+- A query column is never selected, inserted or updated. A record holds no value for it: `Metamodel.getValue` throws, and `update().set` refuses it.
+- It is reached through paths like any other field: a query column declared on `Movie` is `Credit_.movie.titleFolded` from a query on credits. Projections of the same table declare their own.
+- `where`, `orderBy`, `orderFirst`, `search` and `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it. A keyset sort refuses a nullable query column, as it refuses a nullable field; `nullable = true` declares one.
+- Schema validation checks that the column exists with a compatible type, and on an entity that the database fills it, since every insert leaves it out: it must be generated, an identity column, have a default, or allow null. A column the database fills in a way its metadata does not show, such as a trigger, is declared with `validate = false`, which skips its checks as `@DbIgnore` skips a field's.
+
+Where a caller reads the value, map the column as a field with `@Persist(insertable = false, updatable = false)` instead: a query column is for a column no row needs to carry.
+
+---
+
## 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:
diff --git a/docs/metamodel.md b/docs/metamodel.md
index 4562b1348..8c4bf9b40 100644
--- a/docs/metamodel.md
+++ b/docs/metamodel.md
@@ -374,7 +374,7 @@ A direct (non-`Ref`) self-referential foreign key is rejected at generation time
## Generated Code
-Understanding the generated code helps when debugging or reading compiler errors. The metamodel mirrors your entity structure, creating a static field for each entity field. Each field carries generic type parameters that encode both the root entity type and the field's value type, which is how the compiler enforces type safety in queries.
+Understanding the generated code helps when debugging or reading compiler errors. The metamodel mirrors your entity structure, creating a static field for each entity field, and one for each [query column](entities.md#query-columns) the entity declares. Each field carries generic type parameters that encode both the root entity type and the field's value type, which is how the compiler enforces type safety in queries.
```
Entity Metamodel
diff --git a/docs/search.md b/docs/search.md
index a3400201c..7ec8e815c 100644
--- a/docs/search.md
+++ b/docs/search.md
@@ -308,3 +308,53 @@ The search compares `LOWER(fold_accents(m.title)) LIKE LOWER(fold_accents(?))`,
- Name the function exactly as the index does. A different function, or the same one applied in another order, is another expression, and the plan falls back to reading every row.
- The database folds the input too, with its own rules. PostgreSQL's `unaccent` maps `ø` to `o` and `æ` to `ae`, which removing combining marks in the application would leave alone; folding both sides in the database keeps the column and the input in step.
- The function receives the search pattern, wildcards included, and an accent function leaves them alone, as a function compared through a text operator must.
+
+### Storing the Folded Text
+
+The expression index is enough for most catalogs. The index finds the candidate rows, and the database then rechecks each candidate by computing `fold_accents` from the title; on a column where a common word matches many thousands of rows per keystroke, that recheck is where the time goes. Where a search stays slow for that reason, and only then, store the folded title instead of computing it per search: a generated column holds `fold_accents(title)`, the index is built on that column, and the entity declares it as a [query column](entities.md#query-columns), a column the search names and no row carries.
+
+```sql
+ALTER TABLE movie ADD COLUMN title_folded VARCHAR GENERATED ALWAYS AS (fold_accents(title)) STORED NOT NULL;
+CREATE INDEX idx_movie_title_folded ON movie USING gin (LOWER(title_folded) gin_trgm_ops);
+```
+
+The search then compares `LOWER(m.title_folded) LIKE LOWER(?)`, the expression the index holds, and the recheck reads the stored column. The input must be folded by the same function, in the database, before the search; applying `foldAccents` to the query column instead would fold both sides again and bring the recheck back.
+
+
+
+
+```kotlin
+@DbTable("movie")
+@QueryColumn(field = "titleFolded", type = String::class)
+data class Movie(...) : Entity
+
+val folded = orm.query { "SELECT fold_accents($input)" }.singleResult()
+
+orm.entity(Movie::class)
+ .select()
+ .where(search(folded, Movie_.titleFolded))
+ .orderFirst(Movie_.titleFolded eqIgnoreCase folded)
+ .resultList
+```
+
+
+
+
+```java
+@DbTable("movie")
+@QueryColumn(field = "titleFolded", type = String.class)
+record Movie(...) implements Entity {}
+
+String folded = orm.query(RAW."SELECT fold_accents(\{input})").getSingleResult(String.class);
+
+orm.entity(Movie.class)
+ .select()
+ .where(it -> it.search(folded, Movie_.titleFolded))
+ .orderFirst(it -> it.where(Movie_.titleFolded, EQUALS_IGNORE_CASE, folded))
+ .getResultList();
+```
+
+
+
+
+The entity keeps one field for the title, which the query selects with its accents for display; the folded column stays out of every SELECT and INSERT, and the writes pay for the folding once per row.
diff --git a/docs/validation.md b/docs/validation.md
index ae3be65a5..12903661d 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -74,12 +74,14 @@ Schema validation compares your entity and projection definitions against the ac
| Entity primary key columns match the database primary key | `PRIMARY_KEY_MISMATCH` | Error |
| `@FK` constraint references the correct target table | `FOREIGN_KEY_MISMATCH` | Error |
| Sequences referenced by `@PK(generation = SEQUENCE)` exist | `SEQUENCE_NOT_FOUND` | Error |
+| A [query column](entities.md#query-columns) of an entity is filled by the database: generated, an identity column, with a default, or nullable | `QUERY_COLUMN_NOT_FILLED` | Error |
| | | |
| Numeric cross-category conversions (e.g., `Integer` mapped to `DECIMAL`) | `TYPE_NARROWING` | Warning |
| Non-nullable entity field mapped to a nullable database column | `NULLABILITY_MISMATCH` | Warning |
| Entity declares `@PK` but the database has no primary key constraint | `PRIMARY_KEY_MISSING` | Warning |
| `@UK` field has a matching unique constraint in the database | `UNIQUE_KEY_MISSING` | Warning |
| `@FK` field has a matching foreign key constraint in the database | `FOREIGN_KEY_MISSING` | Warning |
+| A `NOT NULL` column without a default that no field or query column maps, and that the database does not generate | `UNMAPPED_COLUMN` | Warning |
**Errors** indicate definitive mismatches that will cause runtime failures, such as missing tables or columns.
diff --git a/storm-core/src/main/java/st/orm/core/template/Column.java b/storm-core/src/main/java/st/orm/core/template/Column.java
index 0b7a5baff..728ba03f4 100644
--- a/storm-core/src/main/java/st/orm/core/template/Column.java
+++ b/storm-core/src/main/java/st/orm/core/template/Column.java
@@ -24,7 +24,8 @@
*/
public interface Column {
/**
- * Gets the 1-based index of the column.
+ * Gets the 1-based index of the column among the model's columns, or {@code 0} for a query column, which has no
+ * position among them.
*
* @return the column index.
*/
diff --git a/storm-core/src/main/java/st/orm/core/template/Model.java b/storm-core/src/main/java/st/orm/core/template/Model.java
index d1d15a5f0..1b9265a59 100644
--- a/storm-core/src/main/java/st/orm/core/template/Model.java
+++ b/storm-core/src/main/java/st/orm/core/template/Model.java
@@ -110,6 +110,20 @@ public interface Model {
*/
List declaredColumns();
+ /**
+ * Returns the query columns declared on this model: the columns a query names but the record does not carry,
+ * declared with {@link st.orm.QueryColumn}. They are not among {@link #columns()} or {@link #declaredColumns()},
+ * so a select, insert or update never names them; {@link #getColumns(Metamodel)} resolves them, for a query
+ * column of this type and for one of a type reached through an expanded relation.
+ *
+ * A query column has no record field: its {@link Column#index()} is {@code 0}, and it is neither insertable
+ * nor updatable.
+ *
+ * @return the query columns declared on this model, in declaration order.
+ * @since 1.16
+ */
+ List queryColumns();
+
/**
* Returns the record type metadata.
*
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java b/storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java
index fa590b2b7..407b91549 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/DatabaseSchema.java
@@ -19,6 +19,7 @@
import java.sql.DatabaseMetaData;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collection;
@@ -48,6 +49,18 @@
*/
public final class DatabaseSchema {
+ /**
+ * Returns whether the result set has a column with the given label.
+ */
+ private static boolean hasColumn(ResultSetMetaData resultSetMetaData, String label) throws SQLException {
+ for (int i = 1; i <= resultSetMetaData.getColumnCount(); i++) {
+ if (label.equalsIgnoreCase(resultSetMetaData.getColumnLabel(i))) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static final Logger LOGGER = LoggerFactory.getLogger("st.orm.validation");
/**
@@ -61,6 +74,8 @@ public final class DatabaseSchema {
* @param nullable whether the column allows NULL values.
* @param autoIncrement whether the column is auto-incremented.
* @param hasDefault whether the column has a default value defined.
+ * @param generated whether the database computes the column from an expression, as the driver reports it;
+ * a driver that does not report it reads as {@code false}.
*/
public record DbColumn(
String tableName,
@@ -70,7 +85,8 @@ public record DbColumn(
int columnSize,
boolean nullable,
boolean autoIncrement,
- boolean hasDefault
+ boolean hasDefault,
+ boolean generated
) {}
/**
@@ -329,6 +345,8 @@ public static DatabaseSchema read(
}
// Discover columns for all tables.
try (ResultSet columns = metadata.getColumns(catalog, schemaPattern, "%", "%")) {
+ // IS_GENERATEDCOLUMN is the last column JDBC specifies, and not every driver returns it.
+ boolean reportsGenerated = hasColumn(columns.getMetaData(), "IS_GENERATEDCOLUMN");
while (columns.next()) {
String tableName = columns.getString("TABLE_NAME");
// Only include columns for tables we discovered.
@@ -347,9 +365,10 @@ public static DatabaseSchema read(
boolean nullable = !"NO".equalsIgnoreCase(nullableStr);
String autoIncrementStr = columns.getString("IS_AUTOINCREMENT");
boolean autoIncrement = "YES".equalsIgnoreCase(autoIncrementStr);
+ boolean generated = reportsGenerated && "YES".equalsIgnoreCase(columns.getString("IS_GENERATEDCOLUMN"));
columnsByTable.computeIfAbsent(tableName, k -> new ArrayList<>())
- .add(new DbColumn(tableName, columnName, dataType, typeName, columnSize, nullable, autoIncrement, hasDefault));
+ .add(new DbColumn(tableName, columnName, dataType, typeName, columnSize, nullable, autoIncrement, hasDefault, generated));
}
}
// A database that names its schemas as catalogs has no schema pattern; its catalog is the schema the
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
index cd91872d5..8aa22a871 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
@@ -18,6 +18,7 @@
import static java.lang.invoke.MethodType.methodType;
import static st.orm.core.spi.Providers.getORMConverter;
import static st.orm.core.template.impl.RecordReflection.findPkField;
+import static st.orm.core.template.impl.RecordReflection.findQueryColumn;
import static st.orm.core.template.impl.RecordReflection.getRecordField;
import static st.orm.core.template.impl.RecordReflection.getRecordFields;
import static st.orm.core.template.impl.RecordReflection.getRefDataType;
@@ -41,6 +42,7 @@
import st.orm.Navigable;
import st.orm.PK;
import st.orm.PersistenceException;
+import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlTemplateException;
import st.orm.UK;
@@ -208,8 +210,14 @@ public static boolean isNullable(Metamodel, ?> metamodel) {
// The root metamodel designates the row itself rather than a column.
return false;
}
+ Class extends Data> fieldResolutionClass = fieldResolutionClass(metamodel.root());
+ QueryColumn queryColumn = findQueryColumnAtPath(fieldResolutionClass, path);
+ if (queryColumn != null) {
+ // A query column has no record field; its declaration states its nullability.
+ return queryColumn.nullable();
+ }
try {
- RecordField field = getRecordField(fieldResolutionClass(metamodel.root()), path);
+ RecordField field = getRecordField(fieldResolutionClass, path);
boolean nullsDistinct = getNullsDistinct(field);
boolean inline = !Ref.class.isAssignableFrom(field.type())
&& getORMConverter(field).isEmpty()
@@ -390,6 +398,10 @@ private static Metamodel getModel(Class rootTable,
return (Metamodel) root(rootTable);
}
Class extends Data> fieldResolutionClass = fieldResolutionClass(rootTable);
+ QueryColumn queryColumn = findQueryColumnAtPath(fieldResolutionClass, path);
+ if (queryColumn != null) {
+ return queryColumnModel(rootTable, fieldResolutionClass, path, queryColumn);
+ }
Class fieldType;
String effectivePath;
StringBuilder effectiveField;
@@ -532,6 +544,66 @@ && isPrimaryKeyName(getRefDataType(referenceField), effectiveField.toString()))
);
}
+ /**
+ * Returns the query column a path ends in, or {@code null} when its last segment is not one. The table the path
+ * reaches declares it, as it declares its components; an inline record declares none.
+ */
+ @SuppressWarnings("unchecked")
+ private static @Nullable QueryColumn findQueryColumnAtPath(Class extends Data> fieldResolutionClass, String path) {
+ String parentPath = stripLast(path);
+ String fieldName = parentPath.isEmpty() ? path : path.substring(parentPath.length() + 1);
+ Class extends Data> tableType;
+ if (parentPath.isEmpty()) {
+ tableType = fieldResolutionClass;
+ } else {
+ try {
+ RecordField parent = getRecordField(fieldResolutionClass, parentPath);
+ if (Ref.class.isAssignableFrom(parent.type())) {
+ tableType = getRefDataType(parent);
+ } else if (Data.class.isAssignableFrom(parent.type())) {
+ tableType = (Class extends Data>) parent.type();
+ } else {
+ return null;
+ }
+ } catch (SqlTemplateException e) {
+ return null;
+ }
+ }
+ return findQueryColumn(tableType, fieldName).orElse(null);
+ }
+
+ /**
+ * Creates the metamodel of a query column reached by path, with the table it belongs to resolved as a
+ * component's table is.
+ */
+ @SuppressWarnings({"unchecked", "rawtypes"})
+ private static Metamodel queryColumnModel(Class rootTable,
+ Class extends Data> fieldResolutionClass,
+ String path,
+ QueryColumn queryColumn) {
+ String tablePathToParent = stripLast(path);
+ Metamodel rootModel = root(rootTable);
+ Metamodel tableModel;
+ if (tablePathToParent.isEmpty()) {
+ tableModel = rootModel;
+ } else {
+ String tablePath = getTablePath(fieldResolutionClass, tablePathToParent);
+ String tableField = tablePath.isEmpty() ? tablePathToParent : tablePathToParent.substring(tablePath.length() + 1);
+ Class extends Data> tableType = resolveDataTypeAtPath(fieldResolutionClass, tablePathToParent);
+ tableModel = new SimpleMetamodel<>(
+ rootTable,
+ tablePath,
+ (Class) tableType,
+ tableField,
+ false,
+ false,
+ rootModel,
+ buildGetterHandle(rootTable, tablePathToParent)
+ );
+ }
+ return new QueryColumnMetamodel<>(rootTable, tablePathToParent, (Class) queryColumn.type(), queryColumn.field(), tableModel);
+ }
+
@Nullable
@SuppressWarnings("unchecked")
private static Metamodel lookupGeneratedMetamodel(
@@ -872,6 +944,57 @@ public boolean isNullable() {
}
}
+ /**
+ * The metamodel of a query column resolved by name: a column the query names and the record does not carry, so
+ * a record holds no value for it.
+ */
+ private static final class QueryColumnMetamodel
+ extends AbstractMetamodel {
+
+ private final Class root;
+ private final Metamodel table;
+
+ QueryColumnMetamodel(Class root,
+ String path,
+ Class fieldType,
+ String field,
+ Metamodel table) {
+ super(fieldType, path, field, false, null, true);
+ this.root = root;
+ this.table = table;
+ }
+
+ @Override
+ public Class root() {
+ return root;
+ }
+
+ @Override
+ public Metamodel table() {
+ return table;
+ }
+
+ @Override
+ public Object getValue(T record) {
+ throw noValue();
+ }
+
+ @Override
+ public boolean isIdentical(T a, T b) {
+ throw noValue();
+ }
+
+ @Override
+ public boolean isSame(T a, T b) {
+ throw noValue();
+ }
+
+ private UnsupportedOperationException noValue() {
+ return new UnsupportedOperationException("%s.%s is a query column; a record holds no value for it."
+ .formatted(table.fieldType().getSimpleName(), field()));
+ }
+ }
+
private static final class SimpleMetamodel
extends AbstractMetamodel {
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
index ba0b4fc2c..1f121ca1b 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
@@ -16,6 +16,7 @@
package st.orm.core.template.impl;
import static java.util.Collections.nCopies;
+import static java.util.stream.Collectors.toSet;
import static st.orm.core.spi.Providers.getORMConverter;
import static st.orm.core.template.impl.RecordReflection.detectSealedPattern;
import static st.orm.core.template.impl.RecordReflection.findJoinedSealedParent;
@@ -27,6 +28,8 @@
import static st.orm.core.template.impl.RecordReflection.getFkLeaves;
import static st.orm.core.template.impl.RecordReflection.getForeignKeys;
import static st.orm.core.template.impl.RecordReflection.getGenerationStrategy;
+import static st.orm.core.template.impl.RecordReflection.getQueryColumnField;
+import static st.orm.core.template.impl.RecordReflection.getQueryColumns;
import static st.orm.core.template.impl.RecordReflection.getRecordType;
import static st.orm.core.template.impl.RecordReflection.getRefDataType;
import static st.orm.core.template.impl.RecordReflection.getSequence;
@@ -37,6 +40,7 @@
import static st.orm.core.template.impl.RecordValidation.validateDataType;
import java.util.ArrayList;
+import java.util.HashSet;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
@@ -49,6 +53,7 @@
import st.orm.Metamodel;
import st.orm.PK;
import st.orm.Persist;
+import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlTemplateException;
import st.orm.Version;
@@ -125,11 +130,13 @@ private static Model createModel(ModelBuilder builde
RecordType recordType = getRecordType(type);
List columns = new ArrayList<>();
List fields = new ArrayList<>();
+ List queryColumns = new ArrayList<>();
try {
- BuildContext ctx = new BuildContext(builder, columns, fields, Metamodel.root(type), new AtomicInteger(1), fetchPlan);
+ BuildContext ctx = new BuildContext(builder, columns, fields, queryColumns, Metamodel.root(type), new AtomicInteger(1), fetchPlan);
for (var field : recordType.fields()) {
createColumns(ctx, ctx.rootMetamodel(), field, false, null, KeyScope.none(), PkContext.none(), null, false);
}
+ createQueryColumns(ctx, ctx.rootMetamodel(), type, false);
var tableName = getTableName(type, builder.tableNameResolver(), builder.schemaResolver());
// For permitted subclasses of a JOINED sealed entity, adjust columns so that
// base non-PK fields are not insertable/updatable (they belong to the base table),
@@ -141,7 +148,7 @@ private static Model createModel(ModelBuilder builde
}
// The first model of an entity is the moment it is first used, where no startup check saw it.
DesignCheck.check(type);
- return new ModelImpl<>(recordType, tableName, fields, finalColumns);
+ return new ModelImpl<>(recordType, tableName, fields, finalColumns, queryColumns);
} catch (UncheckedSqlTemplateException e) {
throw e.getCause();
}
@@ -349,10 +356,15 @@ private static Model createSealedModel(ModelBuilder
var tableName = getTableName(sealedType, builder.tableNameResolver(), builder.schemaResolver());
// Override the type to the sealed interface so that Model.type() returns the sealed interface
// (e.g., Vehicle.class) rather than the first permitted subclass (e.g., Car.class).
- return new ModelImpl<>(firstRecordType, sealedType, tableName, fields, columns);
+ return new ModelImpl<>(firstRecordType, sealedType, tableName, fields, columns, List.of());
}
- record BuildContext(ModelBuilder builder, List columns, List fields, Metamodel, ?> rootMetamodel, AtomicInteger index, FetchPlan fetchPlan) {
+ /**
+ * The state of a model under construction. {@code columns} and {@code fields} are aligned, one record field per
+ * column; {@code queryColumns} holds the columns the query side knows and the record does not carry, which have
+ * no field.
+ */
+ record BuildContext(ModelBuilder builder, List columns, List fields, List queryColumns, Metamodel, ?> rootMetamodel, AtomicInteger index, FetchPlan fetchPlan) {
}
record ColumnSpec(boolean primaryKey,
@@ -510,6 +522,63 @@ private static void expandForeignRelation(BuildContext ctx,
for (var child : targetRecordType.fields()) {
createColumns(ctx, foreignMetamodel, child, nullableDueToJoin, null, KeyScope.none(), PkContext.none(), null, true);
}
+ createQueryColumns(ctx, foreignMetamodel, targetType, nullableDueToJoin);
+ }
+
+ /**
+ * Adds the query columns the type declares, under the parent's path, so that a path through a relation reaches
+ * them as it reaches the components. A query column is never selected, inserted or updated, so it takes no
+ * position among the model's columns and no record field.
+ */
+ private static void createQueryColumns(BuildContext ctx,
+ Metamodel, ?> parentMetamodel,
+ Class extends Data> type,
+ boolean parentNullable) {
+ try {
+ var componentNames = getRecordType(type).fields().stream().map(RecordField::name).collect(toSet());
+ var declared = new HashSet();
+ for (QueryColumn queryColumn : getQueryColumns(type)) {
+ String fieldName = queryColumn.field();
+ if (fieldName.isEmpty()) {
+ throw new SqlTemplateException("A query column of %s names no field.".formatted(type.getSimpleName()));
+ }
+ if (componentNames.contains(fieldName)) {
+ throw new SqlTemplateException("Query column '%s' of %s has the name of a record component; a query column is a column the record does not carry.".formatted(fieldName, type.getSimpleName()));
+ }
+ if (!declared.add(fieldName)) {
+ throw new SqlTemplateException("Query column '%s' of %s is declared twice.".formatted(fieldName, type.getSimpleName()));
+ }
+ RecordField field = getQueryColumnField(type, queryColumn);
+ @SuppressWarnings("unchecked")
+ Metamodel metamodel = (Metamodel) Metamodel.of(
+ (Class extends Data>) parentMetamodel.root(),
+ parentMetamodel.fieldPath().isEmpty() ? fieldName : parentMetamodel.fieldPath() + "." + fieldName);
+ ColumnName columnName = queryColumn.column().isEmpty()
+ ? new ColumnName(ctx.builder().columnNameResolver().resolveColumnName(field))
+ : new ColumnName(queryColumn.column());
+ ctx.queryColumns().add(new ColumnImpl(
+ columnName,
+ 0,
+ queryColumn.type(),
+ queryColumn.type(),
+ false,
+ GenerationStrategy.NONE,
+ "",
+ false,
+ GenerationStrategy.NONE,
+ -1,
+ parentNullable || queryColumn.nullable(),
+ false,
+ false,
+ false,
+ false,
+ metamodel,
+ null
+ ));
+ }
+ } catch (SqlTemplateException e) {
+ throw new UncheckedSqlTemplateException(e);
+ }
}
private static ColumnSpec buildSpec(RecordField field,
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
index 2c4ea2038..9b4d98676 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
@@ -113,6 +113,7 @@ protected Map computeValue(Class> type) {
private final List columns;
private final RecordField primaryKeyField;
private final List declaredColumns;
+ private final List queryColumns;
private final Map, List>> mappedMetamodels;
private final Metamodel primaryKeyMetamodel;
private final Map, List> columnMap;
@@ -162,8 +163,9 @@ protected Map computeValue(Class> type) {
public ModelImpl(RecordType recordType,
TableName tableName,
List fields,
- List columns) throws SqlTemplateException {
- this(recordType, null, tableName, fields, columns);
+ List columns,
+ List queryColumns) throws SqlTemplateException {
+ this(recordType, null, tableName, fields, columns, queryColumns);
}
/**
@@ -178,19 +180,24 @@ public ModelImpl(RecordType recordType,
* @param tableName the table name.
* @param fields the record fields aligned with columns.
* @param columns the column metadata.
+ * @param queryColumns the columns a query names but the record does not carry, those of the type and those
+ * reached through its expanded relations; they are resolved by metamodel and take no position
+ * among {@code columns}.
*/
public ModelImpl(RecordType recordType,
@Nullable Class typeOverride,
TableName tableName,
List fields,
- List columns) throws SqlTemplateException {
+ List columns,
+ List queryColumns) throws SqlTemplateException {
assert fields.size() == columns.size() : "Columns and fields must have the same size";
this.recordType = requireNonNull(recordType, "recordType");
this.typeOverride = typeOverride;
this.tableName = requireNonNull(tableName, "tableName");
this.fields = copyOf(fields);
this.columns = copyOf(columns);
- this.columnMap = initColumnMap(this.columns);
+ this.queryColumns = initDeclaredColumns(queryColumns);
+ this.columnMap = initColumnMap(this.columns, queryColumns);
this.discriminatorColumnIndex = initDiscriminatorColumnIndex(typeOverride);
this.polymorphicFkDiscriminatorColumns = initPolymorphicFkDiscriminatorColumns(this.fields, this.columns);
this.converters = initConverters(this.fields, this.columns);
@@ -202,7 +209,7 @@ public ModelImpl(RecordType recordType,
this.mappedMetamodels = initMappedMetamodels(this.fields, this.columns);
}
- private static Map, List> initColumnMap(List columns) {
+ private static Map, List> initColumnMap(List columns, List queryColumns) {
var map = new HashMap, List>();
for (var column : columns) {
map.computeIfAbsent(column.metamodel().canonical(), ignore -> new ArrayList<>()).add(column);
@@ -210,6 +217,9 @@ public ModelImpl(RecordType recordType,
map.computeIfAbsent(column.secondaryMetamodel().canonical(), ignore -> new ArrayList<>()).add(column);
}
}
+ for (var column : queryColumns) {
+ map.computeIfAbsent(column.metamodel().canonical(), ignore -> new ArrayList<>()).add(column);
+ }
map.replaceAll((k, v) -> List.copyOf(v));
return Map.copyOf(map);
}
@@ -826,7 +836,8 @@ private Object map(Column column, @Nullable Object value) {
case LocalTime it -> Time.valueOf(it);
case Calendar it -> new Timestamp(it.getTimeInMillis());
case java.util.Date it -> new Timestamp(it.getTime());
- case Enum> it -> switch (ofNullable(fields.get(column.index() - 1).getAnnotation(DbEnum.class))
+ // A query column has no record field, and so no @DbEnum: its enum values compare by name.
+ case Enum> it -> switch (ofNullable(column.index() > 0 ? fields.get(column.index() - 1).getAnnotation(DbEnum.class) : null)
.map(DbEnum::value)
.orElse(NAME)) {
case NAME -> it.name();
@@ -850,4 +861,9 @@ public List columns() {
public List declaredColumns() {
return declaredColumns;
}
+
+ @Override
+ public List queryColumns() {
+ return queryColumns;
+ }
}
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
index dbb245835..390c49eb3 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
@@ -51,6 +51,7 @@
import st.orm.PersistenceException;
import st.orm.Polymorphic;
import st.orm.ProjectionQuery;
+import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlTemplateException;
import st.orm.Version;
@@ -826,6 +827,42 @@ private static Stream> columnNames(DbColumn[] dbColumns) {
private static final List> COLUMN_ANNOTATIONS = List.of(PK.class, FK.class, DbColumn.class);
+ /**
+ * Returns the query columns a type declares, in declaration order: the columns a query names but the type does
+ * not carry. A repeated {@code @QueryColumn} and the {@code @QueryColumns} container read alike.
+ *
+ * @param type the entity or projection type.
+ * @return the query columns, empty for a type that declares none.
+ */
+ static List getQueryColumns(Class> type) {
+ return List.of(type.getAnnotationsByType(QueryColumn.class));
+ }
+
+ /**
+ * Returns the query column of the type with the given field name, if the type declares one.
+ *
+ * @param type the entity or projection type.
+ * @param field the metamodel field name.
+ * @return the query column, or empty when the type declares none by that name.
+ */
+ static Optional findQueryColumn(Class> type, String field) {
+ return getQueryColumns(type).stream().filter(queryColumn -> queryColumn.field().equals(field)).findFirst();
+ }
+
+ /**
+ * Returns a record field standing in for a query column, which has no component. It carries the field name, the
+ * declared type and nullability, and the declaration as its only annotation, which is what a column name resolver
+ * reads. It has no accessor, since a record holds no value for the column.
+ *
+ * @param declaringType the type that declares the query column.
+ * @param queryColumn the declaration.
+ * @return the record field.
+ */
+ static RecordField getQueryColumnField(Class> declaringType, QueryColumn queryColumn) {
+ return new RecordField(declaringType, queryColumn.field(), queryColumn.type(), queryColumn.type(),
+ queryColumn.nullable(), false, null, List.of(queryColumn));
+ }
+
/**
* Returns the column name for the specified record field taking the column name resolver into account,
* if present.
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
index afbeaed44..fa16f470b 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
@@ -57,7 +57,12 @@ public enum ErrorKind {
/** A {@code @FK} field does not have a matching foreign key constraint in the database. */
FOREIGN_KEY_MISSING(true),
/** A database column is NOT NULL without a default value, but is not mapped in the entity. @since 1.10 */
- UNMAPPED_COLUMN(true);
+ UNMAPPED_COLUMN(true),
+ /**
+ * A query column of an entity is NOT NULL and the database does not fill it: it is not generated, is not an
+ * identity column and has no default, so every insert, which leaves it out, fails. @since 1.16
+ */
+ QUERY_COLUMN_NOT_FILLED;
private final boolean warning;
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
index 4515e8acc..47e87fb96 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
@@ -15,6 +15,7 @@
*/
package st.orm.core.template.impl;
+import static st.orm.core.template.impl.RecordReflection.findQueryColumn;
import static st.orm.core.template.impl.RecordReflection.isPolymorphicData;
import java.lang.reflect.ParameterizedType;
@@ -44,6 +45,7 @@
import st.orm.GenerationStrategy;
import st.orm.PK;
import st.orm.ProjectionQuery;
+import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlTemplateException;
import st.orm.StormConfig;
@@ -529,41 +531,31 @@ private void validateType(
// Track all column names (including @DbIgnore) for the unmapped column check (step 9).
Set mappedColumns = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
for (Column column : model.declaredColumns()) {
- String columnName = column.name();
- mappedColumns.add(columnName);
+ mappedColumns.add(column.name());
if (isIgnored(column, ignoredComponents)) {
continue;
}
- Optional dbColumn = schema.getColumn(tableName, columnName);
-
- if (dbColumn.isEmpty()) {
- errors.add(new SchemaValidationError(type, ErrorKind.COLUMN_NOT_FOUND,
- "Column '%s' not found in table '%s'.".formatted(columnName, qualifiedTableName)));
+ validateColumn(type, column, schema, tableName, qualifiedTableName, errors);
+ }
+ // A query column is checked as a declared column is, and on an entity for being filled by the database,
+ // since every insert leaves it out.
+ boolean entity = Entity.class.isAssignableFrom(type);
+ for (Column column : model.queryColumns()) {
+ mappedColumns.add(column.name());
+ QueryColumn declaration = findQueryColumn(type, column.metamodel().field()).orElse(null);
+ if (declaration != null && !declaration.validate()) {
continue;
}
- DbColumn dbCol = dbColumn.get();
- // Type compatibility check against the persisted type, so foreign key columns are validated
- // against the referenced key's terminal type rather than being skipped as unknown entity types.
- Compatibility compatibility = typeCompatibility.check(column.persistedType(), dbCol.dataType(), dbCol.typeName());
- if (compatibility == Compatibility.NARROWING) {
- errors.add(new SchemaValidationError(type, ErrorKind.TYPE_NARROWING,
- "Column '%s' in table '%s': Java type '%s' mapped to SQL type '%s' (%d) may involve precision or range loss."
- .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
- dbCol.typeName(), dbCol.dataType())));
- } else if (compatibility == Compatibility.INCOMPATIBLE) {
- errors.add(new SchemaValidationError(type, ErrorKind.TYPE_INCOMPATIBLE,
- "Column '%s' in table '%s': Java type '%s' is not compatible with SQL type '%s' (%d)."
- .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
- dbCol.typeName(), dbCol.dataType())));
- }
- // Nullability check: entity field is non-nullable but database column allows NULL.
- // We only flag the case where the entity says non-null but the DB says nullable,
- // since the reverse (entity nullable, DB not null) is safe. A view's columns read as nullable
- // whatever its query returns, so they carry no answer to check against.
- if (!column.nullable() && dbCol.nullable() && !schema.isView(tableName)) {
- errors.add(new SchemaValidationError(type, ErrorKind.NULLABILITY_MISMATCH,
- "Column '%s' in table '%s': entity field is non-nullable but database column allows NULL."
- .formatted(columnName, qualifiedTableName)));
+ DbColumn dbColumn = validateColumn(type, column, schema, tableName, qualifiedTableName, errors);
+ if (dbColumn != null && entity && !dbColumn.nullable() && !dbColumn.hasDefault()
+ && !dbColumn.autoIncrement() && !dbColumn.generated()) {
+ errors.add(new SchemaValidationError(type, ErrorKind.QUERY_COLUMN_NOT_FILLED,
+ ("Query column '%s' in table '%s' is NOT NULL and the database does not fill it: the column is "
+ + "not generated, is not an identity column and has no default. Every insert of entity '%s' "
+ + "leaves it out and fails. Give the column a generation expression or a default, or declare "
+ + "the query column with validate = false where the database fills it in a way its metadata "
+ + "does not show.")
+ .formatted(column.name(), qualifiedTableName, type.getSimpleName())));
}
}
// 5. Primary key match. Skipped when this table's primary key could not be read: an empty result would
@@ -830,8 +822,8 @@ private void validateUnmappedColumns(
if (mappedColumns.contains(dbColumn.columnName())) {
continue; // Column is mapped in the entity.
}
- // Skip columns that are safe to omit: nullable, has a default, or auto-incremented.
- if (dbColumn.nullable() || dbColumn.hasDefault() || dbColumn.autoIncrement()) {
+ // Skip columns that are safe to omit: nullable, has a default, auto-incremented, or generated.
+ if (dbColumn.nullable() || dbColumn.hasDefault() || dbColumn.autoIncrement() || dbColumn.generated()) {
continue;
}
errors.add(new SchemaValidationError(type, ErrorKind.UNMAPPED_COLUMN,
@@ -840,6 +832,52 @@ private void validateUnmappedColumns(
}
}
+ /**
+ * Checks that a column exists in the table with a compatible type and nullability, and returns the database
+ * column, or {@code null} when the table has none by that name.
+ */
+ private @Nullable DbColumn validateColumn(
+ Class extends Data> type,
+ Column column,
+ DatabaseSchema schema,
+ String tableName,
+ String qualifiedTableName,
+ List errors
+ ) {
+ String columnName = column.name();
+ Optional dbColumn = schema.getColumn(tableName, columnName);
+ if (dbColumn.isEmpty()) {
+ errors.add(new SchemaValidationError(type, ErrorKind.COLUMN_NOT_FOUND,
+ "Column '%s' not found in table '%s'.".formatted(columnName, qualifiedTableName)));
+ return null;
+ }
+ DbColumn dbCol = dbColumn.get();
+ // Type compatibility check against the persisted type, so foreign key columns are validated
+ // against the referenced key's terminal type rather than being skipped as unknown entity types.
+ Compatibility compatibility = typeCompatibility.check(column.persistedType(), dbCol.dataType(), dbCol.typeName());
+ if (compatibility == Compatibility.NARROWING) {
+ errors.add(new SchemaValidationError(type, ErrorKind.TYPE_NARROWING,
+ "Column '%s' in table '%s': Java type '%s' mapped to SQL type '%s' (%d) may involve precision or range loss."
+ .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
+ dbCol.typeName(), dbCol.dataType())));
+ } else if (compatibility == Compatibility.INCOMPATIBLE) {
+ errors.add(new SchemaValidationError(type, ErrorKind.TYPE_INCOMPATIBLE,
+ "Column '%s' in table '%s': Java type '%s' is not compatible with SQL type '%s' (%d)."
+ .formatted(columnName, qualifiedTableName, column.persistedType().getSimpleName(),
+ dbCol.typeName(), dbCol.dataType())));
+ }
+ // Nullability check: entity field is non-nullable but database column allows NULL.
+ // We only flag the case where the entity says non-null but the DB says nullable,
+ // since the reverse (entity nullable, DB not null) is safe. A view's columns read as nullable
+ // whatever its query returns, so they carry no answer to check against.
+ if (!column.nullable() && dbCol.nullable() && !schema.isView(tableName)) {
+ errors.add(new SchemaValidationError(type, ErrorKind.NULLABILITY_MISMATCH,
+ "Column '%s' in table '%s': entity field is non-nullable but database column allows NULL."
+ .formatted(columnName, qualifiedTableName)));
+ }
+ return dbCol;
+ }
+
/**
* Returns the names of fields annotated with {@link DbIgnore}.
*/
diff --git a/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java b/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
new file mode 100644
index 000000000..4201479e9
--- /dev/null
+++ b/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm.core;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static st.orm.Operator.EQUALS;
+import static st.orm.core.template.SqlInterceptor.observe;
+import static st.orm.core.template.TemplateString.raw;
+
+import java.util.ArrayList;
+import java.util.List;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.context.junit.jupiter.SpringExtension;
+import st.orm.Entity;
+import st.orm.Metamodel;
+import st.orm.PK;
+import st.orm.PersistenceException;
+import st.orm.QueryColumn;
+import st.orm.Scrollable;
+import st.orm.core.model.Owner;
+import st.orm.core.model.Owner_;
+import st.orm.core.model.Pet;
+import st.orm.core.model.Pet_;
+import st.orm.core.template.ORMTemplate;
+import st.orm.core.template.impl.PredicateBuilderFactory;
+
+/**
+ * A query column is a column a query names but the record does not carry: {@code owner.last_name_folded} is generated
+ * by the database from the last name, and {@code Owner} declares it as {@code Owner_.lastNameFolded} with no
+ * component.
+ */
+@ExtendWith(SpringExtension.class)
+@ContextConfiguration(classes = IntegrationConfig.class)
+@JdbcTest
+public class QueryColumnIntegrationTest {
+
+ @Autowired
+ private DataSource dataSource;
+
+ private final List statements = new ArrayList<>();
+
+ private ORMTemplate orm() {
+ return ORMTemplate.of(dataSource);
+ }
+
+ private T observed(java.util.function.Supplier action) {
+ statements.clear();
+ return observe(sql -> statements.add(sql.statement().replaceAll("\\s+", " ")), action);
+ }
+
+ private List firstNames(List owners) {
+ return owners.stream().map(Owner::firstName).sorted().toList();
+ }
+
+ @Test
+ public void queryColumnIsNeverSelected() {
+ List owners = observed(() -> orm().entity(Owner.class).select().getResultList());
+ assertEquals(10, owners.size());
+ assertFalse(statements.getFirst().contains("last_name_folded"), statements.getFirst());
+ }
+
+ @Test
+ public void whereMatchesTheQueryColumn() {
+ List owners = observed(() -> orm().entity(Owner.class).select()
+ .where(Owner_.lastNameFolded, EQUALS, "davis")
+ .getResultList());
+ assertEquals(List.of("Betty", "Harold"), firstNames(owners));
+ assertTrue(statements.getFirst().contains("last_name_folded = ?"), statements.getFirst());
+ }
+
+ @Test
+ public void searchMatchesTheQueryColumn() {
+ List owners = orm().entity(Owner.class).select()
+ .where(it -> PredicateBuilderFactory.search("dav", List.of(Owner_.lastNameFolded)))
+ .getResultList();
+ assertEquals(List.of("Betty", "Harold"), firstNames(owners));
+ }
+
+ @Test
+ public void pathReachesTheQueryColumnOfARelatedTable() {
+ List byFolded = observed(() -> orm().entity(Pet.class).select()
+ .where(Pet_.owner.lastNameFolded, EQUALS, "davis")
+ .getResultList());
+ List byName = orm().entity(Pet.class).select()
+ .where(Pet_.owner.lastName, EQUALS, "Davis")
+ .getResultList();
+ assertEquals(List.of("Iggy", "Leo"), byFolded.stream().map(Pet::name).sorted().toList());
+ assertEquals(byName.stream().map(Pet::id).toList(), byFolded.stream().map(Pet::id).toList());
+ assertFalse(statements.getFirst().contains("last_name_folded,"), statements.getFirst());
+ }
+
+ @Test
+ public void orderByAndKeysetSortByTheQueryColumn() {
+ List ordered = orm().entity(Owner.class).select()
+ .orderBy(Owner_.lastNameFolded)
+ .getResultList();
+ assertEquals("Black", ordered.getFirst().lastName());
+ var window = orm().entity(Owner.class).select()
+ .scroll(Scrollable.of(Owner_.id, 3).sortBy(Owner_.lastNameFolded));
+ assertEquals(List.of("Black", "Coleman", "Davis"), window.content().stream().map(Owner::lastName).toList());
+ }
+
+ @Test
+ public void templatePlaceholderNamesTheQueryColumn() {
+ List folded = orm().query(raw("SELECT \0 FROM \0 WHERE \0 = \0", Owner_.lastNameFolded, Owner.class, Owner_.lastName, "Davis"))
+ .getResultList(String.class);
+ assertEquals(List.of("davis", "davis"), folded);
+ }
+
+ @Test
+ public void recordHoldsNoValueForTheQueryColumn() {
+ Owner owner = orm().entity(Owner.class).select().where(Owner_.id, EQUALS, 1).getSingleResult();
+ var exception = assertThrows(UnsupportedOperationException.class, () -> Owner_.lastNameFolded.getValue(owner));
+ assertTrue(exception.getMessage().contains("query column"), exception.getMessage());
+ }
+
+ @Test
+ public void updateCannotSetTheQueryColumn() {
+ var exception = assertThrows(PersistenceException.class,
+ () -> orm().entity(Owner.class).update().set(Owner_.lastNameFolded, "x"));
+ assertTrue(exception.getMessage().contains("last_name_folded"), exception.getMessage());
+ }
+
+ @Test
+ public void insertLeavesTheQueryColumnOut() {
+ Owner inserted = observed(() -> orm().entity(Owner.class).insertAndFetch(
+ Owner.builder().firstName("Ada").lastName("Lovelace").address(
+ orm().entity(Owner.class).select().where(Owner_.id, EQUALS, 1).getSingleResult().address()).version(0).build()));
+ assertFalse(statements.getFirst().contains("last_name_folded"), statements.getFirst());
+ assertEquals(List.of("Ada"), firstNames(orm().entity(Owner.class).select()
+ .where(Owner_.lastNameFolded, EQUALS, "lovelace").getResultList()));
+ assertEquals("Lovelace", inserted.lastName());
+ }
+
+ @Test
+ public void modelListsTheQueryColumnApart() throws Exception {
+ var model = orm().entity(Owner.class).model();
+ assertEquals(1, model.queryColumns().size());
+ var column = model.queryColumns().getFirst();
+ assertEquals("last_name_folded", column.name());
+ assertEquals(0, column.index());
+ assertEquals(String.class, column.type());
+ assertFalse(column.insertable());
+ assertFalse(column.updatable());
+ assertFalse(column.nullable());
+ assertTrue(model.columns().stream().noneMatch(it -> it.name().equals("last_name_folded")));
+ assertEquals(List.of(column), model.getColumns(Owner_.lastNameFolded));
+ }
+
+ @Test
+ public void queryColumnResolvesByName() {
+ assertEquals(Owner_.lastNameFolded, Metamodel.of(Owner.class, "lastNameFolded"));
+ Metamodel throughOwner = Metamodel.of(Pet.class, "owner.lastNameFolded");
+ assertEquals("owner", throughOwner.path());
+ assertEquals("lastNameFolded", throughOwner.field());
+ assertEquals(String.class, throughOwner.fieldType());
+ assertTrue(throughOwner.isColumn());
+ assertEquals(Pet_.owner.lastNameFolded, throughOwner);
+ }
+
+ @QueryColumn(field = "name", type = String.class)
+ public record ComponentNamedQueryColumn(@PK Integer id, String name) implements Entity {}
+
+ @Test
+ public void queryColumnNamedAsAComponentIsRefused() {
+ var exception = assertThrows(PersistenceException.class, () -> orm().entity(ComponentNamedQueryColumn.class).model());
+ var messages = new ArrayList();
+ for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
+ messages.add(String.valueOf(cause.getMessage()));
+ }
+ assertTrue(messages.stream().anyMatch(message -> message.contains("record component")), messages.toString());
+ }
+}
diff --git a/storm-core/src/test/java/st/orm/core/model/Owner.java b/storm-core/src/test/java/st/orm/core/model/Owner.java
index de36a2b58..0377b8662 100644
--- a/storm-core/src/test/java/st/orm/core/model/Owner.java
+++ b/storm-core/src/test/java/st/orm/core/model/Owner.java
@@ -19,6 +19,7 @@
import org.jspecify.annotations.Nullable;
import st.orm.Entity;
import st.orm.PK;
+import st.orm.QueryColumn;
import st.orm.Version;
/**
@@ -26,6 +27,7 @@
*
*/
@Builder(toBuilder = true)
+@QueryColumn(field = "lastNameFolded", type = String.class)
public record Owner(
@PK Integer id,
String firstName,
diff --git a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
index c8e4e7e44..888bfbf0d 100644
--- a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
@@ -43,7 +43,9 @@
import st.orm.GenerationStrategy;
import st.orm.PK;
import st.orm.Persist;
+import st.orm.Projection;
import st.orm.ProjectionQuery;
+import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.UK;
import st.orm.core.QuietValidationLog;
@@ -240,6 +242,26 @@ public record ForeignKeyIgnoredEntity(
@DbIgnore @Nullable @FK Ref foreignKeyTarget
) implements Entity {}
+ @QueryColumn(field = "nameFolded", type = String.class)
+ public record QueryColumnEntity(@PK Integer id, String name) implements Entity {}
+
+ @QueryColumn(field = "nameFolded", type = String.class, validate = false)
+ public record UnvalidatedQueryColumnEntity(@PK Integer id, String name) implements Entity {}
+
+ @QueryColumn(field = "nameFolded", type = String.class, nullable = true)
+ public record NullableQueryColumnEntity(@PK Integer id, String name) implements Entity {}
+
+ @QueryColumn(field = "nameFolded", type = Integer.class)
+ public record MistypedQueryColumnEntity(@PK Integer id, String name) implements Entity {}
+
+ @QueryColumn(field = "nameFolded", type = String.class, column = "folded")
+ public record NamedQueryColumnEntity(@PK Integer id, String name) implements Entity {}
+
+ @QueryColumn(field = "nameFolded", type = String.class)
+ public record QueryColumnProjection(@PK Integer id, String name) implements Projection {}
+
+ public record UnmappedGeneratedEntity(@PK Integer id, String name) implements Entity {}
+
@BeforeEach
void setUp() {
// Each test gets a fresh in-memory database to avoid table name collisions.
@@ -790,6 +812,110 @@ void testUniqueKeyMissingSuppressedByConstraintFalse() throws SQLException {
"Expected no UNIQUE_KEY_MISSING with @UK(constraint = false), but got: " + errors);
}
+ // Query columns
+
+ @Test
+ void testQueryColumnOfAGeneratedColumnPasses() throws SQLException {
+ execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
+ @Test
+ void testQueryColumnWithADefaultPasses() throws SQLException {
+ execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) DEFAULT '' NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
+ @Test
+ void testQueryColumnNotFound() throws SQLException {
+ execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+
+ assertEquals(1, errors.size(), errors.toString());
+ assertEquals(ErrorKind.COLUMN_NOT_FOUND, errors.getFirst().kind());
+ assertTrue(errors.getFirst().message().contains("name_folded"), errors.getFirst().message());
+ }
+
+ @Test
+ void testQueryColumnTheDatabaseDoesNotFillIsAnError() throws SQLException {
+ execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+
+ assertEquals(1, errors.size(), errors.toString());
+ assertEquals(ErrorKind.QUERY_COLUMN_NOT_FILLED, errors.getFirst().kind());
+ assertFalse(errors.getFirst().kind().warning());
+ assertTrue(errors.getFirst().message().contains("name_folded"), errors.getFirst().message());
+ assertTrue(errors.getFirst().message().contains("validate = false"), errors.getFirst().message());
+ }
+
+ @Test
+ void testQueryColumnDeclaredUnvalidatedIsSkipped() throws SQLException {
+ execute("CREATE TABLE unvalidated_query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(UnvalidatedQueryColumnEntity.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
+ @Test
+ void testQueryColumnOfAProjectionIsNotCheckedForFilling() throws SQLException {
+ execute("CREATE TABLE query_column_projection (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnProjection.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
+ @Test
+ void testQueryColumnNullability() throws SQLException {
+ execute("CREATE TABLE nullable_query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255), PRIMARY KEY (id))");
+ execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255), PRIMARY KEY (id))");
+
+ List nullableErrors = SchemaValidator.of(dataSource).validate(List.of(NullableQueryColumnEntity.class));
+ List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+
+ assertTrue(nullableErrors.isEmpty(), "Expected no errors but got: " + nullableErrors);
+ // A nullable column is filled with NULL; the declaration's nullability is checked as a field's is.
+ assertEquals(1, errors.size(), errors.toString());
+ assertEquals(ErrorKind.NULLABILITY_MISMATCH, errors.getFirst().kind());
+ }
+
+ @Test
+ void testQueryColumnTypeIncompatible() throws SQLException {
+ execute("CREATE TABLE mistyped_query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(MistypedQueryColumnEntity.class));
+
+ assertEquals(1, errors.size(), errors.toString());
+ assertEquals(ErrorKind.TYPE_INCOMPATIBLE, errors.getFirst().kind());
+ }
+
+ @Test
+ void testQueryColumnNamesItsColumn() throws SQLException {
+ execute("CREATE TABLE named_query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, folded VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(NamedQueryColumnEntity.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
+ @Test
+ void testUnmappedGeneratedColumnIsNotReported() throws SQLException {
+ execute("CREATE TABLE unmapped_generated_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+
+ List errors = SchemaValidator.of(dataSource).validate(List.of(UnmappedGeneratedEntity.class));
+
+ assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ }
+
// Helpers
private void execute(String sql) throws SQLException {
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
index d7e18fb3d..4c6ebbd03 100644
--- a/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
@@ -178,6 +178,21 @@ void testGetColumn() throws SQLException {
}
}
+ @Test
+ void testGeneratedColumnIsReported() throws SQLException {
+ execute("CREATE TABLE generated_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, name_folded VARCHAR(100) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+ try (Connection connection = getConnection()) {
+ DatabaseSchema schema = DatabaseSchema.read(connection);
+
+ DbColumn foldedColumn = schema.getColumn("generated_test", "name_folded").orElseThrow();
+ assertTrue(foldedColumn.generated());
+ assertFalse(foldedColumn.nullable());
+
+ DbColumn nameColumn = schema.getColumn("generated_test", "name").orElseThrow();
+ assertFalse(nameColumn.generated());
+ }
+ }
+
@Test
void testGetColumnProperties() throws SQLException {
execute("CREATE TABLE prop_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description VARCHAR(500), PRIMARY KEY (id))");
diff --git a/storm-core/src/test/resources/data.sql b/storm-core/src/test/resources/data.sql
index f95a066dc..f88ead5e0 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);
+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 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-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
index d6d37325b..906814bf5 100644
--- a/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
+++ b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
@@ -25,6 +25,7 @@
import st.orm.DbTable;
import st.orm.Entity;
import st.orm.PK;
+import st.orm.QueryColumn;
import st.orm.StormConfig;
import st.orm.UK;
import st.orm.core.spi.Providers;
@@ -77,6 +78,24 @@ protected boolean supportsSequences() {
return true;
}
+ /**
+ * The statement that creates {@code generated_probe} with an integer primary key {@code id}, a {@code NOT NULL}
+ * text column {@code name}, and a {@code NOT NULL} text column {@code name_folded} the database generates from
+ * {@code name}; {@code null} for a database without generated columns.
+ */
+ protected @Nullable String generatedColumnProbeDdl() {
+ return null;
+ }
+
+ /** {@code generated_probe} with its generated column declared as a query column. */
+ @DbTable("generated_probe")
+ @QueryColumn(field = "nameFolded", type = String.class)
+ public record GeneratedProbe(@PK Integer id, String name) implements Entity {}
+
+ /** {@code generated_probe} with its generated column left unmapped. */
+ @DbTable("generated_probe")
+ public record GeneratedProbeUnmapped(@PK Integer id, String name) implements Entity {}
+
public record Vet(
@PK Integer id,
@Nullable String firstName,
@@ -268,6 +287,30 @@ public void testIndexesAreDiscovered() throws SQLException {
}
}
+ @Test
+ public void testAGeneratedColumnIsFilledByTheDatabase() throws SQLException {
+ var ddl = generatedColumnProbeDdl();
+ assumeTrue(ddl != null);
+ try (var connection = dataSource.getConnection(); var statement = connection.createStatement()) {
+ statement.execute(ddl);
+ try {
+ var dialect = Providers.getSqlDialect(connection, StormConfig.defaults());
+ var schema = DatabaseSchema.read(connection, connection.getCatalog(), connection.getSchema(),
+ DatabaseSchema.Discovery.of(dialect));
+ var column = schema.getColumn("generated_probe", "name_folded").orElseThrow();
+ // The driver reports the column as generated or with its expression as default; either way the
+ // database fills it, so neither a query column nor an unmapped column over it fails an insert.
+ assertTrue(column.generated() || column.hasDefault(), column::toString);
+ var errors = SchemaValidator.of(dataSource).validate(List.of(GeneratedProbe.class, GeneratedProbeUnmapped.class));
+ assertFalse(errors.stream().anyMatch(error -> error.kind() == ErrorKind.QUERY_COLUMN_NOT_FILLED
+ || error.kind() == ErrorKind.UNMAPPED_COLUMN || error.kind() == ErrorKind.COLUMN_NOT_FOUND
+ || error.kind() == ErrorKind.TYPE_INCOMPATIBLE), errors::toString);
+ } finally {
+ statement.execute("DROP TABLE generated_probe");
+ }
+ }
+ }
+
@Test
public void testAnExpressionIndexHasNoColumn() throws SQLException {
var ddl = expressionIndexDdl();
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumn.java b/storm-foundation/src/main/java/st/orm/QueryColumn.java
new file mode 100644
index 000000000..897f6964d
--- /dev/null
+++ b/storm-foundation/src/main/java/st/orm/QueryColumn.java
@@ -0,0 +1,88 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Declares a column a query names but the entity or projection does not carry: a query column. It gets a metamodel
+ * field, generated as a record component's is, but no component, so it is never selected, inserted or updated. A
+ * generated column that stores a derived form of another column, which a search matches and an index is built on,
+ * is the typical case: the database computes it, no caller reads it, and a record built by the application has no
+ * value to give it.
+ *
+ * {@code
+ * @DbTable("movie")
+ * @QueryColumn(field = "titleFolded", type = String.class)
+ * record Movie(@PK Integer id, String title, Integer year) implements Entity {}
+ *
+ * orm.entity(Movie.class).select()
+ * .where(it -> it.search(input, Movie_.titleFolded))
+ * .getResultList();
+ * }
+ *
+ * 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
+ * referenced table is {@code Credit_.movie.titleFolded} from a query on credits. A record holds no value for it:
+ * {@link Metamodel#getValue(Data)} throws, and an {@code update} cannot set it.
+ *
+ * The column follows from the field name through the column name resolver, as a component's does;
+ * {@link #column()} names it where the resolver does not. Schema validation checks that the column exists with a
+ * compatible type, and on an entity that the database fills it, since every insert leaves it out: it must be
+ * generated, an identity column, have a default, or allow null.
+ *
+ * @since 1.16
+ */
+@Target(TYPE)
+@Retention(RUNTIME)
+@Repeatable(QueryColumns.class)
+public @interface QueryColumn {
+
+ /**
+ * The name of the metamodel field, such as {@code titleFolded} for {@code Movie_.titleFolded}.
+ */
+ String field();
+
+ /**
+ * The type of the column's values, as a record component's type: {@code String.class}, {@code Integer.class},
+ * {@code LocalDate.class}, an enum type.
+ */
+ Class> type();
+
+ /**
+ * The database column name. By default the column name resolver derives it from the field name, as it does for a
+ * record component: {@code titleFolded} is {@code title_folded} under the default resolver.
+ */
+ String column() default "";
+
+ /**
+ * Whether the column allows null. A nullable query column compares as a nullable field does and is refused as a
+ * keyset sort field, as a nullable field is.
+ */
+ boolean nullable() default false;
+
+ /**
+ * Whether schema validation checks the column. {@code false} skips it, as {@link DbIgnore} skips a record
+ * component: for a column the database fills in a way its metadata does not show, such as a trigger.
+ */
+ boolean validate() default true;
+}
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumns.java b/storm-foundation/src/main/java/st/orm/QueryColumns.java
new file mode 100644
index 000000000..5bcf517bf
--- /dev/null
+++ b/storm-foundation/src/main/java/st/orm/QueryColumns.java
@@ -0,0 +1,38 @@
+/*
+ * Copyright 2024 - 2026 the original author or authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package st.orm;
+
+import static java.lang.annotation.ElementType.TYPE;
+import static java.lang.annotation.RetentionPolicy.RUNTIME;
+
+import java.lang.annotation.Retention;
+import java.lang.annotation.Target;
+
+/**
+ * Declares several {@link QueryColumn query columns} in one annotation. {@code @QueryColumn} is repeatable, so the
+ * container is needed only where repeating is not possible.
+ *
+ * @since 1.16
+ */
+@Target(TYPE)
+@Retention(RUNTIME)
+public @interface QueryColumns {
+
+ /**
+ * The query columns.
+ */
+ QueryColumn[] value();
+}
diff --git a/storm-h2/src/test/java/st/orm/spi/h2/H2SchemaValidatorConformanceTest.java b/storm-h2/src/test/java/st/orm/spi/h2/H2SchemaValidatorConformanceTest.java
index 833bfdbe7..14a0cb178 100644
--- a/storm-h2/src/test/java/st/orm/spi/h2/H2SchemaValidatorConformanceTest.java
+++ b/storm-h2/src/test/java/st/orm/spi/h2/H2SchemaValidatorConformanceTest.java
@@ -9,4 +9,9 @@ public class H2SchemaValidatorConformanceTest extends AbstractSchemaValidatorCon
protected boolean indexesForeignKeys() {
return true;
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(50) NOT NULL, name_folded VARCHAR(50) GENERATED ALWAYS AS (LOWER(name)) NOT NULL)";
+ }
}
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 3bc714ca7..5bbc5364e 100644
--- a/storm-java21/src/main/java/st/orm/template/Column.java
+++ b/storm-java21/src/main/java/st/orm/template/Column.java
@@ -33,7 +33,8 @@
*/
public interface Column {
/**
- * Gets the 1-based index of the column.
+ * Gets the 1-based index of the column among the model's columns, or {@code 0} for a query column, which has no
+ * position among them.
*
* @return the column index.
*/
diff --git a/storm-java21/src/main/java/st/orm/template/Model.java b/storm-java21/src/main/java/st/orm/template/Model.java
index 37750b381..3244a3097 100644
--- a/storm-java21/src/main/java/st/orm/template/Model.java
+++ b/storm-java21/src/main/java/st/orm/template/Model.java
@@ -100,6 +100,17 @@ public interface Model {
*/
List declaredColumns();
+ /**
+ * Returns the query columns declared on this model: the columns a query names but the record does not carry,
+ * declared with {@link st.orm.QueryColumn}. They are not among {@link #columns()} or {@link #declaredColumns()},
+ * so a select, insert or update never names them. A query column has no record field: its
+ * {@link Column#index()} is {@code 0}, and it is neither insertable nor updatable.
+ *
+ * @return the query columns declared on this model, in declaration order.
+ * @since 1.16
+ */
+ List queryColumns();
+
/**
* This method is used to check if the primary key of the entity is a default value. This is useful when
* determining if the entity is new or has been persisted before.
diff --git a/storm-java21/src/main/java/st/orm/template/impl/ModelImpl.java b/storm-java21/src/main/java/st/orm/template/impl/ModelImpl.java
index 04e5b2188..5b3e51d08 100644
--- a/storm-java21/src/main/java/st/orm/template/impl/ModelImpl.java
+++ b/storm-java21/src/main/java/st/orm/template/impl/ModelImpl.java
@@ -31,6 +31,7 @@ public class ModelImpl implements Model {
private final st.orm.core.template.impl.ModelImpl core;
private final List columns;
private final List declaredColumns;
+ private final List queryColumns;
public ModelImpl(st.orm.core.template.impl.ModelImpl core) {
this.core = core;
@@ -42,7 +43,10 @@ public ModelImpl(st.orm.core.template.impl.ModelImpl core) {
.map(ColumnImpl::new)
.map(Column.class::cast)
.toList();
-
+ this.queryColumns = core.queryColumns().stream()
+ .map(ColumnImpl::new)
+ .map(Column.class::cast)
+ .toList();
}
/**
@@ -133,6 +137,11 @@ public List declaredColumns() {
return declaredColumns;
}
+ @Override
+ public List queryColumns() {
+ return queryColumns;
+ }
+
/**
* Iterates over the values of the given columns for the supplied record.
*
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 a7ea5b6e4..ec54291d9 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/Column.kt
@@ -34,7 +34,8 @@ import kotlin.reflect.KClass
*/
public interface Column {
/**
- * Gets the 1-based index of the column.
+ * Gets the 1-based index of the column among the model's columns, or `0` for a query column, which has no
+ * position among them.
*
* @return the column index.
*/
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/Model.kt b/storm-kotlin/src/main/kotlin/st/orm/template/Model.kt
index b09adedea..01c4db3d0 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/Model.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/Model.kt
@@ -87,6 +87,17 @@ public interface Model {
*/
public val declaredColumns: List
+ /**
+ * Returns the query columns declared on this model: the columns a query names but the record does not carry,
+ * declared with [st.orm.QueryColumn]. They are not among [columns] or [declaredColumns], so a select, insert or
+ * update never names them. A query column has no record field: its [Column.index] is `0`, and it is neither
+ * insertable nor updatable.
+ *
+ * @return the query columns declared on this model, in declaration order.
+ * @since 1.16
+ */
+ public val queryColumns: List
+
/**
*
* This method is used to check if the primary key of the entity is a default value. This is useful when
diff --git a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ModelImpl.kt b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ModelImpl.kt
index a0d299abf..6ae79a4a4 100644
--- a/storm-kotlin/src/main/kotlin/st/orm/template/impl/ModelImpl.kt
+++ b/storm-kotlin/src/main/kotlin/st/orm/template/impl/ModelImpl.kt
@@ -24,6 +24,7 @@ internal class ModelImpl(
internal val core: st.orm.core.template.Model,
override val columns: List = core.columns().map(::ColumnImpl),
override val declaredColumns: List = core.declaredColumns().map(::ColumnImpl),
+ override val queryColumns: List = core.queryColumns().map(::ColumnImpl),
) : Model {
override val schema: String get() = core.schema()
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 0cc5f2d98..1236a13f0 100644
--- a/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
+++ b/storm-kotlin/src/test/kotlin/st/orm/template/QueryBuilderTest.kt
@@ -4,6 +4,7 @@ import ch.qos.logback.classic.Level
import ch.qos.logback.classic.LoggerContext
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.read.ListAppender
+import io.kotest.assertions.throwables.shouldThrow
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
import io.kotest.matchers.shouldNotBe
@@ -2276,6 +2277,24 @@ internal open class QueryBuilderTest(
firstNames("") shouldBe emptyList()
}
+ @Test
+ fun `query column is named by a query and carried by no row`() {
+ // data.sql: owner.last_name_folded is generated from the last name; Owner declares it as a query column.
+ val lastNameFolded: Metamodel = Metamodel.of(Owner::class.java, "lastNameFolded")
+ val owners = orm.entity(Owner::class).select().where(lastNameFolded eq "davis").resultList
+ owners.map { it.firstName }.sorted() shouldBe listOf("Betty", "Harold")
+ val ownerLastNameFolded: Metamodel = Metamodel.of(Pet::class.java, "owner.lastNameFolded")
+ ownerLastNameFolded.path() shouldBe "owner"
+ ownerLastNameFolded.field() shouldBe "lastNameFolded"
+ orm.entity(Pet::class).select()
+ .where(ownerLastNameFolded eq "davis")
+ .resultList.map { it.name }.sorted() shouldBe listOf("Iggy", "Leo")
+ val model = orm.entity(Owner::class).model
+ 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()) }
+ }
+
@Test
fun `orderFirst should put the matching rows first in the order of the calls`() {
val ownerRepo = orm.entity(Owner::class)
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 43e4dd860..2d4a68319 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
@@ -17,12 +17,14 @@ package st.orm.template.model
import st.orm.Entity
import st.orm.PK
+import st.orm.QueryColumn
import st.orm.Version
/**
* Simple domain object representing an owner.
*
*/
+@QueryColumn(field = "lastNameFolded", type = String::class)
internal data class Owner(
@PK val id: Int = 0,
override val firstName: String,
diff --git a/storm-kotlin/src/test/resources/data.sql b/storm-kotlin/src/test/resources/data.sql
index 388e922cb..6666aaad6 100644
--- a/storm-kotlin/src/test/resources/data.sql
+++ b/storm-kotlin/src/test/resources/data.sql
@@ -10,7 +10,7 @@ drop table if exists vet_specialty CASCADE;
drop table if exists visit 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), city_id integer, telephone varchar(255), primary key (id), version integer default 0 not null);
+create table owner (id integer auto_increment, first_name varchar(255) not null, last_name varchar(255) not null, address varchar(255), 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 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), primary key (id));
diff --git a/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSchemaValidatorConformanceTest.java b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSchemaValidatorConformanceTest.java
index b88d1afa4..49a621fd2 100644
--- a/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSchemaValidatorConformanceTest.java
+++ b/storm-mariadb/src/test/java/st/orm/spi/mariadb/MariaDBSchemaValidatorConformanceTest.java
@@ -22,4 +22,9 @@ public static synchronized DataSource dataSource() {
protected boolean indexesForeignKeys() {
return true;
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(50) NOT NULL, name_folded VARCHAR(50) AS (LOWER(name)) STORED)";
+ }
}
diff --git a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
index d6c577b1c..7c0a20c0b 100644
--- a/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
+++ b/storm-metamodel-ksp/src/main/kotlin/st/orm/metamodel/MetamodelProcessor.kt
@@ -95,6 +95,21 @@ class MetamodelProcessor(
*/
private val generatedInstantiators = mutableListOf>()
+ /**
+ * The valid query columns per data class, keyed by qualified name. Reading a type's query columns reports its
+ * invalid declarations, so the cache makes each diagnostic print once.
+ */
+ private val queryColumns = mutableMapOf>()
+
+ /**
+ * A column a data class declares through `@QueryColumn`: it gets a metamodel field but has no component.
+ *
+ * @property field the metamodel field name.
+ * @property type the column's value type, as the annotation names it.
+ * @property nullable whether the column allows null.
+ */
+ private data class QueryColumnDeclaration(val field: String, val type: KSType, val nullable: Boolean)
+
companion object {
/**
* The `@Suppress` key that silences the warning for a nullable unique key, on the property or an enclosing
@@ -146,6 +161,8 @@ class MetamodelProcessor(
private const val FOREIGN_KEY = "st.orm.FK"
private const val PRIMARY_KEY = "st.orm.PK"
private const val UNIQUE_KEY = "st.orm.UK"
+ private const val QUERY_COLUMN = "st.orm.QueryColumn"
+ private const val QUERY_COLUMNS = "st.orm.QueryColumns"
private const val K_BOOLEAN = "kotlin.Boolean"
private const val K_BYTE = "kotlin.Byte"
@@ -296,6 +313,102 @@ class MetamodelProcessor(
private fun isSealedInterface(classDeclaration: KSClassDeclaration): Boolean = classDeclaration.classKind == ClassKind.INTERFACE && classDeclaration.modifiers.contains(Modifier.SEALED)
+ /**
+ * Returns the valid query columns the data class declares, in declaration order. Query columns apply to data
+ * classes that implement `Data` or carry `@GenerateMetamodel`; on any other type, a sealed interface included, the
+ * annotation is ignored. The first read of a type reports its invalid declarations (an empty field, a field that
+ * repeats a component or another query column) and leaves them out.
+ */
+ private fun queryColumns(classDeclaration: KSClassDeclaration): List {
+ if (isSealedInterface(classDeclaration) || !classDeclaration.isDataClass()) return emptyList()
+ val qualifiedName = classDeclaration.qualifiedName?.asString() ?: return emptyList()
+ return queryColumns.getOrPut(qualifiedName) { readQueryColumns(classDeclaration) }
+ }
+
+ private fun readQueryColumns(classDeclaration: KSClassDeclaration): List {
+ if (!classDeclaration.implementsInterface(DATA) && !classDeclaration.hasAnnotation(GENERATE_METAMODEL)) {
+ return emptyList()
+ }
+ val declarations = queryColumnAnnotations(classDeclaration)
+ if (declarations.isEmpty()) return emptyList()
+ val className = classDeclaration.simpleName.asString()
+ val componentNames = getModelProperties(classDeclaration).map { it.simpleName.asString() }.toSet()
+ val declaredFields = mutableSetOf()
+ val result = mutableListOf()
+ for (declaration in declarations) {
+ val fieldName = annotationArgument(declaration, "field") as? String ?: ""
+ if (fieldName.isEmpty()) {
+ logger.error(
+ "Query column on $className declares an empty field. " +
+ "Name the metamodel field the query column gets.",
+ classDeclaration,
+ )
+ continue
+ }
+ if (fieldName in componentNames) {
+ logger.error(
+ "Query column field '$fieldName' on $className has the name of a component. " +
+ "A query column declares a column the data class does not carry; the component already has " +
+ "a metamodel field.",
+ classDeclaration,
+ )
+ continue
+ }
+ if (!declaredFields.add(fieldName)) {
+ logger.error("Query column field '$fieldName' is declared more than once on $className.", classDeclaration)
+ continue
+ }
+ // The compiler reports an unresolvable class literal itself.
+ val type = annotationArgument(declaration, "type") as? KSType ?: continue
+ if (type.isError) continue
+ val nullable = annotationArgument(declaration, "nullable") as? Boolean ?: false
+ result.add(QueryColumnDeclaration(fieldName, type, nullable))
+ }
+ return result.toList()
+ }
+
+ /**
+ * Returns the `@QueryColumn` annotations on the declaration in declaration order: direct annotations (a repeated
+ * annotation may be presented as several) and the ones a `@QueryColumns` container holds.
+ */
+ private fun queryColumnAnnotations(classDeclaration: KSClassDeclaration): List = classDeclaration.annotations.flatMap { annotation ->
+ when (annotation.annotationType.resolve().declaration.qualifiedName?.asString()) {
+ QUERY_COLUMN -> sequenceOf(annotation)
+ QUERY_COLUMNS -> when (val value = annotationArgument(annotation, "value")) {
+ is List<*> -> value.asSequence().filterIsInstance()
+ is Array<*> -> value.asSequence().filterIsInstance()
+ else -> emptySequence()
+ }
+ else -> emptySequence()
+ }
+ }.toList()
+
+ private fun annotationArgument(annotation: KSAnnotation, name: String): Any? = annotation.arguments.firstOrNull { it.name?.asString() == name }?.value
+ ?: annotation.defaultArguments.firstOrNull { it.name?.asString() == name }?.value
+
+ /**
+ * Renders the `E` type of a query column's metamodel as a component's addressed type renders, with star
+ * projections for a generic type.
+ */
+ private fun queryColumnTypeName(queryColumn: QueryColumnDeclaration, packageName: String): String {
+ val baseName = kotlinTypeNameOf(queryColumn.type, packageName)
+ val typeParameterCount = (queryColumn.type.declaration as? KSClassDeclaration)?.typeParameters?.size ?: 0
+ return if (typeParameterCount == 0) baseName else baseName + List(typeParameterCount) { "*" }.joinToString(", ", "<", ">")
+ }
+
+ /**
+ * Renders the `V` type of a query column's metamodel: the `E` type, nullable when the column allows null or the
+ * metamodel is a nullable-chain variant.
+ */
+ private fun queryColumnValueTypeName(
+ queryColumn: QueryColumnDeclaration,
+ packageName: String,
+ forceNullableChain: Boolean,
+ ): String {
+ val typeName = queryColumnTypeName(queryColumn, packageName)
+ return if (queryColumn.nullable || forceNullableChain) ensureNullable(typeName) else typeName
+ }
+
/**
* Returns the properties to include in the metamodel for the given class declaration.
* For sealed interfaces, only abstract declared properties are included: a defaulted property has no column.
@@ -878,6 +991,16 @@ class MetamodelProcessor(
)
}
}
+ queryColumns(classDeclaration).forEach { queryColumn ->
+ val fieldRef = escaped(queryColumn.field)
+ val kotlinTypeName = queryColumnTypeName(queryColumn, packageName)
+ val valueKotlinTypeName = queryColumnValueTypeName(queryColumn, packageName, forceNullableChain = false)
+ builder.append(" /** Represents the ${queryColumn.field} query column of $className. */\n")
+ builder.append(
+ " val $fieldRef: AbstractMetamodel<$className, $kotlinTypeName, $valueKotlinTypeName> = " +
+ "$modelRef.$fieldRef\n",
+ )
+ }
if (builder.isNotEmpty()) builder.setLength(builder.length - 1)
return builder.toString()
@@ -914,6 +1037,11 @@ class MetamodelProcessor(
builder.append(" val $fieldRef: $baseClass\n")
}
}
+ queryColumns(classDeclaration).forEach { queryColumn ->
+ val kotlinTypeName = queryColumnTypeName(queryColumn, packageName)
+ val valueKotlinTypeName = queryColumnValueTypeName(queryColumn, packageName, forceNullableChain)
+ builder.append(" val ${escaped(queryColumn.field)}: AbstractMetamodel\n")
+ }
return builder.toString()
}
@@ -1080,6 +1208,24 @@ class MetamodelProcessor(
)
}
}
+ // A data class instance holds no value for a query column, so the value methods throw.
+ val className = classDeclaration.simpleName.asString()
+ queryColumns(classDeclaration).forEach { queryColumn ->
+ val fieldName = queryColumn.field
+ val kotlinTypeName = queryColumnTypeName(queryColumn, packageName)
+ val valueKotlinTypeName = queryColumnValueTypeName(queryColumn, packageName, forceNullableChain)
+ val javaTypeName = javaTypeNameOf(queryColumn.type, packageName)
+ val failure = "throw UnsupportedOperationException(" +
+ "\"$className.$fieldName is a query column; a record holds no value for it.\")"
+ builder.append(
+ " this.${escaped(fieldName)} = object : AbstractMetamodel(" +
+ "$javaTypeName, subPath, fieldBase + \"$fieldName\", false, this) {\n" +
+ " override fun getValue(record: T): $valueKotlinTypeName = $failure\n\n" +
+ " override fun isIdentical(a: T, b: T): Boolean = $failure\n\n" +
+ " override fun isSame(a: T, b: T): Boolean = $failure\n" +
+ " }\n",
+ )
+ }
if (builder.isNotEmpty()) builder.setLength(builder.length - 1)
return builder.toString()
@@ -1171,6 +1317,11 @@ class MetamodelProcessor(
builder.append(" val $fieldRef: st.orm.AbstractNavigableMetamodel\n")
}
}
+ queryColumns(classDeclaration).forEach { queryColumn ->
+ val kotlinTypeName = queryColumnTypeName(queryColumn, packageName)
+ builder.append(" /** Represents navigation to the ${queryColumn.field} query column of $className. */\n")
+ builder.append(" val ${escaped(queryColumn.field)}: st.orm.AbstractNavigableMetamodel\n")
+ }
return builder.toString()
}
@@ -1199,6 +1350,15 @@ class MetamodelProcessor(
)
}
}
+ queryColumns(classDeclaration).forEach { queryColumn ->
+ val fieldName = queryColumn.field
+ val kotlinTypeName = queryColumnTypeName(queryColumn, packageName)
+ val javaTypeName = javaTypeNameOf(queryColumn.type, packageName)
+ builder.append(
+ " this.${escaped(fieldName)} = object : st.orm.AbstractNavigableMetamodel(" +
+ "$javaTypeName, subPath, fieldBase + \"$fieldName\", false, this) {}\n",
+ )
+ }
if (builder.isNotEmpty()) builder.setLength(builder.length - 1)
return builder.toString()
}
diff --git a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt
index 4642cf1e4..58c57f79a 100644
--- a/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt
+++ b/storm-metamodel-ksp/src/test/kotlin/st/orm/metamodel/MetamodelProcessorTest.kt
@@ -303,6 +303,197 @@ class MetamodelProcessorTest {
}
}
+ @Test
+ fun `repeated query columns generate throwing fields`() {
+ val compilation = compile(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.FK
+ import st.orm.PK
+ import st.orm.QueryColumn
+ import st.orm.Ref
+
+ @QueryColumn(field = "titleFolded", type = String::class)
+ @QueryColumn(field = "releaseYear", type = Int::class)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+
+ data class Credit(@PK val id: Int = 0, @FK val movie: Ref) : Entity
+ """.trimIndent(),
+ )
+ val movieInterface = compilation.generatedSource("Movie_.kt")
+ assertTrue(
+ " /** Represents the titleFolded query column of Movie. */\n" +
+ " val titleFolded: AbstractMetamodel = MovieMetamodel.instance().titleFolded"
+ in movieInterface,
+ ) { "expected the query column in the metamodel interface:\n$movieInterface" }
+ assertTrue("val releaseYear: AbstractMetamodel" in movieInterface) {
+ "expected the second query column in the metamodel interface:\n$movieInterface"
+ }
+ assertTrue(movieInterface.indexOf("val title:") < movieInterface.indexOf("val titleFolded:")) {
+ "query columns follow the components:\n$movieInterface"
+ }
+ assertTrue(movieInterface.indexOf("val titleFolded:") < movieInterface.indexOf("val releaseYear:")) {
+ "query columns keep their declaration order:\n$movieInterface"
+ }
+ val movieMetamodel = compilation.generatedSource("MovieMetamodel.kt")
+ assertTrue(" val titleFolded: AbstractMetamodel\n" in movieMetamodel) {
+ "expected the query column field in the metamodel class:\n$movieMetamodel"
+ }
+ val failure = "throw UnsupportedOperationException(\"Movie.titleFolded is a query column; a record holds no value for it.\")"
+ assertTrue(
+ " this.titleFolded = object : AbstractMetamodel(" +
+ "String::class.java, subPath, fieldBase + \"titleFolded\", false, this) {\n" +
+ " override fun getValue(record: T): String = $failure\n\n" +
+ " override fun isIdentical(a: T, b: T): Boolean = $failure\n\n" +
+ " override fun isSame(a: T, b: T): Boolean = $failure\n" +
+ " }"
+ in movieMetamodel,
+ ) { "a record holds no value for a query column, so its value methods throw:\n$movieMetamodel" }
+ assertTrue("object : AbstractMetamodel(Int::class.javaObjectType, subPath, fieldBase + \"releaseYear\"" in movieMetamodel) {
+ "a primitive query column type renders as its object type:\n$movieMetamodel"
+ }
+ val movieNullableMetamodel = compilation.generatedSource("MovieNullableMetamodel.kt")
+ assertTrue("val titleFolded: AbstractMetamodel" in movieNullableMetamodel) {
+ "the nullable-chain variant reads every value as nullable, as for a component:\n$movieNullableMetamodel"
+ }
+ val movieRefMetamodel = compilation.generatedSource("MovieRefMetamodel.kt")
+ assertTrue(
+ "this.titleFolded = object : st.orm.AbstractNavigableMetamodel(" +
+ "String::class.java, subPath, fieldBase + \"titleFolded\", false, this) {}"
+ in movieRefMetamodel,
+ ) { "a query column is reachable beyond a reference:\n$movieRefMetamodel" }
+ }
+
+ @Test
+ fun `query columns in the container generate fields`() {
+ val compilation = compile(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+ import st.orm.QueryColumns
+
+ @QueryColumns(
+ QueryColumn(field = "titleFolded", type = String::class),
+ QueryColumn(field = "releaseYear", type = Int::class),
+ )
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ val movieInterface = compilation.generatedSource("Movie_.kt")
+ assertTrue("val titleFolded: AbstractMetamodel" in movieInterface) {
+ "expected the first contained query column in the interface:\n$movieInterface"
+ }
+ assertTrue("val releaseYear: AbstractMetamodel" in movieInterface) {
+ "expected the second contained query column in the interface:\n$movieInterface"
+ }
+ val movieMetamodel = compilation.generatedSource("MovieMetamodel.kt")
+ assertTrue("UnsupportedOperationException(\"Movie.releaseYear is a query column; a record holds no value for it.\")" in movieMetamodel) {
+ "expected throwing value methods for the contained query columns:\n$movieMetamodel"
+ }
+ }
+
+ @Test
+ fun `a nullable query column renders a nullable value type`() {
+ val compilation = compile(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+
+ @QueryColumn(field = "titleFolded", type = String::class, nullable = true)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ val movieInterface = compilation.generatedSource("Movie_.kt")
+ assertTrue("val titleFolded: AbstractMetamodel" in movieInterface) {
+ "a nullable query column carries a nullable value type:\n$movieInterface"
+ }
+ val movieMetamodel = compilation.generatedSource("MovieMetamodel.kt")
+ assertTrue("override fun getValue(record: T): String? = throw" in movieMetamodel) {
+ "a nullable query column carries a nullable value type:\n$movieMetamodel"
+ }
+ }
+
+ @Test
+ fun `a keyword-named query column is escaped`() {
+ val compilation = compile(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+
+ @QueryColumn(field = "object", type = String::class)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ val movieMetamodel = compilation.generatedSource("MovieMetamodel.kt")
+ assertTrue("this.`object` = object : AbstractMetamodel" in movieMetamodel) {
+ "expected the keyword-named query column backticked:\n$movieMetamodel"
+ }
+ assertTrue("fieldBase + \"object\"" in movieMetamodel) { "the field string literal keeps the raw name:\n$movieMetamodel" }
+ }
+
+ @Test
+ fun `a duplicate query column field fails compilation`() {
+ val messages = compileExpectingError(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+
+ @QueryColumn(field = "titleFolded", type = String::class)
+ @QueryColumn(field = "titleFolded", type = String::class)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ assertTrue("Query column field 'titleFolded' is declared more than once on Movie." in messages) { messages }
+ }
+
+ @Test
+ fun `a query column field named like a component fails compilation`() {
+ val messages = compileExpectingError(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+
+ @QueryColumn(field = "title", type = String::class)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ assertTrue("Query column field 'title' on Movie has the name of a component." in messages) { messages }
+ }
+
+ @Test
+ fun `an empty query column field fails compilation`() {
+ val messages = compileExpectingError(
+ """
+ package com.example
+
+ import st.orm.Entity
+ import st.orm.PK
+ import st.orm.QueryColumn
+
+ @QueryColumn(field = "", type = String::class)
+ data class Movie(@PK val id: Int = 0, val title: String) : Entity
+ """.trimIndent(),
+ )
+ assertTrue("Query column on Movie declares an empty field." in messages) { messages }
+ }
+
@Test
fun `ignores plain data class without annotation`() {
val compilation = compile(
diff --git a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
index d89d6034c..212081810 100644
--- a/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
+++ b/storm-metamodel-processor/src/main/java/st/orm/metamodel/MetamodelProcessor.java
@@ -29,6 +29,8 @@
import java.io.StringWriter;
import java.io.UncheckedIOException;
import java.io.Writer;
+import java.util.ArrayList;
+import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
@@ -80,6 +82,8 @@ public final class MetamodelProcessor extends AbstractProcessor {
private static final String FOREIGN_KEY = "st.orm.FK";
private static final String PRIMARY_KEY = "st.orm.PK";
private static final String UNIQUE_KEY = "st.orm.UK";
+ private static final String QUERY_COLUMN = "st.orm.QueryColumn";
+ private static final String QUERY_COLUMNS = "st.orm.QueryColumns";
private static final Pattern REF_PATTERN = Pattern.compile("^st\\.orm\\.Ref<([^>]+)>$");
@@ -133,6 +137,21 @@ public final class MetamodelProcessor extends AbstractProcessor {
*/
private final Set generatedInstantiators;
+ /**
+ * The valid query columns per record type, keyed by qualified name. Reading a type's query columns reports its
+ * invalid declarations, so the cache makes each diagnostic print once.
+ */
+ private final Map> queryColumns;
+
+ /**
+ * A column a record declares through {@code @QueryColumn}: it gets a metamodel field but has no record component.
+ *
+ * @param field the metamodel field name.
+ * @param type the column's value type, as the annotation names it.
+ * @param nullable whether the column allows null.
+ */
+ private record QueryColumnDeclaration(String field, TypeMirror type, boolean nullable) {}
+
private Elements elementUtils;
private Types typeUtils;
private boolean jspecifyAvailable;
@@ -147,6 +166,7 @@ public MetamodelProcessor() {
this.generatedNavigableMetamodels = new HashSet<>();
this.navPath = new HashSet<>();
this.generatedInstantiators = new LinkedHashSet<>();
+ this.queryColumns = new HashMap<>();
}
@Override
@@ -322,18 +342,232 @@ private boolean implementsData(Element recordElement) {
return implementsInterface(recordElement.asType(), DATA, typeUtils);
}
+ private static boolean hasGenerateMetamodel(Element element) {
+ return element.getAnnotationMirrors().stream()
+ .anyMatch(annotationMirror -> GENERATE_METAMODEL.equals(annotationMirror.getAnnotationType().toString()));
+ }
+
+ /**
+ * Returns the valid query columns the record declares, in declaration order. Query columns apply to records that
+ * implement {@code Data} or carry {@code @GenerateMetamodel}; on any other record the annotation is ignored. The
+ * first read of a type reports its invalid declarations (an empty field, a field that is not a Java identifier, a
+ * field that repeats a record component or another query column) and leaves them out.
+ */
+ private List queryColumns(Element recordElement) {
+ if (!(recordElement instanceof TypeElement typeElement) || !isRecord(recordElement)) {
+ return List.of();
+ }
+ String qualifiedName = typeElement.getQualifiedName().toString();
+ List cached = queryColumns.get(qualifiedName);
+ if (cached == null) {
+ cached = readQueryColumns(typeElement);
+ queryColumns.put(qualifiedName, cached);
+ }
+ return cached;
+ }
+
+ private List readQueryColumns(TypeElement recordElement) {
+ if (!implementsData(recordElement) && !hasGenerateMetamodel(recordElement)) {
+ return List.of();
+ }
+ List declarations = queryColumnMirrors(recordElement);
+ if (declarations.isEmpty()) {
+ return List.of();
+ }
+ String recordName = recordElement.getSimpleName().toString();
+ Set componentNames = new HashSet<>();
+ for (Element enclosed : recordElement.getEnclosedElements()) {
+ if (getRecordComponentType(enclosed).isEmpty()) continue;
+ String fieldName = enclosed.getSimpleName().toString();
+ if (getTypeElement(recordElement, fieldName) != null) {
+ componentNames.add(fieldName);
+ }
+ }
+ Set declaredFields = new HashSet<>();
+ List result = new ArrayList<>();
+ for (AnnotationMirror declaration : declarations) {
+ Object field = annotationAttribute(declaration, "field");
+ Object type = annotationAttribute(declaration, "type");
+ Object nullable = annotationAttribute(declaration, "nullable");
+ String fieldName = field instanceof String name ? name : "";
+ if (fieldName.isEmpty()) {
+ processingEnv.getMessager().printMessage(ERROR,
+ "Query column on " + recordName + " declares an empty field. "
+ + "Name the metamodel field the query column gets.",
+ recordElement, declaration);
+ continue;
+ }
+ if (!SourceVersion.isIdentifier(fieldName) || SourceVersion.isKeyword(fieldName)) {
+ processingEnv.getMessager().printMessage(ERROR,
+ "Query column field '" + fieldName + "' on " + recordName + " is not a valid Java identifier.",
+ recordElement, declaration);
+ continue;
+ }
+ if (componentNames.contains(fieldName)) {
+ processingEnv.getMessager().printMessage(ERROR,
+ "Query column field '" + fieldName + "' on " + recordName + " has the name of a record "
+ + "component. A query column declares a column the record does not carry; the component "
+ + "already has a metamodel field.",
+ recordElement, declaration);
+ continue;
+ }
+ if (!declaredFields.add(fieldName)) {
+ processingEnv.getMessager().printMessage(ERROR,
+ "Query column field '" + fieldName + "' is declared more than once on " + recordName + ".",
+ recordElement, declaration);
+ continue;
+ }
+ if (!(type instanceof TypeMirror typeMirror) || typeMirror.getKind() == TypeKind.ERROR) {
+ // The compiler reports an unresolvable class literal itself.
+ continue;
+ }
+ if (typeMirror.getKind() == TypeKind.VOID) {
+ processingEnv.getMessager().printMessage(ERROR,
+ "Query column field '" + fieldName + "' on " + recordName + " declares type void.",
+ recordElement, declaration);
+ continue;
+ }
+ result.add(new QueryColumnDeclaration(fieldName, typeMirror, Boolean.TRUE.equals(nullable)));
+ }
+ return List.copyOf(result);
+ }
+
+ /**
+ * Returns the {@code @QueryColumn} mirrors on the element in declaration order: a direct annotation, or the
+ * {@code @QueryColumns} container javac synthesizes for a repeated one (or that the source names explicitly).
+ */
+ private static List queryColumnMirrors(Element element) {
+ List mirrors = new ArrayList<>();
+ for (AnnotationMirror annotationMirror : element.getAnnotationMirrors()) {
+ String annotationName = annotationMirror.getAnnotationType().toString();
+ if (QUERY_COLUMN.equals(annotationName)) {
+ mirrors.add(annotationMirror);
+ } else if (QUERY_COLUMNS.equals(annotationName)) {
+ for (var entry : annotationMirror.getElementValues().entrySet()) {
+ if (!"value".equals(entry.getKey().getSimpleName().toString())) continue;
+ if (entry.getValue().getValue() instanceof List> values) {
+ for (Object value : values) {
+ if (value instanceof AnnotationValue annotationValue
+ && annotationValue.getValue() instanceof AnnotationMirror nested) {
+ mirrors.add(nested);
+ }
+ }
+ }
+ }
+ }
+ }
+ return mirrors;
+ }
+
+ @Nullable
+ private Object annotationAttribute(AnnotationMirror annotationMirror, String name) {
+ for (var entry : elementUtils.getElementValuesWithDefaults(annotationMirror).entrySet()) {
+ if (name.equals(entry.getKey().getSimpleName().toString())) {
+ return entry.getValue().getValue();
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Renders the {@code E} type of a query column's metamodel: the boxed type, qualified unless it is declared
+ * directly in the record's package, as a record component's type renders.
+ */
+ private static String queryColumnTypeName(QueryColumnDeclaration queryColumn, String packageName) {
+ return getTypeName(queryColumn.type(), packageName);
+ }
+
+ /**
+ * Renders the {@code V} type of a query column's metamodel: the {@code E} type, marked nullable when the column
+ * allows null.
+ */
+ /**
+ * The value type of a query column field, which is its type: a component's value type carries no nullability
+ * annotation either.
+ */
+ private String queryColumnValueTypeName(QueryColumnDeclaration queryColumn, String packageName) {
+ return queryColumnTypeName(queryColumn, packageName);
+ }
+
+ private static String queryColumnDoc(String recordName, String fieldName) {
+ return "the {@code " + fieldName + "} query column of {@link " + recordName + "}";
+ }
+
+ private void appendQueryColumnInterfaceFields(StringBuilder builder, Element recordElement, String packageName,
+ String recordName, String modelRef) {
+ for (QueryColumnDeclaration queryColumn : queryColumns(recordElement)) {
+ String fieldName = queryColumn.field();
+ builder.append(" /** Represents ").append(queryColumnDoc(recordName, fieldName)).append(". */\n");
+ builder.append(" AbstractMetamodel<").append(recordName).append(", ")
+ .append(queryColumnTypeName(queryColumn, packageName)).append(", ")
+ .append(queryColumnValueTypeName(queryColumn, packageName)).append("> ")
+ .append(fieldName).append(" = ").append(modelRef).append(".").append(fieldName).append(";\n");
+ }
+ }
+
+ private void appendQueryColumnClassFields(StringBuilder builder, Element recordElement, String packageName,
+ String recordName) {
+ for (QueryColumnDeclaration queryColumn : queryColumns(recordElement)) {
+ String fieldName = queryColumn.field();
+ builder.append(" /** Represents ").append(queryColumnDoc(recordName, fieldName)).append(". */\n");
+ builder.append(" public final AbstractMetamodel ")
+ .append(fieldName).append(";\n");
+ }
+ }
+
+ /**
+ * Appends the initializers of the query column fields. A record holds no value for a query column, so the value
+ * methods throw.
+ */
+ private void appendQueryColumnInitializers(StringBuilder builder, Element recordElement, String packageName,
+ String recordName) {
+ for (QueryColumnDeclaration queryColumn : queryColumns(recordElement)) {
+ String fieldName = queryColumn.field();
+ String typeName = queryColumnTypeName(queryColumn, packageName);
+ String valueTypeName = queryColumnValueTypeName(queryColumn, packageName);
+ String failure = "throw new UnsupportedOperationException(\"" + recordName + "." + fieldName
+ + " is a query column; a record holds no value for it.\");";
+ builder.append(" this.").append(fieldName).append(" = new AbstractMetamodel(")
+ .append(typeName).append(".class, subPath, fieldBase + \"").append(fieldName)
+ .append("\", false, this) {\n")
+ .append(" @Override public ").append(nullableReturnType(typeName))
+ .append(" getValue(T record) { ").append(failure).append(" }\n")
+ .append(" @Override public boolean isIdentical(T a, T b) { ").append(failure).append(" }\n")
+ .append(" @Override public boolean isSame(T a, T b) { ").append(failure).append(" }\n")
+ .append(" };\n");
+ }
+ }
+
+ private void appendQueryColumnNavFields(StringBuilder builder, Element recordElement, String packageName,
+ String recordName) {
+ for (QueryColumnDeclaration queryColumn : queryColumns(recordElement)) {
+ String fieldName = queryColumn.field();
+ builder.append(" /** Represents navigation to ").append(queryColumnDoc(recordName, fieldName))
+ .append(". */\n");
+ builder.append(" public final st.orm.AbstractNavigableMetamodel ").append(fieldName).append(";\n");
+ }
+ }
+
+ private void appendQueryColumnNavInitializers(StringBuilder builder, Element recordElement, String packageName) {
+ for (QueryColumnDeclaration queryColumn : queryColumns(recordElement)) {
+ String fieldName = queryColumn.field();
+ String typeName = queryColumnTypeName(queryColumn, packageName);
+ builder.append(" this.").append(fieldName).append(" = new st.orm.AbstractNavigableMetamodel(").append(typeName).append(".class, subPath, fieldBase + \"")
+ .append(fieldName).append("\", false, this) {};\n");
+ }
+ }
+
@Override
public boolean process(Set extends TypeElement> annotations,
RoundEnvironment roundEnv) {
for (Element element : roundEnv.getRootElements()) {
try {
if (isRecord(element)) {
- boolean hasGenerateMetamodel = element.getAnnotationMirrors().stream()
- .anyMatch(annotationMirror -> GENERATE_METAMODEL
- .equals(annotationMirror.getAnnotationType().toString()));
-
- boolean isData = implementsData(element);
- if (hasGenerateMetamodel || isData) {
+ if (hasGenerateMetamodel(element) || implementsData(element)) {
generateMetamodelArtifacts(element);
}
} else if (element.getKind() == ElementKind.INTERFACE
@@ -1074,6 +1308,7 @@ private String buildInterfaceFields(Element recordElement, String packageName) {
.append(fieldName).append(";\n");
}
}
+ appendQueryColumnInterfaceFields(builder, recordElement, packageName, recordName, modelRef);
if (!builder.isEmpty()) {
builder.setLength(builder.length() - 1);
}
@@ -1247,6 +1482,7 @@ private String buildClassFields(Element recordElement,
.append(";\n");
}
}
+ appendQueryColumnClassFields(builder, recordElement, packageName, recordName);
return builder.toString();
}
@@ -1392,6 +1628,7 @@ private String initClassFields(Element recordElement,
.append(" };\n");
}
}
+ appendQueryColumnInitializers(builder, recordElement, packageName, recordName);
if (!builder.isEmpty()) {
builder.setLength(builder.length() - 1);
}
@@ -1569,6 +1806,7 @@ boolean record = isRecord(fieldType) && !isRefType(fieldType);
builder.append(" public final st.orm.AbstractNavigableMetamodel ").append(fieldName).append(";\n");
}
}
+ appendQueryColumnNavFields(builder, recordElement, packageName, recordName);
return builder.toString();
}
@@ -1594,6 +1832,7 @@ boolean record = isRecord(fieldType) && !isRefType(fieldType);
.append(fieldName).append("\", false, this) {};\n");
}
}
+ appendQueryColumnNavInitializers(builder, recordElement, packageName);
if (!builder.isEmpty()) builder.setLength(builder.length() - 1);
return builder.toString();
}
diff --git a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java
index cb4036add..99d5f8a62 100644
--- a/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java
+++ b/storm-metamodel-processor/src/test/java/st/orm/metamodel/MetamodelProcessorTest.java
@@ -381,6 +381,162 @@ record City(@PK Integer id, @FK Ref country) implements Entity
"navigation beyond the Ref boundary reaches the deeper graph");
}
+ @Test
+ void generatesThrowingFieldsForRepeatedQueryColumns() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import st.orm.Entity;
+ import st.orm.FK;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+ import st.orm.Ref;
+
+ @QueryColumn(field = "titleFolded", type = String.class)
+ @QueryColumn(field = "releaseYear", type = int.class)
+ public record Movie(@PK Integer id, String title) implements Entity {}
+
+ record Credit(@PK Integer id, @FK Ref movie) implements Entity {}
+ """);
+ assertTrue(compilation.success(), compilation.errors());
+ String movieInterface = compilation.generatedSource("Movie_.java");
+ assertTrue(movieInterface.contains("""
+ /** Represents the {@code titleFolded} query column of {@link Movie}. */
+ AbstractMetamodel titleFolded = \
+ MovieMetamodel.instance().titleFolded;"""),
+ "expected the query column in the metamodel interface:\n" + movieInterface);
+ assertTrue(movieInterface.contains("AbstractMetamodel releaseYear"),
+ "a primitive query column type renders boxed:\n" + movieInterface);
+ assertTrue(movieInterface.indexOf(" title ") < movieInterface.indexOf("titleFolded")
+ && movieInterface.indexOf("titleFolded") < movieInterface.indexOf("releaseYear"),
+ "query columns follow the components in declaration order:\n" + movieInterface);
+ String movieMetamodel = compilation.generatedSource("MovieMetamodel.java");
+ assertTrue(movieMetamodel.contains(
+ "public final AbstractMetamodel titleFolded;"),
+ "expected the query column field in the metamodel class:\n" + movieMetamodel);
+ assertTrue(movieMetamodel.contains("""
+ this.titleFolded = new AbstractMetamodel(\
+ java.lang.String.class, subPath, fieldBase + "titleFolded", false, this) {
+ @Override public java.lang.@org.jspecify.annotations.Nullable String getValue(T record) { \
+ throw new UnsupportedOperationException("Movie.titleFolded is a query column; a record holds no value for it."); }
+ @Override public boolean isIdentical(T a, T b) { \
+ throw new UnsupportedOperationException("Movie.titleFolded is a query column; a record holds no value for it."); }
+ @Override public boolean isSame(T a, T b) { \
+ throw new UnsupportedOperationException("Movie.titleFolded is a query column; a record holds no value for it."); }
+ };"""),
+ "a record holds no value for a query column, so its value methods throw:\n" + movieMetamodel);
+ String movieNullableMetamodel = compilation.generatedSource("MovieNullableMetamodel.java");
+ assertTrue(movieNullableMetamodel.contains(
+ "public final AbstractMetamodel releaseYear;"),
+ "the nullable-chain variant carries the query columns too:\n" + movieNullableMetamodel);
+ String movieRefMetamodel = compilation.generatedSource("MovieRefMetamodel.java");
+ assertTrue(movieRefMetamodel.contains("""
+ this.titleFolded = new st.orm.AbstractNavigableMetamodel(\
+ java.lang.String.class, subPath, fieldBase + "titleFolded", false, this) {};"""),
+ "a query column is reachable beyond a reference:\n" + movieRefMetamodel);
+ assertTrue(Files.exists(compilation.classes().resolve("Movie_.class"))
+ && Files.exists(compilation.classes().resolve("MovieNullableMetamodel.class"))
+ && Files.exists(compilation.classes().resolve("MovieRefMetamodel.class")),
+ "expected the generated metamodels to compile");
+ }
+
+ @Test
+ void generatesQueryColumnsFromTheContainer() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import st.orm.Entity;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+ import st.orm.QueryColumns;
+
+ @QueryColumns({
+ @QueryColumn(field = "titleFolded", type = String.class),
+ @QueryColumn(field = "releaseYear", type = int.class)
+ })
+ public record Movie(@PK Integer id, String title) implements Entity {}
+ """);
+ assertTrue(compilation.success(), compilation.errors());
+ String movieInterface = compilation.generatedSource("Movie_.java");
+ assertTrue(movieInterface.contains("AbstractMetamodel titleFolded"),
+ "expected the first contained query column in the interface:\n" + movieInterface);
+ assertTrue(movieInterface.contains("AbstractMetamodel releaseYear"),
+ "expected the second contained query column in the interface:\n" + movieInterface);
+ String movieMetamodel = compilation.generatedSource("MovieMetamodel.java");
+ assertTrue(movieMetamodel.contains(
+ "throw new UnsupportedOperationException(\"Movie.releaseYear is a query column; a record holds no value for it.\");"),
+ "expected throwing value methods for the contained query columns:\n" + movieMetamodel);
+ assertTrue(Files.exists(compilation.classes().resolve("MovieMetamodel.class")),
+ "expected the generated metamodel to compile");
+ }
+
+ @Test
+ void rendersANullableQueryColumnAsANullableComponent() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import org.jspecify.annotations.Nullable;
+ import st.orm.Entity;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+
+ @QueryColumn(field = "titleFolded", type = String.class, nullable = true)
+ public record Movie(@PK Integer id, @Nullable String title) implements Entity {}
+ """);
+ assertTrue(compilation.success(), compilation.errors());
+ String movieInterface = compilation.generatedSource("Movie_.java");
+ // A component's value type carries no nullability annotation; a query column's follows it.
+ assertTrue(movieInterface.contains("AbstractMetamodel title "),
+ "a nullable component carries a plain value type:\n" + movieInterface);
+ assertTrue(movieInterface.contains("AbstractMetamodel titleFolded"),
+ "a nullable query column carries the value type a nullable component carries:\n" + movieInterface);
+ String movieMetamodel = compilation.generatedSource("MovieMetamodel.java");
+ assertTrue(movieMetamodel.contains("public final AbstractMetamodel titleFolded;"),
+ "a nullable query column carries the value type a nullable component carries:\n" + movieMetamodel);
+ assertTrue(Files.exists(compilation.classes().resolve("Movie_.class")),
+ "expected the generated metamodels to compile");
+ }
+
+ @Test
+ void rejectsADuplicateQueryColumnField() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import st.orm.Entity;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+
+ @QueryColumn(field = "titleFolded", type = String.class)
+ @QueryColumn(field = "titleFolded", type = String.class)
+ public record Movie(@PK Integer id, String title) implements Entity {}
+ """);
+ assertFalse(compilation.success(), "a query column field declared twice must fail compilation");
+ assertTrue(compilation.errors().contains("Query column field 'titleFolded' is declared more than once on Movie."),
+ compilation.errors());
+ }
+
+ @Test
+ void rejectsAQueryColumnFieldNamedLikeAComponent() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import st.orm.Entity;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+
+ @QueryColumn(field = "title", type = String.class)
+ public record Movie(@PK Integer id, String title) implements Entity {}
+ """);
+ assertFalse(compilation.success(), "a query column named like a component must fail compilation");
+ assertTrue(compilation.errors().contains("Query column field 'title' on Movie has the name of a record component."),
+ compilation.errors());
+ }
+
+ @Test
+ void rejectsAnEmptyQueryColumnField() throws Exception {
+ Compilation compilation = compile("Movie.java", """
+ import st.orm.Entity;
+ import st.orm.PK;
+ import st.orm.QueryColumn;
+
+ @QueryColumn(field = "", type = String.class)
+ public record Movie(@PK Integer id, String title) implements Entity {}
+ """);
+ assertFalse(compilation.success(), "a query column with an empty field must fail compilation");
+ assertTrue(compilation.errors().contains("Query column on Movie declares an empty field."),
+ compilation.errors());
+ }
+
@Test
void ignoresPlainRecordWithoutAnnotation() throws Exception {
Compilation compilation = compile("CityStats.java", """
diff --git a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSchemaValidatorConformanceTest.java b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSchemaValidatorConformanceTest.java
index db7fa3183..ced9417ec 100644
--- a/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSchemaValidatorConformanceTest.java
+++ b/storm-mssqlserver/src/test/java/st/orm/spi/mssqlserver/MSSQLServerSchemaValidatorConformanceTest.java
@@ -18,4 +18,9 @@ public static synchronized DataSource dataSource() {
}
return ContainerDataSource.of(container.getJdbcUrl(), container.getUsername(), container.getPassword());
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(50) NOT NULL, name_folded AS (LOWER(name)) PERSISTED NOT NULL)";
+ }
}
diff --git a/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSchemaValidatorConformanceTest.java b/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSchemaValidatorConformanceTest.java
index 8409c9685..47937e1cc 100644
--- a/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSchemaValidatorConformanceTest.java
+++ b/storm-mysql/src/test/java/st/orm/spi/mysql/MySQLSchemaValidatorConformanceTest.java
@@ -32,4 +32,9 @@ protected String expressionIndexDdl() {
protected boolean indexesForeignKeys() {
return true;
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(50) NOT NULL, name_folded VARCHAR(50) AS (LOWER(name)) STORED NOT NULL)";
+ }
}
diff --git a/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSchemaValidatorConformanceTest.java b/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSchemaValidatorConformanceTest.java
index 990318e8c..306f46cf9 100644
--- a/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSchemaValidatorConformanceTest.java
+++ b/storm-oracle/src/test/java/st/orm/spi/oracle/OracleSchemaValidatorConformanceTest.java
@@ -35,4 +35,9 @@ static synchronized void stopContainer() {
protected String expressionIndexDdl() {
return "CREATE UNIQUE INDEX index_probe_lower ON index_probe (LOWER(a))";
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id NUMBER(10) NOT NULL PRIMARY KEY, name VARCHAR2(50) NOT NULL, name_folded VARCHAR2(50) GENERATED ALWAYS AS (LOWER(name)) VIRTUAL NOT NULL)";
+ }
}
diff --git a/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSchemaValidatorConformanceTest.java b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSchemaValidatorConformanceTest.java
index d737ddfa5..04d732938 100644
--- a/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSchemaValidatorConformanceTest.java
+++ b/storm-postgresql/src/test/java/st/orm/spi/postgresql/PostgreSQLSchemaValidatorConformanceTest.java
@@ -55,4 +55,9 @@ public void testAPartialIndexIsMarkedAndAViewIndexIsLeftOut() throws SQLExceptio
}
}
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(50) NOT NULL, name_folded VARCHAR(50) GENERATED ALWAYS AS (LOWER(name)) STORED NOT NULL)";
+ }
}
diff --git a/storm-sqlite/src/test/java/st/orm/spi/sqlite/SQLiteSchemaValidatorConformanceTest.java b/storm-sqlite/src/test/java/st/orm/spi/sqlite/SQLiteSchemaValidatorConformanceTest.java
index bc7ece31a..cae7d93c4 100644
--- a/storm-sqlite/src/test/java/st/orm/spi/sqlite/SQLiteSchemaValidatorConformanceTest.java
+++ b/storm-sqlite/src/test/java/st/orm/spi/sqlite/SQLiteSchemaValidatorConformanceTest.java
@@ -15,4 +15,9 @@ protected boolean supportsSequences() {
protected String expressionIndexDdl() {
return "CREATE UNIQUE INDEX index_probe_lower ON index_probe (lower(a))";
}
+
+ @Override
+ protected String generatedColumnProbeDdl() {
+ return "CREATE TABLE generated_probe (id INTEGER NOT NULL PRIMARY KEY, name TEXT NOT NULL, name_folded TEXT GENERATED ALWAYS AS (LOWER(name)) STORED NOT NULL)";
+ }
}
diff --git a/website/static/skills/storm-entity-from-schema.md b/website/static/skills/storm-entity-from-schema.md
index c66945975..f4df3a448 100644
--- a/website/static/skills/storm-entity-from-schema.md
+++ b/website/static/skills/storm-entity-from-schema.md
@@ -61,6 +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).
- 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 9369369fd..1f84b37b9 100644
--- a/website/static/skills/storm-entity-java.md
+++ b/website/static/skills/storm-entity-java.md
@@ -146,6 +146,8 @@ 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; `validate = false` skips a column a trigger fills.
+
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).
12. Java records are immutable. Consider Lombok `@Builder(toBuilder = true)` for copy-with-modification.
diff --git a/website/static/skills/storm-entity-kotlin.md b/website/static/skills/storm-entity-kotlin.md
index f14ca329f..91ba554c8 100644
--- a/website/static/skills/storm-entity-kotlin.md
+++ b/website/static/skills/storm-entity-kotlin.md
@@ -166,6 +166,8 @@ 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; `validate = false` skips a column a trigger fills.
+
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).
13. Use descriptive variable names, never abbreviated.
diff --git a/website/static/skills/storm-query-java.md b/website/static/skills/storm-query-java.md
index a5e3f829e..caf178474 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 a generated column or 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. 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, `@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.
### Naming a foreign key column
diff --git a/website/static/skills/storm-query-kotlin.md b/website/static/skills/storm-query-kotlin.md
index 67cd695f5..16b545d9e 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 a generated column or 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. 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, `@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.
Combine with `and`/`or`:
```kotlin
diff --git a/website/static/skills/storm-validate.md b/website/static/skills/storm-validate.md
index d6cf1b568..bd34c5eab 100644
--- a/website/static/skills/storm-validate.md
+++ b/website/static/skills/storm-validate.md
@@ -13,7 +13,7 @@ Compare Storm entities against the live database schema.
- Entities without tables
- Column type mismatches (incompatible types are errors; narrowing like Long mapped to INTEGER is a warning)
- Missing columns (entity field has no matching DB column)
- - Extra columns that are NOT NULL without a default value (these would cause INSERT failures)
+ - Extra columns that are NOT NULL without a default value and not generated (these would cause INSERT failures). A column declared with `@QueryColumn` is not extra; on an entity it must be generated, an identity column, have a default or be nullable, or every insert fails (`QUERY_COLUMN_NOT_FILLED`)
- FK/`@FK` mismatches (FK referencing wrong table is an error; missing FK constraint is a warning)
- Nullability differences (entity non-null but DB nullable is a warning)
- Missing unique constraints for @UK fields (use `uniqueConstraints` array from `describe_table`)
From 1bb57107dc3cbfe3e5e038b086ac732772f5dddb Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 08:25:54 +0100
Subject: [PATCH 2/8] Scan the shipped dependency graph as a CycloneDX bill of
materials with Trivy
The OWASP Dependency-Check job never completed: the NVD API throttles
GitHub-hosted runners to a page every few minutes, the first pull needs
the whole feed, and the job ran into its timeout on every run while the
Maven cache, already saved by the build job, kept none of the data.
The gate now writes a CycloneDX bill of materials of compile and runtime
scope with cyclonedx-maven-plugin and scans it with Trivy, which carries
its own advisory database. No key is needed, so the scan also runs on
Dependabot pull requests, and it finishes in seconds after the build.
The first scan found two High advisories in the shipped graph:
storm-ktor-test carries httpcore5 5.3.6 through ktor-server-test-host and
the Apache HTTP client (CVE-2026-54399, CVE-2026-54428). The module now
declares httpcore5 and httpcore5-h2 5.4.4 directly, so the fixed line also
wins in an application's test classpath.
---
.github/workflows/ci.yml | 41 ++++++++++++++++++++--------------------
pom.xml | 30 +++++++++++------------------
storm-ktor-test/pom.xml | 14 ++++++++++++++
3 files changed, 45 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index dd7b2f214..ffe41cf19 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -79,46 +79,45 @@ jobs:
flags: unittests
fail_ci_if_error: true
- # Known-vulnerability gate over the dependency graph Storm ships (compile and runtime scope): OWASP
- # Dependency-Check against the NVD, failing on a High or Critical advisory. Test and provided scope are the
- # application's own dependencies and stay out of it. The NVD API key is a repository secret; Dependabot runs and
- # forks receive no secrets, so the scan is skipped there and reported as such.
+ # Known-vulnerability gate over the dependency graph Storm ships (compile and runtime scope): the CycloneDX
+ # bill of materials of the reactor, scanned by Trivy, failing on a High or Critical advisory. Test and provided
+ # scope are the application's own dependencies and stay out of it. Trivy carries its own advisory database and
+ # needs no key, so the gate runs on every PR, Dependabot's and forks' included.
dependency-check:
runs-on: ubuntu-latest
- timeout-minutes: 40
- env:
- NVD_API_KEY: ${{ secrets.NVD_API_KEY }}
+ timeout-minutes: 20
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Set up JDK 21
- if: env.NVD_API_KEY != ''
uses: actions/setup-java@v6
with:
distribution: 'temurin'
java-version: '21'
- # The Maven cache holds the NVD data the plugin keeps under ~/.m2 as well, so a run only pulls the
- # advisories published since the last one.
cache: 'maven'
- - name: Scan the shipped dependency graph
- if: env.NVD_API_KEY != ''
- run: mvn -B -DskipTests -Dmaven.javadoc.skip=true -Dspotless.check.skip=true install org.owasp:dependency-check-maven:aggregate
+ - name: Write the bill of materials of the shipped dependency graph
+ run: mvn -B -DskipTests -Dmaven.javadoc.skip=true -Dspotless.check.skip=true package org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
- - name: Upload the Dependency-Check report
- if: env.NVD_API_KEY != '' && always()
+ - name: Scan the bill of materials for known vulnerabilities
+ uses: aquasecurity/trivy-action@0.36.0
+ with:
+ scan-type: sbom
+ scan-ref: target/bom.json
+ scanners: vuln
+ severity: HIGH,CRITICAL
+ exit-code: '1'
+
+ - name: Upload the bill of materials
+ if: always()
uses: actions/upload-artifact@v7
with:
- name: dependency-check-report
- path: target/dependency-check-report.*
+ name: dependency-bom
+ path: target/bom.json
if-no-files-found: ignore
retention-days: 30
- - name: Report a skipped scan
- if: env.NVD_API_KEY == ''
- run: echo "::notice::Dependency-Check skipped - no NVD_API_KEY available to this run."
-
# The CLI ships to users via npx but is plain JavaScript with no compile step,
# so at minimum its syntax must be checked before merge.
cli-check:
diff --git a/pom.xml b/pom.xml
index e03062217..d4e043c81 100644
--- a/pom.xml
+++ b/pom.xml
@@ -265,26 +265,18 @@
-
- org.owasp
- dependency-check-maven
- 13.0.0
+
+ org.cyclonedx
+ cyclonedx-maven-plugin
+ 2.9.1
-
- 7
- true
- true
- NVD_API_KEY
-
- HTML
- JSON
-
-
- false
+ false
+ false
+ json
diff --git a/storm-ktor-test/pom.xml b/storm-ktor-test/pom.xml
index ae9ebffaa..bd180d6d5 100644
--- a/storm-ktor-test/pom.xml
+++ b/storm-ktor-test/pom.xml
@@ -249,6 +249,20 @@
ktor-server-test-host-jvm
${ktor.version}
+
+
+ org.apache.httpcomponents.core5
+ httpcore5
+ 5.4.4
+
+
+ org.apache.httpcomponents.core5
+ httpcore5-h2
+ 5.4.4
+
com.h2database
h2
From bef18b30daaa36d69bf1eb609608336ffb32266f Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 08:41:03 +0100
Subject: [PATCH 3/8] Report a query column the database does not fill as an
unmapped column
For an insert, a query column is a column the entity does not map: every
insert leaves it out. The unmapped column check already reports a NOT NULL
column without a default that the database does not generate, so a query
column now falls under that rule instead of a kind of its own, and the
validate attribute, which only escaped the separate kind, goes with it.
---
CHANGELOG.md | 2 +-
docs/entities.md | 2 +-
docs/validation.md | 3 +--
.../template/impl/SchemaValidationError.java | 8 +++---
.../core/template/impl/SchemaValidator.java | 24 +++---------------
.../core/template/SchemaValidatorTest.java | 25 ++++++-------------
...bstractSchemaValidatorConformanceTest.java | 6 ++---
.../src/main/java/st/orm/QueryColumn.java | 10 ++------
website/static/skills/storm-entity-java.md | 2 +-
website/static/skills/storm-entity-kotlin.md | 2 +-
website/static/skills/storm-validate.md | 2 +-
11 files changed, 24 insertions(+), 62 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a344b7771..47a61c5c4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,7 +24,7 @@ for the CLI, to [npm](https://www.npmjs.com/package/@storm-orm/cli)
- `delete()` and `update()` refuse to run without a condition unless `unsafe()` is called, take explicit joins on every database, and refuse a joined inheritance hierarchy (#712). `SqlDialect.writeJoinSyntax()` replaces `supportsDeleteAlias()`.
- Added `semiJoin` and `antiJoin`, which filter rows by a related table without adding its rows, so no `distinct` is needed (#690). `semiJoin().on()` keeps the owners with a pet matching the conditions that name `Pet`, `antiJoin` the owners without one. They filter `select()`, `delete()` and `update()`, and nest to filter through two to-many relations: `semiJoin().on().semiJoin().on()` (#717). A search over a row and its related rows reads each table through its own indexes: 1.3 s to a few milliseconds over 980,000 credits on PostgreSQL.
- A typed join takes a condition: `on { Pet_.name eq "Leo" }` in Kotlin, `on(Owner.class, it -> ...)` in Java (#690). On a left join it keeps the rows without a match, which the same condition in `where` drops.
-- Added query columns, the columns a query names but the entity does not carry (#743): `@QueryColumn(field = "titleFolded", type = String::class)` on an entity or projection generates `Movie_.titleFolded` as a metamodel field 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. A generated column that stores a folded title for a search index is the typical case. Schema validation checks the column exists with a compatible type and, on an entity, that the database fills it, since every insert leaves it out (`QUERY_COLUMN_NOT_FILLED`); `validate = false` skips a column a trigger fills. Schema validation reads whether a column is generated, and no longer reports a generated `NOT NULL` column no field maps as unmapped. `Model.queryColumns()` lists them.
+- Added query columns, the columns a query names but the entity does not carry (#743): `@QueryColumn(field = "titleFolded", type = String::class)` on an entity or projection generates `Movie_.titleFolded` as a metamodel field 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. A generated column that stores a folded title for a search index is the typical case. Schema validation checks the column exists with a compatible type and, on an entity, reports a `NOT NULL` query column the database does not fill as unmapped (`UNMAPPED_COLUMN`), since every insert leaves it out. Schema validation reads whether a column is generated, and no longer reports a generated `NOT NULL` column no field maps as unmapped. `Model.queryColumns()` lists them.
- Added `suggest`, the `search` for input typed as you go: it ignores words shorter than the database's substring index serves and matches nothing when no word is left, so it never reads every row. `search` tests such short words only on the rows its longer words found: "de niro" over 980,000 credits went from 2.3 s to 19 ms on PostgreSQL. The length comes from `SqlDialect.minimumSearchTermLength()`, 3 on PostgreSQL and 0 elsewhere, and `storm.search.min_term_length` overrides it. `suggest` also takes the words a search box ignores, such as `the` in a catalog of titles, which the index serves but which narrow nothing: `suggest(input, setOf("the", "a", "of"), Movie_.title)` treats them as short words, compared ignoring case (#741).
- Added `orderFirst`, which sorts the rows matching a predicate first: `orderFirst(Movie_.title eqIgnoreCase input)` in Kotlin, `orderFirst(it -> it.where(Movie_.title, EQUALS_IGNORE_CASE, input))` in Java, rendered as `CASE WHEN ... THEN 0 ELSE 1 END` on every database. A keyset `scroll` follows it, and `Scrollable.sortBy` and `sortByDescending` take a field of any table in the query.
- Added `SqlFunction`, a one-argument SQL function applied to a column wherever a query takes one, so a query can match the expression an index is built on (#694). `foldAccents(Movie_.title)` in Kotlin, `foldAccents.apply(Movie_.title)` in Java; a comparison applies it to the column and the value alike, so an accent-insensitive search on PostgreSQL uses an index on `LOWER(fold_accents(title))`.
diff --git a/docs/entities.md b/docs/entities.md
index 8f88a6fa9..d3ed0c655 100644
--- a/docs/entities.md
+++ b/docs/entities.md
@@ -750,7 +750,7 @@ CREATE INDEX idx_movie_title_folded ON movie USING gin (LOWER(title_folded) gin_
- A query column is never selected, inserted or updated. A record holds no value for it: `Metamodel.getValue` throws, and `update().set` refuses it.
- It is reached through paths like any other field: a query column declared on `Movie` is `Credit_.movie.titleFolded` from a query on credits. Projections of the same table declare their own.
- `where`, `orderBy`, `orderFirst`, `search` and `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it. A keyset sort refuses a nullable query column, as it refuses a nullable field; `nullable = true` declares one.
-- Schema validation checks that the column exists with a compatible type, and on an entity that the database fills it, since every insert leaves it out: it must be generated, an identity column, have a default, or allow null. A column the database fills in a way its metadata does not show, such as a trigger, is declared with `validate = false`, which skips its checks as `@DbIgnore` skips a field's.
+- Schema validation checks that the column exists with a compatible type. On an entity every insert leaves it out, so the database must fill it: a `NOT NULL` query column that is not generated, not an identity column and has no default is reported as an [unmapped column](validation.md) is.
Where a caller reads the value, map the column as a field with `@Persist(insertable = false, updatable = false)` instead: a query column is for a column no row needs to carry.
diff --git a/docs/validation.md b/docs/validation.md
index 12903661d..ce0100db0 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -74,14 +74,13 @@ Schema validation compares your entity and projection definitions against the ac
| Entity primary key columns match the database primary key | `PRIMARY_KEY_MISMATCH` | Error |
| `@FK` constraint references the correct target table | `FOREIGN_KEY_MISMATCH` | Error |
| Sequences referenced by `@PK(generation = SEQUENCE)` exist | `SEQUENCE_NOT_FOUND` | Error |
-| A [query column](entities.md#query-columns) of an entity is filled by the database: generated, an identity column, with a default, or nullable | `QUERY_COLUMN_NOT_FILLED` | Error |
| | | |
| Numeric cross-category conversions (e.g., `Integer` mapped to `DECIMAL`) | `TYPE_NARROWING` | Warning |
| Non-nullable entity field mapped to a nullable database column | `NULLABILITY_MISMATCH` | Warning |
| Entity declares `@PK` but the database has no primary key constraint | `PRIMARY_KEY_MISSING` | Warning |
| `@UK` field has a matching unique constraint in the database | `UNIQUE_KEY_MISSING` | Warning |
| `@FK` field has a matching foreign key constraint in the database | `FOREIGN_KEY_MISSING` | Warning |
-| A `NOT NULL` column without a default that no field or query column maps, and that the database does not generate | `UNMAPPED_COLUMN` | Warning |
+| A `NOT NULL` column without a default that no field maps, and that the database does not generate; a [query column](entities.md#query-columns) of an entity counts, since every insert leaves it out | `UNMAPPED_COLUMN` | Warning |
**Errors** indicate definitive mismatches that will cause runtime failures, such as missing tables or columns.
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
index fa16f470b..075302d57 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidationError.java
@@ -56,13 +56,11 @@ public enum ErrorKind {
FOREIGN_KEY_MISMATCH,
/** A {@code @FK} field does not have a matching foreign key constraint in the database. */
FOREIGN_KEY_MISSING(true),
- /** A database column is NOT NULL without a default value, but is not mapped in the entity. @since 1.10 */
- UNMAPPED_COLUMN(true),
/**
- * A query column of an entity is NOT NULL and the database does not fill it: it is not generated, is not an
- * identity column and has no default, so every insert, which leaves it out, fails. @since 1.16
+ * A database column is NOT NULL without a default value, but is not mapped in the entity: no field maps it,
+ * or a query column names it, and the database does not generate it. @since 1.10
*/
- QUERY_COLUMN_NOT_FILLED;
+ UNMAPPED_COLUMN(true);
private final boolean warning;
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
index 47e87fb96..39c76c35b 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/SchemaValidator.java
@@ -15,7 +15,6 @@
*/
package st.orm.core.template.impl;
-import static st.orm.core.template.impl.RecordReflection.findQueryColumn;
import static st.orm.core.template.impl.RecordReflection.isPolymorphicData;
import java.lang.reflect.ParameterizedType;
@@ -45,7 +44,6 @@
import st.orm.GenerationStrategy;
import st.orm.PK;
import st.orm.ProjectionQuery;
-import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlTemplateException;
import st.orm.StormConfig;
@@ -537,26 +535,10 @@ private void validateType(
}
validateColumn(type, column, schema, tableName, qualifiedTableName, errors);
}
- // A query column is checked as a declared column is, and on an entity for being filled by the database,
- // since every insert leaves it out.
- boolean entity = Entity.class.isAssignableFrom(type);
+ // A query column is checked as a declared column is. It stays out of the mapped set: every insert leaves it
+ // out, so the unmapped column check (step 9) covers it as it covers a column no field maps.
for (Column column : model.queryColumns()) {
- mappedColumns.add(column.name());
- QueryColumn declaration = findQueryColumn(type, column.metamodel().field()).orElse(null);
- if (declaration != null && !declaration.validate()) {
- continue;
- }
- DbColumn dbColumn = validateColumn(type, column, schema, tableName, qualifiedTableName, errors);
- if (dbColumn != null && entity && !dbColumn.nullable() && !dbColumn.hasDefault()
- && !dbColumn.autoIncrement() && !dbColumn.generated()) {
- errors.add(new SchemaValidationError(type, ErrorKind.QUERY_COLUMN_NOT_FILLED,
- ("Query column '%s' in table '%s' is NOT NULL and the database does not fill it: the column is "
- + "not generated, is not an identity column and has no default. Every insert of entity '%s' "
- + "leaves it out and fails. Give the column a generation expression or a default, or declare "
- + "the query column with validate = false where the database fills it in a way its metadata "
- + "does not show.")
- .formatted(column.name(), qualifiedTableName, type.getSimpleName())));
- }
+ validateColumn(type, column, schema, tableName, qualifiedTableName, errors);
}
// 5. Primary key match. Skipped when this table's primary key could not be read: an empty result would
// otherwise read as "the table has no primary key".
diff --git a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
index 888bfbf0d..665f082a9 100644
--- a/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/SchemaValidatorTest.java
@@ -245,9 +245,6 @@ public record ForeignKeyIgnoredEntity(
@QueryColumn(field = "nameFolded", type = String.class)
public record QueryColumnEntity(@PK Integer id, String name) implements Entity {}
- @QueryColumn(field = "nameFolded", type = String.class, validate = false)
- public record UnvalidatedQueryColumnEntity(@PK Integer id, String name) implements Entity {}
-
@QueryColumn(field = "nameFolded", type = String.class, nullable = true)
public record NullableQueryColumnEntity(@PK Integer id, String name) implements Entity {}
@@ -844,29 +841,21 @@ void testQueryColumnNotFound() throws SQLException {
}
@Test
- void testQueryColumnTheDatabaseDoesNotFillIsAnError() throws SQLException {
+ void testQueryColumnTheDatabaseDoesNotFillIsUnmapped() throws SQLException {
execute("CREATE TABLE query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnEntity.class));
+ // Every insert leaves the query column out, so the column is unmapped for the insert, as a column no field
+ // maps is, and reported the same way.
assertEquals(1, errors.size(), errors.toString());
- assertEquals(ErrorKind.QUERY_COLUMN_NOT_FILLED, errors.getFirst().kind());
- assertFalse(errors.getFirst().kind().warning());
- assertTrue(errors.getFirst().message().contains("name_folded"), errors.getFirst().message());
- assertTrue(errors.getFirst().message().contains("validate = false"), errors.getFirst().message());
- }
-
- @Test
- void testQueryColumnDeclaredUnvalidatedIsSkipped() throws SQLException {
- execute("CREATE TABLE unvalidated_query_column_entity (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
-
- List errors = SchemaValidator.of(dataSource).validate(List.of(UnvalidatedQueryColumnEntity.class));
-
- assertTrue(errors.isEmpty(), "Expected no errors but got: " + errors);
+ assertEquals(ErrorKind.UNMAPPED_COLUMN, errors.getFirst().kind());
+ assertTrue(errors.getFirst().kind().warning());
+ assertTrue(errors.getFirst().message().toLowerCase().contains("name_folded"), errors.getFirst().message());
}
@Test
- void testQueryColumnOfAProjectionIsNotCheckedForFilling() throws SQLException {
+ void testQueryColumnOfAProjectionIsNotReportedAsUnmapped() throws SQLException {
execute("CREATE TABLE query_column_projection (id INTEGER AUTO_INCREMENT, name VARCHAR(255) NOT NULL, name_folded VARCHAR(255) NOT NULL, PRIMARY KEY (id))");
List errors = SchemaValidator.of(dataSource).validate(List.of(QueryColumnProjection.class));
diff --git a/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
index 906814bf5..013ef0544 100644
--- a/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
+++ b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
@@ -302,9 +302,9 @@ public void testAGeneratedColumnIsFilledByTheDatabase() throws SQLException {
// database fills it, so neither a query column nor an unmapped column over it fails an insert.
assertTrue(column.generated() || column.hasDefault(), column::toString);
var errors = SchemaValidator.of(dataSource).validate(List.of(GeneratedProbe.class, GeneratedProbeUnmapped.class));
- assertFalse(errors.stream().anyMatch(error -> error.kind() == ErrorKind.QUERY_COLUMN_NOT_FILLED
- || error.kind() == ErrorKind.UNMAPPED_COLUMN || error.kind() == ErrorKind.COLUMN_NOT_FOUND
- || error.kind() == ErrorKind.TYPE_INCOMPATIBLE), errors::toString);
+ assertFalse(errors.stream().anyMatch(error -> error.kind() == ErrorKind.UNMAPPED_COLUMN
+ || error.kind() == ErrorKind.COLUMN_NOT_FOUND || error.kind() == ErrorKind.TYPE_INCOMPATIBLE),
+ errors::toString);
} finally {
statement.execute("DROP TABLE generated_probe");
}
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumn.java b/storm-foundation/src/main/java/st/orm/QueryColumn.java
index 897f6964d..7b1c99cc6 100644
--- a/storm-foundation/src/main/java/st/orm/QueryColumn.java
+++ b/storm-foundation/src/main/java/st/orm/QueryColumn.java
@@ -47,8 +47,8 @@
*
* The column follows from the field name through the column name resolver, as a component's does;
* {@link #column()} names it where the resolver does not. Schema validation checks that the column exists with a
- * compatible type, and on an entity that the database fills it, since every insert leaves it out: it must be
- * generated, an identity column, have a default, or allow null.
+ * compatible type. On an entity every insert leaves it out, so the database must fill it: a NOT NULL query column
+ * that is not generated, not an identity column and has no default is reported as an unmapped column is.
*
* @since 1.16
*/
@@ -79,10 +79,4 @@
* keyset sort field, as a nullable field is.
*/
boolean nullable() default false;
-
- /**
- * Whether schema validation checks the column. {@code false} skips it, as {@link DbIgnore} skips a record
- * component: for a column the database fills in a way its metadata does not show, such as a trigger.
- */
- boolean validate() default true;
}
diff --git a/website/static/skills/storm-entity-java.md b/website/static/skills/storm-entity-java.md
index 1f84b37b9..6d59fea57 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; `validate = false` skips a column a trigger fills.
+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. 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 91ba554c8..b2cc9320e 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; `validate = false` skips a column a trigger fills.
+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. 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-validate.md b/website/static/skills/storm-validate.md
index bd34c5eab..36350e247 100644
--- a/website/static/skills/storm-validate.md
+++ b/website/static/skills/storm-validate.md
@@ -13,7 +13,7 @@ Compare Storm entities against the live database schema.
- Entities without tables
- Column type mismatches (incompatible types are errors; narrowing like Long mapped to INTEGER is a warning)
- Missing columns (entity field has no matching DB column)
- - Extra columns that are NOT NULL without a default value and not generated (these would cause INSERT failures). A column declared with `@QueryColumn` is not extra; on an entity it must be generated, an identity column, have a default or be nullable, or every insert fails (`QUERY_COLUMN_NOT_FILLED`)
+ - Extra columns that are NOT NULL without a default value and not generated (these would cause INSERT failures). A column declared with `@QueryColumn` on an entity counts, since every insert leaves it out: it must be generated, an identity column, have a default or be nullable
- FK/`@FK` mismatches (FK referencing wrong table is an error; missing FK constraint is a warning)
- Nullability differences (entity non-null but DB nullable is a warning)
- Missing unique constraints for @UK fields (use `uniqueConstraints` array from `describe_table`)
From 02654ddddd0103630dbe4b5e5cac4d01c71ad260 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 08:42:10 +0100
Subject: [PATCH 4/8] Reference the Trivy action by its tag
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ffe41cf19..cdb66bad0 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -101,7 +101,7 @@ jobs:
run: mvn -B -DskipTests -Dmaven.javadoc.skip=true -Dspotless.check.skip=true package org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
- name: Scan the bill of materials for known vulnerabilities
- uses: aquasecurity/trivy-action@0.36.0
+ uses: aquasecurity/trivy-action@v0.36.0
with:
scan-type: sbom
scan-ref: target/bom.json
From 8a35afc8b4a85d1271b056d3a370897e6d5ffe36 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 08:54:18 +0100
Subject: [PATCH 5/8] Leave the table name to the resolver where the examples
repeat it
---
docs/entities.md | 2 --
docs/search.md | 2 --
.../java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java | 1 -
storm-foundation/src/main/java/st/orm/QueryColumn.java | 1 -
4 files changed, 6 deletions(-)
diff --git a/docs/entities.md b/docs/entities.md
index d3ed0c655..9ae4611d7 100644
--- a/docs/entities.md
+++ b/docs/entities.md
@@ -699,7 +699,6 @@ A query column is a column a query names but the entity does not carry. `@QueryC
```kotlin
-@DbTable("movie")
@QueryColumn(field = "titleFolded", type = String::class)
data class Movie(
@PK val id: Int = 0,
@@ -722,7 +721,6 @@ orm.entity(Movie::class)
```java
-@DbTable("movie")
@QueryColumn(field = "titleFolded", type = String.class)
record Movie(@PK Integer id, String title, Integer year) implements Entity {}
```
diff --git a/docs/search.md b/docs/search.md
index 7ec8e815c..95b3eac3d 100644
--- a/docs/search.md
+++ b/docs/search.md
@@ -324,7 +324,6 @@ The search then compares `LOWER(m.title_folded) LIKE LOWER(?)`, the expression t
```kotlin
-@DbTable("movie")
@QueryColumn(field = "titleFolded", type = String::class)
data class Movie(...) : Entity
@@ -341,7 +340,6 @@ orm.entity(Movie::class)
```java
-@DbTable("movie")
@QueryColumn(field = "titleFolded", type = String.class)
record Movie(...) implements Entity {}
diff --git a/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
index 013ef0544..b89c0a366 100644
--- a/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
+++ b/storm-dialect-tck/src/main/java/st/orm/tck/AbstractSchemaValidatorConformanceTest.java
@@ -88,7 +88,6 @@ protected boolean supportsSequences() {
}
/** {@code generated_probe} with its generated column declared as a query column. */
- @DbTable("generated_probe")
@QueryColumn(field = "nameFolded", type = String.class)
public record GeneratedProbe(@PK Integer id, String name) implements Entity {}
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumn.java b/storm-foundation/src/main/java/st/orm/QueryColumn.java
index 7b1c99cc6..f7828ced7 100644
--- a/storm-foundation/src/main/java/st/orm/QueryColumn.java
+++ b/storm-foundation/src/main/java/st/orm/QueryColumn.java
@@ -30,7 +30,6 @@
* value to give it.
*
* {@code
- * @DbTable("movie")
* @QueryColumn(field = "titleFolded", type = String.class)
* record Movie(@PK Integer id, String title, Integer year) implements Entity {}
*
From dd75a0b604b3d69cf5aa77c239be06b01dab2d7c Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:15:02 +0100
Subject: [PATCH 6/8] Cover the query column paths the suites left out
The Java 21 test model declares the folded last name as a query column, as
the Kotlin one does, and its query builder test reads it through the
generated metamodel, through a path and from the model. The metamodel
factory test resolves a query column of a record without a generated
metamodel, which exercises the reflective metamodel and its refusals, and
a query column behind an unknown path. The schema test reads columns
through a driver whose metadata lacks IS_GENERATEDCOLUMN. The enum mapping
in ModelImpl no longer guards against a query column, which never reaches
it: a record value is mapped per record column only.
---
.../st/orm/core/template/impl/ModelImpl.java | 3 +-
.../template/impl/DatabaseSchemaTest.java | 50 +++++++++++++++++++
.../template/impl/MetamodelFactoryTest.java | 43 ++++++++++++++++
.../st/orm/template/QueryBuilderTest.java | 15 ++++++
.../java/st/orm/template/model/Owner.java | 2 +
storm-java21/src/test/resources/data.sql | 2 +-
6 files changed, 112 insertions(+), 3 deletions(-)
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
index 9b4d98676..892473348 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelImpl.java
@@ -836,8 +836,7 @@ private Object map(Column column, @Nullable Object value) {
case LocalTime it -> Time.valueOf(it);
case Calendar it -> new Timestamp(it.getTimeInMillis());
case java.util.Date it -> new Timestamp(it.getTime());
- // A query column has no record field, and so no @DbEnum: its enum values compare by name.
- case Enum> it -> switch (ofNullable(column.index() > 0 ? fields.get(column.index() - 1).getAnnotation(DbEnum.class) : null)
+ case Enum> it -> switch (ofNullable(fields.get(column.index() - 1).getAnnotation(DbEnum.class))
.map(DbEnum::value)
.orElse(NAME)) {
case NAME -> it.name();
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
index 4c6ebbd03..0aca9c13f 100644
--- a/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/impl/DatabaseSchemaTest.java
@@ -8,6 +8,8 @@
import java.sql.Connection;
import java.sql.DatabaseMetaData;
import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.ResultSetMetaData;
import java.sql.SQLException;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
@@ -193,6 +195,54 @@ void testGeneratedColumnIsReported() throws SQLException {
}
}
+ /**
+ * Wraps the connection so the column metadata carries no {@code IS_GENERATEDCOLUMN} column, the way a driver
+ * that predates it reports columns.
+ */
+ private Connection withoutGeneratedColumnMetadata() throws SQLException {
+ Connection delegate = getConnection();
+ DatabaseMetaData realMetaData = delegate.getMetaData();
+ DatabaseMetaData metaData = (DatabaseMetaData) Proxy.newProxyInstance(
+ DatabaseMetaData.class.getClassLoader(), new Class>[]{DatabaseMetaData.class},
+ (proxy, method, args) -> {
+ Object result = invoke(method, realMetaData, args);
+ if (!method.getName().equals("getColumns")) {
+ return result;
+ }
+ ResultSet columns = (ResultSet) result;
+ ResultSetMetaData realColumnsMetaData = columns.getMetaData();
+ ResultSetMetaData columnsMetaData = (ResultSetMetaData) Proxy.newProxyInstance(
+ ResultSetMetaData.class.getClassLoader(), new Class>[]{ResultSetMetaData.class},
+ (p, m, a) -> {
+ Object value = invoke(m, realColumnsMetaData, a);
+ return m.getName().equals("getColumnLabel") && "IS_GENERATEDCOLUMN".equalsIgnoreCase(String.valueOf(value))
+ ? "UNREPORTED"
+ : value;
+ });
+ return Proxy.newProxyInstance(
+ ResultSet.class.getClassLoader(), new Class>[]{ResultSet.class},
+ (p, m, a) -> m.getName().equals("getMetaData") ? columnsMetaData : invoke(m, columns, a));
+ });
+ return (Connection) Proxy.newProxyInstance(
+ Connection.class.getClassLoader(), new Class>[]{Connection.class},
+ (proxy, method, args) -> method.getName().equals("getMetaData")
+ ? metaData
+ : invoke(method, delegate, args));
+ }
+
+ @Test
+ void testAGeneratedColumnIsNotReportedWithoutTheMetadataColumn() throws SQLException {
+ execute("CREATE TABLE generated_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, name_folded VARCHAR(100) GENERATED ALWAYS AS (LOWER(name)) NOT NULL, PRIMARY KEY (id))");
+ try (Connection connection = withoutGeneratedColumnMetadata()) {
+ DatabaseSchema schema = DatabaseSchema.read(connection);
+
+ // The driver says nothing about generation, so the column reads as an ordinary one.
+ DbColumn foldedColumn = schema.getColumn("generated_test", "name_folded").orElseThrow();
+ assertFalse(foldedColumn.generated());
+ assertFalse(foldedColumn.nullable());
+ }
+ }
+
@Test
void testGetColumnProperties() throws SQLException {
execute("CREATE TABLE prop_test (id INTEGER AUTO_INCREMENT, name VARCHAR(100) NOT NULL, description VARCHAR(500), PRIMARY KEY (id))");
diff --git a/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java b/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java
index 51bf002e2..ce0bf1ff7 100644
--- a/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java
+++ b/storm-core/src/test/java/st/orm/core/template/impl/MetamodelFactoryTest.java
@@ -4,6 +4,7 @@
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.List;
@@ -13,6 +14,8 @@
import st.orm.Inline;
import st.orm.Metamodel;
import st.orm.PK;
+import st.orm.PersistenceException;
+import st.orm.QueryColumn;
import st.orm.Ref;
/**
@@ -30,6 +33,12 @@ public record SimpleEntity(
public record InlineAddress(String street, String zipCode) {}
+ @QueryColumn(field = "nameFolded", type = String.class)
+ public record EntityWithQueryColumn(
+ @PK Integer id,
+ String name
+ ) implements Entity {}
+
public record EntityWithInline(
@PK Integer id,
@Inline InlineAddress address
@@ -316,4 +325,38 @@ void testOfIsSameForFkFieldDifferentPk() {
EntityWithFk entityB = new EntityWithFk(2, new ReferencedEntity(20, "a"));
assertFalse(model.isSame(entityA, entityB));
}
+
+ // Query columns
+
+ @Test
+ void testQueryColumnResolvesWithoutAGeneratedMetamodel() {
+ Metamodel metamodel = MetamodelFactory.of(EntityWithQueryColumn.class, "nameFolded");
+
+ assertEquals(EntityWithQueryColumn.class, metamodel.root());
+ assertEquals(EntityWithQueryColumn.class, metamodel.table().fieldType());
+ assertEquals("", metamodel.path());
+ assertEquals("nameFolded", metamodel.field());
+ assertEquals(String.class, metamodel.fieldType());
+ assertTrue(metamodel.isColumn());
+ assertFalse(MetamodelFactory.isNullable(metamodel));
+ }
+
+ @Test
+ void testQueryColumnHoldsNoValue() {
+ Metamodel metamodel = MetamodelFactory.of(EntityWithQueryColumn.class, "nameFolded");
+ var a = new EntityWithQueryColumn(1, "a");
+ var b = new EntityWithQueryColumn(2, "b");
+
+ var exception = assertThrows(UnsupportedOperationException.class, () -> metamodel.getValue(a));
+ assertTrue(exception.getMessage().contains("query column"), exception.getMessage());
+ assertThrows(UnsupportedOperationException.class, () -> metamodel.isSame(a, b));
+ assertThrows(UnsupportedOperationException.class, () -> metamodel.isIdentical(a, b));
+ }
+
+ @Test
+ void testQueryColumnBehindAnUnknownPathIsRefused() {
+ var exception = assertThrows(PersistenceException.class,
+ () -> MetamodelFactory.of(EntityWithQueryColumn.class, "unknown.nameFolded"));
+ assertTrue(exception.getMessage().contains("unknown.nameFolded"), exception.getMessage());
+ }
}
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 20a41c6b9..3c1a6741b 100644
--- a/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
+++ b/storm-java21/src/test/java/st/orm/template/QueryBuilderTest.java
@@ -1332,4 +1332,19 @@ public void testGetOptionalResultThrowsNonUnique() {
assertThrows(NonUniqueResultException.class, () ->
orm.entity(City.class).select().getOptionalResult());
}
+
+ @Test
+ public void testQueryColumnIsNamedByAQueryAndCarriedByNoRow() {
+ // data.sql: owner.last_name_folded is generated from the last name; Owner declares it as a query column.
+ List owners = orm.entity(Owner.class).select().where(Owner_.lastNameFolded, EQUALS, "davis").getResultList();
+ assertEquals(List.of("Betty", "Harold"), owners.stream().map(Owner::firstName).sorted().toList());
+ assertEquals("owner", Pet_.owner.lastNameFolded.path());
+ assertEquals("lastNameFolded", Pet_.owner.lastNameFolded.field());
+ List pets = orm.entity(Pet.class).select().where(Pet_.owner.lastNameFolded, EQUALS, "davis").getResultList();
+ assertEquals(List.of("Iggy", "Leo"), pets.stream().map(Pet::name).sorted().toList());
+ var model = orm.entity(Owner.class).model();
+ 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()));
+ }
}
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 8016cf64e..7b96f1964 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
@@ -3,8 +3,10 @@
import org.jspecify.annotations.Nullable;
import st.orm.Entity;
import st.orm.PK;
+import st.orm.QueryColumn;
import st.orm.Version;
+@QueryColumn(field = "lastNameFolded", type = String.class)
public record Owner(
@PK Integer id,
String firstName,
diff --git a/storm-java21/src/test/resources/data.sql b/storm-java21/src/test/resources/data.sql
index 377667b95..87d446b6b 100644
--- a/storm-java21/src/test/resources/data.sql
+++ b/storm-java21/src/test/resources/data.sql
@@ -8,7 +8,7 @@ drop table if exists vet_specialty CASCADE;
drop table if exists visit 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 not null, telephone varchar(255), primary key (id), version integer default 0 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 not null, 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 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), primary key (id));
From c05c0c197e3baa343ad9d8810ec77acd611b3148 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:32:48 +0100
Subject: [PATCH 7/8] Refuse a query column that names a column a field maps
A column is carried by a field or named by a query column, never both.
The model builder compares a query column's resolved column name with the
columns the fields of the same table map and refuses a match, naming the
field, as it refuses two query columns of one type naming the same column.
---
docs/entities.md | 1 +
.../st/orm/core/template/impl/ModelFactory.java | 15 +++++++++++++++
.../st/orm/core/QueryColumnIntegrationTest.java | 13 +++++++++++++
.../src/main/java/st/orm/QueryColumn.java | 4 +++-
4 files changed, 32 insertions(+), 1 deletion(-)
diff --git a/docs/entities.md b/docs/entities.md
index 9ae4611d7..d75d50381 100644
--- a/docs/entities.md
+++ b/docs/entities.md
@@ -746,6 +746,7 @@ CREATE INDEX idx_movie_title_folded ON movie USING gin (LOWER(title_folded) gin_
```
- A query column is never selected, inserted or updated. A record holds no value for it: `Metamodel.getValue` throws, and `update().set` refuses it.
+- A column is carried by a field or named by a query column, not both. A query column whose column a field maps is refused when the model is built, as is one named after a record component.
- It is reached through paths like any other field: a query column declared on `Movie` is `Credit_.movie.titleFolded` from a query on credits. Projections of the same table declare their own.
- `where`, `orderBy`, `orderFirst`, `search` and `suggest`, a `SqlFunction`, a keyset sort and a template placeholder take it. A keyset sort refuses a nullable query column, as it refuses a nullable field; `nullable = true` declares one.
- Schema validation checks that the column exists with a compatible type. On an entity every insert leaves it out, so the database must fill it: a `NOT NULL` query column that is not generated, not an identity column and has no default is reported as an [unmapped column](validation.md) is.
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
index 1f121ca1b..8ac52ca70 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
@@ -16,6 +16,8 @@
package st.orm.core.template.impl;
import static java.util.Collections.nCopies;
+import static java.util.Locale.ROOT;
+import static java.util.stream.Collectors.toMap;
import static java.util.stream.Collectors.toSet;
import static st.orm.core.spi.Providers.getORMConverter;
import static st.orm.core.template.impl.RecordReflection.detectSealedPattern;
@@ -536,7 +538,13 @@ private static void createQueryColumns(BuildContext ctx,
boolean parentNullable) {
try {
var componentNames = getRecordType(type).fields().stream().map(RecordField::name).collect(toSet());
+ // The columns the fields of this table map, as emitted: a query column cannot name one of them.
+ var tablePath = parentMetamodel.fieldPath();
+ var fieldColumns = ctx.columns().stream()
+ .filter(column -> column.metamodel().table().fieldPath().equals(tablePath))
+ .collect(toMap(column -> column.name().toLowerCase(ROOT), column -> column.metamodel().field(), (first, second) -> first));
var declared = new HashSet();
+ var declaredColumns = new HashSet();
for (QueryColumn queryColumn : getQueryColumns(type)) {
String fieldName = queryColumn.field();
if (fieldName.isEmpty()) {
@@ -556,6 +564,13 @@ private static void createQueryColumns(BuildContext ctx,
ColumnName columnName = queryColumn.column().isEmpty()
? new ColumnName(ctx.builder().columnNameResolver().resolveColumnName(field))
: new ColumnName(queryColumn.column());
+ String mappedBy = fieldColumns.get(columnName.name().toLowerCase(ROOT));
+ if (mappedBy != null) {
+ throw new SqlTemplateException("Query column '%s' of %s names column '%s', which field '%s' maps; a query column is a column the record does not carry.".formatted(fieldName, type.getSimpleName(), columnName.name(), mappedBy));
+ }
+ if (!declaredColumns.add(columnName.name().toLowerCase(ROOT))) {
+ throw new SqlTemplateException("Query column '%s' of %s names column '%s', which another query column of %s names as well.".formatted(fieldName, type.getSimpleName(), columnName.name(), type.getSimpleName()));
+ }
ctx.queryColumns().add(new ColumnImpl(
columnName,
0,
diff --git a/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java b/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
index 4201479e9..c0d0c147e 100644
--- a/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
+++ b/storm-core/src/test/java/st/orm/core/QueryColumnIntegrationTest.java
@@ -191,4 +191,17 @@ public void queryColumnNamedAsAComponentIsRefused() {
}
assertTrue(messages.stream().anyMatch(message -> message.contains("record component")), messages.toString());
}
+
+ @QueryColumn(field = "nameCopy", type = String.class, column = "name")
+ public record ColumnNamedQueryColumn(@PK Integer id, String name) implements Entity {}
+
+ @Test
+ public void queryColumnNamingAMappedColumnIsRefused() {
+ var exception = assertThrows(PersistenceException.class, () -> orm().entity(ColumnNamedQueryColumn.class).model());
+ var messages = new ArrayList();
+ for (Throwable cause = exception; cause != null; cause = cause.getCause()) {
+ messages.add(String.valueOf(cause.getMessage()));
+ }
+ assertTrue(messages.stream().anyMatch(message -> message.contains("which field 'name' maps")), messages.toString());
+ }
}
diff --git a/storm-foundation/src/main/java/st/orm/QueryColumn.java b/storm-foundation/src/main/java/st/orm/QueryColumn.java
index f7828ced7..d96bc6360 100644
--- a/storm-foundation/src/main/java/st/orm/QueryColumn.java
+++ b/storm-foundation/src/main/java/st/orm/QueryColumn.java
@@ -45,7 +45,9 @@
* {@link Metamodel#getValue(Data)} throws, and an {@code update} cannot set it.
*
* The column follows from the field name through the column name resolver, as a component's does;
- * {@link #column()} names it where the resolver does not. Schema validation checks that the column exists with a
+ * {@link #column()} names it where the resolver does not. A column is carried by a field or named by a query column,
+ * not both: a declaration whose column a field maps is refused when the model is built, as is one named after a
+ * record component. Schema validation checks that the column exists with a
* compatible type. On an entity every insert leaves it out, so the database must fill it: a NOT NULL query column
* that is not generated, not an identity column and has no default is reported as an unmapped column is.
*
From 7cdf8e02804a88f5b855866da834d5f0750d3dd6 Mon Sep 17 00:00:00 2001
From: Leon van Zantvoort <303196+zantvoort@users.noreply.github.com>
Date: Fri, 23 Oct 2026 09:44:40 +0100
Subject: [PATCH 8/8] Keep query columns off the cost of ordinary queries
The keyset sort check resolves the record field first and consults the
query column only where no field exists at the path, so an ordinary sort
field costs what it did. Query columns are read once per declaring type
through a ClassValue instead of assembling the repeatable annotation on
every call, and the model builder returns at once for a type that declares
none instead of collecting its component and column names.
---
.../core/template/impl/MetamodelFactory.java | 53 ++++++++++---------
.../orm/core/template/impl/ModelFactory.java | 6 ++-
.../core/template/impl/RecordReflection.java | 21 +++++++-
3 files changed, 51 insertions(+), 29 deletions(-)
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
index 8aa22a871..d6289328c 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/MetamodelFactory.java
@@ -211,36 +211,37 @@ public static boolean isNullable(Metamodel, ?> metamodel) {
return false;
}
Class extends Data> fieldResolutionClass = fieldResolutionClass(metamodel.root());
- QueryColumn queryColumn = findQueryColumnAtPath(fieldResolutionClass, path);
- if (queryColumn != null) {
- // A query column has no record field; its declaration states its nullability.
- return queryColumn.nullable();
- }
+ RecordField field;
try {
- RecordField field = getRecordField(fieldResolutionClass, path);
- boolean nullsDistinct = getNullsDistinct(field);
- boolean inline = !Ref.class.isAssignableFrom(field.type())
- && getORMConverter(field).isEmpty()
- && isRecord(field.type())
- && !Data.class.isAssignableFrom(field.type());
- if (inline) {
- // Mirrors SimpleKeyMetamodel: an inline record is nullable when its unique constraint treats NULLs
- // as distinct and any of its constituent fields is nullable.
- boolean fieldIsUnique = field.isAnnotationPresent(UK.class) || field.isAnnotationPresent(PK.class);
- if (!fieldIsUnique || !nullsDistinct) {
- return false;
- }
- for (var leaf : metamodel.flatten()) {
- if (leaf instanceof Metamodel.Key, ?> leafKey && leafKey.isNullable()) {
- return true;
- }
- }
- return false;
- }
- return field.nullable() && nullsDistinct;
+ field = getRecordField(fieldResolutionClass, path);
} catch (SqlTemplateException e) {
+ // No record field at the path: a query column has none, and its declaration states its nullability.
+ QueryColumn queryColumn = findQueryColumnAtPath(fieldResolutionClass, path);
+ if (queryColumn != null) {
+ return queryColumn.nullable();
+ }
throw new PersistenceException("Failed to resolve nullability for metamodel field at path '%s' on type %s.".formatted(path, metamodel.root().getName()), e);
}
+ boolean nullsDistinct = getNullsDistinct(field);
+ boolean inline = !Ref.class.isAssignableFrom(field.type())
+ && getORMConverter(field).isEmpty()
+ && isRecord(field.type())
+ && !Data.class.isAssignableFrom(field.type());
+ if (inline) {
+ // Mirrors SimpleKeyMetamodel: an inline record is nullable when its unique constraint treats NULLs
+ // as distinct and any of its constituent fields is nullable.
+ boolean fieldIsUnique = field.isAnnotationPresent(UK.class) || field.isAnnotationPresent(PK.class);
+ if (!fieldIsUnique || !nullsDistinct) {
+ return false;
+ }
+ for (var leaf : metamodel.flatten()) {
+ if (leaf instanceof Metamodel.Key, ?> leafKey && leafKey.isNullable()) {
+ return true;
+ }
+ }
+ return false;
+ }
+ return field.nullable() && nullsDistinct;
}
/**
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
index 8ac52ca70..69d928806 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/ModelFactory.java
@@ -536,6 +536,10 @@ private static void createQueryColumns(BuildContext ctx,
Metamodel, ?> parentMetamodel,
Class extends Data> type,
boolean parentNullable) {
+ List queryColumns = getQueryColumns(type);
+ if (queryColumns.isEmpty()) {
+ return;
+ }
try {
var componentNames = getRecordType(type).fields().stream().map(RecordField::name).collect(toSet());
// The columns the fields of this table map, as emitted: a query column cannot name one of them.
@@ -545,7 +549,7 @@ private static void createQueryColumns(BuildContext ctx,
.collect(toMap(column -> column.name().toLowerCase(ROOT), column -> column.metamodel().field(), (first, second) -> first));
var declared = new HashSet();
var declaredColumns = new HashSet();
- for (QueryColumn queryColumn : getQueryColumns(type)) {
+ for (QueryColumn queryColumn : queryColumns) {
String fieldName = queryColumn.field();
if (fieldName.isEmpty()) {
throw new SqlTemplateException("A query column of %s names no field.".formatted(type.getSimpleName()));
diff --git a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
index 390c49eb3..c28af84ee 100644
--- a/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
+++ b/storm-core/src/main/java/st/orm/core/template/impl/RecordReflection.java
@@ -835,9 +835,21 @@ private static Stream> columnNames(DbColumn[] dbColumns) {
* @return the query columns, empty for a type that declares none.
*/
static List getQueryColumns(Class> type) {
- return List.of(type.getAnnotationsByType(QueryColumn.class));
+ return QUERY_COLUMN_CACHE.get(type);
}
+ /**
+ * Query columns per declaring type, in declaration order. Reading a repeatable annotation assembles the list on
+ * every call, and a query's nullability check reads it per sort field; {@link ClassValue} ties each entry to the
+ * lifetime of the declaring class, so the cache never pins the class or its class loader.
+ */
+ private static final ClassValue> QUERY_COLUMN_CACHE = new ClassValue<>() {
+ @Override
+ protected List computeValue(Class> type) {
+ return List.of(type.getAnnotationsByType(QueryColumn.class));
+ }
+ };
+
/**
* Returns the query column of the type with the given field name, if the type declares one.
*
@@ -846,7 +858,12 @@ static List getQueryColumns(Class> type) {
* @return the query column, or empty when the type declares none by that name.
*/
static Optional findQueryColumn(Class> type, String field) {
- return getQueryColumns(type).stream().filter(queryColumn -> queryColumn.field().equals(field)).findFirst();
+ for (QueryColumn queryColumn : getQueryColumns(type)) {
+ if (queryColumn.field().equals(field)) {
+ return Optional.of(queryColumn);
+ }
+ }
+ return Optional.empty();
}
/**