From 43d347f2c2b3f6dac7d8b22c596ff80bc3161350 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 30 Apr 2026 14:39:22 +0200 Subject: [PATCH 1/2] feat: add materialize() helper for includes subqueries materialize() resolves to Array for multi-row subqueries and T | undefined for findOne() subqueries, so callers don't have to unwrap a singleton array when the child query returns at most one row. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/db/src/query/builder/functions.ts | 51 ++++ packages/db/src/query/builder/index.ts | 17 +- packages/db/src/query/builder/ref-proxy.ts | 16 +- packages/db/src/query/builder/types.ts | 29 ++- packages/db/src/query/index.ts | 1 + packages/db/src/query/ir.ts | 6 +- .../query/live/collection-config-builder.ts | 8 + packages/db/tests/query/includes.test-d.ts | 127 +++++++++ packages/db/tests/query/includes.test.ts | 244 ++++++++++++++++++ 9 files changed, 486 insertions(+), 13 deletions(-) diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 9ab4e735d..7708c8bab 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -2,6 +2,7 @@ import { Aggregate, Func } from '../ir' import { toExpression } from './ref-proxy.js' import type { BasicExpression } from '../ir' import type { RefProxy } from './ref-proxy.js' +import type { SingleResult } from '../../types.js' import type { Context, GetRawResult, @@ -450,8 +451,58 @@ export class ConcatToArrayWrapper<_T = unknown> { constructor(public readonly query: QueryBuilder) {} } +export class MaterializeWrapper< + _T = unknown, + _IsSingle extends boolean = boolean, +> { + readonly __brand = `MaterializeWrapper` as const + declare readonly _type: `materialize` + declare readonly _result: _T + declare readonly _isSingle: _IsSingle + constructor(public readonly query: QueryBuilder) {} +} + export function toArray( query: QueryBuilder, ): ToArrayWrapper> { return new ToArrayWrapper(query) } + +/** + * Materialize an includes subquery into a plain value on the parent row. + * + * - For multi-row subqueries, the parent receives an `Array` snapshot + * (equivalent to `toArray()`). + * - For `findOne()` subqueries, the parent receives a single `T | undefined` + * value — `undefined` when no child matches. + * + * The snapshot updates reactively: parent rows re-emit when the underlying + * children change. + * + * @example + * ```ts + * // Multi-row: produces Array on each project + * select(({ p }) => ({ + * ...p, + * issues: materialize( + * q.from({ i: issues }).where(({ i }) => eq(i.projectId, p.id)), + * ), + * })) + * + * // Singleton: produces Author | undefined on each post + * select(({ p }) => ({ + * ...p, + * author: materialize( + * q.from({ a: authors }).where(({ a }) => eq(a.id, p.authorId)).findOne(), + * ), + * })) + * ``` + */ +export function materialize( + query: QueryBuilder, +): MaterializeWrapper< + GetRawResult, + TContext extends SingleResult ? true : false +> { + return new MaterializeWrapper(query) +} diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index 38acbbfbe..4fc9abce4 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -25,7 +25,11 @@ import { isRefProxy, toExpression, } from './ref-proxy.js' -import { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' +import { + ConcatToArrayWrapper, + MaterializeWrapper, + ToArrayWrapper, +} from './functions.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, @@ -920,6 +924,17 @@ function buildNestedSelect(obj: any, parentAliases: Array = []): any { out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`) continue } + if (v instanceof MaterializeWrapper) { + if (!(v.query instanceof BaseQueryBuilder)) { + throw new Error(`materialize() must wrap a subquery builder`) + } + const childQuery = v.query._getQuery() + const materialization: IncludesMaterialization = childQuery.singleResult + ? `singleton` + : `array` + out[k] = buildIncludesSubquery(v.query, k, parentAliases, materialization) + continue + } out[k] = buildNestedSelect(v, parentAliases) } return out diff --git a/packages/db/src/query/builder/ref-proxy.ts b/packages/db/src/query/builder/ref-proxy.ts index d7ea8d786..a24da8737 100644 --- a/packages/db/src/query/builder/ref-proxy.ts +++ b/packages/db/src/query/builder/ref-proxy.ts @@ -286,8 +286,8 @@ export function createRefProxyWithSelected>( /** * Converts a value to an Expression. * If it's a RefProxy, creates a PropRef. Throws if the value is a - * ToArrayWrapper or ConcatToArrayWrapper (these must be used as direct - * select fields). Otherwise wraps it as a Value. + * ToArrayWrapper, ConcatToArrayWrapper, or MaterializeWrapper (these must be + * used as direct select fields). Otherwise wraps it as a Value. */ export function toExpression(value: T): BasicExpression export function toExpression(value: RefProxy): BasicExpression @@ -295,15 +295,21 @@ export function toExpression(value: any): BasicExpression { if (isRefProxy(value)) { return new PropRef(value.__path) } - // toArray() and concat(toArray()) must be used as direct select fields, not inside expressions + // toArray(), concat(toArray()), and materialize() must be used as direct + // select fields, not inside expressions if ( value && typeof value === `object` && (value.__brand === `ToArrayWrapper` || - value.__brand === `ConcatToArrayWrapper`) + value.__brand === `ConcatToArrayWrapper` || + value.__brand === `MaterializeWrapper`) ) { const name = - value.__brand === `ToArrayWrapper` ? `toArray()` : `concat(toArray())` + value.__brand === `ToArrayWrapper` + ? `toArray()` + : value.__brand === `ConcatToArrayWrapper` + ? `concat(toArray())` + : `materialize()` throw new Error( `${name} cannot be used inside expressions (e.g., coalesce(), eq(), not()). ` + `Use ${name} directly as a select field value instead.`, diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 9416d10c9..4d25fdadf 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -10,7 +10,11 @@ import type { } from '../ir.js' import type { InitialQueryBuilder, QueryBuilder } from './index.js' import type { VirtualRowProps, WithVirtualProps } from '../../virtual-props.js' -import type { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' +import type { + ConcatToArrayWrapper, + MaterializeWrapper, + ToArrayWrapper, +} from './functions.js' /** * Context - The central state container for query builder operations @@ -182,6 +186,7 @@ type SelectValue = | Array> | ToArrayWrapper // toArray() wrapped subquery | ConcatToArrayWrapper // concat(toArray(...)) wrapped subquery + | MaterializeWrapper // materialize() wrapped subquery (Array or T | undefined) | QueryBuilder // includes subquery (produces a child Collection) // Recursive shape for select objects allowing nested projections @@ -234,8 +239,12 @@ export type ResultTypeFromSelectValue = ? Array : TSelectValue extends ConcatToArrayWrapper ? string - : TSelectValue extends QueryBuilder - ? Collection> + : TSelectValue extends MaterializeWrapper + ? IsSingle extends true + ? T | undefined + : Array + : TSelectValue extends QueryBuilder + ? Collection> : TSelectValue extends Ref ? ExtractRef : TSelectValue extends RefLeaf @@ -319,9 +328,17 @@ export type ResultTypeFromSelect = ? Array : TSelectObject[K] extends ConcatToArrayWrapper ? string - : // includes subquery (bare QueryBuilder) — produces a child Collection - TSelectObject[K] extends QueryBuilder - ? Collection> + : // materialize() — Array for multi-row, T | undefined for findOne() + TSelectObject[K] extends MaterializeWrapper< + infer T, + infer IsSingle + > + ? IsSingle extends true + ? T | undefined + : Array + : // includes subquery (bare QueryBuilder) — produces a child Collection + TSelectObject[K] extends QueryBuilder + ? Collection> : // Ref (full object ref or spread with RefBrand) - recursively process properties TSelectObject[K] extends Ref ? ExtractRef diff --git a/packages/db/src/query/index.ts b/packages/db/src/query/index.ts index 889202e5a..eba3d7820 100644 --- a/packages/db/src/query/index.ts +++ b/packages/db/src/query/index.ts @@ -62,6 +62,7 @@ export { max, // Includes helpers toArray, + materialize, } from './builder/functions.js' // Ref proxy utilities diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index a1d9d848e..c55666eb2 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -25,7 +25,11 @@ export interface QueryIR { fnHaving?: Array<(row: NamespacedRow) => any> } -export type IncludesMaterialization = `collection` | `array` | `concat` +export type IncludesMaterialization = + | `collection` + | `array` + | `singleton` + | `concat` export const INCLUDES_SCALAR_FIELD = `__includes_scalar__` diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 7353b2116..443f32283 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -1196,6 +1196,8 @@ function materializeIncludedValue( if (state.materialization === `concat`) { return `` } + // `singleton` and `collection` both fall through to undefined when no + // child entry exists for the parent's correlation key. return undefined } @@ -1212,6 +1214,12 @@ function materializeIncludedValue( return values } + if (state.materialization === `singleton`) { + // findOne() doesn't currently push LIMIT 1 to the IR, so the child + // Collection may hold more than one row; pick the first deterministically. + return values[0] + } + return values.map((value) => String(value ?? ``)).join(``) } diff --git a/packages/db/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index 48e36ba52..5cc733668 100644 --- a/packages/db/tests/query/includes.test-d.ts +++ b/packages/db/tests/query/includes.test-d.ts @@ -4,6 +4,7 @@ import { concat, createLiveQueryCollection, eq, + materialize, queryOnce, toArray, } from '../../src/query/index.js' @@ -421,4 +422,130 @@ describe(`includes subquery types`, () => { queryOnce({ query: scalarRootQuery }) }) }) + + describe(`materialize`, () => { + test(`materialize over a multi-row subquery infers Array`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(result.name).toEqualTypeOf() + expectTypeOf(result.issues).toMatchTypeOf< + Array> + >() + }) + + test(`materialize over a findOne() subquery infers T | undefined`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ i: issues }).select(({ i }) => ({ + id: i.id, + title: i.title, + project: materialize( + q + .from({ p: projects }) + .where(({ p }) => eq(p.id, i.projectId)) + .select(({ p }) => ({ + id: p.id, + name: p.name, + })) + .findOne(), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(result.title).toEqualTypeOf() + expectTypeOf(result.project).toMatchTypeOf< + WithVirtualProps<{ id: number; name: string }> | undefined + >() + }) + + test(`materialize without select on findOne() infers full row | undefined`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ i: issues }).select(({ i }) => ({ + id: i.id, + project: materialize( + q + .from({ p: projects }) + .where(({ p }) => eq(p.id, i.projectId)) + .findOne(), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.project).toMatchTypeOf< + WithVirtualProps | undefined + >() + }) + + test(`materialize over a scalar findOne() infers value | undefined`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + firstChunk: materialize( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text) + .findOne(), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.firstChunk).toEqualTypeOf() + }) + + test(`nested materialize: array of singletons`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + firstComment: materialize( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .select(({ c }) => ({ id: c.id, body: c.body })) + .findOne(), + ), + })), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result.issues[0]!).toMatchTypeOf< + WithVirtualProps<{ + id: number + title: string + firstComment: + | WithVirtualProps<{ id: number; body: string }> + | undefined + }> + >() + }) + }) }) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index a0b490bdf..2de46fcaa 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -6,6 +6,7 @@ import { count, createLiveQueryCollection, eq, + materialize, toArray, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' @@ -5124,4 +5125,247 @@ describe(`includes subqueries`, () => { expect(data().textDeltas).toHaveLength(2) }) }) + + describe(`materialize`, () => { + // For singleton behavior we look up each issue's parent project. + // Each issue references exactly one project via projectId. + function buildSingletonQuery() { + return createLiveQueryCollection((q) => + q.from({ i: issues }).select(({ i }) => ({ + id: i.id, + title: i.title, + project: materialize( + q + .from({ p: projects }) + .where(({ p }) => eq(p.id, i.projectId)) + .select(({ p }) => ({ + id: p.id, + name: p.name, + })) + .findOne(), + ), + })), + ) + } + + it(`findOne() materializes a single value, not an array`, async () => { + const collection = buildSingletonQuery() + await collection.preload() + + const bug = collection.get(10) as any + expect(Array.isArray(bug.project)).toBe(false) + expect(stripVirtualPropsDeep(bug.project)).toEqual({ + id: 1, + name: `Alpha`, + }) + + const betaBug = collection.get(20) as any + expect(stripVirtualPropsDeep(betaBug.project)).toEqual({ + id: 2, + name: `Beta`, + }) + }) + + it(`findOne() with no matching child yields undefined`, async () => { + // Issue referencing a non-existent project + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 99, projectId: 999, title: `Orphan issue` }, + }) + issues.utils.commit() + + const collection = buildSingletonQuery() + await collection.preload() + + const orphan = collection.get(99) as any + expect(orphan.project).toBeUndefined() + }) + + it(`inserting the matching child re-emits parent with populated singleton`, async () => { + // Start with an issue whose project doesn't exist yet + issues.utils.begin() + issues.utils.write({ + type: `insert`, + value: { id: 99, projectId: 999, title: `Orphan issue` }, + }) + issues.utils.commit() + + const collection = buildSingletonQuery() + await collection.preload() + + expect((collection.get(99) as any).project).toBeUndefined() + + // Now create the matching project + projects.utils.begin() + projects.utils.write({ + type: `insert`, + value: { id: 999, name: `Late Project` }, + }) + projects.utils.commit() + + expect(stripVirtualPropsDeep((collection.get(99) as any).project)).toEqual( + { id: 999, name: `Late Project` }, + ) + }) + + it(`updating the matching child re-emits parent with updated singleton`, async () => { + const collection = buildSingletonQuery() + await collection.preload() + + projects.utils.begin() + projects.utils.write({ + type: `update`, + value: { id: 1, name: `Renamed Alpha` }, + }) + projects.utils.commit() + + const bug = collection.get(10) as any + expect(stripVirtualPropsDeep(bug.project)).toEqual({ + id: 1, + name: `Renamed Alpha`, + }) + }) + + it(`deleting the matching child re-emits parent with undefined`, async () => { + const collection = buildSingletonQuery() + await collection.preload() + + expect((collection.get(20) as any).project).toBeDefined() + + projects.utils.begin() + projects.utils.write({ + type: `delete`, + value: sampleProjects.find((p) => p.id === 2)!, + }) + projects.utils.commit() + + expect((collection.get(20) as any).project).toBeUndefined() + }) + + it(`two parents sharing a correlation key both see the singleton`, async () => { + const collection = buildSingletonQuery() + await collection.preload() + + // Issues 10 and 11 both reference project 1 + const bug = collection.get(10) as any + const feature = collection.get(11) as any + expect(stripVirtualPropsDeep(bug.project)).toEqual({ + id: 1, + name: `Alpha`, + }) + expect(stripVirtualPropsDeep(feature.project)).toEqual({ + id: 1, + name: `Alpha`, + }) + + // Updating the shared project re-emits both parents + projects.utils.begin() + projects.utils.write({ + type: `update`, + value: { id: 1, name: `Alpha v2` }, + }) + projects.utils.commit() + + expect(stripVirtualPropsDeep((collection.get(10) as any).project)).toEqual( + { id: 1, name: `Alpha v2` }, + ) + expect(stripVirtualPropsDeep((collection.get(11) as any).project)).toEqual( + { id: 1, name: `Alpha v2` }, + ) + }) + + it(`materialize over a multi-row subquery still returns Array`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + })), + ), + })), + ) + await collection.preload() + + const alpha = collection.get(1) as any + expect(Array.isArray(alpha.issues)).toBe(true) + expect(sortedPlainRows(alpha.issues)).toEqual([ + { id: 10, title: `Bug in Alpha` }, + { id: 11, title: `Feature for Alpha` }, + ]) + + const gamma = collection.get(3) as any + expect(Array.isArray(gamma.issues)).toBe(true) + expect(plainRows(gamma.issues)).toEqual([]) + }) + + it(`materialize over a scalar findOne() returns the scalar value`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ i: issues }).select(({ i }) => ({ + id: i.id, + projectName: materialize( + q + .from({ p: projects }) + .where(({ p }) => eq(p.id, i.projectId)) + .select(({ p }) => p.name) + .findOne(), + ), + })), + ) + await collection.preload() + + expect((collection.get(10) as any).projectName).toBe(`Alpha`) + expect((collection.get(20) as any).projectName).toBe(`Beta`) + }) + + it(`nested materialize: array of issues with singleton first-comment lookup each`, async () => { + const collection = createLiveQueryCollection((q) => + q.from({ p: projects }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: materialize( + q + .from({ i: issues }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ + id: i.id, + title: i.title, + firstComment: materialize( + q + .from({ c: comments }) + .where(({ c }) => eq(c.issueId, i.id)) + .orderBy(({ c }) => c.id, `asc`) + .select(({ c }) => ({ id: c.id, body: c.body })) + .findOne(), + ), + })), + ), + })), + ) + await collection.preload() + + const alpha = collection.get(1) as any + const issuesArr = stripVirtualPropsDeep(alpha.issues).sort( + (a: any, b: any) => a.id - b.id, + ) + expect(issuesArr).toEqual([ + { + id: 10, + title: `Bug in Alpha`, + firstComment: { id: 100, body: `Looks bad` }, + }, + { + id: 11, + title: `Feature for Alpha`, + firstComment: undefined, + }, + ]) + }) + }) }) From 182a96846cab7985a903f597c6ed883b7097161b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 30 Apr 2026 12:41:05 +0000 Subject: [PATCH 2/2] ci: apply automated fixes --- packages/db/src/query/builder/types.ts | 157 ++++++++++++----------- packages/db/tests/query/includes.test.ts | 18 +-- 2 files changed, 89 insertions(+), 86 deletions(-) diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 4d25fdadf..e9e4ca136 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -245,38 +245,38 @@ export type ResultTypeFromSelectValue = : Array : TSelectValue extends QueryBuilder ? Collection> - : TSelectValue extends Ref - ? ExtractRef - : TSelectValue extends RefLeaf - ? IsNullableRef extends true - ? T | undefined - : T - : TSelectValue extends RefLeaf | undefined - ? T | undefined - : TSelectValue extends RefLeaf | null - ? IsNullableRef< - Exclude - > extends true - ? T | null | undefined - : T | null - : TSelectValue extends Ref | undefined - ? - | ExtractRef> - | undefined - : TSelectValue extends Ref | null - ? ExtractRef> | null - : TSelectValue extends Aggregate - ? T - : TSelectValue extends - | string - | number - | boolean - | null - | undefined - ? TSelectValue - : TSelectValue extends Record - ? ResultTypeFromSelect - : never + : TSelectValue extends Ref + ? ExtractRef + : TSelectValue extends RefLeaf + ? IsNullableRef extends true + ? T | undefined + : T + : TSelectValue extends RefLeaf | undefined + ? T | undefined + : TSelectValue extends RefLeaf | null + ? IsNullableRef< + Exclude + > extends true + ? T | null | undefined + : T | null + : TSelectValue extends Ref | undefined + ? + | ExtractRef> + | undefined + : TSelectValue extends Ref | null + ? ExtractRef> | null + : TSelectValue extends Aggregate + ? T + : TSelectValue extends + | string + | number + | boolean + | null + | undefined + ? TSelectValue + : TSelectValue extends Record + ? ResultTypeFromSelect + : never > /** @@ -330,57 +330,60 @@ export type ResultTypeFromSelect = ? string : // materialize() — Array for multi-row, T | undefined for findOne() TSelectObject[K] extends MaterializeWrapper< - infer T, - infer IsSingle - > + infer T, + infer IsSingle + > ? IsSingle extends true ? T | undefined : Array : // includes subquery (bare QueryBuilder) — produces a child Collection TSelectObject[K] extends QueryBuilder ? Collection> - : // Ref (full object ref or spread with RefBrand) - recursively process properties - TSelectObject[K] extends Ref - ? ExtractRef - : // RefLeaf (simple property ref like user.name) - TSelectObject[K] extends RefLeaf - ? IsNullableRef extends true - ? T | undefined - : T - : // RefLeaf | undefined (schema-optional field) - TSelectObject[K] extends RefLeaf | undefined - ? T | undefined - : // RefLeaf | null (schema-nullable field) - TSelectObject[K] extends RefLeaf | null - ? IsNullableRef< - Exclude - > extends true - ? T | null | undefined - : T | null - : // Ref | undefined (optional object-type schema field) - TSelectObject[K] extends Ref | undefined - ? - | ExtractRef< - Exclude - > - | 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 - ? ResultTypeFromSelect - : never + : // Ref (full object ref or spread with RefBrand) - recursively process properties + TSelectObject[K] extends Ref + ? ExtractRef + : // RefLeaf (simple property ref like user.name) + TSelectObject[K] extends RefLeaf + ? IsNullableRef extends true + ? T | undefined + : T + : // RefLeaf | undefined (schema-optional field) + TSelectObject[K] extends RefLeaf | undefined + ? T | undefined + : // RefLeaf | null (schema-nullable field) + TSelectObject[K] extends RefLeaf | null + ? IsNullableRef< + Exclude + > extends true + ? T | null | undefined + : T | null + : // Ref | undefined (optional object-type schema field) + TSelectObject[K] extends Ref | undefined + ? + | ExtractRef< + Exclude + > + | 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 }> > diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 2de46fcaa..283c334d4 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -5204,9 +5204,9 @@ describe(`includes subqueries`, () => { }) projects.utils.commit() - expect(stripVirtualPropsDeep((collection.get(99) as any).project)).toEqual( - { id: 999, name: `Late Project` }, - ) + expect( + stripVirtualPropsDeep((collection.get(99) as any).project), + ).toEqual({ id: 999, name: `Late Project` }) }) it(`updating the matching child re-emits parent with updated singleton`, async () => { @@ -5267,12 +5267,12 @@ describe(`includes subqueries`, () => { }) projects.utils.commit() - expect(stripVirtualPropsDeep((collection.get(10) as any).project)).toEqual( - { id: 1, name: `Alpha v2` }, - ) - expect(stripVirtualPropsDeep((collection.get(11) as any).project)).toEqual( - { id: 1, name: `Alpha v2` }, - ) + expect( + stripVirtualPropsDeep((collection.get(10) as any).project), + ).toEqual({ id: 1, name: `Alpha v2` }) + expect( + stripVirtualPropsDeep((collection.get(11) as any).project), + ).toEqual({ id: 1, name: `Alpha v2` }) }) it(`materialize over a multi-row subquery still returns Array`, async () => {