Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/reject-fn-select-query-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Reject child query builders and query-construction helpers returned from `fn.select()` with type and runtime errors instead of exposing internal query objects.
2 changes: 2 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -1330,6 +1330,8 @@ The singleton vs. array result type is inferred from whether the wrapped query e

Like `toArray()`, `materialize()` is only valid as a top-level value in `.select()` — it cannot be nested inside expression helpers such as `coalesce()` or `eq()`.

Do not return child queries, `toArray()`, `materialize()`, or query expressions such as `eq()` and `caseWhen()` from `.fn.select()`. Functional select callbacks run after the compiler builds the query graph, so they cannot add query operations to it.

### Aggregates

You can use aggregate functions in child queries. Aggregates are computed per parent:
Expand Down
10 changes: 10 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,16 @@ export class FnSelectWithGroupByError extends QueryCompilationError {
}
}

export class UnsupportedFnSelectResultError extends QueryCompilationError {
constructor(valueDescription: string) {
super(
`fn.select() cannot return ${valueDescription}. ` +
`Child query builders, query expressions, and helpers such as eq(), toArray(), materialize(), concat(toArray()), and caseWhen() are query-construction values. ` +
`Use them as direct fields in .select() instead.`,
)
}
}

export class UnsupportedRootScalarSelectError extends QueryCompilationError {
constructor() {
super(
Expand Down
58 changes: 57 additions & 1 deletion packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,57 @@ type CollectionResolver = (
options: CollectionOptionsIdentity<any, string | number, any, any, any>,
) => CollectionImpl<any, string | number, any, any, any>

type FnSelectQueryConstructionValue =
| QueryBuilder<any>
| InitialQueryBuilder
| BasicExpression
| Aggregate
| ToArrayWrapper<any>
| ConcatToArrayWrapper<any>
| MaterializeWrapper<any, boolean>
| CaseWhenWrapper<any>

type IsAnyType<T> = 0 extends 1 & T ? true : false

// Bound recursive inspection so deeply recursive result types do not exceed
// TypeScript's instantiation limit. The runtime check has no depth limit.
type ContainsFnSelectQueryConstructionValue<
T,
TDepth extends ReadonlyArray<unknown> = [],
> =
IsAnyType<T> extends true
? false
: T extends FnSelectQueryConstructionValue
? true
: TDepth[`length`] extends 8
? false
: T extends (...args: Array<any>) => any
? false
: T extends ReadonlyArray<infer TItem>
? ContainsFnSelectQueryConstructionValue<
TItem,
[...TDepth, unknown]
>
: T extends object
? true extends {
[K in keyof T]-?: ContainsFnSelectQueryConstructionValue<
T[K],
[...TDepth, unknown]
>
}[keyof T]
? true
: false
: false

type InvalidFnSelectResult = {
readonly __tanstackDbFnSelectResultError__: `fn.select() cannot return child query builders, query expressions, or query helpers. Use them as direct fields in .select() instead.`
}

type FnSelectQueryResult<TContext extends Context, TResult> =
true extends ContainsFnSelectQueryConstructionValue<TResult>
? InvalidFnSelectResult
: QueryBuilder<WithResult<TContext, TResult>>

export class BaseQueryBuilder<TContext extends Context = Context> {
private readonly query: Partial<QueryIR> = {}

Expand Down Expand Up @@ -889,10 +940,15 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
* age: row.users.age + 1,
* }))
* ```
*
* Child query builders, query expressions, and helpers such as eq(),
* toArray(), and materialize() cannot be returned from fn.select(). Use
* them as fields in select() so the compiler can add them to the query
* graph.
*/
select<TFuncSelectResult>(
callback: (row: TContext[`schema`]) => TFuncSelectResult,
): QueryBuilder<WithResult<TContext, TFuncSelectResult>> {
): FnSelectQueryResult<TContext, TFuncSelectResult> {
return builder._clone({
...builder.query,
select: undefined, // remove the select clause if it exists
Expand Down
53 changes: 53 additions & 0 deletions packages/db/src/query/compiler/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,17 @@ import {
FnSelectWithGroupByError,
HavingRequiresGroupByError,
LimitOffsetRequireOrderByError,
UnsupportedFnSelectResultError,
UnsupportedFromTypeError,
} from '../../errors.js'
import { VIRTUAL_PROP_NAMES } from '../../virtual-props.js'
import { BaseQueryBuilder } from '../builder/index.js'
import {
CaseWhenWrapper,
ConcatToArrayWrapper,
MaterializeWrapper,
ToArrayWrapper,
} from '../builder/functions.js'
import {
ConditionalSelect,
IncludesSubquery,
Expand Down Expand Up @@ -69,6 +77,50 @@ export const INCLUDES_PUBLIC_KEY = Symbol(`includesPublicKey`)
export const FN_SELECT_STATE = Symbol(`fnSelectState`)
const SKIP_INCLUDE = Symbol(`skipInclude`)

function getUnsupportedFnSelectResultDescription(
value: unknown,
seen: Set<object> = new Set(),
): string | undefined {
if (value instanceof BaseQueryBuilder) return `a child query builder`
if (value instanceof ToArrayWrapper) return `toArray()`
if (value instanceof ConcatToArrayWrapper) return `concat(toArray())`
if (value instanceof MaterializeWrapper) return `materialize()`
if (value instanceof CaseWhenWrapper) return `caseWhen()`
if (isExpressionLike(value)) {
return value &&
typeof value === `object` &&
`name` in value &&
typeof value.name === `string`
? `${value.name}()`
: `a query expression`
}
if (value === null || typeof value !== `object` || seen.has(value)) {
return undefined
}

seen.add(value)
const keys = [
...Object.keys(value),
...Object.getOwnPropertySymbols(value).filter((key) =>
Object.prototype.propertyIsEnumerable.call(value, key),
),
]
for (const key of keys) {
const entry = (value as Record<PropertyKey, unknown>)[key]
const unsupported = getUnsupportedFnSelectResultDescription(entry, seen)
if (unsupported) return unsupported
}
return undefined
}

export function validateFnSelectResult(value: unknown): void {
const unsupportedValueDescription =
getUnsupportedFnSelectResultDescription(value)
if (unsupportedValueDescription) {
throw new UnsupportedFnSelectResultError(unsupportedValueDescription)
}
}

type ConditionalSelectGuard = {
condition: BasicExpression
expected: boolean
Expand Down Expand Up @@ -785,6 +837,7 @@ export function compileQuery(
pipeline = pipeline.pipe(
map(([key, namespacedRow]) => {
const selectResults = query.fnSelect!(namespacedRow)
validateFnSelectResult(selectResults)
let selected = selectResults
if (selectResults && typeof selectResults === `object`) {
selected = Array.isArray(selectResults)
Expand Down
7 changes: 6 additions & 1 deletion packages/db/src/query/live/materialized-pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ import {
reduce,
serializeValue,
} from '@tanstack/db-ivm'
import { FN_SELECT_STATE, INCLUDES_ROUTING } from '../compiler/index.js'
import {
FN_SELECT_STATE,
INCLUDES_ROUTING,
validateFnSelectResult,
} from '../compiler/index.js'
import { VIRTUAL_PROP_NAMES } from '../../virtual-props.js'
import { deepEquals } from '../../utils.js'
import type {
Expand Down Expand Up @@ -492,6 +496,7 @@ function setMaterializedInclude(

const sourceRow = setNestedValue(state.sourceRow, path, materialized)
const selectedValue = state.fnSelect(sourceRow)
validateFnSelectResult(selectedValue)
if (!selectedValue || typeof selectedValue !== `object`) {
throw new Error(`fn.select must return an object when it projects includes`)
}
Expand Down
58 changes: 58 additions & 0 deletions packages/db/tests/query/functional-variants.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { describe, expectTypeOf, test } from 'vitest'
import {
Query,
caseWhen,
count,
createLiveQueryCollection,
eq,
gt,
materialize,
toArray,
} from '../../src/query/index.js'
import { createCollection } from '../../src/collection/index.js'
import { mockSyncCollectionOptions } from '../utils.js'
Expand Down Expand Up @@ -144,6 +148,60 @@ describe(`Functional Variants Types`, () => {
>()
})

test(`fn.select rejects child queries and materialization helpers`, () => {
// @ts-expect-error query helpers are only supported in select()
createLiveQueryCollection((q) =>
q.from({ user: usersCollection }).fn.select((row) => ({
id: row.user.id,
nested: {
departments: toArray(q.from({ department: departmentsCollection })),
},
})),
)

// @ts-expect-error materialize() is only supported in select()
createLiveQueryCollection((q) =>
q.from({ user: usersCollection }).fn.select((row) => ({
id: row.user.id,
departments: materialize(q.from({ department: departmentsCollection })),
})),
)

// @ts-expect-error child query builders are only supported in select()
createLiveQueryCollection((q) =>
q.from({ user: usersCollection }).fn.select((row) => ({
id: row.user.id,
departments: q.from({ department: departmentsCollection }),
})),
)

// @ts-expect-error query expressions are only supported in select()
createLiveQueryCollection((q) =>
q.from({ user: usersCollection }).fn.select((row) => ({
id: row.user.id,
active: eq(row.user.active, true),
})),
)

// @ts-expect-error caseWhen() is only supported in select()
createLiveQueryCollection((q) =>
q.from({ user: usersCollection }).fn.select((row) => ({
id: row.user.id,
label: caseWhen(eq(row.user.active, true), `active`, `inactive`),
})),
)
})

test(`fn.select accepts unresolved generic result types`, () => {
const query = new Query().from({ user: usersCollection })

function selectValue<T>(value: T) {
return query.fn.select(() => value)
}

selectValue({ label: `active` })
})

test(`fn.where with filtered original type`, () => {
const liveCollection = createLiveQueryCollection({
query: (q) =>
Expand Down
Loading
Loading