diff --git a/.changeset/preserve-optimizer-source-identity.md b/.changeset/preserve-optimizer-source-identity.md new file mode 100644 index 0000000000..8fb85cc8b0 --- /dev/null +++ b/.changeset/preserve-optimizer-source-identity.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Preserve source identity during query optimization so a joined subquery and sibling include can reuse an alias without hiding parent rows. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index d66b3d3d7e..94ecda93af 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -238,6 +238,7 @@ comment and the current API/architecture contract before extending its model. | --- | --- | --- | | Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [fractional-index window moves](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topKWithFractionalIndex.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law.property.test.ts) | Independent ordered relations and cumulative signed output. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. The fractional-index test checks an empty output message for a window move after all rows retract; it does not prove downstream lazy acquisition. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. The BTree/Map oracle checks point operations, neighbor pairs, and full, partial, boundary, exclusive-high, and empty range scans for integer keys and node sizes 4–8. It does not check balance or space, and it assumes consistent comparators ([code-weight BTree review](oracle-reviews/code-weight-btree-trim.md)). | | Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. Observe raw promised order; fresh queries do not establish continuous publication safety. | +| Includes query shape and lexical source identity | `packages/db/tests/query/includes-query-shape-oracle.test.ts` | A full-recomputation model compares public parent and included rows after preload and each source update. Its bounded grammar crosses reused or distinct sibling aliases, separate or combined parent and joined predicates, a joined-only predicate, and joined or included issue updates. Eleven direct optimizer shapes assert that optimization retains each lexical source ID, alias, and collection without changing input IR; placement clones must create distinct source IDs. The original optimizer fails the public reused-alias initial checkpoint, and each of its four source-copy sites fails a direct identity witness when restored separately. The public grammar has one matching joined row per parent; the owner's older model covers join multiplicity separately. Arbitrary nested include graphs, unrelated predicate forms, asynchronous demand schedules, user mutation of public IR objects after construction, and direct compiler input maps remain outside this witness. | | Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns; a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. New debounce/throttle admission after cleanup, failed persistence, broader capacities and schedules remain outside this owner. | | Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [truncate capture ownership](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-truncate-ownership-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, captured truncate ownership, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | diff --git a/packages/db/src/query/optimizer.ts b/packages/db/src/query/optimizer.ts index b7487533ed..bb6e93d1d2 100644 --- a/packages/db/src/query/optimizer.ts +++ b/packages/db/src/query/optimizer.ts @@ -124,7 +124,6 @@ import { deepEquals } from '../utils.js' import { CannotCombineEmptyExpressionListError } from '../errors.js' import { containsAggregate } from './compiler/group-by.js' import { - CollectionRef as CollectionRefClass, Func, PropRef, QueryRef as QueryRefClass, @@ -135,7 +134,14 @@ import { getWhereExpression, isResidualWhere, } from './ir.js' -import type { BasicExpression, From, QueryIR, Select, Where } from './ir.js' +import type { + BasicExpression, + CollectionRef as CollectionRefClass, + From, + QueryIR, + Select, + Where, +} from './ir.js' /** * Represents a WHERE clause after source analysis @@ -497,7 +503,7 @@ function removeRedundantFromClause(from: From): From { // Return the inner query's FROM clause with this alias const innerFrom = removeRedundantFromClause(processedQuery.from) if (innerFrom.type === `collectionRef`) { - return new CollectionRefClass(innerFrom.collection, from.alias) + return innerFrom } else if (innerFrom.type === `queryRef`) { return new QueryRefClass(innerFrom.query, from.alias) } @@ -814,13 +820,10 @@ function applyOptimizations( } /** - * Helper function to create a deep copy of a QueryIR object for immutability. - * - * This ensures that all optimizations create new objects rather than modifying - * existing ones, preventing infinite recursion and shared reference issues. + * Copy query containers and arrays while retaining lexical source references. * * @param query - QueryIR to deep copy - * @returns New QueryIR object with all nested objects copied + * @returns New QueryIR containers with the same lexical collection sources */ function deepCopyQuery(query: QueryIR): QueryIR { return { @@ -845,7 +848,7 @@ function deepCopyQuery(query: QueryIR): QueryIR { function deepCopyFrom(from: From): From { if (from.type === `collectionRef`) { - return new CollectionRefClass(from.collection, from.alias) + return from } if (from.type === `queryRef`) { @@ -930,9 +933,8 @@ function optimizeFromWithTracking( const whereClause = singleSourceClauses.get(from.alias) if (!whereClause) { - // No optimization needed, but return a copy to maintain immutability if (from.type === `collectionRef`) { - return new CollectionRefClass(from.collection, from.alias) + return from } // Must be queryRef due to type system return new QueryRefClass(deepCopyQuery(from.query), from.alias) @@ -942,7 +944,7 @@ function optimizeFromWithTracking( // Create a new subquery with the WHERE clause for the collection // This is always safe since we're creating a new subquery const subQuery: QueryIR = { - from: new CollectionRefClass(from.collection, from.alias), + from, where: [whereClause], } actuallyOptimized.add(from.alias) // Mark as successfully optimized diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index d59a85d33f..dad112d33b 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -90,6 +90,7 @@ const staticOracleProperties = [ `includes-optimistic.repeated-history`, `includes-optimistic.sibling-route-rollback`, `includes-query-shape.correlation`, + `includes-query-shape.identity`, `includes-query-shape.multiplicity`, `includes-query-shape.nullable`, `includes-work.correlated-links`, diff --git a/packages/db/tests/query/includes-query-shape-oracle.test.ts b/packages/db/tests/query/includes-query-shape-oracle.test.ts index 02ad01cc05..a79cd04878 100644 --- a/packages/db/tests/query/includes-query-shape-oracle.test.ts +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -1,7 +1,20 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { describe, expect } from 'vitest' import { BasicIndex } from '../../src/indexes/basic-index.js' +import { cloneQueryForPlacement } from '../../src/query/builder/clone-query.js' import { + CollectionRef, + Func, + PropRef, + QueryRef, + UnionAll, + UnionFrom, + Value, + collectCollectionSources, +} from '../../src/query/ir.js' +import { optimizeQuery } from '../../src/query/optimizer.js' +import { + and, createLiveQueryCollection, eq, materialize, @@ -10,6 +23,7 @@ import { runTrace } from '../trace-runner.js' import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' import { createControlledCollection } from './includes-oracle-helpers.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' +import type { QueryIR } from '../../src/query/ir.js' /** * # Which distinctions determine the shape of an included result? @@ -20,6 +34,8 @@ import type { TraceDriver, TraceProjection } from '../trace-runner.js' * 1. Join multiplicity keeps a parent visible until its last contributor leaves. * 2. A correlation through the joined alias differs from one through the source. * 3. A null or unmatched singleton is absent, but a later valid key reactivates it. + * 4. Sibling scopes may reuse an alias; optimizer copies must keep the lexical + * source identity so a joined parent and its include read their own inputs. * * These laws form separate model nodes. Each node uses plain Maps and full * recomputation. The shared trace runner applies an action to production and to @@ -38,7 +54,11 @@ import type { TraceDriver, TraceProjection } from '../trace-runner.js' * is bounded: one parent with 1–5 child contributors; one joined * production/order with equal or distinct correlation keys; and one post * with null, unmatched, or existing author keys. Missing child IDs and - * updates to absent orders are invalid driver actions. Each real live-query + * updates to absent orders are invalid driver actions. The source-identity + * grammar crosses sibling alias equality, separate/combined/nullable-only + * predicates, and updates to joined and included source rows. It keeps at + * most one matching joined row per parent; joined multiplicity has its own + * model above. Each real live-query * Collection is compared after preload and after every source write by * runTrace, which preserves the first divergent checkpoint. */ @@ -423,6 +443,296 @@ const nullableProjection: TraceProjection< assertEqual: assertRowsEqual, } +type AliasShape = `reused` | `distinct` +type PredicateShape = `separate` | `combined` | `nullable-only` +type IdentityProject = { id: number; name: string } +type IdentityIssue = { + id: number + projectId: number + title: string +} +type IdentityStep = + | { kind: `join-title`; title: `Bug in Alpha` | `Other` } + | { kind: `join-project`; projectId: 1 | 2 } + | { kind: `included-title`; title: `Feature for Alpha` | `Changed` } + +function createIdentitySources() { + const projects = [ + { id: 1, name: `Alpha` }, + { id: 2, name: `Beta` }, + ] + const issues = [ + { id: 10, projectId: 1, title: `Bug in Alpha` }, + { id: 11, projectId: 1, title: `Feature for Alpha` }, + { id: 20, projectId: 2, title: `Bug in Beta` }, + ] + const sources = { + projects: createControlledCollection( + `identity-projects`, + projects, + ), + issues: createControlledCollection( + `identity-issues`, + issues, + ), + } + sources.issues.collection.createIndex((issue) => issue.projectId, { + indexType: BasicIndex, + }) + return { sources, projects, issues } +} + +function createIdentityQuery( + sources: ReturnType[`sources`], + aliasShape: AliasShape, + predicateShape: PredicateShape, +) { + return createLiveQueryCollection((q) => { + const joined = q + .from({ p: sources.projects.collection }) + .leftJoin({ i: sources.issues.collection }, ({ p, i }) => + eq(i.projectId, p.id), + ) + const parent = + predicateShape === `combined` + ? joined.where(({ p, i }) => + and(eq(i.title, `Bug in Alpha`), eq(p.name, `Alpha`)), + ) + : predicateShape === `separate` + ? joined + .where(({ i }) => eq(i.title, `Bug in Alpha`)) + .where(({ p }) => eq(p.name, `Alpha`)) + : joined.where(({ i }) => eq(i.title, `Bug in Alpha`)) + const parentQuery = parent.select(({ p }) => p) + + return q.from({ p: parentQuery }).select(({ p }) => ({ + id: p.id, + name: p.name, + issues: + aliasShape === `reused` + ? materialize( + q + .from({ i: sources.issues.collection }) + .where(({ i }) => eq(i.projectId, p.id)) + .select(({ i }) => ({ id: i.id, title: i.title })), + ) + : materialize( + q + .from({ includedIssue: sources.issues.collection }) + .where(({ includedIssue }) => eq(includedIssue.projectId, p.id)) + .select(({ includedIssue }) => ({ + id: includedIssue.id, + title: includedIssue.title, + })), + ), + })) + }) +} + +type IdentityContext = { + aliasShape: AliasShape + predicateShape: PredicateShape + sources: ReturnType[`sources`] + live: ReturnType + projects: Map + issues: Map +} + +function createIdentityDriver( + aliasShape: AliasShape, + predicateShape: PredicateShape, +): TraceDriver { + return { + setup: () => { + const { sources, projects, issues } = createIdentitySources() + return { + aliasShape, + predicateShape, + sources, + live: createIdentityQuery(sources, aliasShape, predicateShape), + projects: rowsById(projects), + issues: rowsById(issues), + } + }, + start: ({ live }) => live.preload(), + apply: (step, { issues, sources }) => { + const id = step.kind === `included-title` ? 11 : 10 + const previous = issues.get(id) + if (!previous) throw new Error(`Missing issue ${id}`) + const next = + step.kind === `join-project` + ? { ...previous, projectId: step.projectId } + : { ...previous, title: step.title } + sources.issues.write(`update`, next) + issues.set(id, next) + }, + cleanup: ({ live, sources }) => cleanupQuery(live, Object.values(sources)), + } +} + +type IdentityResult = Array<{ + id: number + name: string + issues: Array<{ id: number; title: string }> +}> + +const identityProjection: TraceProjection< + IdentityContext, + IdentityResult, + IdentityResult +> = { + observe: ({ live }) => + live.toArray + .map((project) => ({ + id: project.id, + name: project.name, + issues: project.issues + .map((issue) => ({ id: issue.id, title: issue.title })) + .sort((left, right) => left.id - right.id), + })) + .sort((left, right) => left.id - right.id), + // Full recomputation uses source rows and ordinary equality, never the + // optimizer's IDs, alias fallback, D2 inputs, or compiled include routes. + recompute: ({ projects, issues, predicateShape }) => + [...projects.values()] + .filter( + (project) => + (predicateShape === `nullable-only` || project.name === `Alpha`) && + [...issues.values()].some( + (issue) => + issue.projectId === project.id && issue.title === `Bug in Alpha`, + ), + ) + .map((project) => ({ + id: project.id, + name: project.name, + issues: [...issues.values()] + .filter((issue) => issue.projectId === project.id) + .map((issue) => ({ id: issue.id, title: issue.title })) + .sort((left, right) => left.id - right.id), + })) + .sort((left, right) => left.id - right.id), + assertEqual: assertRowsEqual, +} + +const identitySteps = fc.oneof( + fc.record({ + kind: fc.constant<`join-title`>(`join-title`), + title: fc.constantFrom<`Bug in Alpha` | `Other`>(`Bug in Alpha`, `Other`), + }), + fc.record({ + kind: fc.constant<`join-project`>(`join-project`), + projectId: fc.constantFrom<1 | 2>(1, 2), + }), + fc.record({ + kind: fc.constant<`included-title`>(`included-title`), + title: fc.constantFrom<`Feature for Alpha` | `Changed`>( + `Feature for Alpha`, + `Changed`, + ), + }), +) + +type OptimizerSourceShape = + | `bare-from` + | `pushed-from` + | `bare-join` + | `unchanged-join` + | `pushed-join` + | `nested-push` + | `redundant-from` + | `renamed-from` + | `nested-renamed-from` + | `union-from` + | `union-all` + +const optimizerSourceShapes: ReadonlyArray = [ + `bare-from`, + `pushed-from`, + `bare-join`, + `unchanged-join`, + `pushed-join`, + `nested-push`, + `redundant-from`, + `renamed-from`, + `nested-renamed-from`, + `union-from`, + `union-all`, +] + +function createOptimizerSourceQuery(shape: OptimizerSourceShape): QueryIR { + const collection = { id: `identity-source` } as never + const root = new CollectionRef(collection, `root`) + const joined = new CollectionRef(collection, `joined`) + const rootPredicate = new Func(`eq`, [ + new PropRef([`root`, `id`]), + new Value(1), + ]) + const joinedPredicate = new Func(`eq`, [ + new PropRef([`joined`, `id`]), + new Value(2), + ]) + const secondRootPredicate = new Func(`gt`, [ + new PropRef([`root`, `id`]), + new Value(0), + ]) + const join = { + type: `inner` as const, + from: joined, + left: new PropRef([`root`, `id`]), + right: new PropRef([`joined`, `id`]), + } + + switch (shape) { + case `bare-from`: + return { from: root } + case `pushed-from`: + return { from: root, where: [rootPredicate, secondRootPredicate] } + case `bare-join`: + return { from: root, join: [join] } + case `unchanged-join`: + return { from: root, join: [join], where: [rootPredicate] } + case `pushed-join`: + return { + from: root, + join: [join], + where: [rootPredicate, joinedPredicate], + } + case `nested-push`: + return { + from: new QueryRef( + { from: root, select: { id: new PropRef([`root`, `id`]) } }, + `outer`, + ), + join: [join], + where: [new Func(`eq`, [new PropRef([`outer`, `id`]), new Value(1)])], + } + case `redundant-from`: + return { from: new QueryRef({ from: root }, `root`) } + case `renamed-from`: + return { from: new QueryRef({ from: root }, `outer`) } + case `nested-renamed-from`: + return { + from: new QueryRef( + { from: new QueryRef({ from: root }, `middle`) }, + `middle`, + ), + } + case `union-from`: + return { from: new UnionFrom([root, joined]) } + case `union-all`: + return { + from: new UnionAll([{ from: root }, { from: joined }]), + } + } +} + +function lexicalSourceIds(query: QueryIR): Array { + return collectCollectionSources(query) + .map((source) => source.sourceId) + .sort() +} + function campaigns(fixedSeed: number, property: string) { // Grammar controls: the pinned 1-child case reconstructs final retirement, // while counts 2–5 keep a surviving contributor. Equal route keys are the @@ -659,4 +969,116 @@ describe(`includes query-shape recompute oracle`, () => { ).rejects.toMatchObject({ name: `TraceAssertionError`, checkpoint: 1 }) }, ) + + fcTest.each([ + [`reused`, `separate`], + [`distinct`, `separate`], + [`reused`, `combined`], + [`distinct`, `combined`], + [`reused`, `nullable-only`], + [`distinct`, `nullable-only`], + ] as const)( + `preserves %s sibling alias with %s predicates through source updates`, + (aliasShape, predicateShape) => + runTrace({ + steps: [ + { kind: `join-title`, title: `Other` }, + { kind: `join-title`, title: `Bug in Alpha` }, + { kind: `included-title`, title: `Changed` }, + { kind: `join-project`, projectId: 2 }, + { kind: `join-project`, projectId: 1 }, + ], + driver: createIdentityDriver(aliasShape, predicateShape), + projection: identityProjection, + }), + ) + + for (const campaign of campaigns(1707, `includes-query-shape.identity`)) { + fcTest.prop( + [ + fc.constantFrom(`reused`, `distinct`), + fc.constantFrom( + `separate`, + `combined`, + `nullable-only`, + ), + fc.array(identitySteps, { minLength: 1, maxLength: 5 }), + ], + campaign.options, + )( + `matches recomputation across sibling aliases and predicate forms (${campaign.name})`, + (aliasShape, predicateShape, steps) => + runTrace({ + steps, + driver: createIdentityDriver(aliasShape, predicateShape), + projection: identityProjection, + }), + ) + } + + fcTest( + `rejects an omitted parent at the initial identity checkpoint`, + async () => { + await expect( + runTrace({ + steps: [], + driver: createIdentityDriver(`reused`, `separate`), + projection: { ...identityProjection, observe: () => [] }, + }), + ).rejects.toMatchObject({ name: `TraceAssertionError`, checkpoint: 0 }) + }, + ) + + fcTest.each(optimizerSourceShapes)( + `keeps lexical source identity through %s optimization and placement`, + (shape) => { + const original = createOptimizerSourceQuery(shape) + const originalFrom = original.from + const originalJoin = original.join + const originalWhere = original.where + const originalSources = collectCollectionSources(original).map( + (source) => ({ + source, + sourceId: source.sourceId, + alias: source.alias, + collection: source.collection, + }), + ) + const firstPlacement = cloneQueryForPlacement(original) + const secondPlacement = cloneQueryForPlacement(original) + const originalIds = lexicalSourceIds(original) + const firstIds = lexicalSourceIds(firstPlacement) + const secondIds = lexicalSourceIds(secondPlacement) + const optimizedOriginal = optimizeQuery(original).optimizedQuery + + // A placement is a new lexical position. Optimization of that placement + // must not create a third source identity for the same position. + expect(new Set([...originalIds, ...firstIds, ...secondIds]).size).toBe( + originalIds.length + firstIds.length + secondIds.length, + ) + expect(lexicalSourceIds(optimizedOriginal)).toEqual(originalIds) + expect(original.from).toBe(originalFrom) + expect(original.join).toBe(originalJoin) + expect(original.where).toBe(originalWhere) + for (const { source, sourceId, alias, collection } of originalSources) { + expect(source.sourceId).toBe(sourceId) + expect(source.alias).toBe(alias) + expect(source.collection).toBe(collection) + } + if (shape === `renamed-from` || shape === `nested-renamed-from`) { + if (optimizedOriginal.from.type !== `queryRef`) { + throw new Error(`Alias-remapped wrapper was removed`) + } + expect(optimizedOriginal.from.alias).toBe( + shape === `renamed-from` ? `outer` : `middle`, + ) + } + expect( + lexicalSourceIds(optimizeQuery(firstPlacement).optimizedQuery), + ).toEqual(firstIds) + expect( + lexicalSourceIds(optimizeQuery(secondPlacement).optimizedQuery), + ).toEqual(secondIds) + }, + ) })