diff --git a/.changeset/fix-query-ref-algebra.md b/.changeset/fix-query-ref-algebra.md new file mode 100644 index 0000000000..c89a76c3aa --- /dev/null +++ b/.changeset/fix-query-ref-algebra.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Preserve whole-object nullability through supported join and `unionAll` projections, retain intrinsic nullish fields when right/full joins follow branch unions, and preserve constrained generic fields through supported join and `unionAll` query chains. diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 5adb14bc5b..2651f1fe3f 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -173,6 +173,8 @@ type ResultFromBranch = type UnionBranchResult>> = ResultFromBranch +declare const BranchUnionRefs: unique symbol + type UnionBranchSchema>> = UnionBranchResult extends infer TResult ? { @@ -183,13 +185,14 @@ type UnionBranchSchema>> = export type ContextFromUnionBranches< TBranches extends readonly [QueryBuilder, ...Array>], > = { - baseSchema: UnionBranchSchema & ContextSchema - schema: UnionBranchSchema & ContextSchema + baseSchema: UnionBranchSchema + schema: UnionBranchSchema refsSchema: UnionBranchSchema fromSourceName: keyof UnionBranchSchema & string hasJoins: false result: PrettifyIfPlainObject> hasResult: true + [BranchUnionRefs]: UnionBranchResult } /** @@ -492,7 +495,7 @@ type ExtractRef = T extends unknown ? IsTrueRef extends true ? T extends RefLeaf ? IsNullableRef extends true - ? DeepNullable + ? U | undefined : U : never : Prettify>> @@ -533,14 +536,6 @@ type RefShapeMatches = ? true : false -// Propagate nullable-join semantics into the user-data shape. -type DeepNullable = - T extends Record - ? IsPlainObject extends true - ? { [K in keyof T]: DeepNullable } - : T | undefined - : T | undefined - // Helper type to extract the underlying type from various expression types type ExtractExpressionType = T extends PropRef @@ -677,46 +672,82 @@ type RefForContextValue = T extends unknown : RefLeaf : never type RefsSchemaForContext = - IsExactlyUndefined extends true - ? TContext[`schema`] - : NonUndefined extends ContextSchema - ? NonUndefined - : TContext[`schema`] + `refsSchema` extends keyof TContext + ? IsExactlyUndefined extends true + ? TContext[`schema`] + : NonUndefined + : TContext[`schema`] -export type RefsForContext = { - [K in KeysOfUnion>]: IsNonExactOptional< - ValueOfUnion, K> - > extends true - ? IsNonExactNullable< - ValueOfUnion, K> - > extends true - ? // T is both non-exact optional and non-exact nullable (e.g., string | null | undefined) - // Extract the non-undefined and non-null part, mark as nullable ref - RefForContextValue< - NonNullable, K>>, - true - > - : // T is optional (T | undefined) but not exactly undefined, and not nullable - // Extract the non-undefined part, mark as nullable ref - RefForContextValue< - NonUndefined, K>>, - true - > - : IsNonExactNullable< - ValueOfUnion, K> - > extends true - ? // T is nullable (T | null) but not exactly null, and not optional - // Extract the non-null part, mark as nullable ref - RefForContextValue< - NonNull, K>>, - true +type IsNullableContextKey = + TContext[`joinTypes`] extends Record + ? K extends keyof TContext[`joinTypes`] + ? Extract extends never + ? false + : true + : K extends FromSourceNamesForOptionality + ? TContext[`hasUnionFrom`] extends true + ? true + : HasRightOrFullJoin + : false + : K extends FromSourceNamesForOptionality + ? TContext[`hasUnionFrom`] extends true + ? true + : HasRightOrFullJoin + : false + +type RefForContextSchemaValue< + T, + ForceNullable extends boolean, +> = ForceNullable extends true + ? RefForContextValue, true> + : IsNonExactOptional extends true + ? IsNonExactNullable extends true + ? RefForContextValue, true> + : RefForContextValue, true> + : IsNonExactNullable extends true + ? RefForContextValue, true> + : RefForContextValue + +type RefsForBranchResult = T extends unknown + ? { + [K in keyof T]: ForceNullable extends true + ? RefForContextValue + : RefForContextSchemaValue + } + : never + +type BranchUnionResultRefs = + typeof BranchUnionRefs extends keyof TContext + ? RefsForBranchResult< + TContext[typeof BranchUnionRefs], + HasRightOrFullJoin + > + : object + +type JoinedRefsForContext = + TContext[`joinTypes`] extends Record + ? { + [K in keyof TContext[`joinTypes`] & + keyof TContext[`schema`]]: RefForContextSchemaValue< + TContext[`schema`][K], + IsNullableContextKey > - : // T is exactly undefined, exactly null, or neither optional nor nullable - // Wrap in Ref as-is (includes exact undefined, exact null, and normal types) - RefForContextValue, K>> + } + : object + +export type RefsForContext = { + [K in Exclude< + KeysOfUnion>, + keyof JoinedRefsForContext | keyof BranchUnionResultRefs + >]: RefForContextSchemaValue< + ValueOfUnion, K>, + IsNullableContextKey + > } & (TContext[`hasResult`] extends true ? { $selected: Ref } - : {}) + : {}) & + BranchUnionResultRefs & + JoinedRefsForContext /** * Type Detection Helpers @@ -886,32 +917,6 @@ type WithoutRefBrand = ? Omit : T -/** - * PreserveSingleResultFlag - Conditionally includes the singleResult flag - * - * This helper type ensures the singleResult flag is only added to the context when it's - * explicitly true. It uses a non-distributive conditional (tuple wrapper) to prevent - * unexpected behavior when TFlag is a union type. - * - * @template TFlag - The singleResult flag value to check - * @returns { singleResult: true } if TFlag is true, otherwise {} - */ -type PreserveSingleResultFlag = [TFlag] extends [true] - ? { singleResult: true } - : {} - -type PreserveHasResultFlag = [TFlag] extends [true] - ? { hasResult: true } - : {} - -type PreserveUnionFromFlag = [TFlag] extends [true] - ? { hasUnionFrom: true } - : {} - -type PreserveFromSourceNames = [TNames] extends [ReadonlyArray] - ? { fromSourceNames: TNames } - : {} - /** * MergeContextWithJoinType - Creates a new context after a join operation * @@ -933,13 +938,13 @@ type PreserveFromSourceNames = [TNames] extends [ReadonlyArray] * - `hasJoins`: Set to true * - `joinTypes`: Updated to track this join type * - `result`: Preserved from previous operations - * - `singleResult`: Preserved only if already true (via PreserveSingleResultFlag) + * - All other context state is preserved */ export type MergeContextWithJoinType< TContext extends Context, TNewSchema extends ContextSchema, TJoinType extends `inner` | `left` | `right` | `full` | `outer` | `cross`, -> = { +> = Omit & { baseSchema: TContext[`baseSchema`] // Apply optionality immediately to the schema schema: ApplyJoinOptionalityToMergedSchema< @@ -962,11 +967,7 @@ export type MergeContextWithJoinType< : {}) & { [K in keyof TNewSchema & string]: TJoinType } - result: TContext[`result`] -} & PreserveSingleResultFlag & - PreserveHasResultFlag & - PreserveUnionFromFlag & - PreserveFromSourceNames +} /** * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas @@ -1267,20 +1268,19 @@ export type HasJoinType< export type MergeContextForJoinCallback< TContext extends Context, TNewSchema extends ContextSchema, -> = { +> = Omit & { baseSchema: TContext[`baseSchema`] // Merge schemas without applying join optionality - both are non-optional in join condition schema: TContext[`schema`] & TNewSchema refsSchema: RefsSchemaForContext & TNewSchema fromSourceName: TContext[`fromSourceName`] hasJoins: true - joinTypes: TContext[`joinTypes`] extends Record + joinTypes: (TContext[`joinTypes`] extends Record ? TContext[`joinTypes`] - : {} - result: TContext[`result`] -} & PreserveHasResultFlag & - PreserveUnionFromFlag & - PreserveFromSourceNames + : {}) & { + [K in keyof TNewSchema & string]: `inner` + } +} /** * WithResult - Updates a context with a new result type after select() @@ -1297,10 +1297,11 @@ export type MergeContextForJoinCallback< * result type display cleanly in IDEs. */ export type WithResult = Prettify< - Omit & { - result: PrettifyIfPlainObject - hasResult: true - } + Omit & + Pick & { + result: PrettifyIfPlainObject + hasResult: true + } > /** diff --git a/packages/db/tests/query/join-subquery.test.ts b/packages/db/tests/query/join-subquery.test.ts index c14e66b113..dfbc2adf11 100644 --- a/packages/db/tests/query/join-subquery.test.ts +++ b/packages/db/tests/query/join-subquery.test.ts @@ -469,7 +469,7 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void { expect(results).toHaveLength(1) expect(results[0]!.product.id).toBe(1) expect(results[0]!.tried).toBeDefined() - expect(results[0]!.tried.userId).toBe(1) + expect(results[0]!.tried!.userId).toBe(1) expect(results[0]).toEqual({ product: { id: 1, a: `8` }, tried: sampleTrials[0], 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 new file mode 100644 index 0000000000..fc4a028439 --- /dev/null +++ b/packages/db/tests/query/query-api-type-algebra.test-d.ts @@ -0,0 +1,487 @@ +/** + * Oracle owner: the query-builder compile-time suites. + * + * Laws and sources: nullable join refs stay nullable when selected whole. In + * the separately enumerated generic callback cells, unresolved constraints + * survive direct queries, joins, and both union forms. These laws preserve the + * reports and prior art from issues 1467 and 1679. + * + * Reference and observation: TypeScript structural assignability and + * `@ts-expect-error` are the independent judges. Product-contract cells cross + * the public source -> query builder/Collection -> consumer type path. The + * paired runtime test owns the unmatched-row value witness. Existing generic, + * join, and nullable-leaf suites remain the broader compatibility owners. + */ +import { describe, expectTypeOf, test } from 'vitest' +import { Query, createLiveQueryCollection, eq } from '../../src/query/index.js' +import type { Collection } from '../../src/collection/index.js' +import type { + Context, + QueryBuilder, + QueryResult, + RefsForContext, + WithResult, +} from '../../src/query/index.js' +import type { WithVirtualProps } from '../../src/virtual-props.js' + +type Row = { id: string; departmentId: string } +type Department = { id: string; name: string } +type DeepNullable = T extends object + ? { [K in keyof T]: DeepNullable } + : T | undefined +type IsAny = 0 extends 1 & T ? true : false +type Selected = WithResult< + TContext, + { selectedId: string } +> +type SelectedSourceContext = Pick< + Selected, + `baseSchema` | `schema` | `fromSourceName` +> + +describe(`query API type algebra`, () => { + test(`select preserves required source fields in generic contexts`, () => { + function preserveSourceContext( + selected: Selected, + ) { + const baseSchema: TContext[`baseSchema`] = selected.baseSchema + const schema: TContext[`schema`] = selected.schema + const fromSourceName: TContext[`fromSourceName`] = selected.fromSourceName + const result: { selectedId: string } = selected.result + const sourceContext: SelectedSourceContext = selected + return { baseSchema, schema, fromSourceName, result, sourceContext } + } + + void preserveSourceContext + }) + + test(`whole-object selections preserve nullable join refs`, () => { + function projectNullableDepartment( + rows: Collection, + departments: Collection, + ) { + const query = createLiveQueryCollection((q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ + department, + departmentName: department.name, + nested: { department }, + })), + ) + + const result = query.toArray[0]! + type ActualDepartment = typeof result.department + type ExpectedDepartment = WithVirtualProps | undefined + + expectTypeOf().toEqualTypeOf() + expectTypeOf< + unknown extends ActualDepartment ? true : false + >().toEqualTypeOf() + expectTypeOf< + null extends ActualDepartment ? true : false + >().toEqualTypeOf() + expectTypeOf< + DeepNullable< + WithVirtualProps + > extends ActualDepartment + ? true + : false + >().toEqualTypeOf() + expectTypeOf< + NonNullable[`name`] + >().toEqualTypeOf() + expectTypeOf(result.nested.department).toEqualTypeOf() + + const absentLeaf: typeof result.departmentName = undefined + + // @ts-expect-error An unmatched whole-object ref requires a guard. + result.department.name + // @ts-expect-error Nested whole-object refs retain the same guard. + result.nested.department.name + + void absentLeaf + return query + } + + void projectNullableDepartment + }) + + test(`branch unions preserve nullable whole-object fields`, () => { + type Address = { city: string } + type Person = Row & { address: Address; optionalAddress?: Address } + + function projectNullableBranchFields( + rowsA: Collection, + rowsB: Collection, + othersA: Collection, + othersB: Collection, + ) { + const joinedBranchA = new Query() + .from({ rowA: rowsA }) + .leftJoin({ otherA: othersA }, ({ rowA, otherA }) => + eq(rowA.id, otherA.id), + ) + .select(({ otherA }) => ({ other: otherA })) + const joinedBranchB = new Query() + .from({ rowB: rowsB }) + .leftJoin({ otherB: othersB }, ({ rowB, otherB }) => + eq(rowB.id, otherB.id), + ) + .select(({ otherB }) => ({ other: otherB })) + const joinedUnion = new Query() + .unionAll(joinedBranchA, joinedBranchB) + .select(({ other }) => ({ + other, + otherAddress: other.address, + })) + + type JoinedResult = QueryResult + const joinedResult = null as unknown as JoinedResult + expectTypeOf(joinedResult.other).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(joinedResult.otherAddress).toEqualTypeOf< + Address | undefined + >() + // @ts-expect-error An unmatched branch whole object requires a guard. + joinedResult.other.id + if (joinedResult.other) { + expectTypeOf(joinedResult.other.id).toEqualTypeOf() + // @ts-expect-error Nested user objects do not gain row virtual props. + joinedResult.other.address.$key + } + + const optionalBranchA = new Query() + .from({ rowA: rowsA }) + .select(({ rowA }) => ({ address: rowA.optionalAddress })) + const optionalBranchB = new Query() + .from({ rowB: rowsB }) + .select(({ rowB }) => ({ address: rowB.optionalAddress })) + const optionalUnion = new Query() + .unionAll(optionalBranchA, optionalBranchB) + .select(({ address }) => ({ address })) + + type OptionalResult = QueryResult + const optionalResult = null as unknown as OptionalResult + expectTypeOf(optionalResult.address).toEqualTypeOf
() + // @ts-expect-error An absent selected object requires a guard. + optionalResult.address.city + + return { joinedUnion, optionalUnion } + } + + void projectNullableBranchFields + }) + + test(`spreading a nullable join ref widens its leaves`, () => { + function spreadNullableDepartment( + rows: Collection, + departments: Collection, + ) { + const query = createLiveQueryCollection((q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ ...department })), + ) + + const result = query.toArray[0]! + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(result.name).toEqualTypeOf() + return query + } + + void spreadNullableDepartment + }) + + test(`unresolved generic constraints survive joins and union sources`, () => { + function composeGenericSources( + rows: Collection, + a: Collection, + b: Collection, + id: string, + ) { + const direct = new Query().from({ item: a }).where(({ item }) => { + // @ts-expect-error The generic constraint guarantees no other field. + void item.notGuaranteed + return eq(item.id, id) + }) + + const joined = new Query() + .from({ row: rows }) + .leftJoin({ item: a }, ({ row, item }) => { + // @ts-expect-error Only the generic constraint is available. + void item.notGuaranteed + return eq(row.id, item.id) + }) + .where(({ item }) => { + // @ts-expect-error Nullable generic refs expose only guaranteed fields. + void item.notGuaranteed + return eq(item.id, id) + }) + .select(({ item }) => { + // @ts-expect-error Only the generic constraint is available. + void item.notGuaranteed + return { id: item.id } + }) + + const sourceUnion = new Query() + .unionAll({ a, b }) + .where(({ a: aRef, b: bRef }) => { + // @ts-expect-error Union refs expose only guaranteed fields. + void aRef.notGuaranteed + // @ts-expect-error Union refs expose only guaranteed fields. + void bRef.notGuaranteed + return eq(aRef.id, bRef.id) + }) + + const aRows = new Query().from({ a }) + const bRows = new Query().from({ b }) + const branchUnion = new Query() + .unionAll(aRows, bRows) + .where(({ id: itemId }) => eq(itemId, id)) + .where((refs) => { + // @ts-expect-error Branch unions expose no unselected field. + void refs.notGuaranteed + return eq(refs.id, id) + }) + + const selectedBranchUnion = new Query() + .unionAll(aRows, bRows) + .select(({ id: itemId }) => ({ renamed: itemId })) + .where((refs) => { + void refs.id + void refs.$selected.renamed + expectTypeOf>().toEqualTypeOf() + expectTypeOf< + IsAny + >().toEqualTypeOf() + // @ts-expect-error Selected aliases require the $selected namespace. + void refs.renamed + // @ts-expect-error Only the generic constraint is available. + void refs.notGuaranteed + return eq(refs.id, refs.$selected.renamed) + }) + + const joinedBranchUnion = new Query() + .unionAll(aRows, bRows) + .leftJoin({ row: rows }, ({ id: itemId, row }) => { + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + // @ts-expect-error The joined source declares no other field. + void row.notGuaranteed + return eq(itemId, row.id) + }) + .where((refs) => { + void refs.id + void refs.row.id + expectTypeOf>().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + // @ts-expect-error Only the generic constraint is available. + void refs.notGuaranteed + return eq(refs.id, refs.row.id) + }) + .select(({ id: itemId, row }) => ({ id: itemId, rowId: row.id })) + + const rightJoinedBranchUnion = new Query() + .unionAll(aRows, bRows) + .rightJoin({ row: rows }, ({ id: itemId, row }) => eq(itemId, row.id)) + .select(({ id: itemId }) => ({ id: itemId })) + + return { + direct, + joined, + sourceUnion, + branchUnion, + selectedBranchUnion, + joinedBranchUnion, + rightJoinedBranchUnion, + } + } + + type Concrete = ReturnType< + typeof composeGenericSources<{ id: string; concrete: number }> + > + type JoinedId = QueryResult[`id`] + type DirectConcrete = QueryResult[`concrete`] + type SourceAId = NonNullable< + QueryResult[`a`] + >[`id`] + type SourceAConcrete = NonNullable< + QueryResult[`a`] + >[`concrete`] + type SourceBId = NonNullable< + QueryResult[`b`] + >[`id`] + type BranchId = QueryResult[`id`] + type BranchConcrete = QueryResult[`concrete`] + type SelectedBranchId = QueryResult< + Concrete[`selectedBranchUnion`] + >[`renamed`] + type JoinedBranchId = QueryResult[`id`] + type JoinedBranchRowId = QueryResult[`rowId`] + type RightJoinedBranchId = QueryResult< + Concrete[`rightJoinedBranchUnion`] + >[`id`] + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf>().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + + void composeGenericSources + }) + + test(`specific query builders remain assignable to erased query builders`, () => { + function eraseBuilder( + source: Collection, + ) { + const specific = new Query().unionAll( + new Query().from({ source }), + new Query().from({ source }), + ) + const erased: QueryBuilder = specific + return erased + } + + void eraseBuilder + }) + + test(`exact nullish schema leaves stay exact`, () => { + type NullishRow = { id: string; nullValue: null; undefinedValue: undefined } + type ExactNullishContext = { + baseSchema: { nullValue: null; undefinedValue: undefined } + schema: { nullValue: null; undefinedValue: undefined } + fromSourceName: `nullValue` + hasJoins: false + } + type ExactNullishRefs = RefsForContext + + function projectNullishLeaves(source: Collection) { + const query = createLiveQueryCollection((q) => + q.from({ row: source }).select(({ row }) => ({ + nullValue: row.nullValue, + undefinedValue: row.undefinedValue, + })), + ) + const result = query.toArray[0]! + expectTypeOf(result.nullValue).toEqualTypeOf() + expectTypeOf(result.undefinedValue).toEqualTypeOf() + return query + } + + expectTypeOf().not.toBeNever() + expectTypeOf().not.toBeNever() + void projectNullishLeaves + }) + + test(`branch unions preserve intrinsic nullish fields through nullable joins`, () => { + type BranchRow = { + id: string + exactNull: null + exactUndefined: undefined + nullableText: string | null + nullableObject: { label: string } | null + } + + function projectNullableBranchUnion( + a: Collection, + b: Collection, + rows: Collection<{ id: string }, string>, + ) { + const branch = (source: Collection) => + new Query().from({ source }).select(({ source: value }) => ({ + id: value.id, + exactNull: value.exactNull, + exactUndefined: value.exactUndefined, + nullableText: value.nullableText, + nullableObject: value.nullableObject, + })) + const union = new Query().unionAll(branch(a), branch(b)) + const rightJoined = union + .rightJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) + .select( + ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + exactNull, + exactUndefined, + nullableText, + nullableObject, + }), + ) + const fullJoined = union + .fullJoin({ row: rows }, ({ id, row }) => eq(id, row.id)) + .select( + ({ exactNull, exactUndefined, nullableText, nullableObject }) => ({ + exactNull, + exactUndefined, + nullableText, + nullableObject, + }), + ) + + type RightResult = QueryResult + type FullResult = QueryResult + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf() + expectTypeOf().toEqualTypeOf< + string | null | undefined + >() + expectTypeOf().toEqualTypeOf< + { label: string } | null | undefined + >() + + const result = null as unknown as RightResult + if (result.nullableObject) { + expectTypeOf(result.nullableObject.label).toEqualTypeOf() + // @ts-expect-error Nested user objects do not gain row virtual props. + result.nullableObject.$key + } + + return { rightJoined, fullJoined } + } + + void projectNullableBranchUnion + }) + + test(`an explicit undefined refs schema falls back to the query schema`, () => { + type ExplicitUndefinedRefsContext = { + baseSchema: { row: Row } + schema: { row: Row } + refsSchema: undefined + fromSourceName: `row` + hasJoins: false + } + + function useSchemaFallback( + refs: RefsForContext, + ) { + void refs.row.id + } + + void useSchemaFallback + }) +}) diff --git a/packages/db/tests/query/query-api-type-algebra.test.ts b/packages/db/tests/query/query-api-type-algebra.test.ts new file mode 100644 index 0000000000..6f276cf395 --- /dev/null +++ b/packages/db/tests/query/query-api-type-algebra.test.ts @@ -0,0 +1,91 @@ +import { expect, test } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createLiveQueryCollection, eq } from '../../src/query/index.js' +import { mockSyncCollectionOptions } from '../utils.js' + +test(`an unmatched whole-object projection publishes undefined`, () => { + const rows = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows`, + getKey: (row: { id: string; departmentId: string }) => row.id, + initialData: [{ id: `row-1`, departmentId: `missing` }], + }), + ) + const departments = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-departments`, + getKey: (row: { id: string; name: string }) => row.id, + initialData: [{ id: `present`, name: `Present` }], + }), + ) + + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ row: rows }) + .leftJoin({ department: departments }, ({ row, department }) => + eq(row.departmentId, department.id), + ) + .select(({ department }) => ({ department })), + }) + + expect(query.toArray).toHaveLength(1) + expect(query.toArray[0]!.department).toBeUndefined() +}) + +test(`branch unions publish unmatched whole-object projections as undefined`, async () => { + type Person = { id: string } + + const rowsA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows-a`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-1` }], + }), + ) + const rowsB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-rows-b`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-2` }], + }), + ) + const othersA = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-others-a`, + getKey: (row: Person) => row.id, + initialData: [{ id: `row-1` }], + }), + ) + const othersB = createCollection( + mockSyncCollectionOptions({ + id: `query-api-type-algebra-others-b`, + getKey: (row: Person) => row.id, + initialData: [{ id: `other-2` }], + }), + ) + + const query = createLiveQueryCollection((q) => { + const branchA = q + .from({ rowA: rowsA }) + .leftJoin({ otherA: othersA }, ({ rowA, otherA }) => + eq(rowA.id, otherA.id), + ) + .select(({ otherA }) => ({ other: otherA })) + const branchB = q + .from({ rowB: rowsB }) + .leftJoin({ otherB: othersB }, ({ rowB, otherB }) => + eq(rowB.id, otherB.id), + ) + .select(({ otherB }) => ({ other: otherB })) + + return q.unionAll(branchA, branchB).select(({ other }) => ({ other })) + }) + + await query.preload() + + expect(query.toArray).toHaveLength(2) + expect(query.toArray[0]!.other).toMatchObject({ id: `row-1` }) + expect(query.toArray[1]!.other).toBeUndefined() +})