Skip to content

Commit 745a93a

Browse files
feat: support concat(toArray(...)) scalar includes (#1384)
* feat: support concat over scalar toArray includes Add scalar child select materialization for toArray() and concat(toArray()) so includes can produce scalar arrays and concatenated strings without widening the supported root query surface. Made-with: Cursor * fix(db): tighten scalar select boundaries Reject unsupported top-level scalar selects at root consumers while preserving scalar subquery composability and limiting direct ref returns to alias-level spread semantics. Made-with: Cursor --------- Co-authored-by: Sam Willis <sam.willis@gmail.com>
1 parent 7626097 commit 745a93a

File tree

15 files changed

+884
-150
lines changed

15 files changed

+884
-150
lines changed

packages/db/src/errors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,15 @@ export class FnSelectWithGroupByError extends QueryCompilationError {
444444
}
445445
}
446446

447+
export class UnsupportedRootScalarSelectError extends QueryCompilationError {
448+
constructor() {
449+
super(
450+
`Top-level scalar select() is not supported by createLiveQueryCollection() or queryOnce(). ` +
451+
`Return an object from .select(), or use the scalar query inside toArray(...) or concat(toArray(...)).`,
452+
)
453+
}
454+
}
455+
447456
export class HavingRequiresGroupByError extends QueryCompilationError {
448457
constructor() {
449458
super(`HAVING clause requires GROUP BY clause`)

packages/db/src/query/builder/functions.ts

Lines changed: 49 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import { Aggregate, Func } from '../ir'
22
import { toExpression } from './ref-proxy.js'
33
import type { BasicExpression } from '../ir'
44
import type { RefProxy } from './ref-proxy.js'
5-
import type { Context, GetResult, RefLeaf } from './types.js'
5+
import type {
6+
Context,
7+
GetRawResult,
8+
RefLeaf,
9+
StringifiableScalar,
10+
} from './types.js'
611
import type { QueryBuilder } from './index.js'
712

813
type StringRef =
@@ -38,8 +43,20 @@ type ComparisonOperandPrimitive<T extends string | number | boolean> =
3843
| undefined
3944
| null
4045

41-
// Helper type for any expression-like value
42-
type ExpressionLike = BasicExpression | RefProxy<any> | RefLeaf<any> | any
46+
// Helper type for values that can be lowered to expressions.
47+
type ExpressionLike =
48+
| Aggregate
49+
| BasicExpression
50+
| RefProxy<any>
51+
| RefLeaf<any>
52+
| string
53+
| number
54+
| boolean
55+
| bigint
56+
| Date
57+
| null
58+
| undefined
59+
| Array<unknown>
4360

4461
// Helper type to extract the underlying type from various expression types
4562
type ExtractType<T> =
@@ -277,9 +294,26 @@ export function length<T extends ExpressionLike>(
277294
return new Func(`length`, [toExpression(arg)]) as NumericFunctionReturnType<T>
278295
}
279296

297+
export function concat<T extends StringifiableScalar>(
298+
arg: ToArrayWrapper<T>,
299+
): ConcatToArrayWrapper<T>
300+
export function concat(...args: Array<ExpressionLike>): BasicExpression<string>
280301
export function concat(
281-
...args: Array<ExpressionLike>
282-
): BasicExpression<string> {
302+
...args: Array<ExpressionLike | ToArrayWrapper<any>>
303+
): BasicExpression<string> | ConcatToArrayWrapper<any> {
304+
const toArrayArg = args.find(
305+
(arg): arg is ToArrayWrapper<any> => arg instanceof ToArrayWrapper,
306+
)
307+
308+
if (toArrayArg) {
309+
if (args.length !== 1) {
310+
throw new Error(
311+
`concat(toArray(...)) currently supports only a single toArray(...) argument`,
312+
)
313+
}
314+
return new ConcatToArrayWrapper(toArrayArg.query)
315+
}
316+
283317
return new Func(
284318
`concat`,
285319
args.map((arg) => toExpression(arg)),
@@ -402,13 +436,20 @@ export const operators = [
402436

403437
export type OperatorName = (typeof operators)[number]
404438

405-
export class ToArrayWrapper<T = any> {
406-
declare readonly _type: T
439+
export class ToArrayWrapper<_T = unknown> {
440+
declare readonly _type: `toArray`
441+
declare readonly _result: _T
442+
constructor(public readonly query: QueryBuilder<any>) {}
443+
}
444+
445+
export class ConcatToArrayWrapper<_T = unknown> {
446+
declare readonly _type: `concatToArray`
447+
declare readonly _result: _T
407448
constructor(public readonly query: QueryBuilder<any>) {}
408449
}
409450

410451
export function toArray<TContext extends Context>(
411452
query: QueryBuilder<TContext>,
412-
): ToArrayWrapper<GetResult<TContext>> {
453+
): ToArrayWrapper<GetRawResult<TContext>> {
413454
return new ToArrayWrapper(query)
414455
}

packages/db/src/query/builder/index.ts

Lines changed: 73 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
Aggregate as AggregateExpr,
44
CollectionRef,
55
Func as FuncExpr,
6+
INCLUDES_SCALAR_FIELD,
67
IncludesSubquery,
78
PropRef,
89
QueryRef,
@@ -24,11 +25,12 @@ import {
2425
isRefProxy,
2526
toExpression,
2627
} from './ref-proxy.js'
27-
import { ToArrayWrapper } from './functions.js'
28+
import { ConcatToArrayWrapper, ToArrayWrapper } from './functions.js'
2829
import type { NamespacedRow, SingleResult } from '../../types.js'
2930
import type {
3031
Aggregate,
3132
BasicExpression,
33+
IncludesMaterialization,
3234
JoinClause,
3335
OrderBy,
3436
OrderByDirection,
@@ -44,10 +46,13 @@ import type {
4446
JoinOnCallback,
4547
MergeContextForJoinCallback,
4648
MergeContextWithJoinType,
49+
NonScalarSelectObject,
4750
OrderByCallback,
4851
OrderByOptions,
4952
RefsForContext,
5053
ResultTypeFromSelect,
54+
ResultTypeFromSelectValue,
55+
ScalarSelectValue,
5156
SchemaFromSource,
5257
SelectObject,
5358
Source,
@@ -489,11 +494,29 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
489494
* ```
490495
*/
491496
select<TSelectObject extends SelectObject>(
492-
callback: (refs: RefsForContext<TContext>) => TSelectObject,
493-
): QueryBuilder<WithResult<TContext, ResultTypeFromSelect<TSelectObject>>> {
497+
callback: (
498+
refs: RefsForContext<TContext>,
499+
) => NonScalarSelectObject<TSelectObject>,
500+
): QueryBuilder<WithResult<TContext, ResultTypeFromSelect<TSelectObject>>>
501+
select<TSelectValue extends ScalarSelectValue>(
502+
callback: (refs: RefsForContext<TContext>) => TSelectValue,
503+
): QueryBuilder<WithResult<TContext, ResultTypeFromSelectValue<TSelectValue>>>
504+
select(
505+
callback: (
506+
refs: RefsForContext<TContext>,
507+
) => SelectObject | ScalarSelectValue,
508+
) {
494509
const aliases = this._getCurrentAliases()
495510
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
496-
const selectObject = callback(refProxy)
511+
let selectObject = callback(refProxy)
512+
513+
// Returning a top-level alias directly is equivalent to spreading it.
514+
// Leaf refs like `row.name` must remain scalar selections.
515+
if (isRefProxy(selectObject) && selectObject.__path.length === 1) {
516+
const sentinelKey = `__SPREAD_SENTINEL__${selectObject.__path[0]}__0`
517+
selectObject = { [sentinelKey]: true }
518+
}
519+
497520
const select = buildNestedSelect(selectObject, aliases)
498521

499522
return new BaseQueryBuilder({
@@ -679,7 +702,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
679702
* // Get countries our users are from
680703
* query
681704
* .from({ users: usersCollection })
682-
* .select(({users}) => users.country)
705+
* .select(({users}) => ({ country: users.country }))
683706
* .distinct()
684707
* ```
685708
*/
@@ -709,7 +732,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
709732
// TODO: enforcing return only one result with also a default orderBy if none is specified
710733
// limit: 1,
711734
singleResult: true,
712-
})
735+
}) as any
713736
}
714737

715738
// Helper methods
@@ -772,7 +795,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
772795
...builder.query,
773796
select: undefined, // remove the select clause if it exists
774797
fnSelect: callback,
775-
})
798+
}) as any
776799
},
777800
/**
778801
* Filter rows using a function that operates on each row
@@ -880,14 +903,21 @@ function buildNestedSelect(obj: any, parentAliases: Array<string> = []): any {
880903
continue
881904
}
882905
if (v instanceof BaseQueryBuilder) {
883-
out[k] = buildIncludesSubquery(v, k, parentAliases, false)
906+
out[k] = buildIncludesSubquery(v, k, parentAliases, `collection`)
884907
continue
885908
}
886909
if (v instanceof ToArrayWrapper) {
887910
if (!(v.query instanceof BaseQueryBuilder)) {
888911
throw new Error(`toArray() must wrap a subquery builder`)
889912
}
890-
out[k] = buildIncludesSubquery(v.query, k, parentAliases, true)
913+
out[k] = buildIncludesSubquery(v.query, k, parentAliases, `array`)
914+
continue
915+
}
916+
if (v instanceof ConcatToArrayWrapper) {
917+
if (!(v.query instanceof BaseQueryBuilder)) {
918+
throw new Error(`concat(toArray(...)) must wrap a subquery builder`)
919+
}
920+
out[k] = buildIncludesSubquery(v.query, k, parentAliases, `concat`)
891921
continue
892922
}
893923
out[k] = buildNestedSelect(v, parentAliases)
@@ -937,7 +967,7 @@ function buildIncludesSubquery(
937967
childBuilder: BaseQueryBuilder,
938968
fieldName: string,
939969
parentAliases: Array<string>,
940-
materializeAsArray: boolean,
970+
materialization: IncludesMaterialization,
941971
): IncludesSubquery {
942972
const childQuery = childBuilder._getQuery()
943973

@@ -1093,14 +1123,45 @@ function buildIncludesSubquery(
10931123
where: pureChildWhere.length > 0 ? pureChildWhere : undefined,
10941124
}
10951125

1126+
const rawChildSelect = modifiedQuery.select as any
1127+
const hasObjectSelect =
1128+
rawChildSelect === undefined || isPlainObject(rawChildSelect)
1129+
let includesQuery = modifiedQuery
1130+
let scalarField: string | undefined
1131+
1132+
if (materialization === `concat`) {
1133+
if (rawChildSelect === undefined || hasObjectSelect) {
1134+
throw new Error(
1135+
`concat(toArray(...)) for "${fieldName}" requires the subquery to select a scalar value`,
1136+
)
1137+
}
1138+
}
1139+
1140+
if (!hasObjectSelect) {
1141+
if (materialization === `collection`) {
1142+
throw new Error(
1143+
`Includes subquery for "${fieldName}" must select an object when materializing as a Collection`,
1144+
)
1145+
}
1146+
1147+
scalarField = INCLUDES_SCALAR_FIELD
1148+
includesQuery = {
1149+
...modifiedQuery,
1150+
select: {
1151+
[scalarField]: rawChildSelect,
1152+
},
1153+
}
1154+
}
1155+
10961156
return new IncludesSubquery(
1097-
modifiedQuery,
1157+
includesQuery,
10981158
parentRef,
10991159
childRef,
11001160
fieldName,
11011161
parentFilters.length > 0 ? parentFilters : undefined,
11021162
parentProjection,
1103-
materializeAsArray,
1163+
materialization,
1164+
scalarField,
11041165
)
11051166
}
11061167

0 commit comments

Comments
 (0)