diff --git a/.changeset/improve-from-clause-error-messages.md b/.changeset/improve-from-clause-error-messages.md new file mode 100644 index 000000000..ce7b5a2b1 --- /dev/null +++ b/.changeset/improve-from-clause-error-messages.md @@ -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 })`). diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 55f9205cc..8abc79fe5 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -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`) diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index bd8b95178..e25cca1f4 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -10,6 +10,7 @@ import { } from "../ir.js" import { InvalidSourceError, + InvalidSourceTypeError, JoinConditionMustBeEqualityError, OnlyOneSourceAllowedError, QueryMustHaveFromClauseError, @@ -60,13 +61,38 @@ export class BaseQueryBuilder { 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 + 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) { diff --git a/packages/db/tests/query/builder/from.test.ts b/packages/db/tests/query/builder/from.test.ts index 62946b094..6e4ec2f64 100644 --- a/packages/db/tests/query/builder/from.test.ts +++ b/packages/db/tests/query/builder/from.test.ts @@ -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" @@ -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/ + ) + }) })