Expressions in where (functions and column vs column)¶
Compare a column against another column, and apply SQL functions on both sides — so a functional index is actually used instead of a sequential scan.
The problem¶
The object form of where always compares column <operator> value:
Two things are left out:
- Column vs column —
WHERE total > paidis not expressible. - A function on the column side — a functional index like
CREATE INDEX ON users (lower(trim(email)))is unreachable, so the query that should use it becomes a scan.
col — a column reference¶
import { col, select } from "tempest-db-js";
select(Order).where(col<OrderRow>("total").gt(col<OrderRow>("paid")));
// SELECT * FROM "orders" WHERE "total" > "paid"
Pass the row type (col<OrderRow>(...)) to get the name checked at compile time,
the same way or<UserRow>(...) already works.
It respects the name mapping
col() takes the property name and goes through the same
column-name map as the rest of the builder. It is not a back door
for writing the physical column name.
An operand that is not an expression is still bound as a parameter, so the common case stays safe by default:
fn — SQL functions¶
import { fn, val, select } from "tempest-db-js";
select(AdminUser).where(fn.lower("username").eq(fn.lower(val(probe))));
// WHERE lower("username") = lower($1)
A string in fn.* is a column; a value needs val()
A function argument had to mean one thing, and a column is the dominant use
(fn.lower("username")). To compare against a literal, wrap it in val(...)
— which also makes it explicit where the parameter is bound.
In the operators (.eq(), .gt(), …) the default is the opposite and
equally safe: anything that is not an expression becomes a parameter.
Functions nest:
select(User).where(fn.lower(fn.trim("email")).eq(val(input.trim().toLowerCase())));
// WHERE lower(trim("email")) = $1
Portable across all three dialects: lower, upper, trim, length, abs,
coalesce.
Any other function: fn.call¶
select(Order).where(fn.call("date_trunc", val("day"), "createdAt").eq(val(day)));
// WHERE date_trunc($1, "created_at") = $2
The function name is interpolated
fn.call writes the name straight into the statement, so it is validated as a
plain SQL identifier and must never come from user input. The arguments,
on the other hand, always go through the expression compiler.
date_trunc is PostgreSQL, strftime is SQLite — fn.call does not pretend
to be portable.
Available operators¶
eq, ne, gt, gte, lt, lte, like, ilike, ieq, in, notIn,
between, isNull — the same names as the object form.
col<OrderRow>("total").between(10, 99);
fn.lower("email").ilike(val("%@acme.com"));
col<UserRow>("email").ieq(col<UserRow>("login")); // lower(a) = lower(b)
in and between bind their operands
Those operators bind the list, so an Expression there would be serialized as
a parameter instead of becoming SQL. Passing one raises immediately:
It composes with everything else¶
import { and } from "tempest-db-js";
select(Order).where(
and(col<OrderRow>("total").gt(col<OrderRow>("paid")), { status: "open" }),
);
// WHERE ("total" > "paid") AND ("status" = $1)
And in joins, the reference is alias.property:
join(Order, "o")
.innerJoin(Customer, "c", { "o.customerId": "c.id" })
.where(col("o.email").eq(col("c.email")));
// WHERE "o"."email" = "c"."email"
CASE — conditional aggregation¶
caseWhen uses the same where language in its branches, so no second grammar
appears just for CASE:
import { caseWhen, col, select, sum, val } from "tempest-db-js";
const rows = await session
.execute(
select(Order).aggregate(["customer"], {
paid: sum(caseWhen([[{ status: "paid" }, col("total")]], val(0))),
all: sum("total"),
}),
)
.all();
// SUM(CASE WHEN "status" = $1 THEN "total" ELSE $2 END) AS "paid"
One pass over the table instead of one query per bucket. Aggregates started accepting
expressions for exactly this — sum, avg, min and max take a column or an
expression.
Branches are evaluated in order; with no ELSE, a row matching none is NULL. A bare
value in a branch is bound as a parameter, not interpolated.
CAST — converting in the database¶
select(Event).where(cast("externalId", "integer").gt(9));
// WHERE CAST("externalId" AS INTEGER) > $1
The target comes from a portable vocabulary, and each dialect renders the name it accepts:
| Target | PostgreSQL | SQLite | MySQL |
|---|---|---|---|
integer |
INTEGER |
INTEGER |
SIGNED |
text |
TEXT |
TEXT |
CHAR |
numeric |
NUMERIC |
NUMERIC |
DECIMAL |
timestamp |
TIMESTAMP |
TEXT |
DATETIME |
A CAST on a column hides that column's index
CAST(col AS ...) in a where stops the plain index on col from being used —
the database has to convert row by row. If the comparison is frequent, the fix is a
functional index over the same expression, or fixing the column's type.
EXISTS — "is there any?"¶
import { col, exists, notExists, select } from "tempest-db-js";
select(User).where(
exists(select(Order).where({ userId: col("users.id"), status: "open" })),
);
// WHERE EXISTS (SELECT * FROM "orders" WHERE "userId" = "users"."id" AND "status" = $1)
notExists is the complement — customers without an open order.
EXISTS for existence, IN for membership
A correlated EXISTS can stop at the first matching row; an IN over a
subquery materializes the set before comparing. When the question is "is there at
least one?", EXISTS is the shape the planner optimizes best.
Column against column, in the object form¶
where({ userId: col("users.id") }) compares two columns. Such a value used to be
bound as a parameter in the object form — the subquery compared the column against the
string "users.id". The where now accepts an expression both as a bare value and
inside an operator ({ total: { gt: col("orders.paid") } }).
Scalar subqueries¶
const biggest = select(Order, ["total"])
.where({ userId: col("users.id") })
.orderBy("total", "desc")
.limit(1)
.asSubquery("total");
select(User).where(scalar(biggest).gt(100));
scalar() takes the result of .asSubquery(column), not a bare builder — that is what
pins the projection to one column. A scalar subquery returning two columns is a
runtime error in every database; here it is a compile error.
Table aliases outside a join¶
join(Model, "a") has always required an alias, so a self-join works there. A plain
select() had no way to name its own table — which is what a correlated subquery over the
same table needs: without an alias, the inner and outer users are the same name.
import { aliased, col, exists, select } from "tempest-db-js";
const sub = aliased(Employee, "sub");
select(Employee).where(
exists(select(sub).where({ managerId: col("employees.id") })),
);
// WHERE EXISTS (SELECT * FROM "employees" AS "sub" WHERE "managerId" = "employees"."id")
aliased(Model, "x") returns a real model: same columns, same naming strategy, same
codecs. It works in select, in join, in col("x.column") and in row coercion, with no
special casing.
An alias is not a second declaration of the table
Table args (unique, check, index) are not carried over. An alias is a way to
read the table; reflecting it into a migration would try to create a table named
after the alias.
For a CTE use cte() — there the name is the relation
In a CTE the name is the relation (FROM "subtree"), not a nickname for another
table (FROM "categories" AS "subtree"). Different things, hence different functions.
Recap¶
col<Row>("column")references a column; comparing two givesWHERE a > b.fn.lower/upper/trim/length/abs/coalesceare portable;fn.callcovers the rest without promising portability.- In
fn.*, a string is a column and a value goes inval(). In the operators, the default is a value. in/betweenrefuse an expression rather than serializing it.- Everything goes through the column-name map and join qualification.