Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions packages/db/src/query/builder/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -450,8 +451,58 @@ export class ConcatToArrayWrapper<_T = unknown> {
constructor(public readonly query: QueryBuilder<any>) {}
}

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<any>) {}
}

export function toArray<TContext extends Context>(
query: QueryBuilder<TContext>,
): ToArrayWrapper<GetRawResult<TContext>> {
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<T>` 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<Issue> 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<TContext extends Context>(
query: QueryBuilder<TContext>,
): MaterializeWrapper<
GetRawResult<TContext>,
TContext extends SingleResult ? true : false
> {
return new MaterializeWrapper(query)
}
17 changes: 16 additions & 1 deletion packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -920,6 +924,17 @@ function buildNestedSelect(obj: any, parentAliases: Array<string> = []): 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
Expand Down
16 changes: 11 additions & 5 deletions packages/db/src/query/builder/ref-proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,24 +286,30 @@ export function createRefProxyWithSelected<T extends Record<string, any>>(
/**
* 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<T = any>(value: T): BasicExpression<T>
export function toExpression(value: RefProxy<any>): BasicExpression<any>
export function toExpression(value: any): BasicExpression<any> {
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.`,
Expand Down
180 changes: 100 additions & 80 deletions packages/db/src/query/builder/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -182,6 +186,7 @@ type SelectValue =
| Array<RefLeaf<any>>
| ToArrayWrapper // toArray() wrapped subquery
| ConcatToArrayWrapper // concat(toArray(...)) wrapped subquery
| MaterializeWrapper // materialize() wrapped subquery (Array<T> or T | undefined)
| QueryBuilder<any> // includes subquery (produces a child Collection)

// Recursive shape for select objects allowing nested projections
Expand Down Expand Up @@ -234,40 +239,44 @@ export type ResultTypeFromSelectValue<TSelectValue> =
? Array<T>
: TSelectValue extends ConcatToArrayWrapper<any>
? string
: TSelectValue extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: TSelectValue extends Ref<infer _T>
? ExtractRef<TSelectValue>
: TSelectValue extends RefLeaf<infer T>
? IsNullableRef<TSelectValue> extends true
? T | undefined
: T
: TSelectValue extends RefLeaf<infer T> | undefined
? T | undefined
: TSelectValue extends RefLeaf<infer T> | null
? IsNullableRef<
Exclude<TSelectValue, null>
> extends true
? T | null | undefined
: T | null
: TSelectValue extends Ref<infer _T> | undefined
?
| ExtractRef<Exclude<TSelectValue, undefined>>
| undefined
: TSelectValue extends Ref<infer _T> | null
? ExtractRef<Exclude<TSelectValue, null>> | null
: TSelectValue extends Aggregate<infer T>
? T
: TSelectValue extends
| string
| number
| boolean
| null
| undefined
? TSelectValue
: TSelectValue extends Record<string, any>
? ResultTypeFromSelect<TSelectValue>
: never
: TSelectValue extends MaterializeWrapper<infer T, infer IsSingle>
? IsSingle extends true
? T | undefined
: Array<T>
: TSelectValue extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: TSelectValue extends Ref<infer _T>
? ExtractRef<TSelectValue>
: TSelectValue extends RefLeaf<infer T>
? IsNullableRef<TSelectValue> extends true
? T | undefined
: T
: TSelectValue extends RefLeaf<infer T> | undefined
? T | undefined
: TSelectValue extends RefLeaf<infer T> | null
? IsNullableRef<
Exclude<TSelectValue, null>
> extends true
? T | null | undefined
: T | null
: TSelectValue extends Ref<infer _T> | undefined
?
| ExtractRef<Exclude<TSelectValue, undefined>>
| undefined
: TSelectValue extends Ref<infer _T> | null
? ExtractRef<Exclude<TSelectValue, null>> | null
: TSelectValue extends Aggregate<infer T>
? T
: TSelectValue extends
| string
| number
| boolean
| null
| undefined
? TSelectValue
: TSelectValue extends Record<string, any>
? ResultTypeFromSelect<TSelectValue>
: never
>

/**
Expand Down Expand Up @@ -319,51 +328,62 @@ export type ResultTypeFromSelect<TSelectObject> =
? Array<T>
: TSelectObject[K] extends ConcatToArrayWrapper<any>
? string
: // includes subquery (bare QueryBuilder) — produces a child Collection
TSelectObject[K] extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: // Ref (full object ref or spread with RefBrand) - recursively process properties
TSelectObject[K] extends Ref<infer _T>
? ExtractRef<TSelectObject[K]>
: // RefLeaf (simple property ref like user.name)
TSelectObject[K] extends RefLeaf<infer T>
? IsNullableRef<TSelectObject[K]> extends true
? T | undefined
: T
: // RefLeaf | undefined (schema-optional field)
TSelectObject[K] extends RefLeaf<infer T> | undefined
? T | undefined
: // RefLeaf | null (schema-nullable field)
TSelectObject[K] extends RefLeaf<infer T> | null
? IsNullableRef<
Exclude<TSelectObject[K], null>
> extends true
? T | null | undefined
: T | null
: // Ref | undefined (optional object-type schema field)
TSelectObject[K] extends Ref<infer _T> | undefined
?
| ExtractRef<
Exclude<TSelectObject[K], undefined>
>
| undefined
: // Ref | null (nullable object-type schema field)
TSelectObject[K] extends Ref<infer _T> | null
? ExtractRef<
Exclude<TSelectObject[K], null>
> | null
: TSelectObject[K] extends Aggregate<infer T>
? T
: TSelectObject[K] extends
| string
| number
| boolean
| null
| undefined
? TSelectObject[K]
: TSelectObject[K] extends Record<string, any>
? ResultTypeFromSelect<TSelectObject[K]>
: never
: // materialize() — Array<T> for multi-row, T | undefined for findOne()
TSelectObject[K] extends MaterializeWrapper<
infer T,
infer IsSingle
>
? IsSingle extends true
? T | undefined
: Array<T>
: // includes subquery (bare QueryBuilder) — produces a child Collection
TSelectObject[K] extends QueryBuilder<infer TChildContext>
? Collection<GetResult<TChildContext>>
: // Ref (full object ref or spread with RefBrand) - recursively process properties
TSelectObject[K] extends Ref<infer _T>
? ExtractRef<TSelectObject[K]>
: // RefLeaf (simple property ref like user.name)
TSelectObject[K] extends RefLeaf<infer T>
? IsNullableRef<TSelectObject[K]> extends true
? T | undefined
: T
: // RefLeaf | undefined (schema-optional field)
TSelectObject[K] extends RefLeaf<infer T> | undefined
? T | undefined
: // RefLeaf | null (schema-nullable field)
TSelectObject[K] extends RefLeaf<infer T> | null
? IsNullableRef<
Exclude<TSelectObject[K], null>
> extends true
? T | null | undefined
: T | null
: // Ref | undefined (optional object-type schema field)
TSelectObject[K] extends Ref<infer _T> | undefined
?
| ExtractRef<
Exclude<TSelectObject[K], undefined>
>
| undefined
: // Ref | null (nullable object-type schema field)
TSelectObject[K] extends Ref<infer _T> | null
? ExtractRef<
Exclude<TSelectObject[K], null>
> | null
: TSelectObject[K] extends Aggregate<infer T>
? T
: TSelectObject[K] extends
| string
| number
| boolean
| null
| undefined
? TSelectObject[K]
: TSelectObject[K] extends Record<
string,
any
>
? ResultTypeFromSelect<TSelectObject[K]>
: never
}>
>

Expand Down
1 change: 1 addition & 0 deletions packages/db/src/query/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
max,
// Includes helpers
toArray,
materialize,
} from './builder/functions.js'

// Ref proxy utilities
Expand Down
6 changes: 5 additions & 1 deletion packages/db/src/query/ir.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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__`

Expand Down
8 changes: 8 additions & 0 deletions packages/db/src/query/live/collection-config-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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(``)
}

Expand Down
Loading
Loading