diff --git a/.changeset/fix-optional-nullable-query-fields.md b/.changeset/fix-optional-nullable-query-fields.md new file mode 100644 index 000000000..9b9be21cd --- /dev/null +++ b/.changeset/fix-optional-nullable-query-fields.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Preserve `null` alongside `undefined` when nullable fields flow through query references, selected results, and branch unions. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index aaac60aa7..ed860c7f1 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -347,24 +347,27 @@ export type ResultTypeFromSelectValue = > extends true ? T | null | undefined : T | null - : TSelectValue extends Ref | undefined - ? - | ExtractRef> + : TSelectValue extends + | Ref + | null | undefined - : TSelectValue extends Ref | null - ? ExtractRef> | null - : TSelectValue extends Aggregate - ? T - : TSelectValue extends - | string - | number - | boolean - | null - | undefined - ? TSelectValue - : TSelectValue extends Record - ? ResultTypeFromSelect - : never + ? + | ExtractRef< + Exclude + > + | Extract + : TSelectValue extends Aggregate + ? T + : TSelectValue extends + | string + | number + | boolean + | null + | undefined + ? TSelectValue + : TSelectValue extends Record + ? ResultTypeFromSelect + : never > /** @@ -452,35 +455,37 @@ export type ResultTypeFromSelect = > extends true ? T | null | undefined : T | null - : // Ref | undefined (optional object-type schema field) + : // Nullable and/or optional object-type schema field TSelectObject[K] extends | Ref + | null | undefined ? | ExtractRef< - Exclude + Exclude< + TSelectObject[K], + null | undefined + > > - | undefined - : // Ref | null (nullable object-type schema field) - TSelectObject[K] extends Ref | null - ? ExtractRef< - Exclude - > | null - : TSelectObject[K] extends Aggregate - ? T - : TSelectObject[K] extends - | string - | number - | boolean - | null - | undefined - ? TSelectObject[K] - : TSelectObject[K] extends Record< - string, - any - > - ? ResultTypeFromSelect - : never + | Extract< + TSelectObject[K], + null | undefined + > + : TSelectObject[K] extends Aggregate + ? T + : TSelectObject[K] extends + | string + | number + | boolean + | null + | undefined + ? TSelectObject[K] + : TSelectObject[K] extends Record< + string, + any + > + ? ResultTypeFromSelect + : never }> > @@ -702,12 +707,16 @@ type RefForContextSchemaValue< ? RefForContextValue, true> : IsNonExactOptional extends true ? IsNonExactNullable extends true - ? RefForContextValue, true> + ? RefForOptionalNullableContextValue> : RefForContextValue, true> : IsNonExactNullable extends true - ? RefForContextValue, true> + ? RefForContextValue, true> | Extract : RefForContextValue +type RefForOptionalNullableContextValue = T extends null + ? null + : RefForContextValue + type RefsForBranchResult = T extends unknown ? { [K in keyof T]: ForceNullable extends true @@ -873,8 +882,8 @@ type RefBranch = { ? IsNonExactNullable extends true ? // Both optional and nullable IsPlainObject> extends true - ? Ref, Nullable> | undefined - : RefLeaf, Nullable> | undefined + ? Ref, Nullable> | null | undefined + : RefLeaf, Nullable> | undefined : // Optional only IsPlainObject> extends true ? Ref, Nullable> | undefined diff --git a/packages/db/tests/query/query-api-type-algebra.test-d.ts b/packages/db/tests/query/query-api-type-algebra.test-d.ts index fc4a02843..7ef833f3f 100644 --- a/packages/db/tests/query/query-api-type-algebra.test-d.ts +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -19,9 +19,11 @@ import type { Context, QueryBuilder, QueryResult, + Ref, RefsForContext, WithResult, } from '../../src/query/index.js' +import type { RefLeaf } from '../../src/query/builder/types.js' import type { WithVirtualProps } from '../../src/virtual-props.js' type Row = { id: string; departmentId: string } @@ -391,11 +393,97 @@ describe(`query API type algebra`, () => { void projectNullishLeaves }) + test(`optional nullable fields preserve both nullish branches`, () => { + type OptionalNullableRow = { + id: string + required: number + optional?: number + nullable: number | null + nullish?: number | null + exactNull: null + exactUndefined: undefined + nullishObject?: { label: string } | null + } + + function projectOptionalNullableFields( + source: Collection, + ) { + const query = new Query() + .from({ row: source }) + .orderBy(({ row }) => { + expectTypeOf(row.required).toEqualTypeOf>() + expectTypeOf(row.optional).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.nullable).toEqualTypeOf | null>() + expectTypeOf(row.nullish).toEqualTypeOf< + RefLeaf | undefined + >() + expectTypeOf(row.exactNull).toEqualTypeOf>() + expectTypeOf(row.exactUndefined).toEqualTypeOf>() + expectTypeOf(row.nullishObject).toEqualTypeOf< + Ref<{ label: string }> | null | undefined + >() + return row.nullish + }) + .select(({ row }) => ({ + required: row.required, + optional: row.optional, + nullable: row.nullable, + nullish: row.nullish, + exactNull: row.exactNull, + exactUndefined: row.exactUndefined, + nullishObject: row.nullishObject, + })) + + type Result = QueryResult + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + const branch = () => + new Query().from({ row: source }).select(({ row }) => ({ + nullish: row.nullish, + nullishObject: row.nullishObject, + })) + const union = new Query() + .unionAll(branch(), branch()) + .orderBy(({ nullish }) => { + expectTypeOf(nullish).toEqualTypeOf | null>() + return nullish + }) + .select(({ nullish, nullishObject }) => ({ + nullish, + nullishObject, + })) + type UnionResult = QueryResult + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + return { query, union } + } + + void projectOptionalNullableFields + }) + test(`branch unions preserve intrinsic nullish fields through nullable joins`, () => { type BranchRow = { id: string exactNull: null exactUndefined: undefined + nullableNumber: number | null nullableText: string | null nullableObject: { label: string } | null } @@ -410,16 +498,41 @@ describe(`query API type algebra`, () => { id: value.id, exactNull: value.exactNull, exactUndefined: value.exactUndefined, + nullableNumber: value.nullableNumber, nullableText: value.nullableText, nullableObject: value.nullableObject, })) const union = new Query().unionAll(branch(a), branch(b)) + const _plain = union.select( + ({ nullableNumber, nullableText, nullableObject }) => { + expectTypeOf(nullableNumber).toEqualTypeOf | null>() + expectTypeOf(nullableText).toEqualTypeOf | null>() + expectTypeOf(nullableObject).toEqualTypeOf | null>() + return { nullableNumber, nullableText, nullableObject } + }, + ) const rightJoined = union .rightJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) .select( - ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + ({ exactNull, exactUndefined, + nullableNumber, + nullableText, + nullableObject, + }) => ({ + exactNull, + exactUndefined, + nullableNumber, nullableText, nullableObject, }), @@ -427,18 +540,38 @@ describe(`query API type algebra`, () => { const fullJoined = union .fullJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) .select( - ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + ({ + exactNull, + exactUndefined, + nullableNumber, + nullableText, + nullableObject, + }) => ({ exactNull, exactUndefined, + nullableNumber, nullableText, nullableObject, }), ) + type PlainResult = QueryResult type RightResult = QueryResult type FullResult = QueryResult + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() expectTypeOf().toEqualTypeOf< string | null | undefined >() @@ -447,6 +580,9 @@ describe(`query API type algebra`, () => { >() expectTypeOf().toEqualTypeOf() expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + number | null | undefined + >() expectTypeOf().toEqualTypeOf< string | null | undefined >() diff --git a/packages/db/tests/query/query-api-type-algebra.test.ts b/packages/db/tests/query/query-api-type-algebra.test.ts index 6f276cf39..eb3025c8d 100644 --- a/packages/db/tests/query/query-api-type-algebra.test.ts +++ b/packages/db/tests/query/query-api-type-algebra.test.ts @@ -89,3 +89,39 @@ test(`branch unions publish unmatched whole-object projections as undefined`, as expect(query.toArray[0]!.other).toMatchObject({ id: `row-1` }) expect(query.toArray[1]!.other).toBeUndefined() }) + +test(`plain branch unions publish nullable-only values as null`, async () => { + type NullableRow = { id: string; value: number | null } + const rowsA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-nullable-rows-a`, + getKey: (row) => row.id, + initialData: [{ id: `null-row`, value: null }], + }), + ) + const rowsB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-nullable-rows-b`, + getKey: (row) => row.id, + initialData: [{ id: `number-row`, value: 1 }], + }), + ) + const query = createLiveQueryCollection((q) => { + const branchA = q.from({ rowsA }).select(({ rowsA: row }) => ({ + id: row.id, + value: row.value, + })) + const branchB = q.from({ rowsB }).select(({ rowsB: row }) => ({ + id: row.id, + value: row.value, + })) + return q.unionAll(branchA, branchB).select(({ id, value }) => ({ + id, + value, + })) + }) + + await query.preload() + + expect(query.toArray.find((row) => row.id === `null-row`)?.value).toBeNull() +})