Skip to content

Commit e41ed7e

Browse files
samwillisKyleAMathewsclaude
authored
Refactor select improving spread (...obj) and enabling nested projection. (#389)
Co-authored-by: Kyle Mathews <mathews.kyle@gmail.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 31acdf2 commit e41ed7e

File tree

15 files changed

+1325
-577
lines changed

15 files changed

+1325
-577
lines changed

.changeset/quick-drinks-talk.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@tanstack/db": patch
3+
---
4+
5+
Refactored `select` improving spread (`...obj`) support and enabling nested projection.

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

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +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 { Ref } from "./types.js"
5+
import type { RefLeaf } from "./types.js"
66

7-
type StringRef = Ref<string> | Ref<string | null> | Ref<string | undefined>
7+
type StringRef =
8+
| RefLeaf<string>
9+
| RefLeaf<string | null>
10+
| RefLeaf<string | undefined>
811
type StringRefProxy =
912
| RefProxy<string>
1013
| RefProxy<string | null>
@@ -23,7 +26,7 @@ type StringLike =
2326

2427
type ComparisonOperand<T> =
2528
| RefProxy<T>
26-
| Ref<T>
29+
| RefLeaf<T>
2730
| T
2831
| BasicExpression<T>
2932
| undefined
@@ -35,13 +38,13 @@ type ComparisonOperandPrimitive<T extends string | number | boolean> =
3538
| null
3639

3740
// Helper type for any expression-like value
38-
type ExpressionLike = BasicExpression | RefProxy<any> | Ref<any> | any
41+
type ExpressionLike = BasicExpression | RefProxy<any> | RefLeaf<any> | any
3942

4043
// Helper type to extract the underlying type from various expression types
4144
type ExtractType<T> =
4245
T extends RefProxy<infer U>
4346
? U
44-
: T extends Ref<infer U>
47+
: T extends RefLeaf<infer U>
4548
? U
4649
: T extends BasicExpression<infer U>
4750
? U

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

Lines changed: 58 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
import { CollectionImpl } from "../../collection.js"
2-
import { CollectionRef, QueryRef } from "../ir.js"
2+
import {
3+
Aggregate as AggregateExpr,
4+
CollectionRef,
5+
Func as FuncExpr,
6+
PropRef,
7+
QueryRef,
8+
Value as ValueExpr,
9+
isExpressionLike,
10+
} from "../ir.js"
311
import {
412
InvalidSourceError,
513
JoinConditionMustBeEqualityError,
614
OnlyOneSourceAllowedError,
715
QueryMustHaveFromClauseError,
816
SubQueryMustHaveFromClauseError,
917
} from "../../errors.js"
10-
import { createRefProxy, isRefProxy, toExpression } from "./ref-proxy.js"
18+
import { createRefProxy, toExpression } from "./ref-proxy.js"
1119
import type { NamespacedRow } from "../../types.js"
1220
import type {
1321
Aggregate,
@@ -26,7 +34,7 @@ import type {
2634
MergeContextWithJoinType,
2735
OrderByCallback,
2836
OrderByOptions,
29-
RefProxyForContext,
37+
RefsForContext,
3038
ResultTypeFromSelect,
3139
SchemaFromSource,
3240
SelectObject,
@@ -152,7 +160,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
152160
// Create a temporary context for the callback
153161
const currentAliases = this._getCurrentAliases()
154162
const newAliases = [...currentAliases, alias]
155-
const refProxy = createRefProxy(newAliases) as RefProxyForContext<
163+
const refProxy = createRefProxy(newAliases) as RefsForContext<
156164
MergeContextForJoinCallback<TContext, SchemaFromSource<TSource>>
157165
>
158166

@@ -324,7 +332,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
324332
*/
325333
where(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
326334
const aliases = this._getCurrentAliases()
327-
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
335+
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
328336
const expression = callback(refProxy)
329337

330338
const existingWhere = this.query.where || []
@@ -365,7 +373,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
365373
*/
366374
having(callback: WhereCallback<TContext>): QueryBuilder<TContext> {
367375
const aliases = this._getCurrentAliases()
368-
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
376+
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
369377
const expression = callback(refProxy)
370378

371379
const existingHaving = this.query.having || []
@@ -411,43 +419,16 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
411419
* ```
412420
*/
413421
select<TSelectObject extends SelectObject>(
414-
callback: (refs: RefProxyForContext<TContext>) => TSelectObject
422+
callback: (refs: RefsForContext<TContext>) => TSelectObject
415423
): QueryBuilder<WithResult<TContext, ResultTypeFromSelect<TSelectObject>>> {
416424
const aliases = this._getCurrentAliases()
417-
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
425+
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
418426
const selectObject = callback(refProxy)
419-
420-
// Check if any tables were spread during the callback
421-
const spreadSentinels = (refProxy as any).__spreadSentinels as Set<string>
422-
423-
// Convert the select object to use expressions, including spread sentinels
424-
const select: Record<string, BasicExpression | Aggregate> = {}
425-
426-
// First, add spread sentinels for any tables that were spread
427-
for (const spreadAlias of spreadSentinels) {
428-
const sentinelKey = `__SPREAD_SENTINEL__${spreadAlias}`
429-
select[sentinelKey] = toExpression(spreadAlias) // Use alias as a simple reference
430-
}
431-
432-
// Then add the explicit select fields
433-
for (const [key, value] of Object.entries(selectObject)) {
434-
if (isRefProxy(value)) {
435-
select[key] = toExpression(value)
436-
} else if (
437-
typeof value === `object` &&
438-
value !== null &&
439-
`type` in value &&
440-
(value.type === `agg` || value.type === `func`)
441-
) {
442-
select[key] = value as BasicExpression | Aggregate
443-
} else {
444-
select[key] = toExpression(value)
445-
}
446-
}
427+
const select = buildNestedSelect(selectObject)
447428

448429
return new BaseQueryBuilder({
449430
...this.query,
450-
select,
431+
select: select,
451432
fnSelect: undefined, // remove the fnSelect clause if it exists
452433
}) as any
453434
}
@@ -483,7 +464,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
483464
options: OrderByDirection | OrderByOptions = `asc`
484465
): QueryBuilder<TContext> {
485466
const aliases = this._getCurrentAliases()
486-
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
467+
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
487468
const result = callback(refProxy)
488469

489470
const opts: CompareOptions =
@@ -551,7 +532,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
551532
*/
552533
groupBy(callback: GroupByCallback<TContext>): QueryBuilder<TContext> {
553534
const aliases = this._getCurrentAliases()
554-
const refProxy = createRefProxy(aliases) as RefProxyForContext<TContext>
535+
const refProxy = createRefProxy(aliases) as RefsForContext<TContext>
555536
const result = callback(refProxy)
556537

557538
const newExpressions = Array.isArray(result)
@@ -760,6 +741,43 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
760741
}
761742
}
762743

744+
// Helper to ensure we have a BasicExpression/Aggregate for a value
745+
function toExpr(value: any): BasicExpression | Aggregate {
746+
if (value === undefined) return toExpression(null)
747+
if (
748+
value instanceof AggregateExpr ||
749+
value instanceof FuncExpr ||
750+
value instanceof PropRef ||
751+
value instanceof ValueExpr
752+
) {
753+
return value as BasicExpression | Aggregate
754+
}
755+
return toExpression(value)
756+
}
757+
758+
function isPlainObject(value: any): value is Record<string, any> {
759+
return (
760+
value !== null &&
761+
typeof value === `object` &&
762+
!isExpressionLike(value) &&
763+
!value.__refProxy
764+
)
765+
}
766+
767+
function buildNestedSelect(obj: any): any {
768+
if (!isPlainObject(obj)) return toExpr(obj)
769+
const out: Record<string, any> = {}
770+
for (const [k, v] of Object.entries(obj)) {
771+
if (typeof k === `string` && k.startsWith(`__SPREAD_SENTINEL__`)) {
772+
// Preserve sentinel key and its value (value is unimportant at compile time)
773+
out[k] = v
774+
continue
775+
}
776+
out[k] = buildNestedSelect(v)
777+
}
778+
return out
779+
}
780+
763781
// Internal function to build a query from a callback
764782
// used by liveQueryCollectionOptions.query
765783
export function buildQuery<TContext extends Context>(
@@ -799,4 +817,4 @@ export type ExtractContext<T> =
799817
: never
800818

801819
// Export the types from types.ts for convenience
802-
export type { Context, Source, GetResult, Ref } from "./types.js"
820+
export type { Context, Source, GetResult, RefLeaf as Ref } from "./types.js"

packages/db/src/query/builder/ref-proxy.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { PropRef, Value } from "../ir.js"
22
import type { BasicExpression } from "../ir.js"
3-
import type { Ref } from "./types.js"
3+
import type { RefLeaf } from "./types.js"
44

55
export interface RefProxy<T = any> {
66
/** @internal */
@@ -20,7 +20,7 @@ export type SingleRowRefProxy<T> =
2020
? {
2121
[K in keyof T]: T[K] extends Record<string, any>
2222
? SingleRowRefProxy<T[K]> & RefProxy<T[K]>
23-
: Ref<T[K]>
23+
: RefLeaf<T[K]>
2424
} & RefProxy<T>
2525
: RefProxy<T>
2626

@@ -84,7 +84,7 @@ export function createRefProxy<T extends Record<string, any>>(
8484
aliases: Array<string>
8585
): RefProxy<T> & T {
8686
const cache = new Map<string, any>()
87-
const spreadSentinels = new Set<string>() // Track which aliases have been spread
87+
let accessId = 0 // Monotonic counter to record evaluation order
8888

8989
function createProxy(path: Array<string>): any {
9090
const pathKey = path.join(`.`)
@@ -110,10 +110,14 @@ export function createRefProxy<T extends Record<string, any>>(
110110
},
111111

112112
ownKeys(target) {
113-
// If this is a table-level proxy (path length 1), mark it as spread
114-
if (path.length === 1) {
115-
const aliasName = path[0]!
116-
spreadSentinels.add(aliasName)
113+
const id = ++accessId
114+
const sentinelKey = `__SPREAD_SENTINEL__${path.join(`.`)}__${id}`
115+
if (!Object.prototype.hasOwnProperty.call(target, sentinelKey)) {
116+
Object.defineProperty(target, sentinelKey, {
117+
enumerable: true,
118+
configurable: true,
119+
value: true,
120+
})
117121
}
118122
return Reflect.ownKeys(target)
119123
},
@@ -136,7 +140,6 @@ export function createRefProxy<T extends Record<string, any>>(
136140
if (prop === `__refProxy`) return true
137141
if (prop === `__path`) return []
138142
if (prop === `__type`) return undefined // Type is only for TypeScript inference
139-
if (prop === `__spreadSentinels`) return spreadSentinels // Expose spread sentinels
140143
if (typeof prop === `symbol`) return Reflect.get(target, prop, receiver)
141144

142145
const propStr = String(prop)
@@ -148,28 +151,18 @@ export function createRefProxy<T extends Record<string, any>>(
148151
},
149152

150153
has(target, prop) {
151-
if (
152-
prop === `__refProxy` ||
153-
prop === `__path` ||
154-
prop === `__type` ||
155-
prop === `__spreadSentinels`
156-
)
154+
if (prop === `__refProxy` || prop === `__path` || prop === `__type`)
157155
return true
158156
if (typeof prop === `string` && aliases.includes(prop)) return true
159157
return Reflect.has(target, prop)
160158
},
161159

162160
ownKeys(_target) {
163-
return [...aliases, `__refProxy`, `__path`, `__type`, `__spreadSentinels`]
161+
return [...aliases, `__refProxy`, `__path`, `__type`]
164162
},
165163

166164
getOwnPropertyDescriptor(target, prop) {
167-
if (
168-
prop === `__refProxy` ||
169-
prop === `__path` ||
170-
prop === `__type` ||
171-
prop === `__spreadSentinels`
172-
) {
165+
if (prop === `__refProxy` || prop === `__path` || prop === `__type`) {
173166
return { enumerable: false, configurable: true }
174167
}
175168
if (typeof prop === `string` && aliases.includes(prop)) {

0 commit comments

Comments
 (0)