diff --git a/package.json b/package.json index b15d8fb..452e22c 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@libpg-query/parser": "^17.6.3", "@opentelemetry/api": "^1.9.0", "@pgsql/types": "^17.6.2", - "@query-doctor/core": "^0.12.0", + "@query-doctor/core": "^0.13.0", "async-sema": "^3.1.1", "capnweb": "^0.7.0", "dedent": "^1.7.1", diff --git a/src/remote/query-optimizer-synthesis.test.ts b/src/remote/query-optimizer-synthesis.test.ts new file mode 100644 index 0000000..f80e28c --- /dev/null +++ b/src/remote/query-optimizer-synthesis.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "vitest"; +import { PostgreSqlContainer } from "@testcontainers/postgresql"; +import { dumpSchema, PostgresVersion, Statistics } from "@query-doctor/core"; +import { ConnectionManager } from "../sync/connection-manager.ts"; +import { Connectable } from "../sync/connectable.ts"; +import { QueryOptimizer } from "./query-optimizer.ts"; + +// When the exported snapshot doesn't cover a table in the current schema — added +// on this branch since the snapshot was captured — setStatistics must pass the +// schema through to core's Statistics so the synthesizer sizes that table, +// instead of it falling to the flat default. This is the analyzer-side wiring; +// the synthesis logic itself is covered in @query-doctor/core. +// +// Requires @query-doctor/core >= 0.13.0 (the currentSchema parameter and the +// syntheticTables getter). +test( + "setStatistics sizes a table missing from the exported snapshot", + async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + create table orders (id serial primary key); + create table refunds (id serial primary key, order_id int references orders(id)); + insert into orders default values; + insert into refunds (order_id) values (1); + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .start(); + + try { + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const version = PostgresVersion.parse("17"); + const connection = manager.getOrCreateConnection(conn); + const ownStats = await Statistics.dumpStats(connection, version); + + // refunds is omitted from the snapshot (uncovered); orders is given a + // production-sized count so synthesis yields a large number rather than + // the single branch row. + const snapshot = ownStats + .filter((t) => t.tableName !== "refunds") + .map((t) => ({ + ...t, + reltuples: t.tableName === "orders" ? 1_000_000 : t.reltuples, + })); + const schema = await dumpSchema(connection); + + await optimizer.setStatistics( + Statistics.statsModeFromExport(snapshot), + schema, + ); + + // refunds is reported as modeled/unverified and sized from the 1M-row + // snapshot (via its foreign key to orders), not its own one row. + expect( + optimizer.syntheticTables.some((t) => t.endsWith(".refunds")), + ).toBe(true); + const refunds = optimizer.computedStats?.reltuples.find( + (r) => r.relname === "refunds", + ); + expect(refunds?.reltuples).toBeGreaterThan(100_000); + } finally { + await pg.stop(); + } + }, + 120_000, +); diff --git a/src/remote/query-optimizer.ts b/src/remote/query-optimizer.ts index 7739eef..16deb5e 100644 --- a/src/remote/query-optimizer.ts +++ b/src/remote/query-optimizer.ts @@ -113,6 +113,15 @@ export class QueryOptimizer extends EventEmitter { return this.target?.statistics.ownMetadata; } + /** + * Tables ("schema.table") sized by the synthesizer because the exported + * snapshot didn't cover them — their costs are modeled, not verified. Empty + * unless a schema was supplied in fromStatisticsExport mode. + */ + get syntheticTables(): string[] { + return this.target?.statistics.syntheticTables ?? []; + } + getExistingIndexes(): FullSchemaIndex[] { return this.existingIndexes; } @@ -146,7 +155,11 @@ export class QueryOptimizer extends EventEmitter { const version = PostgresVersion.parse("17"); const pg = this.manager.getOrCreateConnection(this.connectable); const ownStats = await Statistics.dumpStats(pg, version); - const statistics = new Statistics(pg, version, ownStats, statsMode); + // Pass the current schema so tables the exported snapshot doesn't cover + // (added on this branch / since the snapshot was captured) are sized by the + // synthesizer instead of the flat default. No-op when statsMode isn't + // fromStatisticsExport. + const statistics = new Statistics(pg, version, ownStats, statsMode, schema); this.existingIndexes = schema?.indexes ?? []; const filteredIndexes = this.filterDisabledIndexes(this.existingIndexes); const optimizer = new IndexOptimizer(pg, statistics, filteredIndexes, { diff --git a/src/reporters/reporter.ts b/src/reporters/reporter.ts index 806ba25..a1d0cd2 100644 --- a/src/reporters/reporter.ts +++ b/src/reporters/reporter.ts @@ -117,6 +117,13 @@ export interface ReportContext { * A pure diff heuristic — independent of the baseline comparison. */ testPresenceVerdict?: TestPresenceVerdict; + /** + * Tables ("schema.table") in the analyzed schema the production snapshot + * doesn't cover, so their costs were modeled by the synthesizer rather than + * verified against real data. Empty when the snapshot covers the whole schema. + * Not yet rendered in the comment — plumbed for the surfacing that follows. + */ + modeledTables?: string[]; } export interface IndexStatistic { diff --git a/src/runner.ts b/src/runner.ts index de1c8ab..13af159 100644 --- a/src/runner.ts +++ b/src/runner.ts @@ -195,6 +195,7 @@ export class Runner { const reportContext: ReportContext = { statisticsMode: this.remote.optimizer.statisticsMode, computedStats: this.remote.optimizer.computedStats, + modeledTables: this.remote.optimizer.syntheticTables, recommendations: filteredRecommendations, queriesPastThreshold: filteredThresholdWarnings, queryStats: Object.freeze({