Skip to content
Merged
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/improve-from-clause-error-messages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/db": patch
---

Improved error messages when invalid source types are passed to `.from()` or `.join()` methods. When users mistakenly pass a string, null, array, or other invalid type instead of an object with a collection, they now receive a clear, actionable error message with an example of the correct usage (e.g., `.from({ todos: todosCollection })`).
9 changes: 9 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,15 @@ export class InvalidSourceError extends QueryBuilderError {
}
}

export class InvalidSourceTypeError extends QueryBuilderError {
constructor(context: string, type: string) {
super(
`Invalid source for ${context}: Expected an object with a single key-value pair like { alias: collection }. ` +
`For example: .from({ todos: todosCollection }). Got: ${type}`
)
}
}

export class JoinConditionMustBeEqualityError extends QueryBuilderError {
constructor() {
super(`Join condition must be an equality expression`)
Expand Down
30 changes: 28 additions & 2 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "../ir.js"
import {
InvalidSourceError,
InvalidSourceTypeError,
JoinConditionMustBeEqualityError,
OnlyOneSourceAllowedError,
QueryMustHaveFromClauseError,
Expand Down Expand Up @@ -60,13 +61,38 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
source: TSource,
context: string
): [string, CollectionRef | QueryRef] {
if (Object.keys(source).length !== 1) {
// Validate source is a plain object (not null, array, string, etc.)
// We use try-catch to handle null/undefined gracefully
let keys: Array<string>
try {
keys = Object.keys(source)
} catch {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const type = source === null ? `null` : `undefined`
throw new InvalidSourceTypeError(context, type)
}

// Check if it's an array (arrays pass Object.keys but aren't valid sources)
if (Array.isArray(source)) {
throw new InvalidSourceTypeError(context, `array`)
}

// Validate exactly one key
if (keys.length !== 1) {
if (keys.length === 0) {
throw new InvalidSourceTypeError(context, `empty object`)
}
// Check if it looks like a string was passed (has numeric keys)
if (keys.every((k) => !isNaN(Number(k)))) {
throw new InvalidSourceTypeError(context, `string`)
}
throw new OnlyOneSourceAllowedError(context)
}

const alias = Object.keys(source)[0]!
const alias = keys[0]!
const sourceValue = source[alias]

// Validate the value is a Collection or QueryBuilder
let ref: CollectionRef | QueryRef

if (sourceValue instanceof CollectionImpl) {
Expand Down
57 changes: 57 additions & 0 deletions packages/db/tests/query/builder/from.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { CollectionImpl } from "../../../src/collection/index.js"
import { Query, getQueryIR } from "../../../src/query/builder/index.js"
import { eq } from "../../../src/query/builder/functions.js"
import {
InvalidSourceTypeError,
OnlyOneSourceAllowedError,
QueryMustHaveFromClauseError,
} from "../../../src/errors"
Expand Down Expand Up @@ -108,4 +109,60 @@ describe(`QueryBuilder.from`, () => {
} as any)
}).toThrow(OnlyOneSourceAllowedError)
})

it(`throws helpful error when passing a string instead of an object`, () => {
const builder = new Query()

expect(() => {
builder.from(`employees` as any)
}).toThrow(InvalidSourceTypeError)

expect(() => {
builder.from(`employees` as any)
}).toThrow(
/Invalid source for from clause: Expected an object with a single key-value pair/
)
})

it(`throws helpful error when passing null`, () => {
const builder = new Query()

expect(() => {
builder.from(null as any)
}).toThrow(InvalidSourceTypeError)

expect(() => {
builder.from(null as any)
}).toThrow(
/Invalid source for from clause: Expected an object with a single key-value pair/
)
})

it(`throws helpful error when passing an array`, () => {
const builder = new Query()

expect(() => {
builder.from([employeesCollection] as any)
}).toThrow(InvalidSourceTypeError)

expect(() => {
builder.from([employeesCollection] as any)
}).toThrow(
/Invalid source for from clause: Expected an object with a single key-value pair/
)
})

it(`throws helpful error when passing undefined`, () => {
const builder = new Query()

expect(() => {
builder.from(undefined as any)
}).toThrow(InvalidSourceTypeError)

expect(() => {
builder.from(undefined as any)
}).toThrow(
/Invalid source for from clause: Expected an object with a single key-value pair/
)
})
})
Loading