@tanstack/db
Version:
A reactive client store for building super fast apps on sync
842 lines (776 loc) • 23.1 kB
text/typescript
import { Aggregate, Func } from '../ir'
import { isRefProxy, toExpression } from './ref-proxy.js'
import type { BasicExpression } from '../ir'
import type { RefProxy } from './ref-proxy.js'
import type { SingleResult } from '../../types.js'
import type {
Context,
GetRawResult,
RefLeaf,
StringifiableScalar,
} from './types.js'
import type { QueryBuilder } from './index.js'
type StringRef =
| RefLeaf<string>
| RefLeaf<string | null>
| RefLeaf<string | undefined>
type StringRefProxy =
| RefProxy<string>
| RefProxy<string | null>
| RefProxy<string | undefined>
type StringBasicExpression =
| BasicExpression<string>
| BasicExpression<string | null>
| BasicExpression<string | undefined>
type StringLike =
| StringRef
| StringRefProxy
| StringBasicExpression
| string
| null
| undefined
type ComparisonOperand<T> =
| RefProxy<T>
| RefLeaf<T>
| T
| BasicExpression<T>
| undefined
| null
type ComparisonOperandPrimitive<T extends string | number | boolean> =
| T
| BasicExpression<T>
| undefined
| null
// Helper type for values that can be lowered to expressions.
type ExpressionLike =
| Aggregate
| BasicExpression
| RefProxy<any>
| RefLeaf<any>
| string
| number
| boolean
| bigint
| Date
| null
| undefined
| Array<unknown>
type CaseWhenValue =
| ExpressionLike
| QueryBuilder<any>
| ToArrayWrapper<any>
| ConcatToArrayWrapper<any>
| Record<string, any>
type ExtractCaseWhenValue<T> =
T extends CaseWhenWrapper<infer TResult> ? TResult : T
type CaseWhenResult<
TValues extends Array<CaseWhenValue>,
THasDefault extends boolean,
> = TValues[number] extends ExpressionLike
? BasicExpression<
ExtractType<TValues[number]> | (THasDefault extends true ? never : null)
>
: CaseWhenWrapper<
| ExtractCaseWhenValue<TValues[number]>
| (THasDefault extends true ? never : undefined)
>
// Helper type to extract the underlying type from various expression types
type ExtractType<T> =
T extends RefProxy<infer U>
? U
: T extends RefLeaf<infer U>
? U
: T extends BasicExpression<infer U>
? U
: T
// Helper type to determine aggregate return type based on input nullability
type AggregateReturnType<T> =
ExtractType<T> extends infer U
? U extends number | undefined | null | Date | bigint | string
? Aggregate<U>
: Aggregate<number | undefined | null | Date | bigint | string>
: Aggregate<number | undefined | null | Date | bigint | string>
// Helper type to determine string function return type based on input nullability
type StringFunctionReturnType<T> =
ExtractType<T> extends infer U
? U extends string | undefined | null
? BasicExpression<U>
: BasicExpression<string | undefined | null>
: BasicExpression<string | undefined | null>
// Helper type to determine numeric function return type based on input nullability
// This handles string, array, and number inputs for functions like length()
type NumericFunctionReturnType<T> =
ExtractType<T> extends infer U
? U extends string | Array<any> | undefined | null | number
? BasicExpression<MapToNumber<U>>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
// Transform string/array types to number while preserving nullability
type MapToNumber<T> = T extends string | Array<any>
? number
: T extends undefined
? undefined
: T extends null
? null
: T
// Helper type for binary numeric operations (combines nullability of both operands)
type BinaryNumericReturnType<T1, T2> =
ExtractType<T1> extends infer U1
? ExtractType<T2> extends infer U2
? U1 extends number
? U2 extends number
? BasicExpression<number>
: U2 extends number | undefined
? BasicExpression<number | undefined>
: U2 extends number | null
? BasicExpression<number | null>
: BasicExpression<number | undefined | null>
: U1 extends number | undefined
? U2 extends number
? BasicExpression<number | undefined>
: U2 extends number | undefined
? BasicExpression<number | undefined>
: BasicExpression<number | undefined | null>
: U1 extends number | null
? U2 extends number
? BasicExpression<number | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
: BasicExpression<number | undefined | null>
// Operators
export function eq<T>(
left: ComparisonOperand<T>,
right: ComparisonOperand<T>,
): BasicExpression<boolean>
export function eq<T extends string | number | boolean>(
left: ComparisonOperandPrimitive<T>,
right: ComparisonOperandPrimitive<T>,
): BasicExpression<boolean>
export function eq<T>(left: Aggregate<T>, right: any): BasicExpression<boolean>
export function eq(left: any, right: any): BasicExpression<boolean> {
return new Func(`eq`, [toExpression(left), toExpression(right)])
}
export function gt<T>(
left: ComparisonOperand<T>,
right: ComparisonOperand<T>,
): BasicExpression<boolean>
export function gt<T extends string | number>(
left: ComparisonOperandPrimitive<T>,
right: ComparisonOperandPrimitive<T>,
): BasicExpression<boolean>
export function gt<T>(left: Aggregate<T>, right: any): BasicExpression<boolean>
export function gt(left: any, right: any): BasicExpression<boolean> {
return new Func(`gt`, [toExpression(left), toExpression(right)])
}
export function gte<T>(
left: ComparisonOperand<T>,
right: ComparisonOperand<T>,
): BasicExpression<boolean>
export function gte<T extends string | number>(
left: ComparisonOperandPrimitive<T>,
right: ComparisonOperandPrimitive<T>,
): BasicExpression<boolean>
export function gte<T>(left: Aggregate<T>, right: any): BasicExpression<boolean>
export function gte(left: any, right: any): BasicExpression<boolean> {
return new Func(`gte`, [toExpression(left), toExpression(right)])
}
export function lt<T>(
left: ComparisonOperand<T>,
right: ComparisonOperand<T>,
): BasicExpression<boolean>
export function lt<T extends string | number>(
left: ComparisonOperandPrimitive<T>,
right: ComparisonOperandPrimitive<T>,
): BasicExpression<boolean>
export function lt<T>(left: Aggregate<T>, right: any): BasicExpression<boolean>
export function lt(left: any, right: any): BasicExpression<boolean> {
return new Func(`lt`, [toExpression(left), toExpression(right)])
}
export function lte<T>(
left: ComparisonOperand<T>,
right: ComparisonOperand<T>,
): BasicExpression<boolean>
export function lte<T extends string | number>(
left: ComparisonOperandPrimitive<T>,
right: ComparisonOperandPrimitive<T>,
): BasicExpression<boolean>
export function lte<T>(left: Aggregate<T>, right: any): BasicExpression<boolean>
export function lte(left: any, right: any): BasicExpression<boolean> {
return new Func(`lte`, [toExpression(left), toExpression(right)])
}
// Overloads for and() - support 2 or more arguments
export function and(
left: ExpressionLike,
right: ExpressionLike,
): BasicExpression<boolean>
export function and(
left: ExpressionLike,
right: ExpressionLike,
...rest: Array<ExpressionLike>
): BasicExpression<boolean>
export function and(
left: ExpressionLike,
right: ExpressionLike,
...rest: Array<ExpressionLike>
): BasicExpression<boolean> {
const allArgs = [left, right, ...rest]
return new Func(
`and`,
allArgs.map((arg) => toExpression(arg)),
)
}
// Overloads for or() - support 2 or more arguments
export function or(
left: ExpressionLike,
right: ExpressionLike,
): BasicExpression<boolean>
export function or(
left: ExpressionLike,
right: ExpressionLike,
...rest: Array<ExpressionLike>
): BasicExpression<boolean>
export function or(
left: ExpressionLike,
right: ExpressionLike,
...rest: Array<ExpressionLike>
): BasicExpression<boolean> {
const allArgs = [left, right, ...rest]
return new Func(
`or`,
allArgs.map((arg) => toExpression(arg)),
)
}
export function not(value: ExpressionLike): BasicExpression<boolean> {
return new Func(`not`, [toExpression(value)])
}
// Null/undefined checking functions
export function isUndefined(value: ExpressionLike): BasicExpression<boolean> {
return new Func(`isUndefined`, [toExpression(value)])
}
export function isNull(value: ExpressionLike): BasicExpression<boolean> {
return new Func(`isNull`, [toExpression(value)])
}
export function inArray(
value: ExpressionLike,
array: ExpressionLike,
): BasicExpression<boolean> {
return new Func(`in`, [toExpression(value), toExpression(array)])
}
export function like(
left: StringLike,
right: StringLike,
): BasicExpression<boolean>
export function like(left: any, right: any): BasicExpression<boolean> {
return new Func(`like`, [toExpression(left), toExpression(right)])
}
export function ilike(
left: StringLike,
right: StringLike,
): BasicExpression<boolean> {
return new Func(`ilike`, [toExpression(left), toExpression(right)])
}
// Functions
export function upper<T extends ExpressionLike>(
arg: T,
): StringFunctionReturnType<T> {
return new Func(`upper`, [toExpression(arg)]) as StringFunctionReturnType<T>
}
export function lower<T extends ExpressionLike>(
arg: T,
): StringFunctionReturnType<T> {
return new Func(`lower`, [toExpression(arg)]) as StringFunctionReturnType<T>
}
export function length<T extends ExpressionLike>(
arg: T,
): NumericFunctionReturnType<T> {
return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType<T>
}
export function concat<T extends StringifiableScalar>(
arg: ToArrayWrapper<T>,
): ConcatToArrayWrapper<T>
export function concat(...args: Array<ExpressionLike>): BasicExpression<string>
export function concat(
...args: Array<ExpressionLike | ToArrayWrapper<any>>
): BasicExpression<string> | ConcatToArrayWrapper<any> {
const toArrayArg = args.find(
(arg): arg is ToArrayWrapper<any> => arg instanceof ToArrayWrapper,
)
if (toArrayArg) {
if (args.length !== 1) {
throw new Error(
`concat(toArray(...)) currently supports only a single toArray(...) argument`,
)
}
return new ConcatToArrayWrapper(toArrayArg.query)
}
return new Func(
`concat`,
args.map((arg) => toExpression(arg)),
)
}
// Helper type for coalesce: extracts non-nullish value types from all args
type CoalesceArgTypes<T extends Array<ExpressionLike>> = {
[K in keyof T]: NonNullable<ExtractType<T[K]>>
}[number]
// Whether any arg in the tuple is statically guaranteed non-null (i.e., does not include null | undefined)
type HasGuaranteedNonNull<T extends Array<ExpressionLike>> = {
[K in keyof T]: null extends ExtractType<T[K]>
? false
: undefined extends ExtractType<T[K]>
? false
: true
}[number] extends false
? false
: true
// coalesce() return type: union of all non-null arg types; null included unless a guaranteed non-null arg exists
type CoalesceReturnType<T extends Array<ExpressionLike>> =
HasGuaranteedNonNull<T> extends true
? BasicExpression<CoalesceArgTypes<T>>
: BasicExpression<CoalesceArgTypes<T> | null>
export function coalesce<T extends [ExpressionLike, ...Array<ExpressionLike>]>(
...args: T
): CoalesceReturnType<T> {
return new Func(
`coalesce`,
args.map((arg) => toExpression(arg)),
) as CoalesceReturnType<T>
}
/**
* Returns the value for the first matching condition, similar to SQL
* `CASE WHEN`.
*
* Arguments are evaluated as condition/value pairs followed by an optional
* default value. Scalar branch values return a query expression and can be used
* in expression contexts like `select`, `where`, `orderBy`, `groupBy`,
* `having`, and equality join operands. If no scalar branch matches and no
* default is provided, the result is `null`.
*
* When a branch value is a projection object, `caseWhen` becomes a select-only
* projection value. Projection branches can include nested fields, ref spreads,
* and includes. If no projection branch matches and no default is provided, the
* result is `undefined`.
*
* @example
* ```ts
* caseWhen(gt(user.age, 18), `adult`, `minor`)
* ```
*
* @example
* ```ts
* caseWhen(
* gt(user.age, 65),
* `senior`,
* gt(user.age, 18),
* `adult`,
* `minor`,
* )
* ```
*
* @example
* ```ts
* caseWhen(gt(user.age, 18), {
* ...user,
* posts: q
* .from({ post: postsCollection })
* .where(({ post }) => eq(post.userId, user.id)),
* })
* ```
*/
export function caseWhen<C1 extends ExpressionLike, V1 extends CaseWhenValue>(
condition1: C1,
value1: V1,
): CaseWhenResult<[V1], false>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
D extends CaseWhenValue,
>(condition1: C1, value1: V1, defaultValue: D): CaseWhenResult<[V1, D], true>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
): CaseWhenResult<[V1, V2], false>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
D extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
defaultValue: D,
): CaseWhenResult<[V1, V2, D], true>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
): CaseWhenResult<[V1, V2, V3], false>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
D extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
defaultValue: D,
): CaseWhenResult<[V1, V2, V3, D], true>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
C4 extends ExpressionLike,
V4 extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
condition4: C4,
value4: V4,
): CaseWhenResult<[V1, V2, V3, V4], false>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
C4 extends ExpressionLike,
V4 extends CaseWhenValue,
D extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
condition4: C4,
value4: V4,
defaultValue: D,
): CaseWhenResult<[V1, V2, V3, V4, D], true>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
C4 extends ExpressionLike,
V4 extends CaseWhenValue,
C5 extends ExpressionLike,
V5 extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
condition4: C4,
value4: V4,
condition5: C5,
value5: V5,
): CaseWhenResult<[V1, V2, V3, V4, V5], false>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
C4 extends ExpressionLike,
V4 extends CaseWhenValue,
C5 extends ExpressionLike,
V5 extends CaseWhenValue,
D extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
condition4: C4,
value4: V4,
condition5: C5,
value5: V5,
defaultValue: D,
): CaseWhenResult<[V1, V2, V3, V4, V5, D], true>
export function caseWhen<
C1 extends ExpressionLike,
V1 extends CaseWhenValue,
C2 extends ExpressionLike,
V2 extends CaseWhenValue,
C3 extends ExpressionLike,
V3 extends CaseWhenValue,
C4 extends ExpressionLike,
V4 extends CaseWhenValue,
C5 extends ExpressionLike,
V5 extends CaseWhenValue,
>(
condition1: C1,
value1: V1,
condition2: C2,
value2: V2,
condition3: C3,
value3: V3,
condition4: C4,
value4: V4,
condition5: C5,
value5: V5,
condition6: ExpressionLike,
value6: CaseWhenValue,
...rest: Array<CaseWhenValue>
): any
export function caseWhen(...args: Array<CaseWhenValue>): any {
if (args.length < 2) {
throw new Error(`caseWhen() requires at least two arguments`)
}
const pairCount = Math.floor(args.length / 2)
for (let i = 0; i < pairCount; i++) {
const condition = args[i * 2]
if (!isConditionValue(condition)) {
throw new Error(`caseWhen() conditions must be expression-like values`)
}
}
if (caseWhenHasOnlyExpressionValues(args)) {
return new Func(
`caseWhen`,
args.map((arg) => toExpression(arg)),
)
}
return new CaseWhenWrapper(args)
}
export function add<T1 extends ExpressionLike, T2 extends ExpressionLike>(
left: T1,
right: T2,
): BinaryNumericReturnType<T1, T2> {
return new Func(`add`, [
toExpression(left),
toExpression(right),
]) as BinaryNumericReturnType<T1, T2>
}
// Aggregates
export function count(arg: ExpressionLike): Aggregate<number> {
return new Aggregate(`count`, [toExpression(arg)])
}
export function avg<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`avg`, [toExpression(arg)]) as AggregateReturnType<T>
}
export function sum<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`sum`, [toExpression(arg)]) as AggregateReturnType<T>
}
export function min<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`min`, [toExpression(arg)]) as AggregateReturnType<T>
}
export function max<T extends ExpressionLike>(arg: T): AggregateReturnType<T> {
return new Aggregate(`max`, [toExpression(arg)]) as AggregateReturnType<T>
}
/**
* List of comparison function names that can be used with indexes
*/
export const comparisonFunctions = [
`eq`,
`gt`,
`gte`,
`lt`,
`lte`,
`in`,
`like`,
`ilike`,
] as const
/**
* All supported operator names in TanStack DB expressions
*/
export const operators = [
// Comparison operators
`eq`,
`gt`,
`gte`,
`lt`,
`lte`,
`in`,
`like`,
`ilike`,
// Logical operators
`and`,
`or`,
`not`,
// Null checking
`isNull`,
`isUndefined`,
// String functions
`upper`,
`lower`,
`length`,
`concat`,
// Numeric functions
`add`,
// Utility functions
`coalesce`,
`caseWhen`,
// Aggregate functions
`count`,
`avg`,
`sum`,
`min`,
`max`,
] as const
export type OperatorName = (typeof operators)[number]
export class ToArrayWrapper<_T = unknown> {
readonly __brand = `ToArrayWrapper` as const
declare readonly _type: `toArray`
declare readonly _result: _T
constructor(public readonly query: QueryBuilder<any>) {}
}
export class ConcatToArrayWrapper<_T = unknown> {
readonly __brand = `ConcatToArrayWrapper` as const
declare readonly _type: `concatToArray`
declare readonly _result: _T
constructor(public readonly query: QueryBuilder<any>) {}
}
export class CaseWhenWrapper<_T = any> {
readonly __brand = `CaseWhenWrapper` as const
declare readonly _type: `caseWhen`
readonly _result?: _T
constructor(public readonly args: Array<CaseWhenValue>) {}
}
export class MaterializeWrapper<
_T = unknown,
_IsSingle extends boolean = boolean,
> {
readonly __brand = `MaterializeWrapper` as const
declare readonly _type: `materialize`
declare readonly _result: _T
declare readonly _isSingle: _IsSingle
constructor(public readonly query: QueryBuilder<any>) {}
}
export function toArray<TContext extends Context>(
query: QueryBuilder<TContext>,
): ToArrayWrapper<GetRawResult<TContext>> {
return new ToArrayWrapper(query)
}
function caseWhenHasOnlyExpressionValues(args: Array<CaseWhenValue>): boolean {
const valueIndexes = getCaseWhenValueIndexes(args.length)
return valueIndexes.every((index) => isExpressionValue(args[index]))
}
function getCaseWhenValueIndexes(argCount: number): Array<number> {
const valueIndexes: Array<number> = []
const hasDefaultValue = argCount % 2 === 1
const pairCount = Math.floor(argCount / 2)
for (let i = 0; i < pairCount; i++) {
valueIndexes.push(i * 2 + 1)
}
if (hasDefaultValue) {
valueIndexes.push(argCount - 1)
}
return valueIndexes
}
function isExpressionValue(value: CaseWhenValue | undefined): boolean {
if (isRefProxy(value)) return true
if (value instanceof Aggregate || value instanceof Func) return true
if (value == null) return true
if (
typeof value === `string` ||
typeof value === `number` ||
typeof value === `boolean` ||
typeof value === `bigint`
) {
return true
}
if (value instanceof Date || Array.isArray(value)) return true
if (typeof value === `object`) {
const candidate = value as {
type?: unknown
args?: unknown
name?: unknown
path?: unknown
value?: unknown
}
if (
(candidate.type === `agg` || candidate.type === `func`) &&
typeof candidate.name === `string` &&
Array.isArray(candidate.args)
) {
return true
}
if (candidate.type === `ref` && Array.isArray(candidate.path)) return true
if (candidate.type === `val` && `value` in candidate) return true
}
return false
}
function isConditionValue(value: CaseWhenValue | undefined): boolean {
return isExpressionValue(value) && !Array.isArray(value)
}
/**
* Materialize an includes subquery into a plain value on the parent row.
*
* - For multi-row subqueries, the parent receives an `Array<T>` snapshot
* (equivalent to `toArray()`).
* - For `findOne()` subqueries, the parent receives a single `T | undefined`
* value — `undefined` when no child matches.
*
* The snapshot updates reactively: parent rows re-emit when the underlying
* children change.
*
* @example
* ```ts
* // Multi-row: produces Array<Issue> on each project
* select(({ p }) => ({
* ...p,
* issues: materialize(
* q.from({ i: issues }).where(({ i }) => eq(i.projectId, p.id)),
* ),
* }))
*
* // Singleton: produces Author | undefined on each post
* select(({ p }) => ({
* ...p,
* author: materialize(
* q.from({ a: authors }).where(({ a }) => eq(a.id, p.authorId)).findOne(),
* ),
* }))
* ```
*/
export function materialize<TContext extends Context>(
query: QueryBuilder<TContext>,
): MaterializeWrapper<
GetRawResult<TContext>,
TContext extends SingleResult ? true : false
> {
return new MaterializeWrapper(query)
}