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 @@ -31,6 +31,7 @@ A join widens the query: from the join onward, every clause accepts paths from a
```

In Java the lambda escalation goes with it: `.where(it -> it.whereAny(UserRole_.user, EQUALS, user))` becomes the typed overload `.where(UserRole_.user, EQUALS, user)`. A path on an entity that is not part of the query fails when the query is built, with the error naming the entity, the query root, and — when the table appears more than once — the paths that pin it.
- The predicate combinators follow the same model: `andAny` and `orAny` are removed from every API, and `and`/`or` inherit the query root. In Java every predicate carries the builder's root, so the plain calls absorb the `Any` twins as a rename per call site. In Kotlin, `and`/`or` live in the `whereBuilder { }` scope: a narrow scope combines predicates within the root graph, and a join widens the root so the same expression combines predicates across joined entities — `(Pet_.name eq "Leo") and (Owner_.lastName eq "Davis")` after joining `Owner`. Outside a builder scope, the top-level `and`/`or` extensions combine predicates that share a root.
- Added `widen()` and `narrow(rootType)`, the two directions of the model made explicit: `widen()` widens without a join, admitting short-form references to entities of the query's graph on a query that joins nothing, and `narrow` restores the root after a join for the operations defined relative to it, verified against the query's FROM table. Those are `resultGroupedBy`, which types the map key, and `scroll`, whose key has to identify one row of the root — a unique key on a joined table does not, so scrolling a joined query narrows first.
- Renamed `typed(pkType)` to `typedId(pkType)` — it types the erased primary-key parameter, while `narrow` types the root — and added its missing null check.
- `fetch(...)` comes right after `select()`, before any join, enforced at compile time: resolving references is defined relative to the root, and a join widens the builder past it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,23 +31,13 @@ public interface PredicateBuilder<T extends Data, R, ID> {
* Adds a predicate to the WHERE clause using an AND condition.
*
* <p>This method combines the specified predicate with existing predicates using an AND operation, ensuring
* that all added conditions must be true.</p>
*
* @param predicate the predicate to add.
* @return the predicate builder.
*/
PredicateBuilder<T, R, ID> and(@Nonnull PredicateBuilder<T, ?, ?> predicate);

/**
* Adds a predicate to the WHERE clause using an AND condition.
*
* <p>This method combines the specified predicate with existing predicates using an AND operation, ensuring
* that all added conditions must be true.</p>
* that all added conditions must be true. The predicate inherits the query root: a join widens the root,
* admitting predicates that reference any entity in the query.</p>
*
* @param predicate the predicate to add.
* @return the predicate builder.
*/
<TX extends Data, RX, IDX> PredicateBuilder<TX, RX, IDX> andAny(@Nonnull PredicateBuilder<TX, RX, IDX> predicate);
PredicateBuilder<T, R, ID> and(@Nonnull PredicateBuilder<? extends T, ?, ?> predicate);

/**
* Adds a predicate to the WHERE clause using an AND condition.
Expand All @@ -64,23 +54,13 @@ public interface PredicateBuilder<T extends Data, R, ID> {
* Adds a predicate to the WHERE clause using an OR condition.
*
* <p>This method combines the specified predicate with existing predicates using an OR operation, allowing any
* of the added conditions to be true.</p>
*
* @param predicate the predicate to add.
* @return the predicate builder.
*/
PredicateBuilder<T, R, ID> or(@Nonnull PredicateBuilder<T, ?, ?> predicate);

/**
* Adds a predicate to the WHERE clause using an OR condition.
*
* <p>This method combines the specified predicate with existing predicates using an OR operation, allowing any
* of the added conditions to be true.</p>
* of the added conditions to be true. The predicate inherits the query root: a join widens the root,
* admitting predicates that reference any entity in the query.</p>
*
* @param predicate the predicate to add.
* @return the predicate builder.
*/
<TX extends Data, RX, IDX> PredicateBuilder<TX, RX, IDX> orAny(@Nonnull PredicateBuilder<TX, RX, IDX> predicate);
PredicateBuilder<T, R, ID> or(@Nonnull PredicateBuilder<? extends T, ?, ?> predicate);

/**
* Adds a predicate to the WHERE clause using an OR condition.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -446,37 +446,23 @@ static class PredicateBuilderImpl<TX extends Data, RX, IDX> implements Predicate
}

@Override
public PredicateBuilder<TX, RX, IDX> and(@Nonnull PredicateBuilder<TX, ?, ?> predicate) {
public PredicateBuilder<TX, RX, IDX> and(@Nonnull PredicateBuilder<? extends TX, ?, ?> predicate) {
add(RAW_AND, predicate);
return this;
}

@Override
public <TY extends Data, RY, IDY> PredicateBuilder<TY, RY, IDY> andAny(@Nonnull PredicateBuilder<TY, RY, IDY> predicate) {
add(RAW_AND, predicate);
//noinspection unchecked
return (PredicateBuilder<TY, RY, IDY>) this;
}

@Override
public PredicateBuilder<TX, RX, IDX> and(@Nonnull TemplateString template) {
add(RAW_AND, combine(RAW_OPEN, template, RAW_CLOSE)); // Always wrap a template in parentheses as we don't know if it's a single expression or a complex one.
return this;
}

@Override
public PredicateBuilder<TX, RX, IDX> or(@Nonnull PredicateBuilder<TX, ?, ?> predicate) {
public PredicateBuilder<TX, RX, IDX> or(@Nonnull PredicateBuilder<? extends TX, ?, ?> predicate) {
add(RAW_OR, predicate);
return this;
}

@Override
public <TY extends Data, RY, IDY> PredicateBuilder<TY, RY, IDY> orAny(@Nonnull PredicateBuilder<TY, RY, IDY> predicate) {
add(RAW_OR, predicate);
//noinspection unchecked
return (PredicateBuilder<TY, RY, IDY>) this;
}

@Override
public PredicateBuilder<TX, RX, IDX> or(@Nonnull TemplateString template) {
add(RAW_OR, combine(RAW_OPEN, template, RAW_CLOSE));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -885,7 +885,7 @@ public String mapParameter(@Nullable Object value) {
case Iterable<?> it when template.expandCollection() -> mapArgs(it, template.inlineParameters());
case Object[] ignore -> throw new UncheckedSqlTemplateException(new SqlTemplateException("Array parameters are not supported in SQL templates. Use a List instead of an array to pass multiple values."));
case Iterable<?> ignore ->
throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass collections inside a WHERE ... IN clause using the whereAny/whereAll builder methods."));
throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass the collection to a query builder method such as whereId(ids) or where(path, IN, values), which renders a WHERE ... IN clause."));
case null, default -> {
if (template.inlineParameters()) {
yield toLiteral(value);
Expand Down Expand Up @@ -1200,7 +1200,7 @@ public void bindParameter(@Nullable Object value) {
case Iterable<?> it when template.expandCollection() -> bindArgs(it, template.inlineParameters());
case Object[] ignore -> throw new UncheckedSqlTemplateException(new SqlTemplateException("Array parameters are not supported in SQL templates. Use a List instead of an array to pass multiple values."));
case Iterable<?> ignore ->
throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass collections inside a WHERE ... IN clause using the whereAny/whereAll builder methods."));
throw new UncheckedSqlTemplateException(new SqlTemplateException("Collection parameters are not supported at this position in the SQL template. Use individual parameters, or pass the collection to a query builder method such as whereId(ids) or where(path, IN, values), which renders a WHERE ... IN clause."));
case null, default -> {
if (!template.inlineParameters()) {
parameters.add(new PositionalParameter(parameters.size() + 1, value));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,31 +238,56 @@ public void testPredicateBuilderOrTemplate() {
assertTrue(cities.size() >= 1);
}

// PredicateBuilder.andAny - AND with cross-type predicate
// PredicateBuilder.and - AND with a second predicate

@Test
public void testPredicateBuilderAndAny() {
public void testPredicateBuilderAndPredicate() {
var orm = ORMTemplate.of(dataSource);
List<Visit> visits = orm.selectFrom(Visit.class)
.where(predicate -> predicate.where(Visit_.id, GREATER_THAN, 0)
.andAny(predicate.where(Visit_.id, IN, List.of(1, 2, 3))))
.and(predicate.where(Visit_.id, IN, List.of(1, 2, 3))))
.getResultList();
assertEquals(3, visits.size());
}

// PredicateBuilder.orAny - OR with cross-type predicate
// PredicateBuilder.or - OR with a second predicate

@Test
public void testPredicateBuilderOrAny() {
public void testPredicateBuilderOrPredicate() {
var orm = ORMTemplate.of(dataSource);
List<Visit> visits = orm.selectFrom(Visit.class)
.typedId(Integer.class)
.where(predicate -> predicate.whereId(1)
.orAny(predicate.whereId(2)))
.or(predicate.whereId(2)))
.getResultList();
assertEquals(2, visits.size());
}

// PredicateBuilder.and / or - predicates across entities on a widened builder

@Test
public void testPredicateBuilderAndAcrossEntitiesAfterJoin() {
var orm = ORMTemplate.of(dataSource);
List<Pet> pets = orm.selectFrom(Pet.class)
.innerJoin(Owner.class).on(Pet.class)
.where(predicate -> predicate.where(Pet_.name, EQUALS, "Leo")
.and(predicate.where(Owner_.lastName, EQUALS, "Davis")))
.getResultList();
assertEquals(1, pets.size());
}

@Test
public void testPredicateBuilderOrAcrossEntitiesAfterJoin() {
// Pets named Leo (Betty Davis's pet) or owned by a Davis: Leo and Harold Davis's Iggy.
var orm = ORMTemplate.of(dataSource);
List<Pet> pets = orm.selectFrom(Pet.class)
.innerJoin(Owner.class).on(Pet.class)
.where(predicate -> predicate.where(Pet_.name, EQUALS, "Leo")
.or(predicate.where(Owner_.lastName, EQUALS, "Davis")))
.getResultList();
assertEquals(2, pets.size());
}

// QueryBuilder.having with raw template

@Test
Expand Down
2 changes: 1 addition & 1 deletion storm-foundation/src/main/java/st/orm/DbColumn.java
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import java.lang.annotation.Target;

/**
* Specifies the name for the column, table or view.
* Specifies the name of the database column.
*/
@Target({RECORD_COMPONENT, PARAMETER})
@Retention(RUNTIME)
Expand Down
2 changes: 1 addition & 1 deletion storm-foundation/src/main/java/st/orm/DbTable.java
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import java.lang.annotation.Target;

/**
* Specifies the schema name for the table or view.
* Specifies the name of the database table or view, and optionally its schema.
*/
@Target(TYPE)
@Retention(RUNTIME)
Expand Down
49 changes: 27 additions & 22 deletions storm-foundation/src/main/java/st/orm/Operator.java
Original file line number Diff line number Diff line change
Expand Up @@ -35,102 +35,107 @@ public interface Operator {
*/
Operator IN = (column, placeholders) -> switch (placeholders.length) {
case 0 -> "1 <> 1";
default -> "%s IN (%s)".formatted(column, String.join(", ", placeholders));
default -> "%s IN (%s)".formatted(requireColumn(column), String.join(", ", placeholders));
};

/**
* The {@code NOT IN} operator.
*/
Operator NOT_IN = (column, placeholders) -> switch (placeholders.length) {
case 0 -> "1 = 1";
default -> "%s NOT IN (%s)".formatted(column, String.join(", ", placeholders));
default -> "%s NOT IN (%s)".formatted(requireColumn(column), String.join(", ", placeholders));
};

/**
* The {@code EXISTS} operator.
* The {@code =} operator.
*/
Operator EQUALS = (column, placeholders) -> format("Equals", 1, placeholders.length, "%s = %s".formatted(column, get(placeholders)));
Operator EQUALS = (column, placeholders) -> format("Equals", 1, placeholders.length, "%s = %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code NOT EXISTS} operator.
* The {@code <>} operator.
*/
Operator NOT_EQUALS = (column, placeholders) -> format("Not equals", 1, placeholders.length, "%s <> %s".formatted(column, Operator.get(placeholders)));
Operator NOT_EQUALS = (column, placeholders) -> format("Not equals", 1, placeholders.length, "%s <> %s".formatted(requireColumn(column), Operator.get(placeholders)));

/**
* The {@code LIKE} operator.
*/
Operator LIKE = (column, placeholders) -> format("Like", 1, placeholders.length, "%s LIKE %s".formatted(column, get(placeholders)));
Operator LIKE = (column, placeholders) -> format("Like", 1, placeholders.length, "%s LIKE %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code NOT LIKE} operator.
*/
Operator NOT_LIKE = (column, placeholders) -> format("Not like", 1, placeholders.length, "%s NOT LIKE %s".formatted(column, get(placeholders)));
Operator NOT_LIKE = (column, placeholders) -> format("Not like", 1, placeholders.length, "%s NOT LIKE %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code >} operator.
*/
Operator GREATER_THAN = (column, placeholders) -> format("Greater than", 1 , placeholders.length, "%s > %s".formatted(column, get(placeholders)));
Operator GREATER_THAN = (column, placeholders) -> format("Greater than", 1 , placeholders.length, "%s > %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code >=} operator.
*/
Operator GREATER_THAN_OR_EQUAL = (column, placeholders) -> format("Greater than or equal", 1, placeholders.length, "%s >= %s".formatted(column, get(placeholders)));
Operator GREATER_THAN_OR_EQUAL = (column, placeholders) -> format("Greater than or equal", 1, placeholders.length, "%s >= %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code <} operator.
*/
Operator LESS_THAN = (column, placeholders) -> format("Less than", 1, placeholders.length, "%s < %s".formatted(column, get(placeholders)));
Operator LESS_THAN = (column, placeholders) -> format("Less than", 1, placeholders.length, "%s < %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code <=} operator.
*/
Operator LESS_THAN_OR_EQUAL= (column, placeholders) -> format("Less than or equal", 1, placeholders.length, "%s <= %s".formatted(column, get(placeholders)));
Operator LESS_THAN_OR_EQUAL= (column, placeholders) -> format("Less than or equal", 1, placeholders.length, "%s <= %s".formatted(requireColumn(column), get(placeholders)));

/**
* The {@code BETWEEN} operator.
*/
Operator BETWEEN = (column, placeholders) -> format("Between", 2, placeholders.length, "%s BETWEEN %s AND %s".formatted(column, get(placeholders), get(1, placeholders)));
Operator BETWEEN = (column, placeholders) -> format("Between", 2, placeholders.length, "%s BETWEEN %s AND %s".formatted(requireColumn(column), get(placeholders), get(1, placeholders)));

/**
* The {@code IS TRUE} operator.
*/
Operator IS_TRUE = (column, placeholders) -> format("Is true", 0, placeholders.length, "%s IS TRUE".formatted(column));
Operator IS_TRUE = (column, placeholders) -> format("Is true", 0, placeholders.length, "%s IS TRUE".formatted(requireColumn(column)));

/**
* The {@code IS FALSE} operator.
*/
Operator IS_FALSE = (column, placeholders) -> format("Is false", 0, placeholders.length, "%s IS FALSE".formatted(column));
Operator IS_FALSE = (column, placeholders) -> format("Is false", 0, placeholders.length, "%s IS FALSE".formatted(requireColumn(column)));

/**
* The {@code IS NULL} operator.
*/
Operator IS_NULL = (column, placeholders) -> format("Is null", 0, placeholders.length, "%s IS NULL".formatted(column));
Operator IS_NULL = (column, placeholders) -> format("Is null", 0, placeholders.length, "%s IS NULL".formatted(requireColumn(column)));

/**
* The {@code IS NOT NULL} operator.
*/
Operator IS_NOT_NULL = (column, placeholders) -> format("Is not null", 0, placeholders.length, "%s IS NOT NULL".formatted(column));
Operator IS_NOT_NULL = (column, placeholders) -> format("Is not null", 0, placeholders.length, "%s IS NOT NULL".formatted(requireColumn(column)));

/**
* Formats the operator with bind variables matching the specified size.
*
* @param column the column to compare.
* @param placeholders the placeholders to use in the template.
* @return the formatted operator.
* @throws IllegalArgumentException if the specified size is not supported by the operator.
* @throws IllegalArgumentException if the column is null but required by the operator, or if the number of
* placeholders is not supported by the operator.
*/
String format(@Nullable String column, String... placeholders);

private static String format(@Nullable String name, int requiredSize, int actualSize, @Nonnull String operator) {
if (name == null) {
throw new IllegalArgumentException("Column name cannot be null.");
}
private static String format(@Nonnull String name, int requiredSize, int actualSize, @Nonnull String operator) {
if (requiredSize != actualSize) {
throw new IllegalArgumentException("%s operator requires %s value(s). Found %s value(s).".formatted(name, requiredSize, actualSize));
}
return operator;
}

private static String requireColumn(@Nullable String column) {
if (column == null) {
throw new IllegalArgumentException("Column name cannot be null.");
}
return column;
}

private static String get(String... placeholders) {
return get(0, placeholders);
}
Expand Down
Loading
Loading