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
5 changes: 5 additions & 0 deletions .changeset/fix-reused-subquery-placement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Give each placement of a reused subquery builder an independent source identity so self-joins produce the correct rows.
137 changes: 137 additions & 0 deletions packages/db/src/query/builder/clone-query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {
CollectionRef,
ConditionalSelect,
IncludesSubquery,
QueryRef,
UnionAll,
UnionFrom,
isExpressionLike,
} from '../ir.js'
import type { From, QueryIR, Select, SelectValueExpression } from '../ir.js'

/**
* Gives every query-source placement its own runtime source identities.
*
* A reused builder describes the same query meaning, but each FROM, JOIN,
* UNION, or include placement owns an independent position in the dataflow
* graph. Expressions are immutable and can remain shared; CollectionRefs
* cannot because their sourceId identifies that lexical position.
*/
export function cloneQueryForPlacement(query: QueryIR): QueryIR {
return cloneQuery(query, new WeakMap())
}

function cloneQuery(query: QueryIR, clones: WeakMap<object, object>): QueryIR {
const existing = clones.get(query)
if (existing) return existing as QueryIR

const cloned: QueryIR = {
...query,
}
clones.set(query, cloned)

cloned.from = cloneFromForPlacement(query.from, clones)
cloned.join = query.join?.map((join) => ({
...join,
from: cloneSourceForPlacement(join.from, clones),
}))
cloned.select = query.select
? cloneSelectForPlacement(query.select, clones)
: undefined
return cloned
}

function cloneFromForPlacement(
from: From,
clones: WeakMap<object, object>,
): From {
if (from.type === `unionFrom`) {
return new UnionFrom(
from.sources.map((source) => cloneSourceForPlacement(source, clones)),
)
}

if (from.type === `unionAll`) {
return new UnionAll(from.queries.map((query) => cloneQuery(query, clones)))
}

return cloneSourceForPlacement(from, clones)
}

function cloneSourceForPlacement(
source: CollectionRef | QueryRef,
clones: WeakMap<object, object>,
): CollectionRef | QueryRef {
if (source.type === `collectionRef`) {
return new CollectionRef(source.collection, source.alias)
}

return new QueryRef(cloneQuery(source.query, clones), source.alias)
}

function cloneSelectForPlacement(
select: Select,
clones: WeakMap<object, object>,
): Select {
const existing = clones.get(select)
if (existing) return existing as Select

const cloned: Select = {}
clones.set(select, cloned)
for (const [field, value] of Object.entries(select)) {
cloned[field] = cloneSelectValueForPlacement(value, clones)
}
return cloned
}

function cloneSelectValueForPlacement(
value: unknown,
clones: WeakMap<object, object>,
): SelectValueExpression {
if (value instanceof IncludesSubquery) {
const existing = clones.get(value)
if (existing) return existing as IncludesSubquery

const cloned = new IncludesSubquery(
cloneQuery(value.query, clones),
value.correlationField,
value.childCorrelationField,
value.fieldName,
value.parentFilters,
value.parentProjection,
value.materialization,
value.scalarField,
)
clones.set(value, cloned)
return cloned
}

if (value instanceof ConditionalSelect) {
const existing = clones.get(value)
if (existing) return existing as ConditionalSelect

const cloned = new ConditionalSelect(
value.branches.map((branch) => ({
...branch,
value: cloneSelectValueForPlacement(branch.value, clones),
})),
value.defaultValue !== undefined
? cloneSelectValueForPlacement(value.defaultValue, clones)
: undefined,
)
clones.set(value, cloned)
return cloned
}

if (value === null || typeof value !== `object` || Array.isArray(value)) {
return value as SelectValueExpression
}

if ((value as { __refProxy?: boolean }).__refProxy === true) {
return value as SelectValueExpression
}

return isExpressionLike(value)
? (value as SelectValueExpression)
: cloneSelectForPlacement(value as Select, clones)
}
5 changes: 3 additions & 2 deletions packages/db/src/query/builder/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
SubQueryMustHaveFromClauseError,
} from '../../errors.js'
import { getQueryIR } from './query-ir.js'
import { cloneQueryForPlacement } from './clone-query.js'
import {
createRefProxy,
createRefProxyWithSelected,
Expand Down Expand Up @@ -215,7 +216,7 @@ export class BaseQueryBuilder<TContext extends Context = Context> {
}
ref = new CollectionRef(this.resolveCollection(sourceValue), alias)
} else if (sourceValue instanceof BaseQueryBuilder) {
const subQuery = sourceValue._getQuery()
const subQuery = cloneQueryForPlacement(sourceValue._getQuery())
if (!(subQuery as Partial<QueryIR>).from) {
throw new SubQueryMustHaveFromClauseError(context)
}
Expand Down Expand Up @@ -1370,7 +1371,7 @@ function buildIncludesSubquery(
parentAliases: Array<string>,
materialization: IncludesMaterialization,
): IncludesSubquery {
const childQuery = childBuilder._getQuery()
const childQuery = cloneQueryForPlacement(childBuilder._getQuery())

// Collect child's own aliases
const childAliases = collectQueryAliases(childQuery)
Expand Down
48 changes: 48 additions & 0 deletions packages/db/tests/query/join-subquery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,6 +980,54 @@ function createJoinSubqueryTests(autoIndex: `off` | `eager`): void {
})
})
})

describe(`reused subquery builders`, () => {
let usersCollection: ReturnType<typeof createUsersCollection>

beforeEach(() => {
usersCollection = createUsersCollection(autoIndex)
})

const cases = [
{ shared: true, filterRight: false, expected: [1, 2, 4] },
{ shared: true, filterRight: true, expected: [2] },
{ shared: false, filterRight: false, expected: [1, 2, 4] },
{ shared: false, filterRight: true, expected: [2] },
] as const

for (const { shared, filterRight, expected } of cases) {
test(`${shared ? `shared` : `separate`} builders with${
filterRight ? `` : `out`
} a right-side predicate`, () => {
const joinQuery = createLiveQueryCollection({
startSync: true,
query: (q) => {
const activeUsers = () =>
q
.from({ user: usersCollection })
.where(({ user }) => eq(user.status, `active`))
const left = activeUsers()
const right = shared ? left : activeUsers()
let query = q
.from({ leftUser: left })
.innerJoin({ rightUser: right }, ({ leftUser, rightUser }) =>
eq(leftUser.id, rightUser.id),
)

if (filterRight) {
query = query.where(({ rightUser }) =>
eq(rightUser.name, `Bob`),
)
}

return query.select(({ leftUser }) => ({ id: leftUser.id }))
},
})

expect(joinQuery.toArray.map((row) => row.id)).toEqual(expected)
})
}
})
})
}

Expand Down
Loading