Skip to content

Query API consistency: the same clause reads differently across the four builder surfaces #369

Description

@zantvoort

The query builder exposes the same clause vocabulary four times: st.orm.core.template.QueryBuilder, st.orm.template.QueryBuilder in storm-java21, the Kotlin chained QueryBuilder, and the Kotlin select { } block DSL (SqlScope). Comparing them method by method turns up gaps that are all the same defect: a form exists on one surface or one clause and not on its counterpart, so what a user can express depends on which of the four they reached for.

Navigation-only paths are unusable in the Java API

Metamodel extends Navigable, and every node beyond a Ref is Navigable but not Metamodel. Clause parameters were widened to Navigable in Kotlin, only for the root-typed methods in storm-core, and not at all in storm-java21 (19 Metamodel parameters in its QueryBuilder, 13 in its WhereBuilder).

So docs/metamodel.md's claim that a navigation-only node "can be used anywhere a query needs a column reference: where, orderBy, groupBy, having" does not hold for Java, and does not hold for the *Any variants in core either. Since storm-java21 is the documented Java API, the surface users are pointed at is the one that cannot do it.

The rebuild that makes a navigation-only node resolvable was duplicated as a private static in core and as a Kotlin extension. It becomes Navigable.asMetamodel() in storm-foundation, and all surfaces route through it.

HAVING has no predicate form

where accepts a PredicateBuilder; having does not, so HAVING conditions can only be AND-combined. having(TemplateString) appends to a list that SelectBuilderImpl joins with AND, and no overload takes a composed predicate, so HAVING a > 1 OR b < 2 has no form in code at all.

The predicate is taken directly rather than through a WhereBuilder. Of the builder's 27 public members, roughly half match on row identity (whereId, whereRef, where(record)) and cannot apply to a clause that filters groups; the rest are already reachable without it, since the infix operators cover FK, ref and record matching on a path. Every member is also named where*, so a builder-based having would read it.where(...) at a having call site.

The form lands on storm-core's builder, where the Kotlin facade delegates to it, and on the Kotlin QueryBuilder and block DSL. It is deliberately absent from storm-java21: a PredicateBuilder can only be obtained there inside a where lambda, so an overload taking one would be uncallable, and storm-java21 is the documented Java API. Java composes a HAVING disjunction with the template form, which is what it needs for aggregates in any case.

havingAny and groupByAny are one capability

A HAVING condition on a joined entity's column is valid only once that column is in the GROUP BY list, so havingAny is unusable without groupByAny. The block DSL had neither; adding only the first would have shipped half a feature. Both are added, which also makes the block uniform with the chained builder: every clause whose Any variant exists on one now exists on both.

EXISTS is missing from most surfaces

Kotlin's chained builder reaches EXISTS without the scope through top-level whereExists/whereNotExists, including { } subquery-template variants. Neither Java module has a top-level EXISTS at all — every use goes through where(it -> it.exists(...)) — and the block DSL had only the subquery-argument form.

Added: whereExists/whereNotExists to both Java modules, the { } lambda forms plus whereAnyBuilder to the block DSL, and havingExists/havingNotExists to all four surfaces.

The HAVING pair takes the subquery directly, and also accepts a { } lambda in Kotlin. WhereBuilder is not involved in either: the subquery factory belongs to SubqueryTemplate, which QueryTemplate implements, and WhereBuilder only forwards to it. QueryBuilder.subqueryTemplate() exposes that factory, so the lambda receiver is the query's own factory rather than a WHERE scope borrowed for its side effects.

The lambda is not decoration. A subquery correlates through how it is embedded, not through where it was created, so both forms are equivalent — but the lambda is the form in use: in a production codebase of this framework, whereExists { } accounts for 61 call sites and the subquery-argument form for none, in files that reference orm directly 282 times. Offering havingExists only in the form nobody writes would make it the odd one out.

whereAny(path, operator, values)

Present as havingAny(path, operator, values) on the builder in both languages since 1.2, absent for WHERE, where the form lives only inside WhereBuilder. Added to both Java surfaces.

Considered and rejected

Removing the Kotlin Operator methods. All 15 Operator constants have an infix or extension equivalent, which makes the 10 methods taking an Operator argument look superseded. They are not: each infix operator hardcodes its operator at compile time, so the operator-argument form is the only public way to apply an operator chosen at runtime, as a dynamic filter or spec-driven repository does. Operator is a foundational type and the form is an escape hatch, not a competing style. Infix stays the documented default and the call sites in this change are written that way; the form stays.

Standalone TRUE() / FALSE(). They exist only on WhereBuilder, so conditional assembly appears to need the scope. It does not: the builder is immutable, so conditionally chaining where() calls covers the case without them.

Compatibility

Additive for callers. The path parameter widening is source-compatible for existing call sites, since Metamodel is a Navigable.

Subclassers are affected: ten abstract methods are added across the three QueryBuilder classes, and twelve abstract signatures change on WhereBuilder. Both classes are implemented internally and neither is a documented extension point, so this reaches only code that subclasses them directly.

Metadata

Metadata

Assignees

No one assigned

    Labels

    corestorm-core and foundation workenhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions