diff --git a/deno.json b/deno.json index 7ea6f67..23fe2af 100644 --- a/deno.json +++ b/deno.json @@ -25,7 +25,7 @@ "@libpg-query/parser": "npm:@libpg-query/parser@^17.6.3", "@opentelemetry/api": "jsr:@opentelemetry/api@^1.9.0", "@pgsql/types": "npm:@pgsql/types@^17.6.1", - "@query-doctor/core": "npm:@query-doctor/core@^0.3.0", + "@query-doctor/core": "npm:@query-doctor/core@^0.4.0", "@rabbit-company/rate-limiter": "jsr:@rabbit-company/rate-limiter@^3.0.0", "@std/assert": "jsr:@std/assert@^1.0.14", "@std/collections": "jsr:@std/collections@^1.1.3", diff --git a/deno.lock b/deno.lock index ed8045c..f770d27 100644 --- a/deno.lock +++ b/deno.lock @@ -20,7 +20,7 @@ "npm:@actions/github@^6.0.1": "6.0.1_@octokit+core@5.2.2", "npm:@libpg-query/parser@^17.6.3": "17.6.3", "npm:@pgsql/types@^17.6.1": "17.6.2", - "npm:@query-doctor/core@0.3": "0.3.0", + "npm:@query-doctor/core@0.4": "0.4.0", "npm:@testcontainers/postgresql@^11.9.0": "11.9.0", "npm:@types/node@^24.9.1": "24.10.1", "npm:@types/nunjucks@^3.2.6": "3.2.6", @@ -350,8 +350,8 @@ "@protobufjs/utf8@1.1.0": { "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" }, - "@query-doctor/core@0.3.0": { - "integrity": "sha512-ybfqah0Hzr0grSi1jBnP3WFaOK9S7ZRaNoyDBsVcd6O/0M0yGj8xiS580Rqkoqnww2qa5BFxDMSltaNOCmZo7g==", + "@query-doctor/core@0.4.0": { + "integrity": "sha512-d04ea11S9/hCkhXABFIbcNHP0uBzZ5WjFnCmYmjl3Yzl9XNm4KosJjI0jngnOQKuwSwNii0vvdYMxuOfMUiJNw==", "dependencies": [ "@pgsql/types", "colorette", @@ -1754,7 +1754,7 @@ "npm:@actions/github@^6.0.1", "npm:@libpg-query/parser@^17.6.3", "npm:@pgsql/types@^17.6.1", - "npm:@query-doctor/core@0.3", + "npm:@query-doctor/core@0.4", "npm:@testcontainers/postgresql@^11.9.0", "npm:@types/node@^24.9.1", "npm:@types/nunjucks@^3.2.6", diff --git a/src/remote/gin-indexes.test.ts b/src/remote/gin-indexes.test.ts new file mode 100644 index 0000000..3807dde --- /dev/null +++ b/src/remote/gin-indexes.test.ts @@ -0,0 +1,887 @@ +import { PostgreSqlContainer } from "@testcontainers/postgresql"; +import { QueryOptimizer } from "./query-optimizer.ts"; +import { ConnectionManager } from "../sync/connection-manager.ts"; +import { Connectable } from "../sync/connectable.ts"; +import { assert, assertEquals, assertGreater } from "@std/assert"; +import { type OptimizedQuery } from "../sql/recent-query.ts"; + +function hasGinRecommendation(query: OptimizedQuery): boolean { + if (query.optimization.state !== "improvements_available") return false; + return query.optimization.indexRecommendations.some((r) => + r.definition.toLowerCase().includes("using gin") + ); +} + +function getGinRecommendations(query: OptimizedQuery) { + if (query.optimization.state !== "improvements_available") return []; + return query.optimization.indexRecommendations.filter((r) => + r.definition.toLowerCase().includes("using gin") + ); +} + +function getBtreeRecommendations(query: OptimizedQuery) { + if (query.optimization.state !== "improvements_available") return []; + return query.optimization.indexRecommendations.filter( + (r) => !r.definition.toLowerCase().includes("using gin"), + ); +} + +// ────────────────────────────────────────────── +// 1. Basic @> containment → GIN jsonb_path_ops +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: basic @> containment recommends GIN with jsonb_path_ops", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE products ( + id serial PRIMARY KEY, + data jsonb NOT NULL + ); + INSERT INTO products (data) + SELECT jsonb_build_object('category', CASE WHEN i % 3 = 0 THEN 'electronics' ELSE 'clothing' END, 'name', 'product' || i) + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM products WHERE data @> '{"category": "electronics"}' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "products", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "data", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("@>") || q.query.includes("products") + ); + assert(match, "Expected improvements for @> containment query"); + assert(hasGinRecommendation(match), "Expected a GIN index recommendation"); + + const ginRecs = getGinRecommendations(match); + assert( + ginRecs.some((r) => + r.definition.toLowerCase().includes("jsonb_path_ops") + ), + `Expected jsonb_path_ops, got: ${ginRecs.map((r) => r.definition)}`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 2. Key existence ? → GIN default jsonb_ops +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: key existence (?) recommends GIN with default jsonb_ops", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE events ( + id serial PRIMARY KEY, + payload jsonb NOT NULL + ); + INSERT INTO events (payload) + SELECT jsonb_build_object('type', 'click', 'x', i, 'y', i * 2) || + CASE WHEN i % 5 = 0 THEN '{"element_id": "btn"}'::jsonb ELSE '{}'::jsonb END + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM events WHERE payload ? 'element_id' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "events", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "payload", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("?") || q.query.includes("payload") + ); + assert(match, "Expected improvements for ? key existence query"); + assert(hasGinRecommendation(match), "Expected a GIN index recommendation"); + + const ginRecs = getGinRecommendations(match); + // ? requires jsonb_ops — must NOT have jsonb_path_ops + assert( + ginRecs.every((r) => + !r.definition.toLowerCase().includes("jsonb_path_ops") + ), + `Expected default jsonb_ops (no jsonb_path_ops) for ? operator, got: ${ + ginRecs.map((r) => r.definition) + }`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 3. Any-key existence ?| → GIN default jsonb_ops +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: any-key existence (?|) recommends GIN with default jsonb_ops", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE events ( + id serial PRIMARY KEY, + payload jsonb NOT NULL + ); + INSERT INTO events (payload) + SELECT jsonb_build_object('type', 'click', 'x', i, 'y', i * 2) || + CASE WHEN i % 5 = 0 THEN '{"element_id": "btn"}'::jsonb ELSE '{}'::jsonb END || + CASE WHEN i % 7 = 0 THEN '{"delta": 50}'::jsonb ELSE '{}'::jsonb END + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM events WHERE payload ?| array['element_id', 'delta'] LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "events", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "payload", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("?|") || q.query.includes("payload") + ); + assert(match, "Expected improvements for ?| any-key existence query"); + assert(hasGinRecommendation(match), "Expected a GIN index recommendation"); + + const ginRecs = getGinRecommendations(match); + assert( + ginRecs.every((r) => + !r.definition.toLowerCase().includes("jsonb_path_ops") + ), + `Expected default jsonb_ops for ?| operator, got: ${ + ginRecs.map((r) => r.definition) + }`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 4. All-keys existence ?& → GIN default jsonb_ops +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: all-keys existence (?&) recommends GIN with default jsonb_ops", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE events ( + id serial PRIMARY KEY, + payload jsonb NOT NULL + ); + INSERT INTO events (payload) + SELECT jsonb_build_object('type', 'click', 'x', i, 'y', i * 2) + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM events WHERE payload ?& array['x', 'y'] LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "events", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "payload", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("?&") || q.query.includes("payload") + ); + assert(match, "Expected improvements for ?& all-keys existence query"); + assert(hasGinRecommendation(match), "Expected a GIN index recommendation"); + + const ginRecs = getGinRecommendations(match); + assert( + ginRecs.every((r) => + !r.definition.toLowerCase().includes("jsonb_path_ops") + ), + `Expected default jsonb_ops for ?& operator, got: ${ + ginRecs.map((r) => r.definition) + }`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 5. Mixed JSONB + regular column → GIN + B-tree +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: mixed JSONB and regular column produces both GIN and B-tree", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE products ( + id serial PRIMARY KEY, + data jsonb NOT NULL, + price numeric NOT NULL + ); + INSERT INTO products (data, price) + SELECT jsonb_build_object('active', i % 2 = 0, 'name', 'product' || i), + (random() * 500)::numeric + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM products WHERE data @> '{"active": true}' AND price > 100 LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "products", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "data", stats: null }, + { columnName: "price", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("products") + ); + assert(match, "Expected improvements for mixed JSONB + regular query"); + assert( + match.optimization.state === "improvements_available", + `Expected improvements_available but got ${match.optimization.state}`, + ); + + const ginRecs = getGinRecommendations(match); + const btreeRecs = getBtreeRecommendations(match); + + // Should have a GIN recommendation for the JSONB column + assert( + ginRecs.length > 0, + `Expected GIN recommendation for data column, got: ${ + JSON.stringify(match.optimization.indexRecommendations.map((r) => r.definition)) + }`, + ); + // The two index types should not interfere — GIN for data, B-tree for price + // The optimizer may or may not also produce a B-tree for price depending on + // cost analysis, but the GIN and B-tree candidates must remain separate types + for (const gin of ginRecs) { + assert( + !gin.definition.toLowerCase().includes("price"), + `GIN recommendation should not include non-JSONB column "price", got: ${gin.definition}`, + ); + } + for (const btree of btreeRecs) { + assert( + !btree.definition.toLowerCase().includes("using gin"), + `B-tree recommendation should not be a GIN index, got: ${btree.definition}`, + ); + } + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 6. Mixed @> and ? on same column → ONE GIN +// with default jsonb_ops (opclass escalation) +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: mixed @> and ? on same column escalates to jsonb_ops", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE products ( + id serial PRIMARY KEY, + data jsonb NOT NULL + ); + INSERT INTO products (data) + SELECT jsonb_build_object('a', i, 'name', 'product' || i) || + CASE WHEN i % 3 = 0 THEN '{"b": true}'::jsonb ELSE '{}'::jsonb END + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM products WHERE data @> '{"a": 1}' AND data ? 'b' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "products", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "data", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("products") + ); + assert(match, "Expected improvements for mixed @> and ? query"); + + const ginRecs = getGinRecommendations(match); + + // Should produce exactly ONE GIN index, not two + assertEquals( + ginRecs.length, + 1, + `Expected exactly 1 merged GIN recommendation, got ${ginRecs.length}: ${ + ginRecs.map((r) => r.definition) + }`, + ); + // Should use default jsonb_ops (no jsonb_path_ops) because ? requires it + assert( + !ginRecs[0].definition.toLowerCase().includes("jsonb_path_ops"), + `Expected default jsonb_ops due to ? operator, got: ${ginRecs[0].definition}`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 7. Table-aliased JSONB column resolves correctly +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: table alias resolves to correct table for GIN recommendation", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE products ( + id serial PRIMARY KEY, + data jsonb NOT NULL + ); + INSERT INTO products (data) + SELECT jsonb_build_object('color', CASE WHEN i % 2 = 0 THEN 'red' ELSE 'blue' END, 'name', 'product' || i) + FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM products p WHERE p.data @> '{"color": "red"}' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "products", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "data", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => + q.query.includes("products") + ); + assert(match, "Expected improvements for aliased JSONB query"); + assert(hasGinRecommendation(match), "Expected a GIN index recommendation"); + + const ginRecs = getGinRecommendations(match); + // Should target the real table "products", not the alias "p" + assert( + ginRecs.some((r) => r.table === "products"), + `Expected GIN recommendation on table "products", got: ${ + ginRecs.map((r) => `${r.table}: ${r.definition}`) + }`, + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 8. Non-JSONB query → normal B-tree, no GIN +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: non-JSONB query produces B-tree only, no GIN", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE users ( + id serial PRIMARY KEY, + name text NOT NULL + ); + INSERT INTO users (name) + SELECT 'user' || i FROM generate_series(1, 1000) i; + CREATE EXTENSION pg_stat_statements; + SELECT * FROM users WHERE name = 'alice' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "users", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "name", stats: null }, + ], + indexes: [], + }], + }); + await optimizer.finish; + + const match = improvements.find((q) => q.query.includes("users")); + assert(match, "Expected improvements for non-JSONB equality query"); + + const ginRecs = getGinRecommendations(match); + assertEquals( + ginRecs.length, + 0, + `Expected no GIN recommendations for non-JSONB query, got: ${ + ginRecs.map((r) => r.definition) + }`, + ); + + const btreeRecs = getBtreeRecommendations(match); + assertGreater( + btreeRecs.length, + 0, + "Expected B-tree recommendation for text equality query", + ); + } finally { + await pg.stop(); + } + }, +}); + +// ────────────────────────────────────────────── +// 9. Existing GIN index prevents duplicate +// ────────────────────────────────────────────── + +Deno.test({ + name: "GIN: existing GIN index prevents duplicate recommendation", + sanitizeOps: false, + sanitizeResources: false, + fn: async () => { + const pg = await new PostgreSqlContainer("postgres:17") + .withCopyContentToContainer([ + { + content: ` + CREATE TABLE products ( + id serial PRIMARY KEY, + data jsonb NOT NULL + ); + INSERT INTO products (data) + SELECT jsonb_build_object('category', CASE WHEN i % 3 = 0 THEN 'electronics' ELSE 'clothing' END, 'name', 'product' || i) + FROM generate_series(1, 1000) i; + CREATE INDEX idx_products_data ON products USING gin (data jsonb_path_ops); + CREATE EXTENSION pg_stat_statements; + SELECT * FROM products WHERE data @> '{"category": "electronics"}' LIMIT 10; + `, + target: "/docker-entrypoint-initdb.d/init.sql", + }, + ]) + .withCommand([ + "-c", + "shared_preload_libraries=pg_stat_statements", + "-c", + "autovacuum=off", + "-c", + "track_counts=off", + "-c", + "track_io_timing=off", + "-c", + "track_activities=off", + ]) + .start(); + + const manager = ConnectionManager.forLocalDatabase(); + const conn = Connectable.fromString(pg.getConnectionUri()); + const optimizer = new QueryOptimizer(manager, conn); + + const improvements: OptimizedQuery[] = []; + const noImprovements: OptimizedQuery[] = []; + optimizer.addListener("improvementsAvailable", (query) => { + improvements.push(query); + }); + optimizer.addListener("noImprovements", (query) => { + noImprovements.push(query); + }); + + const connector = manager.getConnectorFor(conn); + try { + const recentQueries = await connector.getRecentQueries(); + await optimizer.start(recentQueries, { + kind: "fromStatisticsExport", + source: { kind: "inline" }, + stats: [{ + tableName: "products", + schemaName: "public", + relpages: 100, + reltuples: 100_000, + relallvisible: 1, + columns: [ + { columnName: "id", stats: null }, + { columnName: "data", stats: null }, + ], + indexes: [{ + indexName: "idx_products_data", + relpages: 50, + reltuples: 100_000, + relallvisible: 1, + }], + }], + }); + await optimizer.finish; + + // Should NOT recommend another GIN index on the same column + const ginImprovement = improvements.find((q) => + q.query.includes("products") && hasGinRecommendation(q) + ); + assert( + !ginImprovement, + `Expected no duplicate GIN recommendation when one already exists, but got: ${ + ginImprovement + ? JSON.stringify( + getGinRecommendations(ginImprovement).map((r) => r.definition), + ) + : "none" + }`, + ); + } finally { + await pg.stop(); + } + }, +});