From e1cfc6269161e6db6cfc067080ba09cdcee40a7c Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Thu, 16 Jul 2026 23:02:09 -0400 Subject: [PATCH 1/2] feat: cost schema the production snapshot doesn't cover via the synthesizer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the current schema into Statistics in QueryOptimizer.setStatistics, so a table the exported production snapshot doesn't cover — added on this branch, or since the snapshot was captured — is sized by core's synthesizer (foreign-key graph, shape class, column inference) instead of the flat default. Both the connected and CI paths already thread the schema through onSuccessfulSync -> optimizer.start, so this one change covers both. Expose the synthesized-table set (QueryOptimizer.syntheticTables) and plumb it onto ReportContext.modeledTables for the run report. Rendering it in the PR comment (a 'modeled, not verified' banner) is the follow-up, done once core publishes so the template and snapshot tests can be validated. Requires @query-doctor/core >= 0.13.0 (the currentSchema parameter and syntheticTables); the dep is bumped but the lockfile can't reconcile until that version is published. CI will fail on install until then — expected. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 2 +- src/remote/query-optimizer.ts | 15 ++++++++++++++- src/reporters/reporter.ts | 7 +++++++ src/runner.ts | 1 + 4 files changed, 23 insertions(+), 2 deletions(-) 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.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({ From 6b8495cd98c65939f37c4cf3e034c3f2423c974d Mon Sep 17 00:00:00 2001 From: Jean-Philippe Sirois Date: Fri, 17 Jul 2026 03:02:39 -0400 Subject: [PATCH 2/2] test: cover the synthesizer wiring in setStatistics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration test (testcontainers, mirroring query-optimizer.test.ts): a table absent from the exported snapshot but present in the schema — foreign-keyed to a covered table sized at 1M — is reported in syntheticTables and costed from the snapshot, not its single branch row. Proves setStatistics forwards the schema so core's synthesizer engages. Can't run until @query-doctor/core@0.13.0 publishes and deps install; staged to validate when the branch is closed out post-publish, alongside the deferred comment-render. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/remote/query-optimizer-synthesis.test.ts | 72 ++++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/remote/query-optimizer-synthesis.test.ts 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, +);