@tanstack/db
Version:
A reactive client store for building super fast apps on sync
823 lines (822 loc) • 27.5 kB
JavaScript
;
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const index = require("../../collection/index.cjs");
const ir = require("../ir.cjs");
const errors = require("../../errors.cjs");
const refProxy = require("./ref-proxy.cjs");
const functions = require("./functions.cjs");
class BaseQueryBuilder {
constructor(query = {}) {
this.query = {};
this.query = { ...query };
}
/**
* Creates a CollectionRef or QueryRef from a source object
* @param source - An object with a single key-value pair
* @param context - Context string for error messages (e.g., "from clause", "join clause")
* @returns A tuple of [alias, ref] where alias is the source key and ref is the created reference
*/
_createRefForSource(source, context) {
let keys;
try {
keys = Object.keys(source);
} catch {
const type = source === null ? `null` : `undefined`;
throw new errors.InvalidSourceTypeError(context, type);
}
if (Array.isArray(source)) {
throw new errors.InvalidSourceTypeError(context, `array`);
}
if (keys.length !== 1) {
if (keys.length === 0) {
throw new errors.InvalidSourceTypeError(context, `empty object`);
}
if (keys.every((k) => !isNaN(Number(k)))) {
throw new errors.InvalidSourceTypeError(context, `string`);
}
throw new errors.OnlyOneSourceAllowedError(context);
}
const alias = keys[0];
const sourceValue = source[alias];
let ref;
if (sourceValue instanceof index.CollectionImpl) {
ref = new ir.CollectionRef(sourceValue, alias);
} else if (sourceValue instanceof BaseQueryBuilder) {
const subQuery = sourceValue._getQuery();
if (!subQuery.from) {
throw new errors.SubQueryMustHaveFromClauseError(context);
}
ref = new ir.QueryRef(subQuery, alias);
} else {
throw new errors.InvalidSourceError(alias);
}
return [alias, ref];
}
/**
* Specify the source table or subquery for the query
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @returns A QueryBuilder with the specified source
*
* @example
* ```ts
* // Query from a collection
* query.from({ users: usersCollection })
*
* // Query from a subquery
* const activeUsers = query.from({ u: usersCollection }).where(({u}) => u.active)
* query.from({ activeUsers })
* ```
*/
from(source) {
const [, from] = this._createRefForSource(source, `from clause`);
return new BaseQueryBuilder({
...this.query,
from
});
}
/**
* Join another table or subquery to the current query
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @param onCallback - A function that receives table references and returns the join condition
* @param type - The type of join: 'inner', 'left', 'right', or 'full' (defaults to 'left')
* @returns A QueryBuilder with the joined table available
*
* @example
* ```ts
* // Left join users with posts
* query
* .from({ users: usersCollection })
* .join({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
*
* // Inner join with explicit type
* query
* .from({ u: usersCollection })
* .join({ p: postsCollection }, ({u, p}) => eq(u.id, p.userId), 'inner')
* ```
*
* // Join with a subquery
* const activeUsers = query.from({ u: usersCollection }).where(({u}) => u.active)
* query
* .from({ activeUsers })
* .join({ p: postsCollection }, ({u, p}) => eq(u.id, p.userId))
*/
join(source, onCallback, type = `left`) {
const [alias, from] = this._createRefForSource(source, `join clause`);
const currentAliases = this._getCurrentAliases();
const newAliases = [...currentAliases, alias];
const refProxy$1 = refProxy.createRefProxy(newAliases);
const onExpression = onCallback(refProxy$1);
let left;
let right;
if (onExpression.type === `func` && onExpression.name === `eq` && onExpression.args.length === 2) {
left = onExpression.args[0];
right = onExpression.args[1];
} else {
throw new errors.JoinConditionMustBeEqualityError();
}
const joinClause = {
from,
type,
left,
right
};
const existingJoins = this.query.join || [];
return new BaseQueryBuilder({
...this.query,
join: [...existingJoins, joinClause]
});
}
/**
* Perform a LEFT JOIN with another table or subquery
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @param onCallback - A function that receives table references and returns the join condition
* @returns A QueryBuilder with the left joined table available
*
* @example
* ```ts
* // Left join users with posts
* query
* .from({ users: usersCollection })
* .leftJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
* ```
*/
leftJoin(source, onCallback) {
return this.join(source, onCallback, `left`);
}
/**
* Perform a RIGHT JOIN with another table or subquery
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @param onCallback - A function that receives table references and returns the join condition
* @returns A QueryBuilder with the right joined table available
*
* @example
* ```ts
* // Right join users with posts
* query
* .from({ users: usersCollection })
* .rightJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
* ```
*/
rightJoin(source, onCallback) {
return this.join(source, onCallback, `right`);
}
/**
* Perform an INNER JOIN with another table or subquery
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @param onCallback - A function that receives table references and returns the join condition
* @returns A QueryBuilder with the inner joined table available
*
* @example
* ```ts
* // Inner join users with posts
* query
* .from({ users: usersCollection })
* .innerJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
* ```
*/
innerJoin(source, onCallback) {
return this.join(source, onCallback, `inner`);
}
/**
* Perform a FULL JOIN with another table or subquery
*
* @param source - An object with a single key-value pair where the key is the table alias and the value is a Collection or subquery
* @param onCallback - A function that receives table references and returns the join condition
* @returns A QueryBuilder with the full joined table available
*
* @example
* ```ts
* // Full join users with posts
* query
* .from({ users: usersCollection })
* .fullJoin({ posts: postsCollection }, ({users, posts}) => eq(users.id, posts.userId))
* ```
*/
fullJoin(source, onCallback) {
return this.join(source, onCallback, `full`);
}
/**
* Filter rows based on a condition
*
* @param callback - A function that receives table references and returns an expression
* @returns A QueryBuilder with the where condition applied
*
* @example
* ```ts
* // Simple condition
* query
* .from({ users: usersCollection })
* .where(({users}) => gt(users.age, 18))
*
* // Multiple conditions
* query
* .from({ users: usersCollection })
* .where(({users}) => and(
* gt(users.age, 18),
* eq(users.active, true)
* ))
*
* // Multiple where calls are ANDed together
* query
* .from({ users: usersCollection })
* .where(({users}) => gt(users.age, 18))
* .where(({users}) => eq(users.active, true))
* ```
*/
where(callback) {
const aliases = this._getCurrentAliases();
const refProxy$1 = refProxy.createRefProxy(aliases);
const rawExpression = callback(refProxy$1);
const expression = refProxy.isRefProxy(rawExpression) ? refProxy.toExpression(rawExpression) : rawExpression;
if (!ir.isExpressionLike(expression)) {
throw new errors.InvalidWhereExpressionError(getValueTypeName(expression));
}
const existingWhere = this.query.where || [];
return new BaseQueryBuilder({
...this.query,
where: [...existingWhere, expression]
});
}
/**
* Filter grouped rows based on aggregate conditions
*
* @param callback - A function that receives table references and returns an expression
* @returns A QueryBuilder with the having condition applied
*
* @example
* ```ts
* // Filter groups by count
* query
* .from({ posts: postsCollection })
* .groupBy(({posts}) => posts.userId)
* .having(({posts}) => gt(count(posts.id), 5))
*
* // Filter by average
* query
* .from({ orders: ordersCollection })
* .groupBy(({orders}) => orders.customerId)
* .having(({orders}) => gt(avg(orders.total), 100))
*
* // Multiple having calls are ANDed together
* query
* .from({ orders: ordersCollection })
* .groupBy(({orders}) => orders.customerId)
* .having(({orders}) => gt(count(orders.id), 5))
* .having(({orders}) => gt(avg(orders.total), 100))
* ```
*/
having(callback) {
const aliases = this._getCurrentAliases();
const refProxy$1 = this.query.select || this.query.fnSelect ? refProxy.createRefProxyWithSelected(aliases) : refProxy.createRefProxy(aliases);
const rawExpression = callback(refProxy$1);
const expression = refProxy.isRefProxy(rawExpression) ? refProxy.toExpression(rawExpression) : rawExpression;
if (!ir.isExpressionLike(expression)) {
throw new errors.InvalidWhereExpressionError(getValueTypeName(expression));
}
const existingHaving = this.query.having || [];
return new BaseQueryBuilder({
...this.query,
having: [...existingHaving, expression]
});
}
select(callback) {
const aliases = this._getCurrentAliases();
const refProxy$1 = refProxy.createRefProxy(aliases);
let selectObject = callback(refProxy$1);
if (refProxy.isRefProxy(selectObject) && selectObject.__path.length === 1) {
const sentinelKey = `__SPREAD_SENTINEL__${selectObject.__path[0]}__0`;
selectObject = { [sentinelKey]: true };
}
const select = buildNestedSelect(selectObject, aliases);
return new BaseQueryBuilder({
...this.query,
select,
fnSelect: void 0
// remove the fnSelect clause if it exists
});
}
/**
* Sort the query results by one or more columns
*
* @param callback - A function that receives table references and returns the field to sort by
* @param direction - Sort direction: 'asc' for ascending, 'desc' for descending (defaults to 'asc')
* @returns A QueryBuilder with the ordering applied
*
* @example
* ```ts
* // Sort by a single column
* query
* .from({ users: usersCollection })
* .orderBy(({users}) => users.name)
*
* // Sort descending
* query
* .from({ users: usersCollection })
* .orderBy(({users}) => users.createdAt, 'desc')
*
* // Multiple sorts (chain orderBy calls)
* query
* .from({ users: usersCollection })
* .orderBy(({users}) => users.lastName)
* .orderBy(({users}) => users.firstName)
* ```
*/
orderBy(callback, options = `asc`) {
const aliases = this._getCurrentAliases();
const refProxy$1 = this.query.select || this.query.fnSelect ? refProxy.createRefProxyWithSelected(aliases) : refProxy.createRefProxy(aliases);
const result = callback(refProxy$1);
const opts = typeof options === `string` ? { direction: options, nulls: `first` } : {
direction: options.direction ?? `asc`,
nulls: options.nulls ?? `first`,
stringSort: options.stringSort,
locale: options.stringSort === `locale` ? options.locale : void 0,
localeOptions: options.stringSort === `locale` ? options.localeOptions : void 0
};
const makeOrderByClause = (res) => {
return {
expression: refProxy.toExpression(res),
compareOptions: opts
};
};
const orderByClauses = Array.isArray(result) ? result.map((r) => makeOrderByClause(r)) : [makeOrderByClause(result)];
const existingOrderBy = this.query.orderBy || [];
return new BaseQueryBuilder({
...this.query,
orderBy: [...existingOrderBy, ...orderByClauses]
});
}
/**
* Group rows by one or more columns for aggregation
*
* @param callback - A function that receives table references and returns the field(s) to group by
* @returns A QueryBuilder with grouping applied (enables aggregate functions in SELECT and HAVING)
*
* @example
* ```ts
* // Group by a single column
* query
* .from({ posts: postsCollection })
* .groupBy(({posts}) => posts.userId)
* .select(({posts, count}) => ({
* userId: posts.userId,
* postCount: count()
* }))
*
* // Group by multiple columns
* query
* .from({ sales: salesCollection })
* .groupBy(({sales}) => [sales.region, sales.category])
* .select(({sales, sum}) => ({
* region: sales.region,
* category: sales.category,
* totalSales: sum(sales.amount)
* }))
* ```
*/
groupBy(callback) {
const aliases = this._getCurrentAliases();
const refProxy$1 = refProxy.createRefProxy(aliases);
const result = callback(refProxy$1);
const newExpressions = Array.isArray(result) ? result.map((r) => refProxy.toExpression(r)) : [refProxy.toExpression(result)];
const existingGroupBy = this.query.groupBy || [];
return new BaseQueryBuilder({
...this.query,
groupBy: [...existingGroupBy, ...newExpressions]
});
}
/**
* Limit the number of rows returned by the query
* `orderBy` is required for `limit`
*
* @param count - Maximum number of rows to return
* @returns A QueryBuilder with the limit applied
*
* @example
* ```ts
* // Get top 5 posts by likes
* query
* .from({ posts: postsCollection })
* .orderBy(({posts}) => posts.likes, 'desc')
* .limit(5)
* ```
*/
limit(count) {
return new BaseQueryBuilder({
...this.query,
limit: count
});
}
/**
* Skip a number of rows before returning results
* `orderBy` is required for `offset`
*
* @param count - Number of rows to skip
* @returns A QueryBuilder with the offset applied
*
* @example
* ```ts
* // Get second page of results
* query
* .from({ posts: postsCollection })
* .orderBy(({posts}) => posts.createdAt, 'desc')
* .offset(page * pageSize)
* .limit(pageSize)
* ```
*/
offset(count) {
return new BaseQueryBuilder({
...this.query,
offset: count
});
}
/**
* Specify that the query should return distinct rows.
* Deduplicates rows based on the selected columns.
* @returns A QueryBuilder with distinct enabled
*
* @example
* ```ts
* // Get countries our users are from
* query
* .from({ users: usersCollection })
* .select(({users}) => ({ country: users.country }))
* .distinct()
* ```
*/
distinct() {
return new BaseQueryBuilder({
...this.query,
distinct: true
});
}
/**
* Specify that the query should return a single result
* @returns A QueryBuilder that returns the first result
*
* @example
* ```ts
* // Get the user matching the query
* query
* .from({ users: usersCollection })
* .where(({users}) => eq(users.id, 1))
* .findOne()
*```
*/
findOne() {
return new BaseQueryBuilder({
...this.query,
// TODO: enforcing return only one result with also a default orderBy if none is specified
// limit: 1,
singleResult: true
});
}
// Helper methods
_getCurrentAliases() {
const aliases = [];
if (this.query.from) {
aliases.push(this.query.from.alias);
}
if (this.query.join) {
for (const join of this.query.join) {
aliases.push(join.from.alias);
}
}
return aliases;
}
/**
* Functional variants of the query builder
* These are imperative function that are called for ery row.
* Warning: that these cannot be optimized by the query compiler, and may prevent
* some type of optimizations being possible.
* @example
* ```ts
* q.fn.select((row) => ({
* name: row.user.name.toUpperCase(),
* age: row.user.age + 1,
* }))
* ```
*/
get fn() {
const builder = this;
return {
/**
* Select fields using a function that operates on each row
* Warning: This cannot be optimized by the query compiler
*
* @param callback - A function that receives a row and returns the selected value
* @returns A QueryBuilder with functional selection applied
*
* @example
* ```ts
* // Functional select (not optimized)
* query
* .from({ users: usersCollection })
* .fn.select(row => ({
* name: row.users.name.toUpperCase(),
* age: row.users.age + 1,
* }))
* ```
*/
select(callback) {
return new BaseQueryBuilder({
...builder.query,
select: void 0,
// remove the select clause if it exists
fnSelect: callback
});
},
/**
* Filter rows using a function that operates on each row
* Warning: This cannot be optimized by the query compiler
*
* @param callback - A function that receives a row and returns a boolean
* @returns A QueryBuilder with functional filtering applied
*
* @example
* ```ts
* // Functional where (not optimized)
* query
* .from({ users: usersCollection })
* .fn.where(row => row.users.name.startsWith('A'))
* ```
*/
where(callback) {
return new BaseQueryBuilder({
...builder.query,
fnWhere: [
...builder.query.fnWhere || [],
callback
]
});
},
/**
* Filter grouped rows using a function that operates on each aggregated row
* Warning: This cannot be optimized by the query compiler
*
* @param callback - A function that receives an aggregated row (with $selected when select() was called) and returns a boolean
* @returns A QueryBuilder with functional having filter applied
*
* @example
* ```ts
* // Functional having (not optimized)
* query
* .from({ posts: postsCollection })
* .groupBy(({posts}) => posts.userId)
* .select(({posts}) => ({ userId: posts.userId, count: count(posts.id) }))
* .fn.having(({ $selected }) => $selected.count > 5)
* ```
*/
having(callback) {
return new BaseQueryBuilder({
...builder.query,
fnHaving: [
...builder.query.fnHaving || [],
callback
]
});
}
};
}
_getQuery() {
if (!this.query.from) {
throw new errors.QueryMustHaveFromClauseError();
}
return this.query;
}
}
function getValueTypeName(value) {
if (value === null) return `null`;
if (value === void 0) return `undefined`;
if (typeof value === `object`) return `object`;
return typeof value;
}
function toExpr(value) {
if (value === void 0) return refProxy.toExpression(null);
if (value instanceof ir.Aggregate || value instanceof ir.Func || value instanceof ir.PropRef || value instanceof ir.Value) {
return value;
}
return refProxy.toExpression(value);
}
function isPlainObject(value) {
return value !== null && typeof value === `object` && !ir.isExpressionLike(value) && !value.__refProxy;
}
function buildNestedSelect(obj, parentAliases = []) {
if (!isPlainObject(obj)) return toExpr(obj);
const out = {};
for (const [k, v] of Object.entries(obj)) {
if (typeof k === `string` && k.startsWith(`__SPREAD_SENTINEL__`)) {
out[k] = v;
continue;
}
if (v instanceof BaseQueryBuilder) {
out[k] = buildIncludesSubquery(v, k, parentAliases, `collection`);
continue;
}
if (v instanceof functions.ToArrayWrapper) {
if (!(v.query instanceof BaseQueryBuilder)) {
throw new Error(`toArray() must wrap a subquery builder`);
}
out[k] = buildIncludesSubquery(v.query, k, parentAliases, `array`);
continue;
}
if (v instanceof functions.ConcatToArrayWrapper) {
if (!(v.query instanceof BaseQueryBuilder)) {
throw new Error(`concat(toArray(...)) must wrap a subquery builder`);
}
out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`);
continue;
}
out[k] = buildNestedSelect(v, parentAliases);
}
return out;
}
function collectRefsFromExpression(expr) {
const refs = [];
switch (expr.type) {
case `ref`:
refs.push(expr);
break;
case `func`:
for (const arg of expr.args ?? []) {
refs.push(...collectRefsFromExpression(arg));
}
break;
}
return refs;
}
function referencesParent(where, parentAliases) {
const expr = typeof where === `object` && `expression` in where ? where.expression : where;
return collectRefsFromExpression(expr).some(
(ref) => ref.path[0] != null && parentAliases.includes(ref.path[0])
);
}
function buildIncludesSubquery(childBuilder, fieldName, parentAliases, materialization) {
const childQuery = childBuilder._getQuery();
const childAliases = [childQuery.from.alias];
if (childQuery.join) {
for (const j of childQuery.join) {
childAliases.push(j.from.alias);
}
}
let parentRef;
let childRef;
let correlationWhereIndex = -1;
let correlationAndArgIndex = -1;
if (childQuery.where) {
for (let i = 0; i < childQuery.where.length; i++) {
const where = childQuery.where[i];
const expr = typeof where === `object` && `expression` in where ? where.expression : where;
if (expr.type === `func` && expr.name === `eq` && expr.args.length === 2) {
const result = extractCorrelation(
expr.args[0],
expr.args[1],
parentAliases,
childAliases
);
if (result) {
parentRef = result.parentRef;
childRef = result.childRef;
correlationWhereIndex = i;
break;
}
}
if (expr.type === `func` && expr.name === `and` && expr.args.length >= 2) {
for (let j = 0; j < expr.args.length; j++) {
const arg = expr.args[j];
if (arg.type === `func` && arg.name === `eq` && arg.args.length === 2) {
const result = extractCorrelation(
arg.args[0],
arg.args[1],
parentAliases,
childAliases
);
if (result) {
parentRef = result.parentRef;
childRef = result.childRef;
correlationWhereIndex = i;
correlationAndArgIndex = j;
break;
}
}
}
if (parentRef) break;
}
}
}
if (!parentRef || !childRef || correlationWhereIndex === -1) {
throw new Error(
`Includes subquery for "${fieldName}" must have a WHERE clause with an eq() condition that correlates a parent field with a child field. Example: .where(({child}) => eq(child.parentId, parent.id))`
);
}
const modifiedWhere = [...childQuery.where];
if (correlationAndArgIndex >= 0) {
const where = modifiedWhere[correlationWhereIndex];
const expr = typeof where === `object` && `expression` in where ? where.expression : where;
const remainingArgs = expr.args.filter(
(_, idx) => idx !== correlationAndArgIndex
);
if (remainingArgs.length === 1) {
const isResidual = typeof where === `object` && `expression` in where && where.residual;
modifiedWhere[correlationWhereIndex] = isResidual ? { expression: remainingArgs[0], residual: true } : remainingArgs[0];
} else {
const newAnd = new ir.Func(`and`, remainingArgs);
const isResidual = typeof where === `object` && `expression` in where && where.residual;
modifiedWhere[correlationWhereIndex] = isResidual ? { expression: newAnd, residual: true } : newAnd;
}
} else {
modifiedWhere.splice(correlationWhereIndex, 1);
}
const pureChildWhere = [];
const parentFilters = [];
for (const w of modifiedWhere) {
if (referencesParent(w, parentAliases)) {
parentFilters.push(w);
} else {
pureChildWhere.push(w);
}
}
let parentProjection;
if (parentFilters.length > 0) {
const seen = /* @__PURE__ */ new Set();
parentProjection = [];
for (const w of parentFilters) {
const expr = typeof w === `object` && `expression` in w ? w.expression : w;
for (const ref of collectRefsFromExpression(expr)) {
if (ref.path[0] != null && parentAliases.includes(ref.path[0]) && !seen.has(ref.path.join(`.`))) {
seen.add(ref.path.join(`.`));
parentProjection.push(ref);
}
}
}
}
const modifiedQuery = {
...childQuery,
where: pureChildWhere.length > 0 ? pureChildWhere : void 0
};
const rawChildSelect = modifiedQuery.select;
const hasObjectSelect = rawChildSelect === void 0 || isPlainObject(rawChildSelect);
let includesQuery = modifiedQuery;
let scalarField;
if (materialization === `concat`) {
if (rawChildSelect === void 0 || hasObjectSelect) {
throw new Error(
`concat(toArray(...)) for "${fieldName}" requires the subquery to select a scalar value`
);
}
}
if (!hasObjectSelect) {
if (materialization === `collection`) {
throw new Error(
`Includes subquery for "${fieldName}" must select an object when materializing as a Collection`
);
}
scalarField = ir.INCLUDES_SCALAR_FIELD;
includesQuery = {
...modifiedQuery,
select: {
[scalarField]: rawChildSelect
}
};
}
return new ir.IncludesSubquery(
includesQuery,
parentRef,
childRef,
fieldName,
parentFilters.length > 0 ? parentFilters : void 0,
parentProjection,
materialization,
scalarField
);
}
function extractCorrelation(argA, argB, parentAliases, childAliases) {
if (argA.type === `ref` && argB.type === `ref`) {
const aAlias = argA.path[0];
const bAlias = argB.path[0];
if (aAlias && bAlias && parentAliases.includes(aAlias) && childAliases.includes(bAlias)) {
return { parentRef: argA, childRef: argB };
}
if (aAlias && bAlias && parentAliases.includes(bAlias) && childAliases.includes(aAlias)) {
return { parentRef: argB, childRef: argA };
}
}
return void 0;
}
function buildQuery(fn) {
const result = fn(new BaseQueryBuilder());
return getQueryIR(result);
}
function getQueryIR(builder) {
return builder._getQuery();
}
const Query = BaseQueryBuilder;
exports.BaseQueryBuilder = BaseQueryBuilder;
exports.Query = Query;
exports.buildQuery = buildQuery;
exports.getQueryIR = getQueryIR;
//# sourceMappingURL=index.cjs.map