Skip to content

Commit bb5d50e

Browse files
authored
fix issue with using optional props in select and join clauses (#377)
1 parent 97b595e commit bb5d50e

File tree

13 files changed

+1675
-53
lines changed

13 files changed

+1675
-53
lines changed

.changeset/early-ties-push.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"@tanstack/electric-db-collection": patch
3+
"@tanstack/query-db-collection": patch
4+
"@tanstack/db": patch
5+
---
6+
7+
Ensure that you can use optional properties in the `select` and `join` clauses of a query, and fix an issue where standard schemas were not properly carried through to live queries.

packages/db/src/collection.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,81 @@ export interface Collection<
155155
* sync: { sync: () => {} }
156156
* })
157157
*
158-
* // Note: You must provide either an explicit type or a schema, but not both.
158+
* // Note: You can provide an explicit type, a schema, or both. When both are provided, the explicit type takes precedence.
159159
*/
160+
161+
// Overload for when schema is provided - infers schema type
162+
export function createCollection<
163+
TSchema extends StandardSchemaV1,
164+
TKey extends string | number = string | number,
165+
TUtils extends UtilsRecord = {},
166+
TFallback extends object = Record<string, unknown>,
167+
>(
168+
options: CollectionConfig<
169+
ResolveType<unknown, TSchema, TFallback>,
170+
TKey,
171+
TSchema,
172+
ResolveInsertInput<unknown, TSchema, TFallback>
173+
> & {
174+
schema: TSchema
175+
utils?: TUtils
176+
}
177+
): Collection<
178+
ResolveType<unknown, TSchema, TFallback>,
179+
TKey,
180+
TUtils,
181+
TSchema,
182+
ResolveInsertInput<unknown, TSchema, TFallback>
183+
>
184+
185+
// Overload for when explicit type is provided with schema - explicit type takes precedence
186+
export function createCollection<
187+
TExplicit extends object,
188+
TKey extends string | number = string | number,
189+
TUtils extends UtilsRecord = {},
190+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
191+
TFallback extends object = Record<string, unknown>,
192+
>(
193+
options: CollectionConfig<
194+
ResolveType<TExplicit, TSchema, TFallback>,
195+
TKey,
196+
TSchema,
197+
ResolveInsertInput<TExplicit, TSchema, TFallback>
198+
> & {
199+
schema: TSchema
200+
utils?: TUtils
201+
}
202+
): Collection<
203+
ResolveType<TExplicit, TSchema, TFallback>,
204+
TKey,
205+
TUtils,
206+
TSchema,
207+
ResolveInsertInput<TExplicit, TSchema, TFallback>
208+
>
209+
210+
// Overload for when explicit type is provided or no schema
211+
export function createCollection<
212+
TExplicit = unknown,
213+
TKey extends string | number = string | number,
214+
TUtils extends UtilsRecord = {},
215+
TSchema extends StandardSchemaV1 = StandardSchemaV1,
216+
TFallback extends object = Record<string, unknown>,
217+
>(
218+
options: CollectionConfig<
219+
ResolveType<TExplicit, TSchema, TFallback>,
220+
TKey,
221+
TSchema,
222+
ResolveInsertInput<TExplicit, TSchema, TFallback>
223+
> & { utils?: TUtils }
224+
): Collection<
225+
ResolveType<TExplicit, TSchema, TFallback>,
226+
TKey,
227+
TUtils,
228+
TSchema,
229+
ResolveInsertInput<TExplicit, TSchema, TFallback>
230+
>
231+
232+
// Implementation
160233
export function createCollection<
161234
TExplicit = unknown,
162235
TKey extends string | number = string | number,

packages/db/src/query/builder/types.ts

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CollectionImpl } from "../../collection.js"
22
import type { Aggregate, BasicExpression, OrderByDirection } from "../ir.js"
33
import type { QueryBuilder } from "./index.js"
4+
import type { ResolveType } from "../../types.js"
45

56
export interface Context {
67
// The collections available in the base schema
@@ -27,13 +28,16 @@ export type Source = {
2728
}
2829

2930
// Helper type to infer collection type from CollectionImpl
31+
// This uses ResolveType directly to ensure consistency with collection creation logic
3032
export type InferCollectionType<T> =
31-
T extends CollectionImpl<infer U> ? U : never
33+
T extends CollectionImpl<infer U, any, any, infer TSchema, any>
34+
? ResolveType<U, TSchema, U>
35+
: never
3236

3337
// Helper type to create schema from source
3438
export type SchemaFromSource<T extends Source> = Prettify<{
35-
[K in keyof T]: T[K] extends CollectionImpl<infer U>
36-
? U
39+
[K in keyof T]: T[K] extends CollectionImpl<any, any, any, any, any>
40+
? InferCollectionType<T[K]>
3741
: T[K] extends QueryBuilder<infer TContext>
3842
? GetResult<TContext>
3943
: never
@@ -58,16 +62,18 @@ export type SelectObject<
5862
// Helper type to get the result type from a select object
5963
export type ResultTypeFromSelect<TSelectObject> = {
6064
[K in keyof TSelectObject]: TSelectObject[K] extends RefProxy<infer T>
61-
? // For RefProxy, preserve the type as-is (including optionality from joins)
62-
T
65+
? T
6366
: TSelectObject[K] extends BasicExpression<infer T>
6467
? T
6568
: TSelectObject[K] extends Aggregate<infer T>
6669
? T
6770
: TSelectObject[K] extends RefProxyFor<infer T>
68-
? // For RefProxyFor, preserve the type as-is (including optionality from joins)
69-
T
70-
: never
71+
? T
72+
: TSelectObject[K] extends undefined
73+
? undefined
74+
: TSelectObject[K] extends { __type: infer U }
75+
? U
76+
: never
7177
}
7278

7379
// Callback type for orderBy clauses
@@ -142,22 +148,26 @@ export type RefProxyFor<T> = OmitRefProxy<
142148
? // T is optional (T | undefined) but not exactly undefined
143149
NonUndefined<T> extends Record<string, any>
144150
? {
145-
// Properties are accessible and their types become optional
146-
[K in keyof NonUndefined<T>]: NonUndefined<T>[K] extends Record<
151+
[K in keyof NonUndefined<T>]-?: NonUndefined<T>[K] extends Record<
147152
string,
148153
any
149154
>
150-
? RefProxyFor<NonUndefined<T>[K] | undefined> &
155+
? RefProxyFor<NonUndefined<T>[K]> &
151156
RefProxy<NonUndefined<T>[K] | undefined>
152157
: RefProxy<NonUndefined<T>[K] | undefined>
153158
} & RefProxy<T>
154159
: RefProxy<T>
155160
: // T is not optional
156161
T extends Record<string, any>
157162
? {
158-
[K in keyof T]: T[K] extends Record<string, any>
159-
? RefProxyFor<T[K]> & RefProxy<T[K]>
160-
: RefProxy<T[K]>
163+
// Make all properties required, but for optional ones, include undefined in the RefProxy type
164+
[K in keyof T]-?: undefined extends T[K]
165+
? T[K] extends Record<string, any>
166+
? RefProxyFor<T[K]> & RefProxy<T[K]>
167+
: RefProxy<T[K]>
168+
: T[K] extends Record<string, any>
169+
? RefProxyFor<T[K]> & RefProxy<T[K]>
170+
: RefProxy<T[K]>
161171
} & RefProxy<T>
162172
: RefProxy<T>
163173
>

packages/db/tests/collection-indexes.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
or,
1515
} from "../src/query/builder/functions"
1616
import { expectIndexUsage, withIndexTracking } from "./utls"
17+
import type { Collection } from "../src/collection"
1718
import type { MutationFn, PendingMutation } from "../src/types"
1819

1920
interface TestItem {
@@ -25,7 +26,7 @@ interface TestItem {
2526
createdAt: Date
2627
}
2728
describe(`Collection Indexes`, () => {
28-
let collection: ReturnType<typeof createCollection<TestItem, string>>
29+
let collection: Collection<TestItem, string>
2930
let testData: Array<TestItem>
3031
let mutationFn: MutationFn
3132
let emitter: any

packages/db/tests/collection.test-d.ts

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -58,21 +58,14 @@ describe(`Collection type resolution tests`, () => {
5858
type SchemaType = StandardSchemaV1.InferOutput<typeof testSchema>
5959
type ItemOf<T> = T extends Array<infer U> ? U : T
6060

61-
it(`should prioritize explicit type when provided`, () => {
61+
it(`should use explicit type when provided without schema`, () => {
6262
const _collection = createCollection<ExplicitType>({
6363
getKey: (item) => item.id,
6464
sync: { sync: () => {} },
65-
schema: testSchema,
6665
})
6766

68-
type ExpectedType = ResolveType<
69-
ExplicitType,
70-
typeof testSchema,
71-
FallbackType
72-
>
7367
type Param = Parameters<typeof _collection.insert>[0]
7468
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExplicitType>()
75-
expectTypeOf<ExpectedType>().toEqualTypeOf<ExplicitType>()
7669
})
7770

7871
it(`should use schema type when explicit type is not provided`, () => {
@@ -134,4 +127,71 @@ describe(`Collection type resolution tests`, () => {
134127
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExplicitType>()
135128
expectTypeOf<ExpectedType>().toEqualTypeOf<ExplicitType>()
136129
})
130+
131+
it(`should automatically infer type from schema without generic arguments`, () => {
132+
// This is the key test case that was missing - no generic arguments at all
133+
const _collection = createCollection({
134+
getKey: (item) => item.id,
135+
sync: { sync: () => {} },
136+
schema: testSchema,
137+
})
138+
139+
type Param = Parameters<typeof _collection.insert>[0]
140+
// Should infer the schema type automatically
141+
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<SchemaType>()
142+
})
143+
144+
it(`should automatically infer type from Zod schema with optional fields`, () => {
145+
// Test with a Zod schema that has optional fields
146+
const userSchema = z.object({
147+
id: z.number(),
148+
name: z.string(),
149+
email: z.string().email().optional(),
150+
created_at: z.date().optional(),
151+
})
152+
153+
const _collection = createCollection({
154+
getKey: (item) => item.id,
155+
sync: { sync: () => {} },
156+
schema: userSchema,
157+
})
158+
159+
type Param = Parameters<typeof _collection.insert>[0]
160+
type ExpectedType = {
161+
id: number
162+
name: string
163+
email?: string
164+
created_at?: Date
165+
}
166+
167+
// Should automatically infer the complete Zod schema type
168+
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExpectedType>()
169+
})
170+
171+
it(`should automatically infer type from Zod schema with nullable fields`, () => {
172+
// Test with nullable fields (different from optional)
173+
const postSchema = z.object({
174+
id: z.string(),
175+
title: z.string(),
176+
author_id: z.string().nullable(),
177+
published_at: z.date().nullable(),
178+
})
179+
180+
const _collection = createCollection({
181+
getKey: (item) => item.id,
182+
sync: { sync: () => {} },
183+
schema: postSchema,
184+
})
185+
186+
type Param = Parameters<typeof _collection.insert>[0]
187+
type ExpectedType = {
188+
id: string
189+
title: string
190+
author_id: string | null
191+
published_at: Date | null
192+
}
193+
194+
// Should automatically infer nullable types correctly
195+
expectTypeOf<ItemOf<Param>>().toEqualTypeOf<ExpectedType>()
196+
})
137197
})

0 commit comments

Comments
 (0)