Skip to content

Declare query columns an entity does not carry - #744

Merged
zantvoort merged 8 commits into
mainfrom
feat/query-columns
Oct 23, 2026
Merged

zantvoort merged 8 commits into
mainfrom
feat/query-columns

Conversation

@zantvoort

@zantvoort zantvoort commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

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 now declare such a column with @QueryColumn, which gives the metamodel a field for it while keeping it out of every SELECT, INSERT and row mapping.

@QueryColumn(field = "titleFolded", type = String.class)
public record Movie(@PK Integer id, String title) implements Entity<Integer> {}

movies.select().where(Movie_.titleFolded, LIKE, folded + "%").getResultList();
  • @QueryColumn (repeatable, field, type, optional column, nullable) in storm-foundation.
  • Model.queryColumns() lists them apart from columns(); a query column has index 0 and never reaches the row mapper.
  • The APT and KSP metamodel processors emit a field per declaration, with the same component type as a regular field, so Owner_.lastNameFolded works in search, where, orderBy and keyset sorting (nullable feeds the sort check).
  • A record holds no value for a query column; reading it through the metamodel throws with a message that says so.
  • Schema validation checks the column exists with the declared type and nullability, as for a field. For an insert a query column is a column the entity does not map, so a NOT NULL query column the database does not fill (not generated, no identity, no default) falls under the existing UNMAPPED_COLUMN warning rather than a kind of its own. Unmapped generated columns no longer trigger UNMAPPED_COLUMN.
  • DatabaseSchema reads IS_GENERATEDCOLUMN defensively since not every driver reports it.
  • Sealed hierarchies ignore the annotation.

Docs: the entities page gains a Query Columns section, the validation table lists the new error, the metamodel page mentions the generated field, and the search page describes the stored folded column as the optional step for a search the expression index leaves slow, folding the input in the database so the recheck stays gone. Skills (entity, entity-from-schema, validate, query, Java and Kotlin) carry the same guidance.

Dependency gate

The dependency-check job has not completed since #740 merged: 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 40-minute timeout on every run (shown as cancelled) 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, failing on High or Critical. Trivy carries its own advisory database, so there is no key, the scan also runs on Dependabot pull requests, and it finishes in seconds after the build. The NVD_API_KEY secret is no longer read.

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, where a managed version would not reach. The storm-ktor-test suite passes on 5.4.4 and the BOM scans clean.

Test plan

  • storm-core suite (3104 tests) incl. new QueryColumnIntegrationTest, SchemaValidatorTest and DatabaseSchemaTest cases
  • APT and KSP processor tests render the generated field
  • storm-java21 and storm-kotlin QueryBuilderTest, each with a query column test over its own test model
  • Diff coverage checked with JaCoCo locally; the lines left are the declaration guards for an empty or duplicate field name, which the annotation processors refuse at compile time, so a processed record cannot reach them
  • Schema validator conformance test for a generated column on H2, PostgreSQL, MySQL, MariaDB, SQLite, Oracle and SQL Server via Testcontainers
  • spotless clean on every touched module
  • CycloneDX BOM built locally, Trivy reports 2 Highs on the current graph and 0 after the httpcore5 pin; storm-ktor-test suite green on 5.4.4

Fixes #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
@zantvoort zantvoort added this to the 1.16.0 milestone Oct 23, 2026
/**
* A column a record declares through {@code @QueryColumn}: it gets a metamodel field but has no record component.
*
* @param field the metamodel field name.
* 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 field the metamodel field name.
* @param type the column's value type, as the annotation names it.
* @param nullable whether the column allows null.
@codecov

codecov Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

…th 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.
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.
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.
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.
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.
@zantvoort
zantvoort merged commit 93ec1b0 into main Oct 23, 2026
10 checks passed
@zantvoort
zantvoort deleted the feat/query-columns branch October 23, 2026 08:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Query columns: metamodel fields for columns a query names but entities do not carry

1 participant