Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ for the CLI, to [npm](https://www.npmjs.com/package/@storm-orm/cli)
- Added `semiJoin` and `antiJoin`, which filter rows by a related table without adding its rows, so no `distinct` is needed (#690). `semiJoin<Pet>().on<Owner>()` 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<Pet>().on<Owner>().semiJoin<Visit>().on<Pet>()` (#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<Owner> { 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, 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.
- A query column names the function that computed it, `@QueryColumn(field = "titleFolded", type = String::class, function = "fold_accents")` (#745): a value the column is compared with passes through the function on the value side, `LOWER(m.title_folded) LIKE LOWER(fold_accents(?))`, so the column is read as stored and the database folds the input in the same statement. It applies to the comparison operators, `IN`, `BETWEEN`, the text operators, `search`, `suggest` and `orderFirst`; a keyset position holds the stored value and is compared as it is. `Column.function()` reports it.
- 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))`.
Expand Down
5 changes: 3 additions & 2 deletions docs/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,7 @@ A query column is a column a query names but the entity does not carry. `@QueryC
<TabItem value="kotlin" label="Kotlin" default>

```kotlin
@QueryColumn(field = "titleFolded", type = String::class)
@QueryColumn(field = "titleFolded", type = String::class, function = "fold_accents")
data class Movie(
@PK val id: Int = 0,
val title: String,
Expand All @@ -721,7 +721,7 @@ orm.entity(Movie::class)
<TabItem value="java" label="Java">

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

Expand All @@ -748,6 +748,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.
- A query column that stores a function's result names it: `function = "fold_accents"`. A value the column is compared with passes through the function, on the value side only, `title_folded = fold_accents(?)`, so the column is read as stored 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`; a pattern passes through it too, so the function must leave the wildcards and the escape character as they are. A keyset position holds the stored value and is compared as it is.
- `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.

Expand Down
18 changes: 7 additions & 11 deletions docs/search.md
Original file line number Diff line number Diff line change
Expand Up @@ -318,37 +318,33 @@ ALTER TABLE movie ADD COLUMN title_folded VARCHAR GENERATED ALWAYS AS (fold_acce
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.
The query column names the function that computed it. A value compared with the column passes through that function, on the value side only, so the search compares `LOWER(m.title_folded) LIKE LOWER(fold_accents(?))`: the column as stored, which is the expression the index holds, and the input folded by the database in the same statement. The recheck reads the stored column, and a caller passes the input as it is. Applying `foldAccents` to the query column instead would fold both sides again and bring the recheck back.

<Tabs groupId="language">
<TabItem value="kotlin" label="Kotlin" default>

```kotlin
@QueryColumn(field = "titleFolded", type = String::class)
@QueryColumn(field = "titleFolded", type = String::class, function = "fold_accents")
data class Movie(...) : Entity<Int>

val folded = orm.query { "SELECT fold_accents($input)" }.singleResult<String>()

orm.entity(Movie::class)
.select()
.where(search(folded, Movie_.titleFolded))
.orderFirst(Movie_.titleFolded eqIgnoreCase folded)
.where(search(input, Movie_.titleFolded))
.orderFirst(Movie_.titleFolded eqIgnoreCase input)
.resultList
```

</TabItem>
<TabItem value="java" label="Java">

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

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))
.where(it -> it.search(input, Movie_.titleFolded))
.orderFirst(it -> it.where(Movie_.titleFolded, EQUALS_IGNORE_CASE, input))
.getResultList();
```

Expand Down
10 changes: 10 additions & 0 deletions storm-core/src/main/java/st/orm/core/template/Column.java
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,16 @@ public interface Column {
*/
boolean ref();

/**
* Returns the SQL function whose result a query column stores, which a value compared with the column passes
* through on the value side: {@code title_folded = fold_accents(?)}. Empty for a column compared with a value as
* it is, which every column a field maps is.
*
* @return the function's name, or an empty string.
* @since 1.16
*/
String function();

/**
* Returns the metamodel for this element.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,16 +74,17 @@ private Object key(
) throws SqlTemplateException {
return switch(cacheable.expression()) {
case TemplateExpression it -> keyGenerator.apply(it.template());
case ObjectExpression(var metamodel, var operator, var object, var indexable) -> {
case ObjectExpression(var metamodel, var operator, var object, var indexable, var fromRow) -> {
var objectShape = getObjectShape(object, ignoreArity);
if (objectShape == null) {
yield null;
}
// Whether the comparison asks for an index is part of what it compiles to.
// Whether the comparison asks for an index, and whether its value came from a row, are part of what it
// compiles to.
if (metamodel == null) {
yield List.of(operator, objectShape, indexable);
yield List.of(operator, objectShape, indexable, fromRow);
}
yield List.of(metamodel, operator, objectShape, indexable);
yield List.of(metamodel, operator, objectShape, indexable, fromRow);
}
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
* @param updatable whether the column is updatable.
* @param version whether the column is a version column.
* @param ref whether the column is a lazily fetched record.
* @param function the SQL function whose result a query column stores, which a value compared with the column
* passes through; empty for a column compared with a value as it is.
* @param metamodel the metamodel for the column.
* @param secondaryMetamodel the secondary metamodel for the column: the referenced primary key metamodel for
* foreign keys, or the component's own metamodel for compound key components.
Expand All @@ -59,6 +61,7 @@ public record ColumnImpl(
boolean updatable,
boolean version,
boolean ref,
String function,
Metamodel<Data, ?> metamodel,
Metamodel<Data, ?> secondaryMetamodel
) implements Column {
Expand Down
11 changes: 7 additions & 4 deletions storm-core/src/main/java/st/orm/core/template/impl/Elements.java
Original file line number Diff line number Diff line change
Expand Up @@ -195,12 +195,15 @@ public sealed interface Expression {}
* When the metamodel is {@code null}, the object is matched against the table's primary key.
*
* <p>{@code indexable} is {@code false} for a comparison that does not select rows, such as the condition an
* {@code orderFirst} ranks the rows by: an index on its column spares no read, so the comparison asks for none.</p>
* {@code orderFirst} ranks the rows by: an index on its column spares no read, so the comparison asks for none.
* {@code fromRow} is {@code true} for a value read from a row of the table, as a keyset position holds one: it is
* in the column's own form already and is compared as it is, without the function a query column names for the
* values a caller supplies.</p>
*/
public record ObjectExpression(@Nullable Metamodel<?, ?> metamodel, Operator operator, Object object,
boolean indexable) implements Expression {
boolean indexable, boolean fromRow) implements Expression {
public ObjectExpression(@Nullable Metamodel<?, ?> metamodel, Operator operator, Object object) {
this(metamodel, operator, object, true);
this(metamodel, operator, object, true, false);
}
public ObjectExpression(Operator operator, Object object) {
this(null, operator, object);
Expand All @@ -213,7 +216,7 @@ public ObjectExpression(Object object) {
* Returns this comparison as one that does not select rows, and so asks no index for its column.
*/
public ObjectExpression ranking() {
return indexable ? new ObjectExpression(metamodel, operator, object, false) : this;
return indexable ? new ObjectExpression(metamodel, operator, object, false, fromRow) : this;
}
public ObjectExpression {
requireNonNull(object, "object");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
import st.orm.Persist;
import st.orm.QueryColumn;
import st.orm.Ref;
import st.orm.SqlFunction;
import st.orm.SqlTemplateException;
import st.orm.Version;
import st.orm.core.spi.Name;
Expand Down Expand Up @@ -175,7 +176,7 @@ private static List<Column> adjustColumnsForJoinedSubtype(List<Column> columns,
ci.columnName(), ci.index(), ci.type(), ci.persistedType(), true,
GenerationStrategy.NONE, "",
ci.foreignKey(), ci.foreignKeyGeneration(), ci.keyIndex(), ci.nullable(),
ci.insertable(), ci.updatable(), ci.version(), ci.ref(),
ci.insertable(), ci.updatable(), ci.version(), ci.ref(), ci.function(),
ci.metamodel(), ci.secondaryMetamodel()
));
} else {
Expand All @@ -192,7 +193,7 @@ private static List<Column> adjustColumnsForJoinedSubtype(List<Column> columns,
ci.columnName(), ci.index(), ci.type(), ci.persistedType(), false,
ci.generation(), ci.sequence(),
ci.foreignKey(), ci.foreignKeyGeneration(), ci.keyIndex(), ci.nullable(),
false, false, ci.version(), ci.ref(),
false, false, ci.version(), ci.ref(), ci.function(),
ci.metamodel(), ci.secondaryMetamodel()
));
} else {
Expand Down Expand Up @@ -258,6 +259,7 @@ private static <T extends Data, ID> Model<T, ID> createSealedModel(ModelBuilder
discriminator, // updatable only when discriminator exists (supports type changes)
false, // not version
false, // not ref
"",
(Metamodel<Data, ?>) rootMetamodel,
null
));
Expand Down Expand Up @@ -350,6 +352,7 @@ private static <T extends Data, ID> Model<T, ID> createSealedModel(ModelBuilder
!extensionColumn && !isPk, // updatable
field.isAnnotationPresent(Version.class),
false, // not ref
"",
columnMetamodel,
sealedFieldMetamodel
));
Expand Down Expand Up @@ -568,6 +571,13 @@ private static void createQueryColumns(BuildContext ctx,
ColumnName columnName = queryColumn.column().isEmpty()
? new ColumnName(ctx.builder().columnNameResolver().resolveColumnName(field))
: new ColumnName(queryColumn.column());
if (!queryColumn.function().isEmpty()) {
try {
SqlFunction.of(queryColumn.function());
} catch (IllegalArgumentException e) {
throw new SqlTemplateException("Query column '%s' of %s names function '%s', which is not a SQL identifier.".formatted(fieldName, type.getSimpleName(), queryColumn.function()), e);
}
}
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));
Expand All @@ -591,6 +601,7 @@ private static void createQueryColumns(BuildContext ctx,
false,
false,
false,
queryColumn.function(),
metamodel,
null
));
Expand Down Expand Up @@ -659,6 +670,7 @@ private static void emitColumns(BuildContext ctx,
spec.updatable(),
spec.version(),
spec.ref(),
"",
metamodel,
secondaryMetamodel
));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,9 @@ private static <T extends Data, R, ID> PredicateBuilder<T, R, ID> compare(WhereB
return wb.where(TemplateString.combine(ranks.get(index), TemplateString.of(" " + symbol + " "), wrap(value)));
}
int field = index - ranks.size();
return wb.where((Metamodel<T, Object>) orders.get(field).field(), operator, new Object[] {values.get(field)});
// The value was read from a row, so it is in the column's form already and is compared as it is, without the
// function a query column names for the values a caller supplies.
return new PredicateBuilderImpl<>(wrap(new ObjectExpression(orders.get(field).field(), operator, new Object[] {values.get(field)}, true, true)));
}

/**
Expand Down
Loading
Loading