diff --git a/packages/db/src/query/builder/functions.ts b/packages/db/src/query/builder/functions.ts index 82b806192..cc4d7945b 100644 --- a/packages/db/src/query/builder/functions.ts +++ b/packages/db/src/query/builder/functions.ts @@ -2,7 +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 { Context, GetResult, RefLeaf } from './types.js' +import type { Context, GetRawResult, RefLeaf } from './types.js' import type { QueryBuilder } from './index.js' type StringRef = @@ -277,9 +277,24 @@ export function length( return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType } +export function concat(arg: ToArrayWrapper): ConcatToArrayWrapper +export function concat(...args: Array): BasicExpression export function concat( ...args: Array -): BasicExpression { +): BasicExpression | ConcatToArrayWrapper { + const toArrayArg = args.find( + (arg): arg is ToArrayWrapper => arg instanceof ToArrayWrapper, + ) + + if (toArrayArg) { + if (args.length !== 1) { + throw new Error( + `concat(toArray(...)) currently supports only a single toArray(...) argument`, + ) + } + return new ConcatToArrayWrapper(toArrayArg.query) + } + return new Func( `concat`, args.map((arg) => toExpression(arg)), @@ -378,13 +393,18 @@ export const operators = [ export type OperatorName = (typeof operators)[number] -export class ToArrayWrapper { - declare readonly _type: T +export class ToArrayWrapper<_T = any> { + declare readonly _type: `toArray` + constructor(public readonly query: QueryBuilder) {} +} + +export class ConcatToArrayWrapper<_T = any> { + declare readonly _type: `concatToArray` constructor(public readonly query: QueryBuilder) {} } export function toArray( query: QueryBuilder, -): ToArrayWrapper> { +): ToArrayWrapper> { return new ToArrayWrapper(query) } diff --git a/packages/db/src/query/builder/index.ts b/packages/db/src/query/builder/index.ts index ac1bc1582..e6904ace6 100644 --- a/packages/db/src/query/builder/index.ts +++ b/packages/db/src/query/builder/index.ts @@ -3,6 +3,7 @@ import { Aggregate as AggregateExpr, CollectionRef, Func as FuncExpr, + INCLUDES_SCALAR_FIELD, IncludesSubquery, PropRef, QueryRef, @@ -24,11 +25,12 @@ import { isRefProxy, toExpression, } from './ref-proxy.js' -import { ToArrayWrapper } from './functions.js' +import { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' import type { NamespacedRow, SingleResult } from '../../types.js' import type { Aggregate, BasicExpression, + IncludesMaterialization, JoinClause, OrderBy, OrderByDirection, @@ -44,11 +46,15 @@ import type { JoinOnCallback, MergeContextForJoinCallback, MergeContextWithJoinType, + NonScalarSelectObject, OrderByCallback, OrderByOptions, RefsForContext, ResultTypeFromSelect, + ResultTypeFromSelectValue, + ScalarSelectValue, SchemaFromSource, + SelectCallbackResult, SelectObject, Source, WhereCallback, @@ -489,8 +495,16 @@ export class BaseQueryBuilder { * ``` */ select( - callback: (refs: RefsForContext) => TSelectObject, - ): QueryBuilder>> { + callback: ( + refs: RefsForContext, + ) => NonScalarSelectObject, + ): QueryBuilder>> + select( + callback: (refs: RefsForContext) => TSelectValue, + ): QueryBuilder>> + select( + callback: (refs: RefsForContext) => SelectCallbackResult, + ): QueryBuilder> { const aliases = this._getCurrentAliases() const refProxy = createRefProxy(aliases) as RefsForContext const selectObject = callback(refProxy) @@ -709,7 +723,7 @@ export class BaseQueryBuilder { // TODO: enforcing return only one result with also a default orderBy if none is specified // limit: 1, singleResult: true, - }) + }) as any } // Helper methods @@ -772,7 +786,7 @@ export class BaseQueryBuilder { ...builder.query, select: undefined, // remove the select clause if it exists fnSelect: callback, - }) + }) as any }, /** * Filter rows using a function that operates on each row @@ -880,14 +894,21 @@ function buildNestedSelect(obj: any, parentAliases: Array = []): any { continue } if (v instanceof BaseQueryBuilder) { - out[k] = buildIncludesSubquery(v, k, parentAliases, false) + out[k] = buildIncludesSubquery(v, k, parentAliases, `collection`) continue } if (v instanceof ToArrayWrapper) { if (!(v.query instanceof BaseQueryBuilder)) { throw new Error(`toArray() must wrap a subquery builder`) } - out[k] = buildIncludesSubquery(v.query, k, parentAliases, true) + out[k] = buildIncludesSubquery(v.query, k, parentAliases, `array`) + continue + } + if (v instanceof ConcatToArrayWrapper) { + if (!(v.query instanceof BaseQueryBuilder)) { + throw new Error(`concat(toArray(...)) must wrap a subquery builder`) + } + out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`) continue } out[k] = buildNestedSelect(v, parentAliases) @@ -937,7 +958,7 @@ function buildIncludesSubquery( childBuilder: BaseQueryBuilder, fieldName: string, parentAliases: Array, - materializeAsArray: boolean, + materialization: IncludesMaterialization, ): IncludesSubquery { const childQuery = childBuilder._getQuery() @@ -1093,14 +1114,45 @@ function buildIncludesSubquery( where: pureChildWhere.length > 0 ? pureChildWhere : undefined, } + const rawChildSelect = modifiedQuery.select as any + const hasObjectSelect = + rawChildSelect === undefined || isPlainObject(rawChildSelect) + let includesQuery = modifiedQuery + let scalarField: string | undefined + + if (materialization === `concat`) { + if (rawChildSelect === undefined || hasObjectSelect) { + throw new Error( + `concat(toArray(...)) for "${fieldName}" requires the subquery to select a scalar value`, + ) + } + } + + if (!hasObjectSelect) { + if (materialization === `collection`) { + throw new Error( + `Includes subquery for "${fieldName}" must select an object when materializing as a Collection`, + ) + } + + scalarField = INCLUDES_SCALAR_FIELD + includesQuery = { + ...modifiedQuery, + select: { + [scalarField]: rawChildSelect, + }, + } + } + return new IncludesSubquery( - modifiedQuery, + includesQuery, parentRef, childRef, fieldName, parentFilters.length > 0 ? parentFilters : undefined, parentProjection, - materializeAsArray, + materialization, + scalarField, ) } diff --git a/packages/db/src/query/builder/types.ts b/packages/db/src/query/builder/types.ts index 4a8eea3a8..34076d88d 100644 --- a/packages/db/src/query/builder/types.ts +++ b/packages/db/src/query/builder/types.ts @@ -10,7 +10,7 @@ import type { } from '../ir.js' import type { QueryBuilder } from './index.js' import type { VirtualRowProps, WithVirtualProps } from '../../virtual-props.js' -import type { ToArrayWrapper } from './functions.js' +import type { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js' /** * Context - The central state container for query builder operations @@ -50,6 +50,8 @@ export interface Context { > // The result type after select (if select has been called) result?: any + // Whether select/fn.select has been called + hasResult?: true // Single result only (if findOne has been called) singleResult?: boolean } @@ -179,10 +181,21 @@ type SelectValue = | { [key: string]: SelectValue } | Array> | ToArrayWrapper // toArray() wrapped subquery + | ConcatToArrayWrapper // concat(toArray(...)) wrapped subquery | QueryBuilder // includes subquery (produces a child Collection) // Recursive shape for select objects allowing nested projections type SelectShape = { [key: string]: SelectValue | SelectShape } +export type ScalarSelectValue = + | BasicExpression + | Aggregate + | Ref + | RefLeaf + | string + | number + | boolean + | null + | undefined /** * SelectObject - Wrapper type for select clause objects @@ -192,6 +205,70 @@ type SelectShape = { [key: string]: SelectValue | SelectShape } * messages when invalid selections are attempted. */ export type SelectObject = T +export type SelectCallbackResult = SelectObject | ScalarSelectValue +type RefBrandKeys = typeof RefBrand | typeof NullableBrand +type HasNamedSelectKeys = + Exclude extends never ? false : true +type IsScalarSelectLike = T extends BasicExpression | Aggregate + ? true + : T extends string | number | boolean | null | undefined + ? true + : typeof RefBrand extends keyof T + ? HasNamedSelectKeys extends true + ? false + : true + : false +export type NonScalarSelectObject = T extends SelectObject + ? IsScalarSelectLike extends true + ? never + : T + : never + +export type ResultTypeFromSelectValue = + IsAny extends true + ? any + : WithoutRefBrand< + NeedsExtraction extends true + ? ExtractExpressionType + : TSelectValue extends ToArrayWrapper + ? Array + : TSelectValue extends ConcatToArrayWrapper + ? string + : 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 + > /** * ResultTypeFromSelect - Infers the result type from a select object @@ -229,53 +306,71 @@ export type SelectObject = T * { id: number, name: string, status: 'active', count: 42, profile: { bio: string } } * ``` */ -export type ResultTypeFromSelect = WithoutRefBrand< - Prettify<{ - [K in keyof TSelectObject]: NeedsExtraction extends true - ? ExtractExpressionType - : TSelectObject[K] extends ToArrayWrapper - ? 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> extends true - ? T | null | undefined - : T | null - : // Ref | undefined (optional object-type schema field) - TSelectObject[K] extends Ref | undefined - ? - | ExtractRef> - | undefined - : // Ref | null (nullable object-type schema field) - TSelectObject[K] extends Ref | null - ? ExtractRef> | null - : TSelectObject[K] extends Aggregate - ? T - : TSelectObject[K] extends - | string - | number - | boolean - | null - | undefined - ? TSelectObject[K] - : TSelectObject[K] extends Record - ? ResultTypeFromSelect - : never - }> -> +export type ResultTypeFromSelect = + IsAny extends true + ? any + : WithoutRefBrand< + Prettify<{ + [K in keyof TSelectObject]: NeedsExtraction< + TSelectObject[K] + > extends true + ? ExtractExpressionType + : TSelectObject[K] extends ToArrayWrapper + ? Array + : TSelectObject[K] extends ConcatToArrayWrapper + ? string + : // 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 + }> + > + +export type SelectResult = + IsPlainObject extends true + ? ResultTypeFromSelect + : ResultTypeFromSelectValue // Extract Ref or subobject with a spread or a Ref type ExtractRef = Prettify>> @@ -386,7 +481,7 @@ export type JoinOnCallback = ( * Example (no GROUP BY): `(row) => row.user.salary > 70000 && row.$selected.user_count > 2` */ export type FunctionalHavingRow = TContext[`schema`] & - (TContext[`result`] extends object ? { $selected: TContext[`result`] } : {}) + (TContext[`hasResult`] extends true ? { $selected: TContext[`result`] } : {}) /** * RefsForContext - Creates ref proxies for all tables/collections in a query context @@ -422,7 +517,7 @@ export type RefsForContext = { : // T is exactly undefined, exactly null, or neither optional nor nullable // Wrap in Ref as-is (includes exact undefined, exact null, and normal types) Ref -} & (TContext[`result`] extends object +} & (TContext[`hasResult`] extends true ? { $selected: Ref } : {}) @@ -586,7 +681,7 @@ type IsNullableRef = typeof NullableBrand extends keyof T ? true : false // Helper type to remove RefBrand and NullableBrand from objects type WithoutRefBrand = - T extends Record + IsPlainObject extends true ? Omit : T @@ -604,6 +699,10 @@ type PreserveSingleResultFlag = [TFlag] extends [true] ? { singleResult: true } : {} +type PreserveHasResultFlag = [TFlag] extends [true] + ? { hasResult: true } + : {} + /** * MergeContextWithJoinType - Creates a new context after a join operation * @@ -649,7 +748,8 @@ export type MergeContextWithJoinType< [K in keyof TNewSchema & string]: TJoinType } result: TContext[`result`] -} & PreserveSingleResultFlag +} & PreserveSingleResultFlag & + PreserveHasResultFlag /** * ApplyJoinOptionalityToMergedSchema - Applies optionality rules when merging schemas @@ -711,6 +811,13 @@ type WithVirtualPropsIfObject = TResult extends object ? WithVirtualProps : TResult +type PrettifyIfPlainObject = IsPlainObject extends true ? Prettify : T +type ResultValue = TContext[`hasResult`] extends true + ? WithVirtualPropsIfObject + : TContext[`hasJoins`] extends true + ? TContext[`schema`] + : TContext[`schema`][TContext[`fromSourceName`]] + /** * GetResult - Determines the final result type of a query * @@ -736,14 +843,10 @@ type WithVirtualPropsIfObject = TResult extends object * The `Prettify` wrapper ensures clean type display in IDEs by flattening * complex intersection types into readable object types. */ +export type GetRawResult = ResultValue + export type GetResult = Prettify< - TContext[`result`] extends object - ? WithVirtualPropsIfObject - : TContext[`hasJoins`] extends true - ? // Optionality is already applied in the schema, just return it - TContext[`schema`] - : // Single table query - return the specific table - TContext[`schema`][TContext[`fromSourceName`]] + ResultValue > /** @@ -878,7 +981,7 @@ export type MergeContextForJoinCallback< ? TContext[`joinTypes`] : {} result: TContext[`result`] -} +} & PreserveHasResultFlag /** * WithResult - Updates a context with a new result type after select() @@ -895,8 +998,9 @@ export type MergeContextForJoinCallback< * result type display cleanly in IDEs. */ export type WithResult = Prettify< - Omit & { - result: Prettify + Omit & { + result: PrettifyIfPlainObject + hasResult: true } > @@ -920,6 +1024,8 @@ type IsPlainObject = T extends unknown : false : false +type IsAny = 0 extends 1 & T ? true : false + /** * JsBuiltIns - List of JavaScript built-ins */ diff --git a/packages/db/src/query/compiler/index.ts b/packages/db/src/query/compiler/index.ts index 707f3add2..70786ca8d 100644 --- a/packages/db/src/query/compiler/index.ts +++ b/packages/db/src/query/compiler/index.ts @@ -32,6 +32,7 @@ import type { OrderByOptimizationInfo } from './order-by.js' import type { BasicExpression, CollectionRef, + IncludesMaterialization, QueryIR, QueryRef, } from '../ir.js' @@ -68,8 +69,10 @@ export interface IncludesCompilationResult { childCompilationResult: CompilationResult /** Parent-side projection refs for parent-referencing filters */ parentProjection?: Array - /** When true, the output layer materializes children as Array instead of Collection */ - materializeAsArray: boolean + /** How the output layer materializes the child result on the parent row */ + materialization: IncludesMaterialization + /** Internal field used to unwrap scalar child selects */ + scalarField?: string } /** @@ -418,7 +421,8 @@ export function compileQuery( ), childCompilationResult: childResult, parentProjection: subquery.parentProjection, - materializeAsArray: subquery.materializeAsArray, + materialization: subquery.materialization, + scalarField: subquery.scalarField, }) // Capture routing function for INCLUDES_ROUTING tagging diff --git a/packages/db/src/query/ir.ts b/packages/db/src/query/ir.ts index c115974b2..a1d9d848e 100644 --- a/packages/db/src/query/ir.ts +++ b/packages/db/src/query/ir.ts @@ -25,6 +25,10 @@ export interface QueryIR { fnHaving?: Array<(row: NamespacedRow) => any> } +export type IncludesMaterialization = `collection` | `array` | `concat` + +export const INCLUDES_SCALAR_FIELD = `__includes_scalar__` + export type From = CollectionRef | QueryRef export type Select = { @@ -141,7 +145,8 @@ export class IncludesSubquery extends BaseExpression { public fieldName: string, // Result field name (e.g., "issues") public parentFilters?: Array, // WHERE clauses referencing parent aliases (applied post-join) public parentProjection?: Array, // Parent field refs used by parentFilters - public materializeAsArray: boolean = false, // When true, parent gets Array instead of Collection + public materialization: IncludesMaterialization = `collection`, + public scalarField?: string, ) { super() } diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 47eceac15..bf9e7446c 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -17,6 +17,19 @@ import type { } from '../types.js' import type { Context, GetResult } from './builder/types.js' +type IsExactlyContext = [Context] extends [TContext] + ? [TContext] extends [Context] + ? true + : false + : false + +type QueryResultObject = + IsExactlyContext extends true + ? any + : GetResult extends object + ? GetResult + : any + type CollectionConfigForContext< TContext extends Context, TResult extends object, @@ -61,7 +74,7 @@ type CollectionForContext< */ export function liveQueryCollectionOptions< TContext extends Context, - TResult extends object = GetResult, + TResult extends object = QueryResultObject, >( config: LiveQueryCollectionConfig, ): CollectionConfigForContext & { @@ -112,30 +125,28 @@ export function liveQueryCollectionOptions< */ // Overload 1: Accept just the query function -export function createLiveQueryCollection< - TContext extends Context, - TResult extends object = GetResult, ->( +export function createLiveQueryCollection( query: (q: InitialQueryBuilder) => QueryBuilder, -): CollectionForContext & { +): CollectionForContext> & { utils: LiveQueryCollectionUtils } // Overload 2: Accept full config object with optional utilities export function createLiveQueryCollection< TContext extends Context, - TResult extends object = GetResult, TUtils extends UtilsRecord = {}, >( - config: LiveQueryCollectionConfig & { utils?: TUtils }, -): CollectionForContext & { + config: LiveQueryCollectionConfig> & { + utils?: TUtils + }, +): CollectionForContext> & { utils: LiveQueryCollectionUtils & TUtils } // Implementation export function createLiveQueryCollection< TContext extends Context, - TResult extends object = GetResult, + TResult extends object = QueryResultObject, TUtils extends UtilsRecord = {}, >( configOrQuery: diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 8ac7307cd..869ef649b 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -36,7 +36,12 @@ import type { UtilsRecord, } from '../../types.js' import type { Context, GetResult } from '../builder/types.js' -import type { BasicExpression, PropRef, QueryIR } from '../ir.js' +import type { + BasicExpression, + IncludesMaterialization, + PropRef, + QueryIR, +} from '../ir.js' import type { LazyCollectionCallbacks } from '../compiler/joins.js' import type { Changes, @@ -825,7 +830,8 @@ export class CollectionConfigBuilder< fieldName: entry.fieldName, childCorrelationField: entry.childCorrelationField, hasOrderBy: entry.hasOrderBy, - materializeAsArray: entry.materializeAsArray, + materialization: entry.materialization, + scalarField: entry.scalarField, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1144,8 +1150,10 @@ type IncludesOutputState = { childCorrelationField: PropRef /** Whether the child query has an ORDER BY clause */ hasOrderBy: boolean - /** When true, parent gets Array instead of Collection */ - materializeAsArray: boolean + /** How the child result is materialized on the parent row */ + materialization: IncludesMaterialization + /** Internal field used to unwrap scalar child selects */ + scalarField?: string /** Maps correlation key value → child Collection entry */ childRegistry: Map /** Pending child changes: correlationKey → Map */ @@ -1169,6 +1177,40 @@ type ChildCollectionEntry = { includesStates?: Array } +function materializesInline(state: IncludesOutputState): boolean { + return state.materialization !== `collection` +} + +function materializeIncludedValue( + state: IncludesOutputState, + entry: ChildCollectionEntry | undefined, +): unknown { + if (!entry) { + if (state.materialization === `array`) { + return [] + } + if (state.materialization === `concat`) { + return `` + } + return undefined + } + + if (state.materialization === `collection`) { + return entry.collection + } + + const rows = [...entry.collection.toArray] + const values = state.scalarField + ? rows.map((row) => row?.[state.scalarField!]) + : rows + + if (state.materialization === `array`) { + return values + } + + return values.map((value) => String(value ?? ``)).join(``) +} + /** * Sets up shared buffers for nested includes pipelines. * Instead of writing directly into a single shared IncludesOutputState, @@ -1252,7 +1294,8 @@ function createPerEntryIncludesStates( fieldName: setup.compilationResult.fieldName, childCorrelationField: setup.compilationResult.childCorrelationField, hasOrderBy: setup.compilationResult.hasOrderBy, - materializeAsArray: setup.compilationResult.materializeAsArray, + materialization: setup.compilationResult.materialization, + scalarField: setup.compilationResult.scalarField, childRegistry: new Map(), pendingChildChanges: new Map(), correlationToParentKeys: new Map(), @@ -1535,15 +1578,11 @@ function flushIncludesState( } parentKeys.add(parentKey) - // Attach child Collection (or array snapshot for toArray) to the parent result - const childValue = state.materializeAsArray - ? [...state.childRegistry.get(routingKey)!.collection.toArray] - : state.childRegistry.get(routingKey)!.collection - if (state.materializeAsArray) { - parentResult[state.fieldName] = childValue - } else { - parentResult[state.fieldName] = childValue - } + const childValue = materializeIncludedValue( + state, + state.childRegistry.get(routingKey), + ) + parentResult[state.fieldName] = childValue // Parent rows may already be materialized in the live collection by the // time includes state is flushed, so update the stored row as well. @@ -1556,8 +1595,8 @@ function flushIncludesState( } } - // Track affected correlation keys for toArray re-emit (before clearing pendingChildChanges) - const affectedCorrelationKeys = state.materializeAsArray + // Track affected correlation keys for inline materializations before clearing child changes. + const affectedCorrelationKeys = materializesInline(state) ? new Set(state.pendingChildChanges.keys()) : null @@ -1582,9 +1621,7 @@ function flushIncludesState( state.childRegistry.set(correlationKey, entry) } - // For non-toArray: attach the child Collection to ANY parent that has this correlation key - // For toArray: skip — the array snapshot is set during re-emit below - if (!state.materializeAsArray) { + if (state.materialization === `collection`) { attachChildCollectionToParent( parentCollection, state.fieldName, @@ -1658,18 +1695,18 @@ function flushIncludesState( } } - // For toArray entries: re-emit affected parents with updated array snapshots. + // For inline materializations: re-emit affected parents with updated snapshots. // We mutate items in-place (so collection.get() reflects changes immediately) // and emit UPDATE events directly. We bypass the sync methods because // commitPendingTransactions compares previous vs new visible state using // deepEquals, but in-place mutation means both sides reference the same // object, so the comparison always returns true and suppresses the event. - const toArrayReEmitKeys = state.materializeAsArray + const inlineReEmitKeys = materializesInline(state) ? new Set([...(affectedCorrelationKeys || []), ...dirtyFromBuffers]) : null - if (parentSyncMethods && toArrayReEmitKeys && toArrayReEmitKeys.size > 0) { + if (parentSyncMethods && inlineReEmitKeys && inlineReEmitKeys.size > 0) { const events: Array> = [] - for (const correlationKey of toArrayReEmitKeys) { + for (const correlationKey of inlineReEmitKeys) { const parentKeys = state.correlationToParentKeys.get(correlationKey) if (!parentKeys) continue const entry = state.childRegistry.get(correlationKey) @@ -1679,9 +1716,7 @@ function flushIncludesState( const key = parentSyncMethods.collection.getKeyFromItem(item) // Capture previous value before in-place mutation const previousValue = { ...item } - if (entry) { - item[state.fieldName] = [...entry.collection.toArray] - } + item[state.fieldName] = materializeIncludedValue(state, entry) events.push({ type: `update`, key, diff --git a/packages/db/tests/query/builder/functions.test.ts b/packages/db/tests/query/builder/functions.test.ts index fb6cb4f35..946cf8a3e 100644 --- a/packages/db/tests/query/builder/functions.test.ts +++ b/packages/db/tests/query/builder/functions.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' import { CollectionImpl } from '../../../src/collection/index.js' import { Query, getQueryIR } from '../../../src/query/builder/index.js' +import { + INCLUDES_SCALAR_FIELD, + IncludesSubquery, +} from '../../../src/query/ir.js' import { add, and, @@ -22,6 +26,7 @@ import { not, or, sum, + toArray, upper, } from '../../../src/query/builder/functions.js' @@ -191,6 +196,36 @@ describe(`QueryBuilder Functions`, () => { expect((select.full_name as any).name).toBe(`concat`) }) + it(`concat(toArray(subquery)) lowers to concat includes materialization`, () => { + const query = new Query() + .from({ manager: employeesCollection }) + .select(({ manager }) => ({ + report_names: concat( + toArray( + new Query() + .from({ employee: employeesCollection }) + .where(({ employee }) => eq(employee.department_id, manager.id)) + .select(({ employee }) => employee.name), + ), + ), + })) + + const builtQuery = getQueryIR(query) + const select = builtQuery.select! + const reportNames = select.report_names + + expect(reportNames).toBeInstanceOf(IncludesSubquery) + expect((reportNames as IncludesSubquery).materialization).toBe(`concat`) + expect((reportNames as IncludesSubquery).scalarField).toBe( + INCLUDES_SCALAR_FIELD, + ) + expect((reportNames as IncludesSubquery).query.select).toEqual({ + [INCLUDES_SCALAR_FIELD]: expect.objectContaining({ + type: `ref`, + }), + }) + }) + it(`coalesce function works`, () => { const query = new Query() .from({ employees: employeesCollection }) diff --git a/packages/db/tests/query/includes.test-d.ts b/packages/db/tests/query/includes.test-d.ts index 7d5b97f3f..32b4919c4 100644 --- a/packages/db/tests/query/includes.test-d.ts +++ b/packages/db/tests/query/includes.test-d.ts @@ -1,5 +1,6 @@ import { describe, expectTypeOf, test } from 'vitest' import { + concat, createLiveQueryCollection, eq, toArray, @@ -25,6 +26,18 @@ type Comment = { body: string } +type Message = { + id: number + role: string +} + +type Chunk = { + id: number + messageId: number + text: string + timestamp: number +} + function createProjectsCollection() { return createCollection( mockSyncCollectionOptions({ @@ -55,10 +68,32 @@ function createCommentsCollection() { ) } +function createMessagesCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-type-messages`, + getKey: (m) => m.id, + initialData: [], + }), + ) +} + +function createChunksCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-type-chunks`, + getKey: (c) => c.id, + initialData: [], + }), + ) +} + describe(`includes subquery types`, () => { const projects = createProjectsCollection() const issues = createIssuesCollection() const comments = createCommentsCollection() + const messages = createMessagesCollection() + const chunks = createChunksCollection() describe(`Collection includes`, () => { test(`includes with select infers child result as Collection`, () => { @@ -265,5 +300,50 @@ describe(`includes subquery types`, () => { }> >() }) + + test(`toArray supports scalar child subquery selects`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + contentParts: toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + })), + ) + + const result = collection.toArray[0]! + expectTypeOf(result).toMatchTypeOf< + WithVirtualProps<{ + id: number + contentParts: Array + }> + >() + }) + + test(`concat(toArray(scalar subquery)) infers string`, () => { + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + content: concat( + toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + ), + })), + ) + + const result = collection.toArray[0]! + const content: string = result.content + expectTypeOf(result.id).toEqualTypeOf() + expectTypeOf(content).toEqualTypeOf() + }) }) }) diff --git a/packages/db/tests/query/includes.test.ts b/packages/db/tests/query/includes.test.ts index 99c61907a..fa6dbe416 100644 --- a/packages/db/tests/query/includes.test.ts +++ b/packages/db/tests/query/includes.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { and, + concat, count, createLiveQueryCollection, eq, @@ -158,6 +159,126 @@ describe(`includes subqueries`, () => { ) } + describe(`scalar includes materialization`, () => { + type Message = { + id: number + role: string + } + + type Chunk = { + id: number + messageId: number + text: string + timestamp: number + } + + const sampleMessages: Array = [ + { id: 1, role: `assistant` }, + { id: 2, role: `user` }, + ] + + const sampleChunks: Array = [ + { id: 10, messageId: 1, text: `world`, timestamp: 3 }, + { id: 11, messageId: 1, text: `Hello`, timestamp: 1 }, + { id: 12, messageId: 1, text: ` `, timestamp: 2 }, + { id: 20, messageId: 2, text: `Question`, timestamp: 1 }, + ] + + function createMessagesCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-messages`, + getKey: (message) => message.id, + initialData: sampleMessages, + }), + ) + } + + function createChunksCollection() { + return createCollection( + mockSyncCollectionOptions({ + id: `includes-chunks`, + getKey: (chunk) => chunk.id, + initialData: sampleChunks, + }), + ) + } + + it(`toArray unwraps scalar child selects into scalar arrays`, async () => { + const messages = createMessagesCollection() + const chunks = createChunksCollection() + + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + contentParts: toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + })), + ) + + await collection.preload() + + expect((collection.get(1) as any).contentParts).toEqual([ + `Hello`, + ` `, + `world`, + ]) + expect((collection.get(2) as any).contentParts).toEqual([`Question`]) + }) + + it(`concat(toArray(subquery.select(...))) materializes and re-emits a string`, async () => { + const messages = createMessagesCollection() + const chunks = createChunksCollection() + + const collection = createLiveQueryCollection((q) => + q.from({ m: messages }).select(({ m }) => ({ + id: m.id, + role: m.role, + content: concat( + toArray( + q + .from({ c: chunks }) + .where(({ c }) => eq(c.messageId, m.id)) + .orderBy(({ c }) => c.timestamp) + .select(({ c }) => c.text), + ), + ), + })), + ) + + await collection.preload() + + expect((collection.get(1) as any).content).toBe(`Hello world`) + expect((collection.get(2) as any).content).toBe(`Question`) + + const changeCallback = vi.fn() + const subscription = collection.subscribeChanges(changeCallback, { + includeInitialState: false, + }) + changeCallback.mockClear() + + chunks.utils.begin() + chunks.utils.write({ + type: `insert`, + value: { id: 13, messageId: 1, text: `!`, timestamp: 4 }, + }) + chunks.utils.commit() + + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(changeCallback).toHaveBeenCalled() + expect((collection.get(1) as any).content).toBe(`Hello world!`) + expect((collection.get(2) as any).content).toBe(`Question`) + + subscription.unsubscribe() + }) + }) + describe(`basic includes`, () => { it(`produces child Collections on parent rows`, async () => { const collection = buildIncludesQuery()