diff --git a/package.json b/package.json index 64b9b9a..b57fcc8 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:browser": "vitest run --config vitest.browser.config.ts", "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", + "bench:local-relation-site": "vitest bench --run src/vnext/__tests__/local-relation-site.bench.ts", "bench:parser-adapter": "vitest bench --run src/vnext/__tests__/node-sql-parser-adapter.bench.ts", "bench:query-site": "vitest bench --run src/vnext/__tests__/query-site.bench.ts", "bench:statement-index": "vitest bench --run src/vnext/__tests__/statement-index.bench.ts", diff --git a/src/vnext/__tests__/local-relation-site.bench.ts b/src/vnext/__tests__/local-relation-site.bench.ts new file mode 100644 index 0000000..9c10844 --- /dev/null +++ b/src/vnext/__tests__/local-relation-site.bench.ts @@ -0,0 +1,134 @@ +import { bench, describe } from "vitest"; +import { MAX_CTE_DECLARATIONS } from "../cte-layout.js"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationStatement, +} from "../local-relation-site.js"; +import { DUCKDB_SQL_RELATION_DIALECT } from "../relation-dialect.js"; +import { createIdentitySqlSource } from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, +} from "../statement-index.js"; + +interface Fixture { + readonly index: ReturnType; + readonly position: number; + readonly slot: ReturnType; + readonly source: ReturnType; + readonly statement: SqlLocalRelationStatement; +} + +function fixture(text: string): Fixture { + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + DUCKDB_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const position = text.length; + const slot = findSqlStatementSlot(index, position, "left"); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + DUCKDB_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Local relation benchmark must prepare"); + } + return { + index, + position, + slot, + source, + statement: preparation.statement, + }; +} + +function coldAnalyze(value: Fixture) { + const preparation = prepareSqlLocalRelationStatement( + value.source, + value.index, + value.slot, + DUCKDB_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Cold local relation benchmark must prepare"); + } + return analyzeSqlLocalRelationSite( + preparation.statement, + value.position, + ); +} + +const tenKibibytes = 10 * 1_024; +const withPrefix = "WITH local_cte AS (SELECT 1) SELECT "; +const withSuffix = " FROM target"; +const projectionLength = + tenKibibytes - withPrefix.length - withSuffix.length; +const withText = `${withPrefix}${"x,".repeat( + Math.floor((projectionLength - 1) / 2), +)}x${withSuffix}`; +const paddedWithText = `${withText.slice( + 0, + -withSuffix.length, +)}${" ".repeat(tenKibibytes - withText.length)}${withSuffix}`; +if (paddedWithText.length !== tenKibibytes) { + throw new Error("Local relation benchmark must be exactly 10 KiB"); +} +const withFixture = fixture(paddedWithText); + +const maximumCteText = `WITH ${Array.from( + { length: MAX_CTE_DECLARATIONS }, + (_, index) => `c${index} AS (SELECT ${index})`, +).join(", ")} SELECT * FROM target`; +const maximumCteFixture = fixture(maximumCteText); + +const withPreflight = analyzeSqlLocalRelationSite( + withFixture.statement, + withFixture.position, +); +const maximumCtePreflight = analyzeSqlLocalRelationSite( + maximumCteFixture.statement, + maximumCteFixture.position, +); +if ( + withPreflight.status !== "ready" || + withPreflight.local.kind !== "unqualified" || + withPreflight.local.cteVisibility.ctes.length !== 1 +) { + throw new Error("10 KiB benchmark must expose one visible CTE"); +} +if ( + maximumCtePreflight.status !== "ready" || + maximumCtePreflight.local.kind !== "unqualified" || + maximumCtePreflight.local.cteVisibility.ctes.length !== + MAX_CTE_DECLARATIONS +) { + throw new Error("Maximum benchmark must expose every CTE"); +} + +describe("local relation site", () => { + bench("cold 10 KiB WITH statement", () => { + coldAnalyze(withFixture); + }); + + bench("warm 10 KiB WITH statement", () => { + analyzeSqlLocalRelationSite( + withFixture.statement, + withFixture.position, + ); + }); + + bench("cold 256-CTE statement", () => { + coldAnalyze(maximumCteFixture); + }); + + bench("warm 256-CTE statement", () => { + analyzeSqlLocalRelationSite( + maximumCteFixture.statement, + maximumCteFixture.position, + ); + }); +}); diff --git a/src/vnext/__tests__/local-relation-site.test.ts b/src/vnext/__tests__/local-relation-site.test.ts new file mode 100644 index 0000000..899e6c1 --- /dev/null +++ b/src/vnext/__tests__/local-relation-site.test.ts @@ -0,0 +1,543 @@ +import { describe, expect, it } from "vitest"; +import { + analyzeSqlLocalRelationSite, + prepareSqlLocalRelationStatement, + type SqlLocalRelationSiteResult, +} from "../local-relation-site.js"; +import { + BIGQUERY_SQL_RELATION_DIALECT, + DREMIO_SQL_RELATION_DIALECT, + DUCKDB_SQL_RELATION_DIALECT, + POSTGRESQL_SQL_RELATION_DIALECT, + type SqlRelationDialectRuntime, +} from "../relation-dialect.js"; +import { + createIdentitySqlSource, + type SqlSourceSnapshot, +} from "../source.js"; +import { + buildSqlStatementIndex, + findSqlStatementSlot, + type SqlStatementIndex, + type SqlStatementSlot, +} from "../statement-index.js"; + +const RUNTIMES = [ + { + dialect: POSTGRESQL_SQL_RELATION_DIALECT, + name: "PostgreSQL", + }, + { dialect: DUCKDB_SQL_RELATION_DIALECT, name: "DuckDB" }, + { dialect: BIGQUERY_SQL_RELATION_DIALECT, name: "BigQuery" }, + { dialect: DREMIO_SQL_RELATION_DIALECT, name: "Dremio" }, +] as const; + +function markedText(marked: string): { + readonly position: number; + readonly text: string; +} { + const position = marked.indexOf("|"); + if (position < 0 || marked.indexOf("|", position + 1) >= 0) { + throw new Error("Fixture requires exactly one cursor marker"); + } + return { + position, + text: `${marked.slice(0, position)}${marked.slice(position + 1)}`, + }; +} + +function hostileProxy( + target: Value, + onInvoke: () => void, +): Value { + return new Proxy(target, { + get() { + onInvoke(); + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + onInvoke(); + throw new Error("hostile"); + }, + ownKeys() { + onInvoke(); + throw new Error("hostile"); + }, + }); +} + +function analyzeMarked( + marked: string, + dialect: SqlRelationDialectRuntime = + POSTGRESQL_SQL_RELATION_DIALECT, +): { + readonly index: SqlStatementIndex; + readonly position: number; + readonly result: SqlLocalRelationSiteResult; + readonly slot: SqlStatementSlot; + readonly source: SqlSourceSnapshot; +} { + const { position, text } = markedText(marked); + const source = createIdentitySqlSource(text); + const index = buildSqlStatementIndex( + source.analysisText, + dialect.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot(index, position, "left"); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + dialect, + ); + if (preparation.status === "unavailable") { + return { + index, + position, + result: preparation, + slot, + source, + }; + } + return { + index, + position, + result: analyzeSqlLocalRelationSite( + preparation.statement, + position, + ), + slot, + source, + }; +} + +function expectReady( + result: SqlLocalRelationSiteResult, +): Extract { + expect(result.status).toBe("ready"); + if (result.status !== "ready") { + throw new Error("Expected a ready local relation site"); + } + return result; +} + +function visibleNames( + result: SqlLocalRelationSiteResult, +): string[] { + const ready = expectReady(result); + expect(ready.local.kind).toBe("unqualified"); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + return ready.local.cteVisibility.ctes.map( + (cte) => cte.sourceSpelling, + ); +} + +describe("local relation statement preparation", () => { + it("fails closed for mixed runtime and source/index/slot evidence", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + const mixedRuntime: SqlRelationDialectRuntime = { + ...POSTGRESQL_SQL_RELATION_DIALECT, + querySite: DUCKDB_SQL_RELATION_DIALECT.querySite, + }; + expect( + prepareSqlLocalRelationStatement( + source, + index, + slot, + mixedRuntime, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + { ...source }, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + { ...index }, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + { ...slot }, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + + const otherSource = createIdentitySqlSource(source.analysisText); + const otherIndex = buildSqlStatementIndex( + otherSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const otherSlot = findSqlStatementSlot( + otherIndex, + otherSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + source, + otherIndex, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + otherSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + }); + + it("rejects hostile proxies without invoking traps", () => { + const source = createIdentitySqlSource("SELECT * FROM "); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + let invoked = false; + const onInvoke = () => { + invoked = true; + }; + expect( + prepareSqlLocalRelationStatement( + hostileProxy(source, onInvoke), + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + hostileProxy(index, onInvoke), + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + hostileProxy(slot, onInvoke), + POSTGRESQL_SQL_RELATION_DIALECT, + ).status, + ).toBe("unavailable"); + expect( + prepareSqlLocalRelationStatement( + source, + index, + slot, + hostileProxy( + POSTGRESQL_SQL_RELATION_DIALECT, + onInvoke, + ), + ).status, + ).toBe("unavailable"); + expect(invoked).toBe(false); + }); + + it("preserves explicit opaque and resource failures", () => { + const opaqueSource = createIdentitySqlSource("DELIMITER $$"); + const opaqueIndex = buildSqlStatementIndex( + opaqueSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const opaqueSlot = findSqlStatementSlot( + opaqueIndex, + opaqueSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + opaqueSource, + opaqueIndex, + opaqueSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "opaque-statement", + status: "unavailable", + }); + + const longSource = createIdentitySqlSource( + `SELECT * FROM ${" ".repeat(65_537)}`, + ); + const longIndex = buildSqlStatementIndex( + longSource.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const longSlot = findSqlStatementSlot( + longIndex, + longSource.analysisText.length, + "left", + ); + expect( + prepareSqlLocalRelationStatement( + longSource, + longIndex, + longSlot, + POSTGRESQL_SQL_RELATION_DIALECT, + ), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + }); +}); + +describe("local relation-site evidence", () => { + it.each(RUNTIMES)( + "projects visible main-query CTEs for $name", + ({ dialect }) => { + const declarations = + dialect === DREMIO_SQL_RELATION_DIALECT + ? "first(id) AS (SELECT 1)" + : "first AS (SELECT 1), second AS (SELECT 2)"; + const result = analyzeMarked( + `WITH ${declarations} SELECT * FROM |`, + dialect, + ).result; + expect(visibleNames(result)).toEqual( + dialect === DREMIO_SQL_RELATION_DIALECT + ? ["first"] + : ["first", "second"], + ); + const ready = expectReady(result); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + expect(ready.querySite.recognition.quality).toBe("exact"); + expect(ready.local.cteVisibility).toMatchObject({ + issues: [], + quality: "exact", + shadowing: { coverage: "complete" }, + }); + }, + ); + + it.each(RUNTIMES)( + "keeps ordinary $name sites locally empty", + ({ dialect }) => { + const result = analyzeMarked( + "SELECT * FROM |", + dialect, + ).result; + expect(visibleNames(result)).toEqual([]); + }, + ); + + it("projects non-recursive sibling visibility", () => { + expect( + visibleNames( + analyzeMarked( + "WITH first AS (SELECT * FROM |), second AS (SELECT 2) SELECT 1", + ).result, + ), + ).toEqual([]); + expect( + visibleNames( + analyzeMarked( + "WITH first AS (SELECT 1), second AS (SELECT * FROM |) SELECT 1", + ).result, + ), + ).toEqual(["first"]); + }); + + it("preserves recursive uncertainty without inventing candidates", () => { + const ready = expectReady( + analyzeMarked( + "WITH RECURSIVE first AS (SELECT * FROM |) SELECT 1", + ).result, + ); + if (ready.local.kind !== "unqualified") { + throw new Error("Expected unqualified local evidence"); + } + expect(ready.local.cteVisibility).toMatchObject({ + ctes: [], + issues: ["recursive-cte-position"], + quality: "recovered", + shadowing: { coverage: "complete" }, + }); + }); + + it("uses statement-relative coordinates in later statements", () => { + const fixture = analyzeMarked( + "SELECT 0; WITH later_cte AS (SELECT 1) SELECT * FROM |", + ); + const ready = expectReady(fixture.result); + if ( + ready.local.kind !== "unqualified" || + fixture.slot.boundaryQuality === "opaque" + ) { + throw new Error("Expected exact unqualified later-statement evidence"); + } + const cte = ready.local.cteVisibility.ctes[0]; + expect(cte?.sourceSpelling).toBe("later_cte"); + expect(cte?.declarationPosition).toBe( + fixture.source.analysisText.indexOf("later_cte") - + fixture.slot.source.from, + ); + expect(ready.querySite.finalSegmentRange.from).toBe( + fixture.position - fixture.slot.source.from, + ); + }); + + it("separates qualified sites from irrelevant CTE uncertainty", () => { + const ready = expectReady( + analyzeMarked( + "WITH RECURSIVE first AS (SELECT 1) SELECT * FROM schema.|", + ).result, + ); + expect(ready.local).toEqual({ kind: "qualified" }); + expect(ready.querySite.qualifier).toEqual([ + { quoted: false, value: "schema" }, + ]); + }); + + it("passes inactive query-site states through without local evidence", () => { + expect(analyzeMarked("SELECT |").result).toEqual({ + reason: "not-relation-position", + status: "inactive", + }); + expect(analyzeMarked("SELECT * FROM '|'").result).toEqual({ + reason: "cursor-in-string", + status: "inactive", + }); + }); + + it("rejects copied and proxied prepared statements without traps", () => { + const fixture = markedText("SELECT * FROM |"); + const source = createIdentitySqlSource(fixture.text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + fixture.position, + "left", + ); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Prepared statement fixture must be ready"); + } + expect( + analyzeSqlLocalRelationSite( + { ...preparation.statement }, + fixture.position, + ), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + let invoked = false; + const proxied = new Proxy(preparation.statement, { + get() { + invoked = true; + throw new Error("hostile"); + }, + getOwnPropertyDescriptor() { + invoked = true; + throw new Error("hostile"); + }, + ownKeys() { + invoked = true; + throw new Error("hostile"); + }, + }); + expect( + analyzeSqlLocalRelationSite(proxied, fixture.position), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + expect(invoked).toBe(false); + expect( + Reflect.apply(analyzeSqlLocalRelationSite, undefined, [ + null, + fixture.position, + ]), + ).toEqual({ + reason: "ambiguous-query-site", + status: "unavailable", + }); + }); + + it("is deterministic and freezes every exposed wrapper", () => { + const fixture = markedText( + "WITH first AS (SELECT 1) SELECT * FROM |", + ); + const source = createIdentitySqlSource(fixture.text); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + fixture.position, + "left", + ); + const preparation = prepareSqlLocalRelationStatement( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT, + ); + if (preparation.status === "unavailable") { + throw new Error("Prepared statement fixture must be ready"); + } + const first = analyzeSqlLocalRelationSite( + preparation.statement, + fixture.position, + ); + const second = analyzeSqlLocalRelationSite( + preparation.statement, + fixture.position, + ); + expect(first).toEqual(second); + expect(Object.isFrozen(preparation)).toBe(true); + expect(Object.isFrozen(preparation.statement)).toBe(true); + expect(Object.isFrozen(first)).toBe(true); + const ready = expectReady(first); + expect(Object.isFrozen(ready.local)).toBe(true); + expect(Object.isFrozen(ready.querySite)).toBe(true); + if (ready.local.kind === "unqualified") { + expect(Object.isFrozen(ready.local.cteVisibility)).toBe(true); + expect(Object.isFrozen(ready.local.cteVisibility.ctes)).toBe(true); + } + }); +}); diff --git a/src/vnext/__tests__/query-site.test.ts b/src/vnext/__tests__/query-site.test.ts index 089b52a..9f58b02 100644 --- a/src/vnext/__tests__/query-site.test.ts +++ b/src/vnext/__tests__/query-site.test.ts @@ -857,6 +857,43 @@ describe("authenticated CTE main-query entrypoints", () => { }); }); + it("preserves authenticated CTE resource failures", () => { + const source = createIdentitySqlSource( + `SELECT * FROM ${" ".repeat(65_537)}`, + ); + const index = buildSqlStatementIndex( + source.analysisText, + POSTGRESQL_SQL_RELATION_DIALECT.querySite.lexicalProfile, + ); + const slot = findSqlStatementSlot( + index, + source.analysisText.length, + "left", + ); + if (slot.boundaryQuality === "opaque") { + throw new Error("Resource fixture requires an exact statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + POSTGRESQL_SQL_RELATION_DIALECT.cteLayout, + ); + expect( + recognizeSqlRelationQuerySiteWithCteLayout( + source, + slot, + source.analysisText.length, + POSTGRESQL_SQL_RELATION_DIALECT, + layout, + ), + ).toEqual({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); + }); + it.each([ ["invalid-header", "header"], ["skipped", "skipped"], diff --git a/src/vnext/cte-layout.ts b/src/vnext/cte-layout.ts index 820fefc..ac11cdf 100644 --- a/src/vnext/cte-layout.ts +++ b/src/vnext/cte-layout.ts @@ -173,11 +173,19 @@ export interface SqlCteVisibility { readonly shadowing: SqlCteShadowing; } +type AuthenticatedSqlCteLayout = + | Exclude + | { + readonly reason: "resource-limit"; + readonly resource: "active-statement"; + readonly status: "unavailable"; + }; + interface SqlCteLayoutProvenance { readonly dialect: SqlCteLayoutDialect; - readonly entrypoints: readonly SqlCteMainQueryEntrypoint[]; readonly index: SqlStatementIndex; readonly lexicalProfile: SqlLexicalProfile; + readonly layout: AuthenticatedSqlCteLayout; readonly slot: ExactSqlStatementSlot; readonly source: SqlSourceSnapshot; } @@ -187,6 +195,40 @@ const sqlCteLayoutProvenance = new WeakMap< SqlCteLayoutProvenance >(); +function registerSqlCteLayout< + Layout extends AuthenticatedSqlCteLayout, +>( + layout: Layout, + source: SqlSourceSnapshot, + index: SqlStatementIndex, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, + lexicalProfile: SqlLexicalProfile, +): Layout { + if ( + isSqlSourceSnapshot(source) && + isExactSqlStatementSlotSnapshotFor( + index, + slot, + source.analysisText, + lexicalProfile, + ) + ) { + sqlCteLayoutProvenance.set( + layout, + Object.freeze({ + dialect, + index, + layout, + lexicalProfile, + slot, + source, + }), + ); + } + return layout; +} + type HeaderState = | "after-as" | "after-body" @@ -650,13 +692,19 @@ function freezeIssues( function layoutUnavailable( reason: "opaque-statement" | "resource-limit", - resource?: SqlCteLayoutResource, ): SqlCteLayout { - return Object.freeze( - resource === undefined - ? { reason, status: "unavailable" } - : { reason, resource, status: "unavailable" }, - ); + return Object.freeze({ reason, status: "unavailable" }); +} + +function activeStatementUnavailable(): Extract< + AuthenticatedSqlCteLayout, + { readonly status: "unavailable" } +> { + return Object.freeze({ + reason: "resource-limit", + resource: "active-statement", + status: "unavailable", + }); } function findOpenParentFrame( @@ -1038,14 +1086,21 @@ export function analyzeSqlCteLayout( dialect: SqlCteLayoutDialect, ): SqlCteLayout { const statementLength = slot.source.to - slot.source.from; - if (statementLength > MAX_CTE_STATEMENT_LENGTH) { - return layoutUnavailable("resource-limit", "active-statement"); - } const validatedDialect = validateDialect(dialect); if (!validatedDialect) { return layoutUnavailable("resource-limit"); } const { grammar, lexicalProfile } = validatedDialect; + if (statementLength > MAX_CTE_STATEMENT_LENGTH) { + return registerSqlCteLayout( + activeStatementUnavailable(), + source, + index, + slot, + dialect, + lexicalProfile, + ); + } const text = source.analysisText; const statementFrom = slot.source.from; const lexer = new BoundedSqlLexer( @@ -1583,36 +1638,22 @@ export function analyzeSqlCteLayout( issues, resource, ); - if ( - isSqlSourceSnapshot(source) && - isExactSqlStatementSlotSnapshotFor( - index, - slot, - source.analysisText, - lexicalProfile, - ) - ) { - sqlCteLayoutProvenance.set( - layout, - Object.freeze({ - dialect, - entrypoints: layout.mainQueryEntrypoints, - index, - lexicalProfile, - slot, - source, - }), - ); - } - return layout; + return registerSqlCteLayout( + layout, + source, + index, + slot, + dialect, + lexicalProfile, + ); } -export function resolveAuthenticatedSqlCteEntrypoints( +export function resolveAuthenticatedSqlCteLayout( candidate: unknown, source: SqlSourceSnapshot, slot: ExactSqlStatementSlot, dialect: SqlCteLayoutDialect, -): readonly SqlCteMainQueryEntrypoint[] | null { +): AuthenticatedSqlCteLayout | null { if ( candidate === null || typeof candidate !== "object" || @@ -1634,7 +1675,25 @@ export function resolveAuthenticatedSqlCteEntrypoints( ) { return null; } - return provenance.entrypoints; + return provenance.layout; +} + +export function resolveAuthenticatedSqlCteEntrypoints( + candidate: unknown, + source: SqlSourceSnapshot, + slot: ExactSqlStatementSlot, + dialect: SqlCteLayoutDialect, +): readonly SqlCteMainQueryEntrypoint[] | null { + const layout = resolveAuthenticatedSqlCteLayout( + candidate, + source, + slot, + dialect, + ); + if (!layout || layout.status === "unavailable") { + return null; + } + return layout.mainQueryEntrypoints; } function framePhase( diff --git a/src/vnext/local-relation-site.ts b/src/vnext/local-relation-site.ts new file mode 100644 index 0000000..5199eb0 --- /dev/null +++ b/src/vnext/local-relation-site.ts @@ -0,0 +1,200 @@ +import { + analyzeSqlCteLayout, + type SqlCteLayoutResource, + type SqlCteVisibility, + visibleSqlCtesAt, +} from "./cte-layout.js"; +import { + recognizeSqlRelationQuerySiteWithEntrypoints, + type SqlQuerySiteResult, +} from "./query-site.js"; +import type { SqlRelationDialectRuntime } from "./relation-dialect.js"; +import { isSqlRelationDialectRuntime } from "./relation-runtime-auth.js"; +import { + isSqlSourceSnapshot, + type SqlSourceSnapshot, +} from "./source.js"; +import { + isSqlStatementSlotSnapshotFor, + type ExactSqlStatementSlot, + type SqlStatementIndex, + type SqlStatementSlot, +} from "./statement-index.js"; + +const localRelationStatementBrand: unique symbol = Symbol( + "SqlLocalRelationStatement", +); + +export interface SqlLocalRelationStatement { + readonly [localRelationStatementBrand]: "SqlLocalRelationStatement"; +} + +export type SqlLocalRelationStatementPreparation = + | { + readonly status: "ready"; + readonly statement: SqlLocalRelationStatement; + } + | { + readonly status: "unavailable"; + readonly reason: + | "ambiguous-query-site" + | "opaque-statement" + | "resource-limit"; + readonly resource?: SqlCteLayoutResource; + }; + +type SqlReadyQuerySite = Extract< + SqlQuerySiteResult, + { readonly status: "ready" } +>; + +export type SqlLocalRelationSiteResult = + | Exclude + | Extract< + SqlLocalRelationStatementPreparation, + { readonly status: "unavailable" } + > + | { + readonly status: "ready"; + readonly querySite: SqlReadyQuerySite; + readonly local: + | { + readonly kind: "qualified"; + } + | { + readonly kind: "unqualified"; + readonly cteVisibility: SqlCteVisibility; + }; + }; + +interface SqlLocalRelationStatementContext { + readonly dialect: SqlRelationDialectRuntime; + readonly layout: Exclude< + ReturnType, + { readonly status: "unavailable" } + >; + readonly slot: ExactSqlStatementSlot; + readonly source: SqlSourceSnapshot; +} + +const localRelationStatements = new WeakMap< + object, + SqlLocalRelationStatementContext +>(); + +const QUALIFIED_LOCAL = Object.freeze({ + kind: "qualified" as const, +}); + +function unavailablePreparation( + reason: Extract< + SqlLocalRelationStatementPreparation, + { readonly status: "unavailable" } + >["reason"], + resource?: SqlCteLayoutResource, +): SqlLocalRelationStatementPreparation { + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); +} + +function unavailableSite(): SqlLocalRelationSiteResult { + return Object.freeze({ + reason: "ambiguous-query-site", + status: "unavailable", + }); +} + +export function prepareSqlLocalRelationStatement( + source: SqlSourceSnapshot, + index: SqlStatementIndex, + slot: SqlStatementSlot, + dialect: SqlRelationDialectRuntime, +): SqlLocalRelationStatementPreparation { + if ( + !isSqlRelationDialectRuntime(dialect) || + !isSqlSourceSnapshot(source) || + !isSqlStatementSlotSnapshotFor( + index, + slot, + source.analysisText, + dialect.querySite.lexicalProfile, + ) + ) { + return unavailablePreparation("ambiguous-query-site"); + } + if (slot.boundaryQuality === "opaque") { + return unavailablePreparation("opaque-statement"); + } + const layout = analyzeSqlCteLayout( + source, + index, + slot, + dialect.cteLayout, + ); + if (layout.status === "unavailable") { + return unavailablePreparation(layout.reason, layout.resource); + } + const statement: SqlLocalRelationStatement = Object.freeze({ + [localRelationStatementBrand]: "SqlLocalRelationStatement" as const, + }); + localRelationStatements.set( + statement, + Object.freeze({ dialect, layout, slot, source }), + ); + return Object.freeze({ statement, status: "ready" }); +} + +export function analyzeSqlLocalRelationSite( + statement: SqlLocalRelationStatement, + position: number, +): SqlLocalRelationSiteResult { + if ( + statement === null || + typeof statement !== "object" + ) { + return unavailableSite(); + } + const context = localRelationStatements.get(statement); + if (!context) { + return unavailableSite(); + } + const querySite = recognizeSqlRelationQuerySiteWithEntrypoints( + context.source, + context.slot, + position, + context.dialect.querySite, + context.layout.mainQueryEntrypoints, + ); + if (querySite.status !== "ready") { + return querySite; + } + if (querySite.qualifier.length > 0) { + return Object.freeze({ + local: QUALIFIED_LOCAL, + querySite, + status: "ready", + }); + } + const relativePosition = position - context.slot.source.from; + if ( + !Number.isSafeInteger(relativePosition) || + relativePosition < 0 || + relativePosition > context.layout.statementLength + ) { + return unavailableSite(); + } + return Object.freeze({ + local: Object.freeze({ + cteVisibility: visibleSqlCtesAt( + context.layout, + relativePosition, + ), + kind: "unqualified" as const, + }), + querySite, + status: "ready", + }); +} diff --git a/src/vnext/relation-query-site.ts b/src/vnext/relation-query-site.ts index f324acb..f739715 100644 --- a/src/vnext/relation-query-site.ts +++ b/src/vnext/relation-query-site.ts @@ -1,5 +1,5 @@ import { - resolveAuthenticatedSqlCteEntrypoints, + resolveAuthenticatedSqlCteLayout, type SqlCteLayout, } from "./cte-layout.js"; import { @@ -18,9 +18,17 @@ import { } from "./statement-index.js"; function unavailable( - reason: "ambiguous-query-site" | "opaque-statement", + reason: + | "ambiguous-query-site" + | "opaque-statement" + | "resource-limit", + resource?: "active-statement", ): SqlQuerySiteResult { - return Object.freeze({ reason, status: "unavailable" }); + return Object.freeze( + resource === undefined + ? { reason, status: "unavailable" } + : { reason, resource, status: "unavailable" }, + ); } export function recognizeSqlRelationQuerySiteWithCteLayout( @@ -40,20 +48,26 @@ export function recognizeSqlRelationQuerySiteWithCteLayout( if (slot.boundaryQuality === "opaque") { return unavailable("opaque-statement"); } - const entrypoints = resolveAuthenticatedSqlCteEntrypoints( + const authenticatedLayout = resolveAuthenticatedSqlCteLayout( layout, source, slot, dialect.cteLayout, ); - if (!entrypoints) { + if (!authenticatedLayout) { return unavailable("ambiguous-query-site"); } + if (authenticatedLayout.status === "unavailable") { + return unavailable( + authenticatedLayout.reason, + authenticatedLayout.resource, + ); + } return recognizeSqlRelationQuerySiteWithEntrypoints( source, slot, position, dialect.querySite, - entrypoints, + authenticatedLayout.mainQueryEntrypoints, ); } diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index fd7f7c5..bd5c0c0 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -144,10 +144,27 @@ export function isExactSqlStatementSlotSnapshotFor( analysisText: string, profile: SqlLexicalProfile, ): candidate is ExactSqlStatementSlot { + return ( + isExactSqlStatementSlotSnapshot(candidate) && + isSqlStatementSlotSnapshotFor( + index, + candidate, + analysisText, + profile, + ) + ); +} + +export function isSqlStatementSlotSnapshotFor( + index: unknown, + candidate: unknown, + analysisText: string, + profile: SqlLexicalProfile, +): candidate is SqlStatementSlot { if ( index === null || typeof index !== "object" || - !isExactSqlStatementSlotSnapshot(candidate) + !isSqlStatementSlotSnapshot(candidate) ) { return false; }