Skip to content

Let a query column name the function that computed it - #746

Merged
zantvoort merged 1 commit into
mainfrom
feat/query-column-function
Oct 23, 2026
Merged

zantvoort merged 1 commit into
mainfrom
feat/query-column-function

Conversation

@zantvoort

Copy link
Copy Markdown
Collaborator

Summary

A query column that stores a function's result, such as a generated column holding fold_accents(title), is compared with a value the same function must have folded. Until now the docs had the application fold the input in a query of its own first, one round trip per search that every call site had to remember, and a comparison passing the raw input compiled and ran while silently missing accented matches. Wrapping the column in a SqlFunction folds the stored side again and brings back the per-row recheck the stored column exists to avoid.

@QueryColumn now names the function, and a value compared with the column passes through it on the value side only:

@QueryColumn(field = "titleFolded", type = String.class, function = "fold_accents")
record Movie(...) implements Entity<Integer> {}

orm.entity(Movie.class).select()
    .where(it -> it.search(input, Movie_.titleFolded))
    .orderFirst(it -> it.where(Movie_.titleFolded, EQUALS_IGNORE_CASE, input))
    .getResultList();

renders

WHERE LOWER(m.title_folded) LIKE LOWER(fold_accents(?)) ESCAPE '!'
ORDER BY CASE WHEN LOWER(m.title_folded) = LOWER(fold_accents(?)) THEN 0 ELSE 1 END
  • The column is read as stored, which is what its index holds; the database computes the value in the same statement, so a caller passes the input as it is.
  • All value comparisons render through one method in QueryModelImpl, so the wrap covers the comparison operators, IN, BETWEEN, the text operators, search, suggest and orderFirst. An IN list with a function keeps its placeholders rather than binding one array, as it does for a SqlFunction. A SqlFunction call on such a column wraps outside it: lower(o.last_name_upper) = lower(upper(?)).
  • A keyset position holds a value read from a row, so it is in the column's form already and is compared as it is. ObjectExpression carries a fromRow flag for that, set by the keyset predicate builder and part of the compiled-SQL cache key. The flag states where the value came from and nothing else; for a column without a function it changes nothing.
  • The function name is validated as SqlFunction validates one, and a bad name is refused when the model is built.
  • Column.function() reports it in core and the Java 21 wrapper, function: String? in Kotlin; Model.queryColumns() carries it. The attribute defaults to none, so a query column compared as it is, such as a tsvector or a sort key, is unchanged.

Docs: the search page's "Storing the Folded Text" loses the folding query and shows the declaration with the function; the entities page, the annotation Javadoc and the changelog describe the attribute; the entity, entity-from-schema and query skills (Java and Kotlin) follow. The branch also carries a small factory test for the inline branch of the nullability check, left over from the coverage review of #744.

Test plan

  • storm-core suite (3116 tests): the core test Owner gains lastNameUpper over upper(last_name) with SQL-shape assertions for an equality, a search, an IN list, orderFirst, a SqlFunction around the column, the second scroll window (last_name_upper > ?, no function) and the refusal of a non-identifier function name
  • storm-java21 (586) and storm-kotlin (1785) suites: both test Owners declare function = "lower" on the folded column and match an upper-cased input; the wrappers report the function
  • spotless clean on foundation, core, java21 and kotlin

Fixes #745

A query column that stores a function's result, such as a generated
column holding fold_accents(title), is compared with a value the same
function must have folded. The docs had the application fold the input in
a query of its own, one round trip per search that every call site had to
remember, and a SqlFunction on the column folds the stored side again.

@QueryColumn names the function, and a value compared with the column
passes through it on the value side only: LOWER(m.title_folded) LIKE
LOWER(fold_accents(?)). The column is read as stored, which is what its
index holds, and the database computes the value in the same statement.
It applies to the comparison operators, IN, BETWEEN, the text operators,
search, suggest and orderFirst; an IN list keeps its placeholders rather
than binding one array, as for a SqlFunction. A keyset position holds a
value read from a row, so it is compared as it is: the expression carries
a fromRow flag, part of the compiled-SQL cache key. Column.function()
reports the function in core, Java 21 and Kotlin.

Fixes #745
@zantvoort zantvoort added this to the 1.16.0 milestone Oct 23, 2026
@zantvoort
zantvoort merged commit c5248a7 into main Oct 23, 2026
10 checks passed
@zantvoort
zantvoort deleted the feat/query-column-function branch October 23, 2026 11:07
@codecov

codecov Bot commented Oct 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.15385% with 1 line in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...src/main/kotlin/st/orm/template/impl/ColumnImpl.kt 0.00% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

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 column: name the function that computed it, so a compared value is folded by the database in the same statement

1 participant