diff --git a/CHANGELOG.md b/CHANGELOG.md index d2ae567d0..05bf5183a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### New Features + +- PostgreSQL projects can now index `.sql`, `.psql`, and `.pgsql` files, including schemas, tables and altered columns, views, routines, types and enum changes, triggers, policies, indexes, sequences, extensions, and their table, foreign-key, trigger, type, sequence, and static SQL or PL/pgSQL routine-body dependencies. Migration-ordered relation drops, re-creations and renames, constraint renames, removed foreign keys, routine-local `search_path`, and malformed SQL are handled without inventing stale relationships. ## [1.5.0] - 2026-07-21 diff --git a/README.md b/README.md index dccebd3e4..b894144dd 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ CodeGraph's parsing engine is a **native Rust kernel**: 20 languages — TypeScr | **Full-Text Search** | Find code by name instantly across your entire codebase, powered by FTS5 | | **Impact Analysis** | Trace callers, callees, and the full impact radius of any symbol before making changes | | **Always Fresh** | File watcher uses native OS events (FSEvents/inotify/ReadDirectoryChangesW) with debounced auto-sync — the graph stays current as you code, zero config | -| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, Svelte, Vue, Astro, Liquid, Pascal/Delphi | +| **20+ Languages** | TypeScript, JavaScript, ArkTS, Python, Go, Rust, Java, C#, VB.NET, PHP, Ruby, C, C++, CUDA, Objective-C, Metal, Swift, Kotlin, Scala, Dart, Lua, Luau, R, Nix, Erlang, CFML, COBOL, Solidity, Terraform/OpenTofu, PostgreSQL, Svelte, Vue, Astro, Liquid, Pascal/Delphi | | **Framework-aware Routes** | Recognizes web-framework routing files and links URL patterns to their handlers across 17 frameworks | | **Mixed iOS / React Native / Expo** | Closes cross-language flows that static parsing misses: Swift ↔ ObjC bridging, React Native legacy bridge + TurboModules + Fabric view components, native → JS event emitters, Expo Modules | | **100% Local** | No data leaves your machine. No API keys. No external services. SQLite database only | @@ -794,6 +794,7 @@ is written): | Erlang | `.erl`, `.hrl`, `.escript`, `.app.src`, `.app` | Full support (functions with multi-clause/multi-arity grouping, `-spec` signatures, records with fields, `-type`/`-opaque` aliases, `-define` macros, `-include`/`-include_lib`/`-import` edges, local and `mod:fn` remote call edges, `fun name/arity` references, `spawn`/`apply`/`proc_lib`/`timer`/`rpc` MFA-argument call edges, `gen_server:call/cast(?MODULE)` → own `handle_call`/`handle_cast` links, `-behaviour` links, `-export`-based visibility) | | Solidity | `.sol` | Full support (contracts, libraries, interfaces, structs, enums, modifiers, events, errors, state variables, `import`/`using` directives, `emit`/`revert` calls) | | Terraform / OpenTofu | `.tf`, `.tfvars`, `.tofu` | Full support (resources, data sources, modules, variables, outputs, providers incl. aliases, `locals`; `var.`/`local.`/`module.`/resource references with Terraform's per-directory scoping enforced; module calls bridged across the boundary — inputs to the child module's variables, `module.M.out` to the child's output, `source` to the module's files; cloudposse/atmos `remote-state` cross-component wiring when the component is statically named; `provider = aws.east` selections resolved up the module tree; `moved`/`import`/`removed`/`check` block references; `.tfvars` assignments linked to the variables they set) | +| PostgreSQL | `.sql`, `.psql`, `.pgsql` | Initial support (schemas, tables and columns introduced by `CREATE TABLE` or relation-linked `ALTER TABLE ... ADD COLUMN` migration deltas, views/materialized views, functions/procedures, enums/domains, triggers, policies, indexes, sequences, extensions, foreign keys, relation dependencies, and routine calls; string-literal routine bodies including dollar-quoted PL/pgSQL are not parsed yet) | | Nix | `.nix` | Full support (functions with simple/destructured/curried params, `let`/attrset bindings, `inherit`, `import ./path` file edges — `./dir` resolving through `default.nix` — plus NixOS module `imports = [ ./x.nix ]` lists and `callPackage ./pkg.nix` file edges; call edges; module-system option wiring — a config write like `launchd.user.agents.x = { ... }` links to the module declaring `options.launchd.user.agents`, so option flows trace across modules) | ## Measured cross-file coverage diff --git a/__tests__/db-perf.test.ts b/__tests__/db-perf.test.ts index 9be0803b2..574b512d7 100644 --- a/__tests__/db-perf.test.ts +++ b/__tests__/db-perf.test.ts @@ -252,6 +252,73 @@ describe('runMaintenance', () => { }); }); +describe('bulk-load index crash recovery', () => { + it('recreates every secondary index dropped by interrupted bulk-load windows on reopen', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'db-bulk-heal-')); + const dbPath = path.join(dir, 'test.db'); + const db = DatabaseConnection.initialize(dbPath); + + // Simulate a process dying with all three bulk windows open. close() does + // not repair schema objects, so the persisted database matches the state a + // SIGKILL would leave behind. + db.beginBulkParseLoad(); + db.beginBulkRefLoad(); + + const dropped = [ + 'idx_nodes_kind', + 'idx_nodes_name', + 'idx_nodes_qualified_name', + 'idx_nodes_file_path', + 'idx_nodes_language', + 'idx_nodes_file_line', + 'idx_nodes_lower_name', + 'idx_unresolved_from_node', + 'idx_unresolved_name', + 'idx_unresolved_file_path', + 'idx_unresolved_from_name', + 'idx_unresolved_status', + 'idx_unresolved_failed_tail', + 'idx_files_language', + 'idx_files_modified_at', + 'idx_edges_kind', + 'idx_edges_source_kind', + 'idx_edges_target_kind', + 'idx_edges_provenance', + 'idx_edges_synthesized_by', + ]; + const indexExists = (connection: DatabaseConnection, name: string) => + connection + .getDb() + .prepare("SELECT 1 FROM sqlite_master WHERE type = 'index' AND name = ?") + .get(name); + + for (const name of dropped) expect(indexExists(db, name)).toBeUndefined(); + db.close(); + + const reopened = DatabaseConnection.open(dbPath); + try { + for (const name of dropped) expect(indexExists(reopened, name), name).toBeTruthy(); + + // The new expression index is not merely present by name: SQLite can use + // it for the ownership predicate exercised by whole-graph synthesizers. + const plan = reopened + .getDb() + .prepare( + `EXPLAIN QUERY PLAN + SELECT source, target, kind + FROM edges + WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') = ? + AND json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL` + ) + .all('postgres-foreign-key') as Array<{ detail: string }>; + expect(plan.map((row) => row.detail).join(' ')).toContain('idx_edges_synthesized_by'); + } finally { + reopened.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + // The edges table carried no UNIQUE constraint, so `insertEdge`'s // `INSERT OR IGNORE` had nothing to conflict on and silently admitted // byte-identical duplicate rows when two passes emitted the same edge (#1034). @@ -344,7 +411,7 @@ describe('migration v6: dedup edges + add identity index on upgrade (#1034)', () runMigrations(raw, 5); expect(count()).toBe(2); // duplicate collapsed, the distinct `calls` edge kept - expect(getCurrentVersion(raw)).toBe(8); + expect(getCurrentVersion(raw)).toBe(9); const idx = raw .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_edges_identity'") .get(); diff --git a/__tests__/foundation.test.ts b/__tests__/foundation.test.ts index 8ed73123e..08b9f222f 100644 --- a/__tests__/foundation.test.ts +++ b/__tests__/foundation.test.ts @@ -370,7 +370,7 @@ describe('Database Connection', () => { const version = db.getSchemaVersion(); expect(version).not.toBeNull(); - expect(version?.version).toBe(8); + expect(version?.version).toBe(9); db.close(); }); diff --git a/__tests__/postgres-enum-rename-lifecycle.test.ts b/__tests__/postgres-enum-rename-lifecycle.test.ts new file mode 100644 index 000000000..ec22dfe4a --- /dev/null +++ b/__tests__/postgres-enum-rename-lifecycle.test.ts @@ -0,0 +1,198 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { + decodePostgresEnumValueMutationDescriptor, + decodePostgresTypeRenameDescriptor, +} from '../src/postgres/type-lifecycle'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); +}); + +describe('PostgreSQL enum rename lifecycle', () => { + it('encodes type identity and old/new value labels without classifying the alias early', () => { + const result = extractFromSource('db/renames.sql', [ + "CREATE TYPE app.job_status AS ENUM ('queued', 'running');", + 'ALTER TYPE app.job_status RENAME TO task_status;', + "ALTER TYPE app.task_status RENAME VALUE 'queued' TO 'pending';", + ].join('\n'), 'postgres'); + + expect(result.errors).toEqual([]); + const alias = result.nodes.find((node) => node.qualifiedName === 'app.task_status')!; + expect(alias).toMatchObject({ + kind: 'type_alias', + decorators: expect.arrayContaining(['postgres:type', 'postgres:renamed-type']), + }); + expect(alias.decorators).not.toContain('postgres:enum'); + expect(decodePostgresTypeRenameDescriptor(alias.decorators)).toEqual({ + sourceType: 'app.job_status', + targetType: 'app.task_status', + }); + + const pending = result.nodes.find((node) => node.qualifiedName === 'app.task_status.pending')!; + expect(decodePostgresEnumValueMutationDescriptor(pending.decorators)).toEqual({ + mutation: 'rename', + enumType: 'app.task_status', + sourceValue: 'queued', + targetValue: 'pending', + }); + }); + + it('carries exact effective membership through chained type and value renames', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-enum-lifecycle-')); + fs.writeFileSync( + path.join(projectDir, '001_enum.sql'), + "CREATE TYPE app.job_status AS ENUM ('queued', 'running');\n" + ); + fs.writeFileSync( + path.join(projectDir, '002_first_rename.sql'), + 'ALTER TYPE app.job_status RENAME TO task_status;\n' + ); + fs.writeFileSync( + path.join(projectDir, '003_values.sql'), + [ + "ALTER TYPE app.task_status ADD VALUE 'paused';", + "ALTER TYPE app.task_status RENAME VALUE 'queued' TO 'pending';", + ].join('\n') + '\n' + ); + fs.writeFileSync( + path.join(projectDir, '004_second_rename.sql'), + 'ALTER TYPE app.task_status RENAME TO workflow_status;\n' + ); + fs.writeFileSync( + path.join(projectDir, '005_final_values.sql'), + [ + "ALTER TYPE app.workflow_status RENAME VALUE 'running' TO 'active';", + "ALTER TYPE app.workflow_status ADD VALUE 'done';", + ].join('\n') + '\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const original = graph.getNodesByQualifiedName('app.job_status')[0]!; + const intermediate = graph.getNodesByQualifiedName('app.task_status')[0]!; + const current = graph.getNodesByQualifiedName('app.workflow_status')[0]!; + + expect(current).toMatchObject({ + kind: 'type_alias', + decorators: expect.arrayContaining(['postgres:type', 'postgres:renamed-type']), + }); + const relationTargets = (nodeId: string) => graph.getOutgoingEdges(nodeId) + .filter((edge) => edge.metadata?.postgresRelation === 'enum-rename') + .map((edge) => edge.target); + expect(relationTargets(original.id)).toEqual([intermediate.id]); + expect(relationTargets(intermediate.id)).toEqual([current.id]); + + const effectiveMembers = (nodeId: string) => graph.getOutgoingEdges(nodeId) + .filter((edge) => edge.metadata?.postgresRelation === 'enum-effective-containment') + .map((edge) => graph.getNode(edge.target)!.name) + .sort(); + expect(effectiveMembers(intermediate.id)).toEqual(['paused', 'pending', 'running']); + expect(effectiveMembers(current.id)).toEqual(['active', 'done', 'paused', 'pending']); + expect(effectiveMembers(current.id)).not.toContain('queued'); + expect(effectiveMembers(current.id)).not.toContain('running'); + + // Historical declarations and mutation nodes remain queryable even when + // their labels are no longer part of the current enum identity. + expect(graph.getNodesByQualifiedName('app.job_status.queued')).not.toEqual([]); + expect(graph.getNodesByQualifiedName('app.task_status.pending')).not.toEqual([]); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('records a native enum value rename as an explicit historical transition', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-native-enum-rename-')); + fs.writeFileSync( + path.join(projectDir, '001_enum.sql'), + "CREATE TYPE app.status AS ENUM ('pending', 'active');\n" + ); + fs.writeFileSync( + path.join(projectDir, '002_rename.sql'), + "ALTER TYPE app.status RENAME VALUE 'pending' TO 'queued';\n" + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const status = graph.getNodesByQualifiedName('app.status')[0]!; + const pending = graph.getNodesByQualifiedName('app.status.pending')[0]!; + const queued = graph.getNodesByQualifiedName('app.status.queued')[0]!; + const containedMembers = graph.getOutgoingEdges(status.id) + .filter((edge) => edge.kind === 'contains') + .map((edge) => graph.getNode(edge.target)!) + .filter((node) => node.kind === 'enum_member'); + + // Ordinary containment is source history: CREATE TYPE's old label stays + // queryable, and ALTER TYPE's replacement is attached by synthesis. + expect(containedMembers.map((member) => member.name).sort()).toEqual([ + 'active', + 'pending', + 'queued', + ]); + + const transition = graph.getOutgoingEdges(pending.id).find( + (edge) => edge.metadata?.postgresRelation === 'enum-value-rename' + ); + expect(transition).toMatchObject({ + target: queued.id, + kind: 'references', + metadata: { + postgresRelation: 'enum-value-rename', + enumType: 'app.status', + sourceValue: 'pending', + targetValue: 'queued', + }, + }); + + const superseded = new Set(containedMembers + .filter((member) => graph.getOutgoingEdges(member.id).some( + (edge) => edge.metadata?.postgresRelation === 'enum-value-rename' + )) + .map((member) => member.id)); + expect(containedMembers + .filter((member) => !superseded.has(member.id)) + .map((member) => member.name) + .sort()).toEqual(['active', 'queued']); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not infer enum identity for a renamed composite type', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-type-lifecycle-')); + fs.writeFileSync( + path.join(projectDir, '001_type.sql'), + 'CREATE TYPE app.point AS (x integer, y integer);\n' + ); + fs.writeFileSync( + path.join(projectDir, '002_rename.sql'), + 'ALTER TYPE app.point RENAME TO coordinate;\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const alias = graph.getNodesByQualifiedName('app.coordinate')[0]!; + expect(alias.kind).toBe('type_alias'); + expect(graph.getIncomingEdges(alias.id).some( + (edge) => edge.metadata?.postgresRelation === 'enum-rename' + )).toBe(false); + expect(graph.getOutgoingEdges(alias.id).some( + (edge) => edge.metadata?.postgresRelation === 'enum-effective-containment' + )).toBe(false); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/postgres-extraction.test.ts b/__tests__/postgres-extraction.test.ts new file mode 100644 index 000000000..6878a2ba9 --- /dev/null +++ b/__tests__/postgres-extraction.test.ts @@ -0,0 +1,1102 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { + POSTGRES_FOREIGN_KEY_DECORATOR, + decodePostgresForeignKeyDescriptor, +} from '../src/postgres/foreign-key'; +import { + POSTGRES_DROP_CONSTRAINT_DECORATOR, + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + decodePostgresDropConstraintDescriptor, + decodePostgresRenameConstraintDescriptor, +} from '../src/postgres/constraint-mutation'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + decodePostgresTableRelationDescriptor, +} from '../src/postgres/table-relation'; +import { + POSTGRES_SEQUENCE_REFERENCE_KIND, + POSTGRES_TYPE_REFERENCE_KIND, +} from '../src/postgres/reference-intent'; +import { + POSTGRES_DROP_RELATION_DECORATOR, + decodePostgresDropRelationDescriptor, +} from '../src/postgres/relation-lifecycle'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); +}); + +function extract(source: string) { + return extractFromSource('db/schema.sql', source, 'postgres'); +} + +describe('PostgreSQL extraction', () => { + it('reports recovered syntax errors instead of silently marking the file clean', () => { + const result = extract('CREATE TABLE broken (id bigint,,);'); + expect(result.errors).toEqual([ + expect.objectContaining({ + code: 'parse_error', + filePath: 'db/schema.sql', + severity: 'error', + }), + ]); + }); + + it('extracts database objects and columns with generic CodeGraph kinds', () => { + const result = extract([ + 'CREATE SCHEMA app;', + "CREATE TYPE app.status AS ENUM ('active', 'disabled');", + 'CREATE TYPE app.coordinate AS (x integer, y integer);', + "CREATE DOMAIN app.email AS text CHECK (VALUE <> '');", + 'CREATE TABLE app.users (id bigint PRIMARY KEY, email app.email);', + 'CREATE FOREIGN TABLE app.remote_events (id bigint) SERVER analytics;', + 'CREATE TABLE app.user_copy (id, email) AS SELECT id, email FROM app.users;', + 'CREATE VIEW app.active_users (id, email) AS SELECT id, email FROM app.users;', + 'CREATE MATERIALIZED VIEW app.user_rollup AS SELECT count(*) FROM app.users;', + 'CREATE FUNCTION app.touch_user(p_id bigint) RETURNS void LANGUAGE sql AS \'SELECT 1\';', + 'CREATE PROCEDURE app.refresh_users() LANGUAGE sql AS \'SELECT 1\';', + 'CREATE TRIGGER users_touch AFTER UPDATE ON app.users EXECUTE FUNCTION app.touch_user();', + 'CREATE POLICY users_read ON app.users USING (true);', + 'CREATE INDEX users_email_idx ON app.users(email);', + 'CREATE SEQUENCE app.event_id_seq;', + 'CREATE EXTENSION IF NOT EXISTS pgcrypto;', + ].join('\n')); + + expect(result.errors).toEqual([]); + + const byQualifiedName = new Map(result.nodes.map((node) => [node.qualifiedName, node])); + expect(byQualifiedName.get('app')?.kind).toBe('namespace'); + expect(byQualifiedName.get('app.status')?.kind).toBe('enum'); + expect(byQualifiedName.get('app.status.active')?.kind).toBe('enum_member'); + expect(byQualifiedName.get('app.coordinate')?.kind).toBe('type_alias'); + expect(byQualifiedName.get('app.email')?.decorators).toContain('postgres:domain'); + + expect(byQualifiedName.get('app.users')?.kind).toBe('struct'); + expect(byQualifiedName.get('app.users.id')?.kind).toBe('field'); + expect(byQualifiedName.get('app.remote_events')?.decorators).toContain('postgres:foreign-table'); + expect(byQualifiedName.get('app.user_copy')?.decorators).toContain('postgres:table'); + expect(byQualifiedName.get('app.user_copy.email')?.kind).toBe('field'); + expect(byQualifiedName.get('app.active_users')?.decorators).toContain('postgres:view'); + expect(byQualifiedName.get('app.active_users.id')?.kind).toBe('field'); + expect(byQualifiedName.get('app.user_rollup')?.decorators).toContain('postgres:materialized-view'); + + expect(byQualifiedName.get('app.touch_user')?.kind).toBe('function'); + expect(byQualifiedName.get('app.refresh_users')?.decorators).toContain('postgres:procedure'); + expect(byQualifiedName.get('app.users.users_touch')?.decorators).toContain('postgres:trigger'); + expect(byQualifiedName.get('app.users.users_read')?.decorators).toContain('postgres:policy'); + expect(byQualifiedName.get('app.users_email_idx')?.decorators).toContain('postgres:index'); + expect(byQualifiedName.get('app.event_id_seq')?.decorators).toContain('postgres:sequence'); + expect(byQualifiedName.get('pgcrypto')?.decorators).toContain('postgres:extension'); + + const trigger = byQualifiedName.get('app.users.users_touch')!; + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromNodeId: trigger.id, + referenceKind: 'references', + referenceName: 'app.users', + }), + expect.objectContaining({ + fromNodeId: trigger.id, + referenceKind: 'calls', + referenceName: 'app.touch_user', + }), + ])); + }); + + it('does not treat PostgreSQL range and multirange catalog types as user-defined', () => { + const result = extract([ + 'CREATE TABLE app.availability (', + ' dates daterange, date_sets datemultirange,', + ' integers int4range, integer_sets int4multirange,', + ' bigints int8range, bigint_sets int8multirange,', + ' numbers numrange, number_sets nummultirange,', + ' timestamps tsrange, timestamp_sets tsmultirange,', + ' zoned_times tstzrange, zoned_time_sets tstzmultirange', + ');', + ].join('\n')); + + expect(result.errors).toEqual([]); + expect(result.unresolvedReferences.filter( + (ref) => ref.referenceKind === POSTGRES_TYPE_REFERENCE_KIND + )).toEqual([]); + }); + + it('emits sequence references only for literal first arguments through casts', () => { + const result = extract([ + 'CREATE TABLE app.events (', + " literal_id bigint DEFAULT nextval((('app.literal_seq'))::regclass),", + " cast_id bigint DEFAULT setval(CAST('app.cast_seq' AS regclass), 1),", + " current_id bigint DEFAULT currval('app.current_seq'::regclass),", + " concatenated_id bigint DEFAULT nextval(('app.foo_' || tenant)::regclass),", + " formatted_id bigint DEFAULT nextval(format('app.%s_seq', tenant)::regclass)", + ');', + ].join('\n')); + + expect(result.errors).toEqual([]); + expect(result.unresolvedReferences + .filter((ref) => ref.referenceKind === POSTGRES_SEQUENCE_REFERENCE_KIND) + .map((ref) => ref.referenceName) + .sort() + ).toEqual(['app.cast_seq', 'app.current_seq', 'app.literal_seq']); + }); + + it('emits and de-duplicates relation and routine references while ignoring CTE aliases', () => { + const result = extract([ + 'CREATE VIEW app.dashboard AS', + 'WITH recent AS (', + ' SELECT * FROM audit.events', + ')', + 'SELECT *', + 'FROM recent', + 'JOIN app.users u ON true', + 'JOIN app.users u2 ON true', + 'WHERE app.can_view(u.id) AND app.can_view(u2.id);', + ].join('\n')); + + const view = result.nodes.find((node) => node.qualifiedName === 'app.dashboard'); + expect(view).toBeTruthy(); + + const refs = result.unresolvedReferences.filter((ref) => ref.fromNodeId === view!.id); + expect(refs.map((ref) => `${ref.referenceKind}:${ref.referenceName}`).sort()).toEqual([ + 'calls:app.can_view', + 'references:app.users', + 'references:audit.events', + ]); + expect(refs.some((ref) => ref.referenceName === 'recent')).toBe(false); + }); + + it('retains calls whose names collide with PostgreSQL built-ins', () => { + const result = extract([ + 'CREATE VIEW app.dashboard AS', + 'SELECT count(*), lower(app.display_name), app.custom_metric()', + 'FROM app.users;', + ].join('\n')); + + const view = result.nodes.find((node) => node.qualifiedName === 'app.dashboard'); + expect(view).toBeTruthy(); + + const calls = result.unresolvedReferences + .filter((ref) => ref.fromNodeId === view!.id && ref.referenceKind === 'calls') + .map((ref) => ref.referenceName) + .sort(); + expect(calls).toEqual(['app.custom_metric', 'count', 'lower']); + }); + + it('does not leak a nested query CTE alias into the outer query scope', () => { + const result = extract([ + 'SELECT * FROM recent', + 'WHERE EXISTS (', + ' WITH recent AS (SELECT * FROM audit.events)', + ' SELECT * FROM recent', + ');', + ].join('\n')); + + const file = result.nodes.find((node) => node.kind === 'file')!; + const refs = result.unresolvedReferences + .filter((ref) => ref.fromNodeId === file.id && ref.referenceKind === 'references') + .map((ref) => ref.referenceName) + .sort(); + expect(refs).toEqual(['audit.events', 'recent']); + }); + + it('canonicalizes quoted and unquoted identifiers and captures ALTER TABLE foreign keys', () => { + const result = extract([ + 'CREATE TABLE "App"."Users" ("ID" bigint);', + 'CREATE TABLE PUBLIC.TEAMS (ID bigint);', + 'ALTER TABLE "App"."Users"', + ' ADD CONSTRAINT users_team_fk FOREIGN KEY ("ID") REFERENCES PUBLIC.TEAMS(ID);', + 'SELECT * FROM "App"."Users";', + ].join('\n')); + + expect(result.nodes.some((node) => node.qualifiedName === '"App"."Users"')).toBe(true); + expect(result.nodes.some((node) => node.qualifiedName === '"App"."Users"."ID"')).toBe(true); + expect(result.nodes.some((node) => node.qualifiedName === 'public.teams')).toBe(true); + + const file = result.nodes.find((node) => node.kind === 'file')!; + const fileRefs = result.unresolvedReferences + .filter((ref) => ref.fromNodeId === file.id && ref.referenceKind === 'references') + .map((ref) => ref.referenceName) + .sort(); + expect(fileRefs).toEqual(['"App"."Users"']); + + const foreignKey = result.nodes.find((node) => + node.decorators?.includes(POSTGRES_FOREIGN_KEY_DECORATOR) + ); + expect(foreignKey).toBeTruthy(); + expect(foreignKey!.qualifiedName).toBe('"App"."Users".users_team_fk'); + expect(decodePostgresForeignKeyDescriptor(foreignKey!.decorators)).toEqual({ + sourceTable: '"App"."Users"', + targetTable: 'public.teams', + constraintName: 'users_team_fk', + sourceColumns: ['ID'], + targetColumns: ['id'], + }); + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === foreignKey!.id) + .map((ref) => ref.referenceName) + .sort() + ).toEqual(['"App"."Users"', 'public.teams']); + }); + + it('preserves quoted identifier segment boundaries and escaped quotes', () => { + const result = extract( + 'CREATE TABLE "public.app".users (id bigint); ' + + 'CREATE TABLE public."app.users" (id bigint); ' + + 'CREATE TABLE "a""b"."c.d" ("e.f" bigint);' + ); + const tables = result.nodes + .filter((node) => node.kind === 'struct') + .map((node) => node.qualifiedName); + expect(tables).toEqual(expect.arrayContaining([ + '"public.app".users', + 'public."app.users"', + '"a""b"."c.d"', + ])); + expect(new Set(result.nodes.map((node) => node.id)).size).toBe(result.nodes.length); + expect(result.nodes.some((node) => node.qualifiedName === '"public.app".users.id')).toBe(true); + expect(result.nodes.some((node) => node.qualifiedName === 'public."app.users".id')).toBe(true); + expect(result.nodes.some((node) => node.qualifiedName === '"a""b"."c.d"."e.f"')).toBe(true); + }); + + it('indexes authorization-only schemas, dynamic nested DDL, and temporary tables safely', () => { + const result = extract([ + 'CREATE SCHEMA AUTHORIZATION joe CREATE TABLE users (id bigint);', + 'CREATE SCHEMA AUTHORIZATION CURRENT_USER CREATE TABLE runtime_table (id bigint);', + 'CREATE TABLE public.sessions (id bigint);', + 'CREATE TEMP TABLE sessions (id bigint);', + 'ALTER TABLE sessions ADD COLUMN token text;', + 'CREATE TEMP TABLE session_copy AS SELECT * FROM sessions;', + 'CREATE TEMP VIEW active_sessions AS SELECT * FROM sessions;', + 'CREATE TEMPORARY SEQUENCE session_ids;', + 'CREATE VIEW pg_temp.explicit_temp_view AS SELECT 1;', + 'CREATE SEQUENCE pg_temp.explicit_temp_sequence;', + 'DROP TABLE sessions;', + 'ALTER TABLE sessions ADD COLUMN persistent_token text;', + 'CREATE UNLOGGED TABLE events (id bigint);', + ].join('\n')); + const byQualifiedName = new Map(result.nodes.map((node) => [node.qualifiedName, node])); + expect(byQualifiedName.get('joe')?.decorators).toContain('postgres:schema'); + expect(byQualifiedName.get('joe.users')?.decorators).toContain('postgres:table'); + expect(byQualifiedName.get('joe.users.id')?.decorators).toContain('postgres:column'); + expect(byQualifiedName.get('runtime_table')?.decorators).toContain('postgres:table'); + expect(byQualifiedName.has('public.runtime_table')).toBe(false); + expect(byQualifiedName.get('public.sessions')?.decorators).not.toContain('postgres:temporary'); + expect(byQualifiedName.get('pg_temp.sessions')?.decorators).toEqual(expect.arrayContaining([ + 'postgres:table', + 'postgres:temporary', + ])); + expect(byQualifiedName.get('pg_temp.sessions.id')?.decorators).toContain('postgres:column'); + expect(byQualifiedName.get('pg_temp.sessions."ADD COLUMN token"')).toBeTruthy(); + expect(byQualifiedName.get('pg_temp.session_copy')?.decorators).toContain('postgres:temporary'); + expect(byQualifiedName.get('pg_temp.active_sessions')?.decorators).toEqual( + expect.arrayContaining(['postgres:view', 'postgres:temporary']) + ); + expect(byQualifiedName.get('pg_temp.session_ids')?.decorators).toEqual( + expect.arrayContaining(['postgres:sequence', 'postgres:temporary']) + ); + expect(byQualifiedName.get('pg_temp.explicit_temp_view')?.decorators).toEqual( + expect.arrayContaining(['postgres:view', 'postgres:temporary']) + ); + expect(byQualifiedName.get('pg_temp.explicit_temp_sequence')?.decorators).toEqual( + expect.arrayContaining(['postgres:sequence', 'postgres:temporary']) + ); + expect(byQualifiedName.get('public.sessions."ADD COLUMN persistent_token"')).toBeTruthy(); + expect(byQualifiedName.get('public.events')?.decorators).not.toContain('postgres:temporary'); + }); + + it('links both tables and the routine named by a constraint trigger', () => { + const result = extract([ + 'CREATE TABLE app.orders (id bigint);', + 'CREATE TABLE app.users (id bigint);', + 'CREATE FUNCTION app.check_order() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;', + 'CREATE CONSTRAINT TRIGGER orders_check AFTER INSERT ON app.orders FROM app.users', + ' DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION app.check_order();', + ].join('\n')); + const trigger = result.nodes.find((node) => node.name === 'orders_check')!; + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === trigger.id) + .map((ref) => `${ref.referenceKind}:${ref.referenceName}`) + .sort() + ).toEqual([ + 'calls:app.check_order', + 'references:app.orders', + 'references:app.users', + ]); + }); + + it('extracts partition, inheritance, LIKE, and ALTER relation facts', () => { + const result = extract([ + 'CREATE TABLE app.parent (id bigint);', + 'CREATE TABLE app.template (id bigint);', + 'CREATE TABLE app.child (LIKE app.template INCLUDING ALL) INHERITS (app.parent, audit.parent);', + 'CREATE TABLE app.partition_child PARTITION OF app.parent DEFAULT;', + 'ALTER TABLE app.parent ATTACH PARTITION app.attached DEFAULT;', + 'ALTER TABLE app.parent DETACH PARTITION app.detached CONCURRENTLY;', + 'ALTER TABLE app.child INHERIT audit.base;', + 'ALTER TABLE app.child NO INHERIT app.parent;', + ].join('\n')); + const facts = result.nodes.filter((node) => + node.decorators?.includes(POSTGRES_TABLE_RELATION_DECORATOR) + ); + const descriptors = facts + .map((node) => decodePostgresTableRelationDescriptor(node.decorators)) + .filter((descriptor) => descriptor !== null); + expect(descriptors).toEqual(expect.arrayContaining([ + expect.objectContaining({ relation: 'like', sourceTable: 'app.child', targetTable: 'app.template' }), + expect.objectContaining({ relation: 'inherits', sourceTable: 'app.child', targetTable: 'app.parent' }), + expect.objectContaining({ relation: 'inherits', sourceTable: 'app.child', targetTable: 'audit.parent' }), + expect.objectContaining({ relation: 'partition-of', sourceTable: 'app.partition_child', targetTable: 'app.parent' }), + expect.objectContaining({ relation: 'attach-partition', sourceTable: 'app.attached', targetTable: 'app.parent' }), + expect.objectContaining({ relation: 'detach-partition', sourceTable: 'app.detached', targetTable: 'app.parent', mode: 'concurrently' }), + expect.objectContaining({ relation: 'inherit', sourceTable: 'app.child', targetTable: 'audit.base' }), + expect.objectContaining({ relation: 'no-inherit', sourceTable: 'app.child', targetTable: 'app.parent' }), + ])); + expect(facts).toHaveLength(8); + for (const fact of facts) { + expect(result.unresolvedReferences.filter((ref) => ref.fromNodeId === fact.id)).toHaveLength(2); + } + }); + + it('indexes inline, table-level, and ALTER TABLE foreign-key facts', () => { + const result = extract([ + 'CREATE TABLE app.parents (tenant_id bigint, id bigint, PRIMARY KEY (tenant_id, id));', + 'CREATE TABLE app.children (', + ' tenant_id bigint,', + ' parent_id bigint CONSTRAINT children_parent_inline REFERENCES app.parents(id) ON DELETE SET NULL,', + ' backup_parent_id bigint REFERENCES app.parents(id),', + ' CONSTRAINT children_parent_fk FOREIGN KEY (tenant_id, parent_id)', + ' REFERENCES app.parents(tenant_id, id) MATCH FULL ON UPDATE CASCADE DEFERRABLE INITIALLY DEFERRED', + ');', + 'ALTER TABLE ONLY app.children', + ' ADD CONSTRAINT children_backup_fk FOREIGN KEY (tenant_id, backup_parent_id)', + ' REFERENCES app.parents(tenant_id, id) ON DELETE CASCADE NOT VALID;', + ].join('\n')); + + expect(result.errors).toEqual([]); + const facts = result.nodes + .filter((node) => node.decorators?.includes(POSTGRES_FOREIGN_KEY_DECORATOR)) + .map((node) => ({ + node, + data: decodePostgresForeignKeyDescriptor(node.decorators), + })); + expect(facts).toHaveLength(4); + + const composite = facts.find(({ node }) => node.name === 'children_parent_fk')!; + expect(composite.data).toEqual({ + sourceTable: 'app.children', + targetTable: 'app.parents', + constraintName: 'children_parent_fk', + sourceColumns: ['tenant_id', 'parent_id'], + targetColumns: ['tenant_id', 'id'], + match: 'full', + onUpdate: 'cascade', + deferrable: true, + initially: 'deferred', + }); + + const alter = facts.find(({ node }) => node.name === 'children_backup_fk')!; + expect(alter.data).toEqual({ + sourceTable: 'app.children', + targetTable: 'app.parents', + constraintName: 'children_backup_fk', + sourceColumns: ['tenant_id', 'backup_parent_id'], + targetColumns: ['tenant_id', 'id'], + onDelete: 'cascade', + notValid: true, + }); + for (const { node } of facts) { + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === node.id) + .map((ref) => ref.referenceName) + .sort() + ).toEqual(['app.children', 'app.parents']); + } + }); + + it('keeps same-line anonymous and same-named foreign keys as distinct facts', () => { + const result = extract([ + 'CREATE TABLE app.child (a bigint REFERENCES app.parent_a(id), b bigint REFERENCES app.parent_b(id));', + 'ALTER TABLE app.one ADD CONSTRAINT user_fk FOREIGN KEY (user_id) REFERENCES app.users(id); ALTER TABLE app.two ADD CONSTRAINT user_fk FOREIGN KEY (owner_id) REFERENCES app.users(id);', + ].join('\n')); + + expect(result.errors).toEqual([]); + const facts = result.nodes + .filter((node) => node.decorators?.includes(POSTGRES_FOREIGN_KEY_DECORATOR)) + .map((node) => ({ + id: node.id, + name: node.name, + data: decodePostgresForeignKeyDescriptor(node.decorators), + })); + expect(facts).toHaveLength(4); + expect(new Set(facts.map((fact) => fact.id)).size).toBe(4); + expect(facts.map((fact) => fact.data?.targetTable)).toEqual(expect.arrayContaining([ + 'app.parent_a', + 'app.parent_b', + 'app.users', + 'app.users', + ])); + expect(facts.filter((fact) => fact.name === 'user_fk').map((fact) => fact.data?.sourceTable)) + .toEqual(expect.arrayContaining(['app.one', 'app.two'])); + }); + + it('qualifies unqualified declarations from an explicit single-schema search_path', () => { + const result = extract([ + 'CREATE FUNCTION default_path_fn() RETURNS integer LANGUAGE sql AS $$ SELECT 1 $$;', + 'SET search_path = history;', + 'CREATE TABLE users (id bigint PRIMARY KEY);', + 'SET search_path = public;', + 'CREATE TABLE users (id bigint PRIMARY KEY);', + 'SET search_path = app;', + 'CREATE FUNCTION touch_user() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;', + ].join('\n')); + + expect(result.errors).toEqual([]); + expect(result.nodes.map((node) => node.qualifiedName)).toEqual(expect.arrayContaining([ + 'history.users', + 'public.users', + 'app.touch_user', + 'public.default_path_fn', + ])); + }); + + it('keeps same-line search_path declarations and their columns distinct', () => { + const result = extract( + 'SET search_path=a; CREATE TABLE users(id int); ' + + 'SET search_path=b; CREATE TABLE users(id int);' + ); + const tables = result.nodes.filter((node) => node.decorators?.includes('postgres:table')); + const columns = result.nodes.filter((node) => node.decorators?.includes('postgres:column')); + + expect(tables.map((node) => node.qualifiedName)).toEqual(['a.users', 'b.users']); + expect(new Set(tables.map((node) => node.id)).size).toBe(2); + expect(columns.map((node) => node.qualifiedName)).toEqual(['a.users.id', 'b.users.id']); + expect(new Set(columns.map((node) => node.id)).size).toBe(2); + }); + + it('models ALTER TABLE ADD COLUMN as a relation-linked migration delta', () => { + const result = extract([ + 'CREATE TABLE app.users (id bigint);', + 'ALTER TABLE app.users', + ' ADD COLUMN email text,', + ' ADD COLUMN IF NOT EXISTS display_name text;', + ].join('\n')); + + const fields = result.nodes + .filter((node) => node.kind === 'field') + .map((node) => node.qualifiedName) + .sort(); + expect(fields).toEqual(['app.users.display_name', 'app.users.email', 'app.users.id']); + + const deltas = result.nodes.filter( + (node) => node.decorators?.includes('postgres:alter-table-add-column') + ); + expect(deltas.map((node) => node.qualifiedName).sort()).toEqual([ + 'app.users."ADD COLUMN display_name"', + 'app.users."ADD COLUMN email"', + ]); + + for (const delta of deltas) { + expect(result.unresolvedReferences.some( + (ref) => ref.fromNodeId === delta.id && + ref.referenceKind === 'references' && + ref.referenceName === 'app.users' + )).toBe(true); + expect(result.edges.some( + (edge) => edge.source === delta.id && + edge.kind === 'contains' && + result.nodes.find((candidate) => candidate.id === edge.target)?.kind === 'field' + )).toBe(true); + } + }); + + it('indexes renames, ALTER policy/enum/sequence state, and non-FK constraints', () => { + const result = extract([ + 'SET search_path = public;', + "CREATE TYPE lease_status AS ENUM ('draft');", + "ALTER TYPE lease_status ADD VALUE 'pending_signature' BEFORE 'completed';", + "CREATE TABLE submission_tags (id bigint, label text, status lease_status, sequence_id bigint DEFAULT nextval('daily_application_counts_id_seq'::regclass),", + ' CONSTRAINT submission_tags_pkey PRIMARY KEY (id),', + ' CONSTRAINT submission_tags_label_key UNIQUE (label),', + ' CONSTRAINT submission_tags_label_check CHECK (length(label) > 0));', + 'ALTER TABLE submission_tags RENAME TO tags;', + 'ALTER TABLE tags RENAME COLUMN label TO title;', + 'ALTER TABLE tags ALTER COLUMN status SET DATA TYPE lease_status USING status::text::lease_status;', + 'CREATE POLICY tags_read ON tags USING (true);', + 'ALTER POLICY tags_read ON tags USING (EXISTS (SELECT 1 FROM organizations));', + 'ALTER POLICY tags_read ON tags RENAME TO tags_select;', + 'CREATE SEQUENCE daily_application_counts_id_seq;', + 'ALTER SEQUENCE daily_application_counts_id_seq OWNED BY tags.id;', + 'ALTER TABLE tags ALTER COLUMN id ADD GENERATED BY DEFAULT AS IDENTITY', + ' (SEQUENCE NAME tags_id_seq);', + 'ALTER TABLE tags RENAME CONSTRAINT submission_tags_label_key TO tags_title_key;', + 'ALTER TABLE tags DROP CONSTRAINT submission_tags_pkey;', + ].join('\n')); + + expect(result.errors).toEqual([]); + const byQualifiedName = new Map(result.nodes.map((node) => [node.qualifiedName, node])); + expect(byQualifiedName.get('public.tags')?.decorators).toEqual(expect.arrayContaining([ + 'postgres:table', + 'postgres:renamed-relation', + ])); + expect(byQualifiedName.get('public.tags.title')?.decorators).toEqual(expect.arrayContaining([ + 'postgres:column', + 'postgres:renamed-column', + ])); + expect(byQualifiedName.get('public.lease_status.pending_signature')?.decorators).toEqual( + expect.arrayContaining(['postgres:enum-value', 'postgres:alter-enum-add-value']) + ); + expect(byQualifiedName.get('public.tags.tags_select')?.decorators).toEqual( + expect.arrayContaining(['postgres:policy', 'postgres:renamed-policy']) + ); + expect(result.nodes.find((node) => + node.qualifiedName === 'public.tags.tags_read' && + node.decorators?.includes('postgres:alter-policy') + )).toBeTruthy(); + expect(result.nodes.filter((node) => node.decorators?.includes('postgres:constraint'))) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'submission_tags_pkey' }), + expect.objectContaining({ name: 'submission_tags_label_key' }), + expect.objectContaining({ name: 'submission_tags_label_check' }), + ])); + expect(byQualifiedName.get('public.tags_id_seq')?.decorators).toEqual( + expect.arrayContaining(['postgres:sequence', 'postgres:identity-sequence']) + ); + expect(result.nodes.find((node) => + node.name === 'daily_application_counts_id_seq' && + node.decorators?.includes('postgres:sequence-ownership') + )).toBeTruthy(); + + const statusField = byQualifiedName.get('public.submission_tags.status')!; + expect(result.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromNodeId: statusField.id, + referenceKind: POSTGRES_TYPE_REFERENCE_KIND, + referenceName: 'lease_status', + }), + expect.objectContaining({ + fromNodeId: byQualifiedName.get('public.submission_tags.sequence_id')!.id, + referenceKind: POSTGRES_SEQUENCE_REFERENCE_KIND, + referenceName: 'daily_application_counts_id_seq', + }), + ])); + const typeDelta = result.nodes.find((node) => + node.decorators?.includes('postgres:alter-table-column-type') + )!; + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === typeDelta.id && + ref.referenceKind === POSTGRES_TYPE_REFERENCE_KIND && + ref.referenceName === 'lease_status' + )).toBe(true); + const sequenceOwnership = result.nodes.find((node) => + node.decorators?.includes('postgres:sequence-ownership') + )!; + expect(result.unresolvedReferences.some((ref) => + ref.fromNodeId === sequenceOwnership.id && + ref.referenceKind === POSTGRES_SEQUENCE_REFERENCE_KIND && + ref.referenceName === 'public.daily_application_counts_id_seq' + )).toBe(true); + + const alteredPolicy = result.nodes.find((node) => + node.decorators?.includes('postgres:alter-policy') + )!; + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === alteredPolicy.id) + .map((ref) => ref.referenceName) + .sort() + ).toEqual(['organizations', 'tags']); + + const drop = result.nodes.find((node) => + node.decorators?.includes(POSTGRES_DROP_CONSTRAINT_DECORATOR) + )!; + expect(decodePostgresDropConstraintDescriptor(drop.decorators)).toEqual({ + table: 'public.tags', + constraintName: 'submission_tags_pkey', + }); + + const rename = result.nodes.find((node) => + node.decorators?.includes(POSTGRES_RENAME_CONSTRAINT_DECORATOR) + )!; + expect(decodePostgresRenameConstraintDescriptor(rename.decorators)).toEqual({ + table: 'public.tags', + sourceConstraint: 'submission_tags_label_key', + targetConstraint: 'tags_title_key', + }); + }); + + it('records ordered table and view drops as relation lifecycle facts', () => { + const result = extract([ + 'SET search_path = app;', + 'DROP TABLE IF EXISTS users, audit.events CASCADE;', + 'DROP MATERIALIZED VIEW IF EXISTS user_rollup;', + ].join('\n')); + + expect(result.errors).toEqual([]); + const drops = result.nodes.filter((node) => + node.decorators?.includes(POSTGRES_DROP_RELATION_DECORATOR) + ); + expect(drops.map((node) => decodePostgresDropRelationDescriptor(node.decorators))) + .toEqual(expect.arrayContaining([ + { relationName: 'app.users', relationKind: 'table' }, + { relationName: 'audit.events', relationKind: 'table' }, + { relationName: 'app.user_rollup', relationKind: 'materialized-view' }, + ])); + expect(result.unresolvedReferences.filter((ref) => + drops.some((drop) => drop.id === ref.fromNodeId) + ).map((ref) => ref.referenceName).sort()).toEqual([ + 'app.user_rollup', + 'app.users', + 'audit.events', + ]); + }); + + it('indexes static SQL regions inside PL/pgSQL routines and DO blocks', () => { + const result = extract([ + 'CREATE FUNCTION app.run_job() RETURNS void', + 'LANGUAGE plpgsql AS $$', + 'BEGIN', + ' PERFORM app.hidden_call();', + ' INSERT INTO app.hidden_table VALUES (1);', + 'END', + '$$;', + 'DO $$', + 'BEGIN', + ' UPDATE app.job_state SET complete = true;', + 'END', + '$$;', + ].join('\n')); + + const routine = result.nodes.find((node) => node.qualifiedName === 'app.run_job')!; + const doBlock = result.nodes.find((node) => node.decorators?.includes('postgres:do-block'))!; + expect(routine).toBeTruthy(); + expect(doBlock).toBeTruthy(); + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === routine.id) + .map((ref) => `${ref.referenceKind}:${ref.referenceName}`) + .sort() + ).toEqual(['calls:app.hidden_call', 'references:app.hidden_table']); + expect(result.unresolvedReferences + .filter((ref) => ref.fromNodeId === doBlock.id) + .map((ref) => `${ref.referenceKind}:${ref.referenceName}`) + ).toEqual(['references:app.job_state']); + }); + + it('indexes and resolves PostgreSQL dependencies through the full CodeGraph pipeline', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-')); + fs.writeFileSync( + path.join(projectDir, '001_accounts.sql'), + 'CREATE TABLE app.accounts (id bigint PRIMARY KEY, active boolean);\n' + ); + fs.writeFileSync( + path.join(projectDir, '002_active_accounts.sql'), + 'CREATE VIEW app.active_accounts AS SELECT * FROM app.accounts WHERE active;\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const table = graph.getNodesByName('accounts').find( + (node) => node.qualifiedName === 'app.accounts' + ); + const view = graph.getNodesByName('active_accounts').find( + (node) => node.qualifiedName === 'app.active_accounts' + ); + expect(table).toMatchObject({ kind: 'struct', language: 'postgres' }); + expect(view).toMatchObject({ kind: 'struct', language: 'postgres' }); + + const incoming = graph.getIncomingEdges(table!.id); + expect(incoming.some( + (edge) => edge.source === view!.id && edge.kind === 'references' + )).toBe(true); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('resolves an unqualified user routine that shadows a PostgreSQL built-in name', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-shadow-')); + fs.writeFileSync( + path.join(projectDir, '001_count.sql'), + "CREATE FUNCTION count(value integer) RETURNS integer LANGUAGE sql AS 'SELECT value';\n" + ); + fs.writeFileSync( + path.join(projectDir, '002_summary.sql'), + 'CREATE VIEW app.summary AS SELECT count(1);\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const routine = graph.getNodesByName('count').find( + (node) => node.language === 'postgres' && node.decorators?.includes('postgres:function') + ); + const view = graph.getNodesByName('summary').find( + (node) => node.qualifiedName === 'app.summary' + ); + expect(routine).toBeTruthy(); + expect(view).toBeTruthy(); + + const incoming = graph.getIncomingEdges(routine!.id); + expect(incoming.some( + (edge) => edge.source === view!.id && edge.kind === 'calls' + )).toBe(true); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('links cross-migration foreign keys and triggers and removes stale FK edges on sync', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-relations-')); + const relationsPath = path.join(projectDir, '004_relations.sql'); + fs.writeFileSync( + path.join(projectDir, '001_users.sql'), + 'CREATE TABLE app.users (id bigint PRIMARY KEY);\n' + ); + fs.writeFileSync( + path.join(projectDir, '002_orders.sql'), + 'CREATE TABLE app.orders (id bigint PRIMARY KEY, user_id bigint);\n' + ); + fs.writeFileSync( + path.join(projectDir, '003_touch.sql'), + "CREATE FUNCTION app.touch_user() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';\n" + ); + const triggerSql = + 'CREATE TRIGGER users_touch AFTER UPDATE ON app.users EXECUTE FUNCTION app.touch_user();\n'; + const foreignKeySql = [ + 'ALTER TABLE app.orders ADD CONSTRAINT orders_user_fk', + ' FOREIGN KEY (user_id) REFERENCES app.users(id) ON DELETE CASCADE;', + ].join('\n') + '\n'; + fs.writeFileSync(relationsPath, foreignKeySql + triggerSql); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const users = graph.getNodesByName('users').find((node) => node.qualifiedName === 'app.users')!; + const orders = graph.getNodesByName('orders').find((node) => node.qualifiedName === 'app.orders')!; + const routine = graph.getNodesByName('touch_user').find( + (node) => node.decorators?.includes('postgres:function') + )!; + const trigger = graph.getNodesByName('users_touch').find( + (node) => node.decorators?.includes('postgres:trigger') + )!; + + const fkEdge = graph.getOutgoingEdges(orders.id).find((edge) => + edge.target === users.id && edge.metadata?.postgresRelation === 'foreign-key' + ); + expect(fkEdge).toBeTruthy(); + expect(fkEdge!.provenance).toBe('tree-sitter'); + expect(fkEdge!.metadata?.constraints).toEqual([ + expect.objectContaining({ + constraintName: 'orders_user_fk', + sourceColumns: ['user_id'], + targetColumns: ['id'], + onDelete: 'cascade', + filePath: '004_relations.sql', + }), + ]); + + expect(graph.getIncomingEdges(users.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ source: orders.id, kind: 'references' }), + expect.objectContaining({ source: trigger.id, kind: 'references' }), + ])); + expect(graph.getOutgoingEdges(trigger.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: users.id, kind: 'references' }), + expect.objectContaining({ target: routine.id, kind: 'calls' }), + ])); + + graph.clear(); + await graph.indexAll(); + expect(graph.getOutgoingEdges(orders.id).some((edge) => + edge.target === users.id && edge.metadata?.postgresRelation === 'foreign-key' + )).toBe(true); + + // Simulate a process dying after the store phase of a pure removal but + // before FK synthesis. The source/target tables survive, so the direct + // edge is intentionally stale while the persisted fact fingerprint is + // old; the next no-change sync must detect and recover it. + fs.unlinkSync(relationsPath); + await (graph as any).orchestrator.sync(); + expect(graph.getOutgoingEdges(orders.id).some((edge) => + edge.target === users.id && edge.metadata?.postgresRelation === 'foreign-key' + )).toBe(true); + + await graph.sync(); + expect(graph.getOutgoingEdges(orders.id).some((edge) => + edge.target === users.id && edge.metadata?.postgresRelation === 'foreign-key' + )).toBe(false); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('removes an FK projection when a target table becomes a view', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-fk-target-')); + const usersPath = path.join(projectDir, '001_users.sql'); + fs.writeFileSync(usersPath, 'CREATE TABLE app.users (id bigint PRIMARY KEY);\n'); + fs.writeFileSync( + path.join(projectDir, '002_orders.sql'), + 'CREATE TABLE app.orders (id bigint, user_id bigint REFERENCES app.users(id));\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const users = graph.getNodesByQualifiedName('app.users')[0]!; + const orders = graph.getNodesByQualifiedName('app.orders')[0]!; + expect(graph.getOutgoingEdges(orders.id).some((edge) => + edge.target === users.id && edge.metadata?.postgresRelation === 'foreign-key' + )).toBe(true); + + fs.writeFileSync(usersPath, 'CREATE VIEW app.users AS SELECT 1::bigint AS id;\n'); + await graph.sync(); + const view = graph.getNodesByQualifiedName('app.users')[0]!; + expect(view.decorators).toContain('postgres:view'); + expect(graph.getOutgoingEdges(orders.id).some((edge) => + edge.target === view.id && edge.metadata?.postgresRelation === 'foreign-key' + )).toBe(false); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not replace FK edges for an unrelated TypeScript-only sync', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-fk-noop-')); + fs.writeFileSync( + path.join(projectDir, 'schema.sql'), + [ + 'CREATE TABLE app.users (id bigint PRIMARY KEY);', + 'CREATE TABLE app.orders (user_id bigint REFERENCES app.users(id));', + ].join('\n') + ); + const tsPath = path.join(projectDir, 'app.ts'); + fs.writeFileSync(tsPath, 'export const value = 1;\n'); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const queries = (graph as any).queries; + const plan = (graph as any).db.getDb().prepare(` + EXPLAIN QUERY PLAN + SELECT source FROM edges + WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') = ? + AND json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL + `).all('postgres-foreign-key') as Array<{ detail: string }>; + expect(plan.map((row) => row.detail).join(' ')).toContain('idx_edges_synthesized_by'); + const originalReplace = queries.replaceEdgesBySynthesizer.bind(queries); + let replacements = 0; + queries.replaceEdgesBySynthesizer = (...args: unknown[]) => { + replacements++; + return originalReplace(...args); + }; + + fs.writeFileSync(tsPath, 'export const value = 2;\n'); + await graph.sync(); + expect(replacements).toBe(0); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('synthesizes cross-schema FK roles through the synchronous resolution API', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-sync-resolve-')); + fs.writeFileSync( + path.join(projectDir, '001_public.sql'), + 'CREATE TABLE public.users (id bigint PRIMARY KEY);\n' + ); + fs.writeFileSync( + path.join(projectDir, '002_audit.sql'), + 'CREATE TABLE audit.users (id bigint PRIMARY KEY, parent_id bigint);\n' + ); + fs.writeFileSync( + path.join(projectDir, '003_fk.sql'), + [ + 'SET search_path = public;', + 'ALTER TABLE audit.users ADD CONSTRAINT audit_users_parent_fk', + ' FOREIGN KEY (parent_id) REFERENCES users(id);', + '', + ].join('\n') + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexFiles(['001_public.sql', '002_audit.sql', '003_fk.sql']); + graph.resolveReferences(); + + const publicUsers = graph.getNodesByQualifiedName('public.users')[0]!; + const auditUsers = graph.getNodesByQualifiedName('audit.users')[0]!; + expect(publicUsers).toBeTruthy(); + expect(auditUsers).toBeTruthy(); + expect(graph.getOutgoingEdges(auditUsers.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: publicUsers.id, + kind: 'references', + metadata: expect.objectContaining({ postgresRelation: 'foreign-key' }), + }), + ])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('links search_path-qualified declarations and references end to end', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-search-path-')); + fs.writeFileSync( + path.join(projectDir, 'schema.sql'), + [ + 'SET search_path = app;', + 'CREATE TABLE users (id bigint PRIMARY KEY);', + 'CREATE TABLE orders (id bigint, user_id bigint REFERENCES users(id));', + 'CREATE FUNCTION touch_user() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;', + 'CREATE TRIGGER users_touch AFTER UPDATE ON users EXECUTE FUNCTION touch_user();', + ].join('\n') + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const users = graph.getNodesByQualifiedName('app.users')[0]!; + const orders = graph.getNodesByQualifiedName('app.orders')[0]!; + const routine = graph.getNodesByQualifiedName('app.touch_user')[0]!; + expect(users).toBeTruthy(); + expect(orders).toBeTruthy(); + expect(routine).toBeTruthy(); + expect(graph.getOutgoingEdges(orders.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: users.id, + metadata: expect.objectContaining({ postgresRelation: 'foreign-key' }), + }), + ])); + const trigger = graph.getNodesByName('users_touch')[0]!; + expect(trigger.qualifiedName).toBe('app.users.users_touch'); + expect(graph.getOutgoingEdges(trigger.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: users.id, kind: 'references' }), + expect.objectContaining({ target: routine.id, kind: 'calls' }), + ])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('keeps references distinct when a temporary relation changes the binding', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-temp-order-')); + fs.writeFileSync(path.join(projectDir, 'schema.sql'), [ + 'CREATE TABLE public.sessions (id bigint);', + 'SELECT * FROM sessions;', + 'CREATE TEMP TABLE sessions (id bigint);', + 'SELECT * FROM sessions;', + 'DROP TABLE sessions;', + 'SELECT * FROM sessions;', + ].join('\n')); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const persistent = graph.getNodesByQualifiedName('public.sessions')[0]!; + const temporary = graph.getNodesByQualifiedName('pg_temp.sessions')[0]!; + const file = graph.getNodesByName('schema.sql').find((node) => node.kind === 'file')!; + expect(persistent).toBeTruthy(); + expect(temporary.decorators).toContain('postgres:temporary'); + + const targets = graph.getOutgoingEdges(file.id) + .filter((edge) => edge.kind === 'references') + .map((edge) => edge.target); + expect(targets).toEqual(expect.arrayContaining([persistent.id, temporary.id])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('projects structural table relations and cross-file schema containment end to end', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-structure-')); + fs.writeFileSync(path.join(projectDir, '001_base.sql'), [ + 'CREATE SCHEMA app;', + 'CREATE SCHEMA nested CREATE TABLE things (id bigint);', + 'CREATE TABLE app.parent (id bigint);', + 'CREATE TABLE app.template (id bigint);', + 'CREATE FUNCTION app.check_parent() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RETURN NEW; END $$;', + ].join('\n')); + const childPath = path.join(projectDir, '002_children.sql'); + fs.writeFileSync(childPath, [ + 'CREATE TABLE app.child (LIKE app.template INCLUDING ALL) INHERITS (app.parent);', + 'CREATE TABLE app.partition_child PARTITION OF app.parent DEFAULT;', + 'CREATE TABLE app.attached (id bigint);', + 'CREATE TABLE app.detached (id bigint);', + ].join('\n')); + fs.writeFileSync(path.join(projectDir, '003_operations.sql'), [ + 'ALTER TABLE app.parent ATTACH PARTITION app.attached DEFAULT;', + 'ALTER TABLE app.parent DETACH PARTITION app.detached;', + 'CREATE CONSTRAINT TRIGGER child_parent_check AFTER UPDATE ON app.child FROM app.parent', + ' DEFERRABLE INITIALLY DEFERRED FOR EACH ROW EXECUTE FUNCTION app.check_parent();', + ].join('\n')); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + const schema = graph.getNodesByQualifiedName('app')[0]!; + const nestedSchema = graph.getNodesByQualifiedName('nested')[0]!; + const nestedTable = graph.getNodesByQualifiedName('nested.things')[0]!; + const parent = graph.getNodesByQualifiedName('app.parent')[0]!; + const template = graph.getNodesByQualifiedName('app.template')[0]!; + const child = graph.getNodesByQualifiedName('app.child')[0]!; + const partition = graph.getNodesByQualifiedName('app.partition_child')[0]!; + const attached = graph.getNodesByQualifiedName('app.attached')[0]!; + const detached = graph.getNodesByQualifiedName('app.detached')[0]!; + + const expectRelation = (sourceId: string, targetId: string, relation: string) => { + expect(graph.getOutgoingEdges(sourceId)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: targetId, + kind: 'references', + metadata: expect.objectContaining({ postgresRelation: relation }), + }), + ])); + }; + expectRelation(child.id, parent.id, 'inherits'); + expectRelation(child.id, template.id, 'like'); + expectRelation(child.id, parent.id, 'constraint-trigger'); + expectRelation(partition.id, parent.id, 'partition-of'); + expectRelation(attached.id, parent.id, 'attach-partition'); + expectRelation(detached.id, parent.id, 'detach-partition'); + + const contained = graph.getOutgoingEdges(schema.id, ['contains']).map((edge) => edge.target); + expect(contained).toEqual(expect.arrayContaining([parent.id, template.id, child.id])); + expect(graph.getOutgoingEdges(nestedSchema.id, ['contains']).filter( + (edge) => edge.target === nestedTable.id + )).toHaveLength(1); + + fs.writeFileSync(childPath, [ + 'CREATE TABLE app.child (LIKE app.template INCLUDING ALL);', + 'CREATE TABLE app.partition_child PARTITION OF app.parent DEFAULT;', + 'CREATE TABLE app.attached (id bigint);', + 'CREATE TABLE app.detached (id bigint);', + ].join('\n')); + await graph.sync(); + const refreshedChild = graph.getNodesByQualifiedName('app.child')[0]!; + expect(graph.getOutgoingEdges(refreshedChild.id).some( + (edge) => edge.metadata?.postgresRelation === 'inherits' + )).toBe(false); + expect(graph.getOutgoingEdges(refreshedChild.id).some( + (edge) => edge.metadata?.postgresRelation === 'like' + )).toBe(true); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/postgres-grammar-wiring.test.ts b/__tests__/postgres-grammar-wiring.test.ts new file mode 100644 index 000000000..b284e4ac7 --- /dev/null +++ b/__tests__/postgres-grammar-wiring.test.ts @@ -0,0 +1,175 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { createHash } from 'node:crypto'; +import { + detectLanguage, + getParser, + getSupportedLanguages, + initGrammars, + isLanguageSupported, + isSourceFile, + loadGrammarsForLanguages, + readGrammarWasmBytes, +} from '../src/extraction/grammars'; +import { preParsePostgresSource } from '../src/extraction/languages/postgres'; + +describe('PostgreSQL grammar wiring', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); + }); + + it('recognizes SQL files as PostgreSQL source', () => { + expect(detectLanguage('migrations/001_create_users.sql')).toBe('postgres'); + expect(detectLanguage('migrations/001_create_users.SQL')).toBe('postgres'); + expect(detectLanguage('scripts/maintenance.psql')).toBe('postgres'); + expect(detectLanguage('scripts/maintenance.pgsql')).toBe('postgres'); + expect(isSourceFile('migrations/001_create_users.sql')).toBe(true); + expect(isSourceFile('scripts/maintenance.psql')).toBe(true); + expect(isSourceFile('scripts/maintenance.pgsql')).toBe(true); + expect(isLanguageSupported('postgres')).toBe(true); + expect(getSupportedLanguages()).toContain('postgres'); + }); + + it('ships and loads the vendored PostgreSQL grammar', async () => { + const grammarBytes = await readGrammarWasmBytes(['postgres']); + expect(grammarBytes.postgres?.byteLength).toBeGreaterThan(0); + expect(createHash('sha256').update(grammarBytes.postgres!).digest('hex')).toBe( + '084883e58414c407dfac6f37f0facc983afdfe8103e17f5fd2ca138b79a22b92' + ); + + const parser = getParser('postgres'); + expect(parser).not.toBeNull(); + + const tree = parser!.parse( + 'CREATE TABLE public.users (id bigint PRIMARY KEY); SELECT id FROM public.users;' + ); + expect(tree!.rootNode.hasError).toBe(false); + tree!.delete(); + }); + + it('prepares psql variables, COPY payloads, and valid CHECK comparisons without shifting offsets', () => { + const source = [ + "\\set winner_id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'", + "SELECT public.merge_user(:'winner_id'::uuid);", + 'CREATE TABLE audit.events (', + ' op text,', + ' record jsonb,', + " CHECK (op IN ('INSERT', 'UPDATE') = (record IS NOT NULL))", + ');', + 'COPY audit.events (op, record) FROM STDIN;', + 'INSERT\t{"id": 1}', + '\\.', + 'SELECT * FROM audit.events;', + ].join('\n'); + const prepared = preParsePostgresSource(source); + expect(prepared.length).toBe(source.length); + expect([...prepared.matchAll(/\n/g)].map((match) => match.index)) + .toEqual([...source.matchAll(/\n/g)].map((match) => match.index)); + expect(prepared.split('\n')[0]).toMatch(/^\s+$/); + expect(prepared.split('\n')[8]).toMatch(/^\s+$/); + expect(prepared.split('\n')[9]).toMatch(/^\s+$/); + + const unicode = "-- 😀\n\\set value 'x'\nSELECT :value::text;"; + const preparedUnicode = preParsePostgresSource(unicode); + expect(preparedUnicode.length).toBe(unicode.length); + expect(preparedUnicode).toMatch(/SELECT \$1\s+::text;/); + + const parser = getParser('postgres')!; + const tree = parser.parse(prepared); + expect(tree!.rootNode.hasError).toBe(false); + tree!.delete(); + }); + + it('limits COPY payload masking to real server and psql COPY commands', () => { + const psqlCopy = [ + '\\copy audit.events FROM STDIN', + 'INSERT\t{"id": 1};', + '\\.', + 'SELECT * FROM audit.events;', + ].join('\n'); + const preparedPsqlCopy = preParsePostgresSource(psqlCopy); + expect(preparedPsqlCopy.split('\n').slice(0, 3)).toEqual([ + expect.stringMatching(/^\s+$/), + expect.stringMatching(/^\s+$/), + expect.stringMatching(/^\s+$/), + ]); + expect(preparedPsqlCopy.split('\n')[3]).toBe('SELECT * FROM audit.events;'); + + const psqlQueryCopy = [ + '\\copy (SELECT copy FROM stdin) TO STDOUT', + 'SELECT * FROM users;', + ].join('\n'); + expect(preParsePostgresSource(psqlQueryCopy).split('\n')[1]) + .toBe('SELECT * FROM users;'); + + const identifierUse = 'SELECT copy FROM stdin;\nSELECT * FROM users;'; + expect(preParsePostgresSource(identifierUse)).toBe(identifierUse); + + const protocolOnly = 'COPY audit.events FROM STDIN;\nSELECT * FROM users;'; + expect(preParsePostgresSource(protocolOnly)).toBe(protocolOnly); + + const csvCopy = [ + 'COPY audit.events FROM STDIN WITH (FORMAT csv);', + 'INSERT,{"id": 1}', + '\\.', + 'SELECT * FROM audit.events;', + ].join('\n'); + const preparedCsvCopy = preParsePostgresSource(csvCopy); + expect(preparedCsvCopy.split('\n')[1]).toMatch(/^\s+$/); + expect(preparedCsvCopy.split('\n')[2]).toMatch(/^\s+$/); + expect(preparedCsvCopy.split('\n')[3]).toBe('SELECT * FROM audit.events;'); + + const queryCopy = 'COPY (SELECT copy FROM stdin) TO STDOUT;\nSELECT * FROM users;'; + expect(preParsePostgresSource(queryCopy)).toBe(queryCopy); + + const parser = getParser('postgres')!; + for (const prepared of [ + preparedPsqlCopy, + identifierUse, + protocolOnly, + preparedCsvCopy, + queryCopy, + ]) { + const tree = parser.parse(prepared); + expect(tree.rootNode.hasError).toBe(false); + tree.delete(); + } + }); + + it('uses backslash escapes only in E/e strings while locating COPY payloads', () => { + const sources = [ + // With standard_conforming_strings, the backslash is data and the quote + // immediately after it terminates the ordinary string. + String.raw`SELECT 'x\';`, + // In an escape string, the first quote is escaped and the second closes + // the literal. Exercise both accepted prefix spellings. + String.raw`SELECT E'x\'';`, + String.raw`SELECT e'x\'';`, + ]; + + for (const firstStatement of sources) { + const source = [ + firstStatement, + String.raw`\copy audit.psql_events FROM STDIN`, + '1\tpsql payload', + String.raw`\.`, + 'COPY audit.server_events FROM STDIN;', + '2\tserver payload', + String.raw`\.`, + 'SELECT * FROM audit.server_events;', + ].join('\n'); + const prepared = preParsePostgresSource(source); + const lines = prepared.split('\n'); + + expect(prepared.length).toBe(source.length); + expect(lines[0]).toBe(firstStatement); + expect(lines[1]).toMatch(/^\s+$/); + expect(lines[2]).toMatch(/^\s+$/); + expect(lines[3]).toMatch(/^\s+$/); + expect(lines[4]).toBe('COPY audit.server_events FROM STDIN;'); + expect(lines[5]).toMatch(/^\s+$/); + expect(lines[6]).toMatch(/^\s+$/); + expect(lines[7]).toBe('SELECT * FROM audit.server_events;'); + } + }); +}); diff --git a/__tests__/postgres-migration-synthesis.test.ts b/__tests__/postgres-migration-synthesis.test.ts new file mode 100644 index 000000000..b8dc898c2 --- /dev/null +++ b/__tests__/postgres-migration-synthesis.test.ts @@ -0,0 +1,451 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { DatabaseConnection } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; +import { + POSTGRES_DROP_CONSTRAINT_DECORATOR, + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + encodePostgresDropConstraintDescriptor, + encodePostgresRenameConstraintDescriptor, +} from '../src/postgres/constraint-mutation'; +import { + POSTGRES_FOREIGN_KEY_DECORATOR, + encodePostgresForeignKeyDescriptor, +} from '../src/postgres/foreign-key'; +import { + POSTGRES_DROP_RELATION_DECORATOR, + encodePostgresDropRelationDescriptor, +} from '../src/postgres/relation-lifecycle'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + encodePostgresTableRelationDescriptor, + type PostgresTableRelationKind, +} from '../src/postgres/table-relation'; +import { + POSTGRES_FOREIGN_KEY_SYNTHESIZER, + refreshPostgresForeignKeyEdgesSync, +} from '../src/resolution/postgres-foreign-key-synthesizer'; +import { + POSTGRES_STRUCTURE_SYNTHESIZER, + refreshPostgresStructureEdgesSync, +} from '../src/resolution/postgres-structure-synthesizer'; +import type { Edge, Node, NodeKind } from '../src/types'; + +function makeNode( + id: string, + qualifiedName: string, + filePath: string, + line: number, + kind: NodeKind, + decorators: string[], + column = 0 +): Node { + return { + id, + kind, + name: qualifiedName.split('.').at(-1) ?? qualifiedName, + qualifiedName, + filePath, + language: 'postgres', + startLine: line, + endLine: line, + startColumn: column, + endColumn: column, + decorators, + updatedAt: Date.now(), + }; +} + +function table(id: string, name: string, filePath: string, line = 1): Node { + return makeNode(id, name, filePath, line, 'struct', ['postgres:table']); +} + +describe('PostgreSQL ordered migration synthesis', () => { + let dir: string; + let db: DatabaseConnection; + let queries: QueryBuilder; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-migrations-')); + db = DatabaseConnection.initialize(path.join(dir, 'codegraph.db')); + queries = new QueryBuilder(db.getDb()); + }); + + afterEach(() => { + db.close(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + function insertReference(fact: Node, target: Node, refName: string): void { + queries.insertEdge({ + source: fact.id, + target: target.id, + kind: 'references', + line: fact.startLine, + column: fact.startColumn, + provenance: 'tree-sitter', + metadata: { refName }, + }); + } + + function insertRelationFact( + id: string, + relation: PostgresTableRelationKind, + source: string, + target: string, + filePath: string, + line: number, + endpoints: Array<[Node, string]>, + column = 0 + ): Node { + const fact = makeNode(id, id, filePath, line, 'constant', [ + POSTGRES_TABLE_RELATION_DECORATOR, + encodePostgresTableRelationDescriptor({ + relation, + sourceTable: source, + targetTable: target, + }), + ], column); + queries.insertNode(fact); + for (const [endpoint, refName] of endpoints) insertReference(fact, endpoint, refName); + return fact; + } + + function insertForeignKeyFact( + id: string, + source: Node, + target: Node, + sourceName: string, + targetName: string, + constraintName: string, + filePath: string, + line: number + ): Node { + const fact = makeNode(id, id, filePath, line, 'constant', [ + POSTGRES_FOREIGN_KEY_DECORATOR, + encodePostgresForeignKeyDescriptor({ + sourceTable: sourceName, + targetTable: targetName, + constraintName, + sourceColumns: ['target_id'], + targetColumns: ['id'], + }), + ]); + queries.insertNode(fact); + insertReference(fact, source, sourceName); + insertReference(fact, target, targetName); + return fact; + } + + function insertConstraintRename( + id: string, + tableName: string, + sourceConstraint: string, + targetConstraint: string, + filePath: string, + line: number + ): Node { + const fact = makeNode(id, id, filePath, line, 'constant', [ + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + encodePostgresRenameConstraintDescriptor({ + table: tableName, + sourceConstraint, + targetConstraint, + }), + ]); + queries.insertNode(fact); + return fact; + } + + function synthesizedEdges(source: Node, synthesizer: string): Edge[] { + return queries.getOutgoingEdges(source.id).filter( + (edge) => edge.metadata?.synthesizedBy === synthesizer + ); + } + + it('retains immediate rename edges and projects only older structural facts to the final alias', () => { + const oldTable = table('table:old', 'public.submission_tags', '000_schema.sql'); + const middleTable = table('table:middle', 'public.tags', '002_rename.sql'); + const finalTable = table('table:final', 'public.labels', '003_rename.sql'); + const before = table('table:before', 'public.before_relation', '001_relations.sql'); + const after = table('table:after', 'public.after_relation', '004_relations.sql'); + for (const node of [oldTable, middleTable, finalTable, before, after]) { + queries.insertNode(node); + } + + insertRelationFact( + 'fact:before', 'like', before.qualifiedName, oldTable.qualifiedName, + '001_relations.sql', 10, + [[before, before.qualifiedName], [oldTable, oldTable.qualifiedName]] + ); + // Deliberately resolve only the old endpoint. The target aliases must come + // from the unique exact qualified-name index, not a fuzzy name lookup. + insertRelationFact( + 'fact:rename-middle', 'rename', oldTable.qualifiedName, middleTable.qualifiedName, + '002_rename.sql', 1, [[oldTable, oldTable.qualifiedName]] + ); + insertRelationFact( + 'fact:rename-final', 'rename', middleTable.qualifiedName, finalTable.qualifiedName, + '003_rename.sql', 1, [[middleTable, middleTable.qualifiedName]] + ); + insertRelationFact( + 'fact:after', 'inherits', after.qualifiedName, oldTable.qualifiedName, + '004_relations.sql', 1, + [[after, after.qualifiedName], [oldTable, oldTable.qualifiedName]] + ); + + refreshPostgresStructureEdgesSync(queries); + + expect(synthesizedEdges(oldTable, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: middleTable.id, + metadata: expect.objectContaining({ postgresRelation: 'rename' }), + }), + ]) + ); + expect(synthesizedEdges(middleTable, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: finalTable.id, + metadata: expect.objectContaining({ postgresRelation: 'rename' }), + }), + ]) + ); + expect(synthesizedEdges(before, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: finalTable.id, + metadata: expect.objectContaining({ postgresRelation: 'like' }), + }), + ]) + ); + // Earlier rename events are not applied retroactively to later facts. + expect(synthesizedEdges(after, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + target: oldTable.id, + metadata: expect.objectContaining({ postgresRelation: 'inherits' }), + }), + ]) + ); + }); + + it('does not guess a rename target when the exact qualified name is ambiguous', () => { + const oldTable = table('table:old', 'public.old_name', '001_schema.sql'); + const aliasOne = table('table:new-1', 'public.new_name', '002_rename.sql'); + const aliasTwo = table('table:new-2', 'public.new_name', '009_duplicate.sql'); + for (const node of [oldTable, aliasOne, aliasTwo]) queries.insertNode(node); + insertRelationFact( + 'fact:rename', 'rename', oldTable.qualifiedName, aliasOne.qualifiedName, + '002_rename.sql', 1, [[oldTable, oldTable.qualifiedName]] + ); + + refreshPostgresStructureEdgesSync(queries); + expect(synthesizedEdges(oldTable, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual([]); + }); + + it('does not infer migration chronology across independent SQL directories', () => { + const oldTable = table('table:old', 'public.old_name', 'schema/001_tables.sql'); + const alias = table('table:new', 'public.new_name', 'migrations/002_rename.sql'); + const dependent = table('table:dependent', 'public.dependent', 'schema/001_tables.sql'); + for (const node of [oldTable, alias, dependent]) queries.insertNode(node); + insertRelationFact( + 'fact:before', 'like', dependent.qualifiedName, oldTable.qualifiedName, + 'schema/001_tables.sql', 2, + [[dependent, dependent.qualifiedName], [oldTable, oldTable.qualifiedName]] + ); + insertRelationFact( + 'fact:rename', 'rename', oldTable.qualifiedName, alias.qualifiedName, + 'migrations/002_rename.sql', 1, + [[oldTable, oldTable.qualifiedName], [alias, alias.qualifiedName]] + ); + + refreshPostgresStructureEdgesSync(queries); + expect(synthesizedEdges(dependent, POSTGRES_STRUCTURE_SYNTHESIZER)).toEqual([ + expect.objectContaining({ + target: oldTable.id, + metadata: expect.objectContaining({ postgresRelation: 'like' }), + }), + ]); + }); + + it('reprojects FKs after renames and suppresses only facts preceding a named drop', () => { + const oldOrders = table('table:orders', 'public.orders', '000_schema.sql'); + const newOrders = table('table:purchases', 'public.purchases', '002_renames.sql'); + const oldTags = table('table:tags-old', 'public.submission_tags', '000_schema.sql'); + const newTags = table('table:tags-new', 'public.tags', '002_renames.sql'); + for (const node of [oldOrders, newOrders, oldTags, newTags]) queries.insertNode(node); + + insertForeignKeyFact( + 'fk:keep', oldOrders, oldTags, + oldOrders.qualifiedName, oldTags.qualifiedName, + 'orders_keep_fk', '001_constraints.sql', 1 + ); + insertForeignKeyFact( + 'fk:drop-old', oldOrders, oldTags, + oldOrders.qualifiedName, oldTags.qualifiedName, + 'orders_drop_fk', '001_constraints.sql', 2 + ); + + refreshPostgresForeignKeyEdgesSync(queries); + expect(synthesizedEdges(oldOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER)).toHaveLength(1); + + insertRelationFact( + 'rename:orders', 'rename', oldOrders.qualifiedName, newOrders.qualifiedName, + '002_renames.sql', 1, [[oldOrders, oldOrders.qualifiedName]] + ); + insertRelationFact( + 'rename:tags', 'rename', oldTags.qualifiedName, newTags.qualifiedName, + '002_renames.sql', 2, [[oldTags, oldTags.qualifiedName]] + ); + insertConstraintRename( + 'rename:keep-fk', newOrders.qualifiedName, + 'orders_keep_fk', 'purchases_keep_fk', '002a_constraint_renames.sql', 1 + ); + insertConstraintRename( + 'rename:drop-fk', newOrders.qualifiedName, + 'orders_drop_fk', 'purchases_drop_fk', '002a_constraint_renames.sql', 2 + ); + + refreshPostgresForeignKeyEdgesSync(queries); + expect(synthesizedEdges(oldOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER)).toEqual([]); + let projected = synthesizedEdges(newOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER); + expect(projected).toEqual([ + expect.objectContaining({ target: newTags.id }), + ]); + expect((projected[0]!.metadata?.constraints as Array<{ constraintName: string }>)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ + constraintName: 'purchases_keep_fk', + originalConstraintName: 'orders_keep_fk', + }), + expect.objectContaining({ + constraintName: 'purchases_drop_fk', + originalConstraintName: 'orders_drop_fk', + }), + ])); + + const drop = makeNode('drop:orders-fk', 'drop:orders-fk', '003_drop.sql', 1, 'constant', [ + POSTGRES_DROP_CONSTRAINT_DECORATOR, + encodePostgresDropConstraintDescriptor({ + table: newOrders.qualifiedName, + constraintName: 'purchases_drop_fk', + }), + ]); + queries.insertNode(drop); + refreshPostgresForeignKeyEdgesSync(queries); + projected = synthesizedEdges(newOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER); + expect((projected[0]!.metadata?.constraints as Array<{ constraintName: string }>)) + .toEqual([expect.objectContaining({ constraintName: 'purchases_keep_fk' })]); + + // Re-adding the same named constraint after the drop is a new live fact. + insertForeignKeyFact( + 'fk:drop-readded', newOrders, newTags, + newOrders.qualifiedName, newTags.qualifiedName, + 'purchases_drop_fk', '004_readd.sql', 1 + ); + refreshPostgresForeignKeyEdgesSync(queries); + projected = synthesizedEdges(newOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER); + expect((projected[0]!.metadata?.constraints as Array<{ constraintName: string }>)) + .toEqual(expect.arrayContaining([ + expect.objectContaining({ constraintName: 'purchases_keep_fk' }), + expect.objectContaining({ constraintName: 'purchases_drop_fk', filePath: '004_readd.sql' }), + ])); + expect((projected[0]!.metadata?.constraints as Array<{ filePath: string }>).some( + (constraint) => constraint.filePath === '001_constraints.sql' && + (constraint as { constraintName?: string }).constraintName === 'purchases_drop_fk' + )).toBe(false); + }); + + it('suppresses an old table FK after DROP but keeps a constraint added after recreation', () => { + const users = table('table:users', 'public.users', '000_schema.sql'); + const oldOrders = table('table:orders:v1', 'public.orders', '000_schema.sql'); + queries.insertNode(users); + queries.insertNode(oldOrders); + insertForeignKeyFact( + 'fk:orders:v1', oldOrders, users, + oldOrders.qualifiedName, users.qualifiedName, + 'orders_user_fk', '001_constraints.sql', 1 + ); + + const drop = makeNode('drop:orders', 'drop:orders', '002_drop.sql', 1, 'constant', [ + POSTGRES_DROP_RELATION_DECORATOR, + encodePostgresDropRelationDescriptor({ + relationName: oldOrders.qualifiedName, + relationKind: 'table', + }), + ]); + queries.insertNode(drop); + refreshPostgresForeignKeyEdgesSync(queries); + expect(synthesizedEdges(oldOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER)).toEqual([]); + + const newOrders = table('table:orders:v2', 'public.orders', '003_recreate.sql'); + queries.insertNode(newOrders); + insertForeignKeyFact( + 'fk:orders:v2', newOrders, users, + newOrders.qualifiedName, users.qualifiedName, + 'orders_owner_fk', '004_constraints.sql', 1 + ); + refreshPostgresForeignKeyEdgesSync(queries); + expect(synthesizedEdges(oldOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER)).toEqual([]); + expect(synthesizedEdges(newOrders, POSTGRES_FOREIGN_KEY_SYNTHESIZER)).toEqual([ + expect.objectContaining({ target: users.id }), + ]); + }); + + it('adds exact enum-member containment without duplicating native edges', () => { + const enumNode = makeNode( + 'enum:status', 'public.status', '001_schema.sql', 1, 'enum', ['postgres:enum'] + ); + const nativeMember = makeNode( + 'enum:status:open', 'public.status.open', '001_schema.sql', 1, + 'enum_member', ['postgres:enum-value'] + ); + const alteredMember = makeNode( + 'enum:status:closed', 'public.status.closed', '002_alter.sql', 1, + 'enum_member', ['postgres:enum-value'] + ); + for (const node of [enumNode, nativeMember, alteredMember]) queries.insertNode(node); + queries.insertEdge({ + source: enumNode.id, + target: nativeMember.id, + kind: 'contains', + line: 1, + column: 0, + provenance: 'tree-sitter', + }); + + refreshPostgresStructureEdgesSync(queries); + let contained = queries.getOutgoingEdges(enumNode.id, ['contains']); + expect(contained.filter((edge) => edge.target === nativeMember.id)).toHaveLength(1); + expect(contained.filter((edge) => edge.target === alteredMember.id)).toEqual([ + expect.objectContaining({ + metadata: expect.objectContaining({ + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'enum-containment', + }), + }), + ]); + + const recreatedEnum = makeNode( + 'enum:status:v2', 'public.status', '003_recreate.sql', 1, + 'enum', ['postgres:enum'] + ); + const laterMember = makeNode( + 'enum:status:archived', 'public.status.archived', '004_alter.sql', 1, + 'enum_member', ['postgres:enum-value'] + ); + queries.insertNode(recreatedEnum); + queries.insertNode(laterMember); + refreshPostgresStructureEdgesSync(queries); + contained = queries.getOutgoingEdges(enumNode.id, ['contains']); + expect(contained.some((edge) => edge.target === alteredMember.id)).toBe(true); + expect(contained.some((edge) => edge.target === laterMember.id)).toBe(false); + expect(queries.getOutgoingEdges(recreatedEnum.id, ['contains']).some( + (edge) => edge.target === laterMember.id + )).toBe(true); + }); +}); diff --git a/__tests__/postgres-name-matcher.test.ts b/__tests__/postgres-name-matcher.test.ts new file mode 100644 index 000000000..775b4da7a --- /dev/null +++ b/__tests__/postgres-name-matcher.test.ts @@ -0,0 +1,977 @@ +/** + * PostgreSQL reference-resolution precision. + * + * Migration repositories commonly repeat an object across historical files + * and reuse simple names in different schemas. These tests pin that PostgreSQL + * references never inherit the generic matcher's suffix, fuzzy, or + * path-proximity guesses. + */ +import { describe, expect, it } from 'vitest'; +import { matchReference } from '../src/resolution/name-matcher'; +import type { Language, Node, NodeKind } from '../src/types'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; + +interface NodeOptions { + id: string; + name: string; + qualifiedName?: string; + filePath: string; + kind?: NodeKind; + language?: Language; + decorators?: string[]; + signature?: string; + startLine?: number; + endLine?: number; + startColumn?: number; + endColumn?: number; +} + +function makeNode(options: NodeOptions): Node { + return { + id: options.id, + name: options.name, + qualifiedName: options.qualifiedName ?? options.name, + filePath: options.filePath, + kind: options.kind ?? 'struct', + language: options.language ?? 'postgres', + decorators: options.decorators, + signature: options.signature, + startLine: options.startLine ?? 1, + endLine: options.endLine ?? options.startLine ?? 1, + startColumn: options.startColumn ?? 0, + endColumn: options.endColumn ?? 1, + updatedAt: 0, + }; +} + +function makeRef( + referenceName: string, + filePath = 'migrations/003_consumer.sql', + referenceKind: UnresolvedRef['referenceKind'] = 'references' +): UnresolvedRef { + return { + fromNodeId: `from:${filePath}`, + referenceName, + referenceKind, + line: 10, + column: 2, + filePath, + language: 'postgres', + }; +} + +function contextFor(nodes: Node[], sources: Record = {}): ResolutionContext { + return { + getNodesInFile: (filePath) => nodes.filter((node) => node.filePath === filePath), + getNodesByName: (name) => nodes.filter((node) => node.name === name), + getNodesByQualifiedName: (qualifiedName) => + nodes.filter((node) => node.qualifiedName === qualifiedName), + getNodesByKind: (kind) => nodes.filter((node) => node.kind === kind), + fileExists: (filePath) => nodes.some((node) => node.filePath === filePath), + readFile: (filePath) => sources[filePath] ?? null, + getProjectRoot: () => '/project', + getAllFiles: () => [...new Set(nodes.map((node) => node.filePath))], + getNodesByLowerName: (lowerName) => + nodes.filter((node) => node.name.toLowerCase() === lowerName), + getImportMappings: () => [], + }; +} + +describe('PostgreSQL qualified-name resolution', () => { + it.each(['public.users', 'public::users'])( + 'matches only the exact canonical qualified name (%s)', + (qualifiedName) => { + const publicUsers = makeNode({ + id: 'public-users', + name: 'users', + qualifiedName, + filePath: 'migrations/001_public.sql', + }); + const authUsers = makeNode({ + id: 'auth-users', + name: 'users', + qualifiedName: qualifiedName.replace('public', 'auth'), + filePath: 'migrations/001_auth.sql', + }); + + expect(matchReference(makeRef(qualifiedName), contextFor([publicUsers, authUsers]))) + .toMatchObject({ targetNodeId: publicUsers.id, resolvedBy: 'qualified-name' }); + } + ); + + it('does not use a qualified-name suffix match', () => { + const nested = makeNode({ + id: 'nested-users', + name: 'users', + qualifiedName: 'tenant::public::users', + filePath: 'migrations/001.sql', + }); + + expect(matchReference(makeRef('public::users'), contextFor([nested]))).toBeNull(); + }); + + it('distinguishes dots inside quoted identifier segments', () => { + const quotedSchema = makeNode({ + id: 'quoted-schema', + name: 'users', + qualifiedName: '"public.app".users', + filePath: 'migrations/001.sql', + }); + const quotedTable = makeNode({ + id: 'quoted-table', + name: 'app.users', + qualifiedName: 'public."app.users"', + filePath: 'migrations/002.sql', + }); + const context = contextFor([quotedSchema, quotedTable]); + expect(matchReference(makeRef('"public.app".users'), context)) + .toMatchObject({ targetNodeId: quotedSchema.id }); + expect(matchReference(makeRef('public."app.users"'), context)) + .toMatchObject({ targetNodeId: quotedTable.id }); + }); + + it('searches an unqualified quoted-dot name without treating its dot as a separator', () => { + const filePath = 'migrations/003.sql'; + const table = makeNode({ + id: 'versioned-users', + name: 'users.v2', + qualifiedName: 'app."users.v2"', + filePath: 'migrations/001.sql', + }); + const source = 'SET search_path=app; SELECT * FROM "users.v2";'; + expect(matchReference( + { ...makeRef('"users.v2"', filePath), line: 1, column: source.indexOf('"users.v2"') }, + contextFor([table], { [filePath]: source }) + )).toMatchObject({ targetNodeId: table.id }); + }); + + it('prefers same-file pg_temp relations and never leaks them across migration files', () => { + const localFile = 'migrations/003.sql'; + const persistent = makeNode({ + id: 'public-sessions', + name: 'sessions', + qualifiedName: 'public.sessions', + filePath: 'migrations/001.sql', + }); + const temporary = makeNode({ + id: 'temp-sessions', + name: 'sessions', + qualifiedName: 'pg_temp.sessions', + filePath: localFile, + decorators: ['postgres:table', 'postgres:temporary'], + }); + const context = contextFor([persistent, temporary]); + expect(matchReference(makeRef('sessions', localFile), context)) + .toMatchObject({ targetNodeId: temporary.id }); + expect(matchReference(makeRef('public.sessions', localFile), context)) + .toMatchObject({ targetNodeId: persistent.id }); + expect(matchReference(makeRef('sessions', 'migrations/004.sql'), context)) + .toMatchObject({ targetNodeId: persistent.id }); + expect(matchReference(makeRef('pg_temp.sessions', 'migrations/004.sql'), context)).toBeNull(); + }); + + it('honors same-file temporary relation declaration and drop order', () => { + const filePath = 'migrations/003.sql'; + const source = [ + 'SELECT * FROM sessions;', + 'CREATE TEMP TABLE sessions (id bigint);', + 'SELECT * FROM sessions;', + 'DROP TABLE sessions;', + 'SELECT * FROM sessions;', + ].join('\n'); + const persistent = makeNode({ + id: 'public-sessions', + name: 'sessions', + qualifiedName: 'public.sessions', + filePath: 'migrations/001.sql', + }); + const temporary = makeNode({ + id: 'temp-sessions', + name: 'sessions', + qualifiedName: 'pg_temp.sessions', + filePath, + decorators: ['postgres:table', 'postgres:temporary'], + startLine: 2, + endLine: 2, + endColumn: source.split('\n')[1]!.length - 1, + }); + const context = contextFor([persistent, temporary], { [filePath]: source }); + const atLine = (line: number) => matchReference( + { ...makeRef('sessions', filePath), line, column: source.split('\n')[line - 1]!.indexOf('sessions') }, + context + ); + + expect(atLine(1)).toMatchObject({ targetNodeId: persistent.id }); + expect(atLine(3)).toMatchObject({ targetNodeId: temporary.id }); + expect(atLine(5)).toMatchObject({ targetNodeId: persistent.id }); + }); + + it('applies ON COMMIT DROP and rolls transactional DROP back', () => { + const filePath = 'migrations/003.sql'; + const source = [ + 'BEGIN;', + 'CREATE TEMP TABLE sessions (id bigint) ON COMMIT DROP;', + 'SELECT * FROM sessions;', + 'COMMIT;', + 'SELECT * FROM sessions;', + 'CREATE TEMP TABLE scratch (id bigint);', + 'BEGIN;', + 'DROP TABLE scratch;', + 'SELECT * FROM scratch;', + 'ROLLBACK;', + 'SELECT * FROM scratch;', + 'BEGIN;', + 'SAVEPOINT keep_scratch;', + 'DROP TABLE scratch;', + 'ROLLBACK TO SAVEPOINT keep_scratch;', + 'SELECT * FROM scratch;', + 'SAVEPOINT before_later;', + 'CREATE TEMP TABLE later (id bigint);', + 'ROLLBACK TO before_later;', + 'SELECT * FROM later;', + 'COMMIT;', + ].join('\n'); + const persistentSessions = makeNode({ + id: 'public-sessions', + name: 'sessions', + qualifiedName: 'public.sessions', + filePath: 'migrations/001.sql', + }); + const persistentScratch = makeNode({ + id: 'public-scratch', + name: 'scratch', + qualifiedName: 'public.scratch', + filePath: 'migrations/001.sql', + }); + const persistentLater = makeNode({ + id: 'public-later', + name: 'later', + qualifiedName: 'public.later', + filePath: 'migrations/001.sql', + }); + const temporarySessions = makeNode({ + id: 'temp-sessions', + name: 'sessions', + qualifiedName: 'pg_temp.sessions', + filePath, + decorators: ['postgres:table', 'postgres:temporary'], + startLine: 2, + endLine: 2, + endColumn: source.split('\n')[1]!.length - 1, + signature: 'CREATE TEMP TABLE sessions (id bigint) ON COMMIT DROP', + }); + const temporaryScratch = makeNode({ + id: 'temp-scratch', + name: 'scratch', + qualifiedName: 'pg_temp.scratch', + filePath, + decorators: ['postgres:table', 'postgres:temporary'], + startLine: 6, + endLine: 6, + endColumn: source.split('\n')[5]!.length - 1, + }); + const temporaryLater = makeNode({ + id: 'temp-later', + name: 'later', + qualifiedName: 'pg_temp.later', + filePath, + decorators: ['postgres:table', 'postgres:temporary'], + startLine: 18, + endLine: 18, + endColumn: source.split('\n')[17]!.length - 1, + }); + const context = contextFor( + [ + persistentSessions, + persistentScratch, + persistentLater, + temporarySessions, + temporaryScratch, + temporaryLater, + ], + { [filePath]: source } + ); + const resolveAt = (name: string, line: number) => matchReference({ + ...makeRef(name, filePath), + line, + column: source.split('\n')[line - 1]!.indexOf(name), + }, context); + + expect(resolveAt('sessions', 3)).toMatchObject({ targetNodeId: temporarySessions.id }); + expect(resolveAt('sessions', 5)).toMatchObject({ targetNodeId: persistentSessions.id }); + expect(resolveAt('scratch', 9)).toMatchObject({ targetNodeId: persistentScratch.id }); + expect(resolveAt('scratch', 11)).toMatchObject({ targetNodeId: temporaryScratch.id }); + expect(resolveAt('scratch', 16)).toMatchObject({ targetNodeId: temporaryScratch.id }); + expect(resolveAt('later', 20)).toMatchObject({ targetNodeId: persistentLater.id }); + }); + + it('prefers one exact same-file revision over duplicate historical definitions', () => { + const historical = makeNode({ + id: 'users-v1', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/001_create_users.sql', + }); + const local = makeNode({ + id: 'users-v2', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/003_consumer.sql', + }); + + expect(matchReference(makeRef('public::users'), contextFor([historical, local]))) + .toMatchObject({ targetNodeId: local.id }); + }); + + it('leaves duplicate exact definitions unresolved when none is in the reference file', () => { + const nodes = [ + makeNode({ + id: 'users-v1', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/001_create_users.sql', + }), + makeNode({ + id: 'users-v2', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/002_replace_users.sql', + }), + ]; + + expect(matchReference(makeRef('public::users'), contextFor(nodes))).toBeNull(); + }); + + it('leaves multiple same-file overloads/revisions unresolved', () => { + const nodes = [ + makeNode({ + id: 'calculate-int', + name: 'calculate', + qualifiedName: 'public::calculate', + filePath: 'migrations/003_consumer.sql', + kind: 'function', + decorators: ['postgres:function'], + }), + makeNode({ + id: 'calculate-text', + name: 'calculate', + qualifiedName: 'public::calculate', + filePath: 'migrations/003_consumer.sql', + kind: 'function', + decorators: ['postgres:function'], + }), + ]; + + expect(matchReference(makeRef('public::calculate', undefined, 'calls'), contextFor(nodes))) + .toBeNull(); + }); +}); + +describe('PostgreSQL unqualified-name resolution', () => { + it('prefers one same-file object over same-named objects in other schemas/files', () => { + const local = makeNode({ + id: 'local-users', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/003_consumer.sql', + }); + const other = makeNode({ + id: 'auth-users', + name: 'users', + qualifiedName: 'auth::users', + filePath: 'migrations/001_auth.sql', + }); + + expect(matchReference(makeRef('users'), contextFor([other, local]))) + .toMatchObject({ targetNodeId: local.id, resolvedBy: 'exact-match' }); + }); + + it('resolves one globally unique object', () => { + const users = makeNode({ + id: 'users', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/001.sql', + }); + + expect(matchReference(makeRef('users'), contextFor([users]))) + .toMatchObject({ targetNodeId: users.id }); + }); + + it('uses PostgreSQL default public before same-named objects in other schemas', () => { + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public::users', + filePath: 'migrations/002_near.sql', + }), + makeNode({ + id: 'far-users', + name: 'users', + qualifiedName: 'auth::users', + filePath: 'schemas/auth/001.sql', + }), + ]; + + expect(matchReference(makeRef('users'), contextFor(nodes))) + .toMatchObject({ targetNodeId: 'public-users' }); + }); + + it('preserves ambiguity in the first matching schema over a legacy fallback', () => { + const nodes = [ + makeNode({ + id: 'public-users-v1', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'public-users-v2', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/002_public.sql', + }), + makeNode({ + id: 'legacy-users', + name: 'users', + qualifiedName: 'users', + filePath: 'migrations/000_legacy.sql', + }), + ]; + + expect(matchReference(makeRef('users'), contextFor(nodes))).toBeNull(); + }); + + it('does not pick a path-proximate object when no search-path schema matches', () => { + const nodes = [ + makeNode({ + id: 'auth-users', + name: 'users', + qualifiedName: 'auth::users', + filePath: 'migrations/002_near.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history::users', + filePath: 'schemas/history/001.sql', + }), + ]; + + expect(matchReference(makeRef('users'), contextFor(nodes))).toBeNull(); + }); + + it('does not resolve a unique relation outside the default search path', () => { + const authUsers = makeNode({ + id: 'auth-users', + name: 'users', + qualifiedName: 'auth.users', + filePath: 'migrations/001_auth.sql', + }); + + expect(matchReference(makeRef('users'), contextFor([authUsers]))).toBeNull(); + }); + + it('honors the active SET search_path before the reference line', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + const source = 'SET search_path TO history, public;\n\nSELECT * FROM users;\n'; + + expect(matchReference(makeRef('users', filePath), contextFor(nodes, { [filePath]: source }))) + .toMatchObject({ targetNodeId: 'history-users' }); + }); + + it('does not invent a public fallback after an explicitly empty search_path', () => { + const filePath = 'migrations/003_consumer.sql'; + const publicUsers = makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }); + const source = "SELECT pg_catalog.set_config('search_path', '', false);\nSELECT * FROM users;\n"; + + expect(matchReference( + makeRef('users', filePath), + contextFor([publicUsers], { [filePath]: source }) + )).toBeNull(); + }); + + it('decodes quoted SET values and preserves same-line statement order', () => { + const filePath = 'migrations/003_consumer.sql'; + const historyUsers = makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }); + const source = "SET search_path = 'history'; SELECT * FROM users;\r\n"; + const ref = { + ...makeRef('users', filePath), + line: 1, + column: source.indexOf('users'), + }; + + expect(matchReference(ref, contextFor([historyUsers], { [filePath]: source }))) + .toMatchObject({ targetNodeId: 'history-users' }); + }); + + it('preserves case and commas in SET string-literal schema names', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'camel-users', + name: 'users', + qualifiedName: '"CamelSchema".users', + filePath: 'migrations/001_camel.sql', + }), + makeNode({ + id: 'comma-users', + name: 'users', + qualifiedName: '"a,b, public".users', + filePath: 'migrations/001_comma.sql', + }), + ]; + + for (const [source, targetNodeId] of [ + ["SET search_path = 'CamelSchema'; SELECT * FROM users;", 'camel-users'], + ["SET search_path = 'a,b, public'; SELECT * FROM users;", 'comma-users'], + ] as const) { + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.indexOf('users') }, + contextFor(nodes, { [filePath]: source }) + )).toMatchObject({ targetNodeId }); + } + }); + + it('treats SET SCHEMA literals as a one-element search path', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + makeNode({ + id: 'camel-users', + name: 'users', + qualifiedName: '"CamelSchema".users', + filePath: 'migrations/001_camel.sql', + }), + makeNode({ + id: 'comma-users', + name: 'users', + qualifiedName: '"a,b".users', + filePath: 'migrations/001_comma.sql', + }), + ]; + + for (const [source, targetNodeId] of [ + ["set schema 'history'; SELECT * FROM users;", 'history-users'], + ["SET SESSION SCHEMA 'CamelSchema'; SELECT * FROM users;", 'camel-users'], + ["SeT ScHeMa 'a,b'; SELECT * FROM users;", 'comma-users'], + ] as const) { + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.indexOf('users') }, + contextFor(nodes, { [filePath]: source }) + )).toMatchObject({ targetNodeId }); + } + }); + + it('applies SET LOCAL SCHEMA only inside its transaction', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + const source = [ + "SET LOCAL SCHEMA 'history'; SELECT * FROM users;", + "BEGIN; SET LOCAL SCHEMA 'history'; SELECT * FROM users; COMMIT;", + 'SELECT * FROM users;', + ].join('\n'); + const first = source.indexOf('users'); + const second = source.indexOf('users', first + 1); + const context = contextFor(nodes, { [filePath]: source }); + + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: first }, + context + )).toMatchObject({ targetNodeId: 'public-users' }); + expect(matchReference( + { ...makeRef('users', filePath), line: 2, column: second - source.indexOf('\n') - 1 }, + context + )).toMatchObject({ targetNodeId: 'history-users' }); + expect(matchReference( + { ...makeRef('users', filePath), line: 3, column: 14 }, + context + )).toMatchObject({ targetNodeId: 'public-users' }); + }); + + it('rolls back a session-level SET SCHEMA issued in a transaction', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + const source = "BEGIN; SET SESSION SCHEMA 'history'; SELECT * FROM users; " + + 'ROLLBACK; SELECT * FROM users;'; + const context = contextFor(nodes, { [filePath]: source }); + + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.indexOf('users') }, + context + )).toMatchObject({ targetNodeId: 'history-users' }); + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.lastIndexOf('users') }, + context + )).toMatchObject({ targetNodeId: 'public-users' }); + }); + + it('supports dollar-quoted SET values without mistaking $ inside identifiers', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'camel-users', + name: 'users', + qualifiedName: '"CamelSchema".users', + filePath: 'migrations/001_camel.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + const dollar = 'SET search_path = $$CamelSchema$$; SELECT * FROM users;'; + const identifier = [ + 'CREATE TABLE foo$tag$bar(id int);', + 'SET search_path = history;', + 'SELECT * FROM users;', + ].join('\n'); + + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: dollar.indexOf('users') }, + contextFor(nodes, { [filePath]: dollar }) + )).toMatchObject({ targetNodeId: 'camel-users' }); + expect(matchReference( + { ...makeRef('users', filePath), line: 3, column: 14 }, + contextFor(nodes, { [filePath]: identifier }) + )).toMatchObject({ targetNodeId: 'history-users' }); + }); + + it('resumes SQL parsing after a pg_dump COPY FROM STDIN payload', () => { + const filePath = 'migrations/dump.sql'; + const historyUsers = makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }); + const source = [ + 'COPY public.seed FROM stdin;', + '1;not SQL', + '\\.', + 'SET search_path = history;', + 'SELECT * FROM users;', + ].join('\n'); + + expect(matchReference( + { ...makeRef('users', filePath), line: 5, column: 14 }, + contextFor([historyUsers], { [filePath]: source }) + )).toMatchObject({ targetNodeId: 'history-users' }); + }); + + it('tracks explicit transaction-local paths and rollback restoration', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + for (const terminator of ['COMMIT', 'ROLLBACK'] as const) { + const source = terminator === 'COMMIT' + ? 'BEGIN; SET LOCAL search_path=history; SELECT * FROM users; COMMIT; SELECT * FROM users;' + : 'BEGIN; SET search_path=history; SELECT * FROM users; ROLLBACK; SELECT * FROM users;'; + const first = source.indexOf('users'); + const second = source.lastIndexOf('users'); + const context = contextFor(nodes, { [filePath]: source }); + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: first }, + context + )).toMatchObject({ targetNodeId: 'history-users' }); + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: second }, + context + )).toMatchObject({ targetNodeId: 'public-users' }); + } + }); + + it('restores search_path state when rolling back to a savepoint', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + for (const set of ['SET search_path=history', 'SET LOCAL search_path=history']) { + const source = `BEGIN; ${set}; SAVEPOINT s; SET LOCAL search_path=public; ` + + 'ROLLBACK WORK TO SAVEPOINT s; SELECT * FROM users;'; + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.lastIndexOf('users') }, + contextFor(nodes, { [filePath]: source }) + )).toMatchObject({ targetNodeId: 'history-users' }); + } + }); + + it('keeps SET LOCAL active in transactions started by AND CHAIN', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + for (const terminator of ['COMMIT AND CHAIN', 'ROLLBACK AND CHAIN']) { + const source = 'BEGIN; SET search_path=history; ' + terminator + + '; SET LOCAL search_path=public; SELECT * FROM users;'; + expect(matchReference( + { ...makeRef('users', filePath), line: 1, column: source.lastIndexOf('users') }, + contextFor(nodes, { [filePath]: source }) + )).toMatchObject({ targetNodeId: 'public-users' }); + } + }); + + it('treats a quoted empty SET value as an explicitly empty path', () => { + const filePath = 'migrations/003_consumer.sql'; + const publicUsers = makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }); + const source = "SET search_path = '';\r\nSELECT * FROM users;\r\n"; + + expect(matchReference( + { ...makeRef('users', filePath), line: 2, column: 14 }, + contextFor([publicUsers], { [filePath]: source }) + )).toBeNull(); + }); + + it('ignores transaction-local search_path changes', () => { + const filePath = 'migrations/003_consumer.sql'; + const nodes = [ + makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }), + makeNode({ + id: 'history-users', + name: 'users', + qualifiedName: 'history.users', + filePath: 'migrations/001_history.sql', + }), + ]; + + for (const source of [ + 'SET LOCAL search_path = history;\nSELECT * FROM users;\n', + "SELECT set_config('search_path', 'history', true);\nSELECT * FROM users;\n", + ]) { + expect(matchReference( + { ...makeRef('users', filePath), line: 2, column: 14 }, + contextFor(nodes, { [filePath]: source }) + )).toMatchObject({ targetNodeId: 'public-users' }); + } + }); + + it('ignores comments, function bodies, and CREATE FUNCTION SET clauses', () => { + const filePath = 'migrations/003_consumer.sql'; + const publicUsers = makeNode({ + id: 'public-users', + name: 'users', + qualifiedName: 'public.users', + filePath: 'migrations/001_public.sql', + }); + const source = [ + '-- SET search_path = history;', + '/* SET search_path = history; */', + "SELECT 'path\\';", + 'CREATE FUNCTION private.f() RETURNS void', + 'LANGUAGE plpgsql SET search_path = \'\'', + 'AS $body$', + 'BEGIN', + ' SET search_path = history;', + 'END', + '$body$;', + 'SELECT * FROM users;', + ].join('\n'); + + expect(matchReference( + { ...makeRef('users', filePath), line: 11, column: 14 }, + contextFor([publicUsers], { [filePath]: source }) + )).toMatchObject({ targetNodeId: 'public-users' }); + }); + + it('does not fuzzy-match case variants', () => { + const users = makeNode({ + id: 'users', + name: 'users', + filePath: 'migrations/001.sql', + }); + + expect(matchReference(makeRef('Users'), contextFor([users]))).toBeNull(); + }); +}); + +describe('PostgreSQL target-kind precision', () => { + it('resolves calls only to decorated routines, never a same-named trigger', () => { + const relation = makeNode({ + id: 'relation-refresh', + name: 'refresh_cache', + filePath: 'migrations/001.sql', + kind: 'struct', + }); + const routine = makeNode({ + id: 'routine-refresh', + name: 'refresh_cache', + filePath: 'migrations/002.sql', + kind: 'function', + decorators: ['postgres:function'], + }); + const trigger = makeNode({ + id: 'trigger-refresh', + name: 'refresh_cache', + qualifiedName: 'app.cache.refresh_cache', + filePath: 'migrations/003_consumer.sql', + kind: 'function', + decorators: ['postgres:trigger'], + }); + + expect( + matchReference( + makeRef('refresh_cache', undefined, 'calls'), + contextFor([relation, trigger, routine]) + ) + ) + .toMatchObject({ targetNodeId: routine.id }); + }); + + it('resolves relation references only to struct nodes', () => { + const file = makeNode({ + id: 'file-users', + name: 'users', + filePath: 'users.sql', + kind: 'file', + }); + const imported = makeNode({ + id: 'import-users', + name: 'users', + filePath: 'imports.sql', + kind: 'import', + }); + const column = makeNode({ + id: 'column-users', + name: 'users', + filePath: 'columns.sql', + kind: 'field', + }); + const policy = makeNode({ + id: 'policy-users', + name: 'users', + filePath: 'policies.sql', + kind: 'constant', + }); + const enumMember = makeNode({ + id: 'enum-member-users', + name: 'users', + qualifiedName: 'app.object_kind.users', + filePath: 'types.sql', + kind: 'enum_member', + }); + + expect( + matchReference(makeRef('users'), contextFor([file, imported, column, policy, enumMember])) + ).toBeNull(); + }); + + it('ignores same-named symbols from other languages', () => { + const typescript = makeNode({ + id: 'ts-users', + name: 'users', + filePath: 'src/users.ts', + kind: 'constant', + language: 'typescript', + }); + const postgres = makeNode({ + id: 'pg-users', + name: 'users', + filePath: 'migrations/001.sql', + }); + + expect(matchReference(makeRef('users'), contextFor([typescript, postgres]))) + .toMatchObject({ targetNodeId: postgres.id }); + }); +}); diff --git a/__tests__/postgres-object-reference-intent.test.ts b/__tests__/postgres-object-reference-intent.test.ts new file mode 100644 index 000000000..64f9e63e9 --- /dev/null +++ b/__tests__/postgres-object-reference-intent.test.ts @@ -0,0 +1,332 @@ +/** + * PostgreSQL object-reference intents. + * + * Type and sequence uses share PostgreSQL's identifier/search_path rules with + * relation uses, but they must never bind to a same-named table (or fall into + * the generic framework/fuzzy pipeline). The internal intent is retained only + * until resolution; persisted graph edges remain ordinary `references`. + */ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { DatabaseConnection } from '../src/db'; +import { QueryBuilder } from '../src/db/queries'; +import { + POSTGRES_SEQUENCE_REFERENCE_KIND, + POSTGRES_TYPE_REFERENCE_KIND, +} from '../src/postgres/reference-intent'; +import { createResolver } from '../src/resolution'; +import { matchReference } from '../src/resolution/name-matcher'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import type { Language, Node, NodeKind, UnresolvedReference } from '../src/types'; + +interface NodeOptions { + id: string; + name: string; + qualifiedName: string; + filePath?: string; + kind?: NodeKind; + language?: Language; + decorators?: string[]; +} + +function makeNode(options: NodeOptions): Node { + return { + id: options.id, + name: options.name, + qualifiedName: options.qualifiedName, + filePath: options.filePath ?? 'migrations/001.sql', + kind: options.kind ?? 'struct', + language: options.language ?? 'postgres', + decorators: options.decorators, + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 1, + updatedAt: 0, + }; +} + +function makeRef( + referenceName: string, + referenceKind: UnresolvedRef['referenceKind'], + filePath = 'migrations/consumer.sql' +): UnresolvedRef { + return { + fromNodeId: 'consumer', + referenceName, + referenceKind, + line: 2, + column: 1, + filePath, + language: 'postgres', + }; +} + +function contextFor(nodes: Node[], sources: Record = {}): ResolutionContext { + return { + getNodesInFile: (filePath) => nodes.filter((node) => node.filePath === filePath), + getNodesByName: (name) => nodes.filter((node) => node.name === name), + getNodesByQualifiedName: (name) => + nodes.filter((node) => node.qualifiedName === name), + getNodesByKind: (kind) => nodes.filter((node) => node.kind === kind), + fileExists: (filePath) => nodes.some((node) => node.filePath === filePath), + readFile: (filePath) => sources[filePath] ?? null, + getProjectRoot: () => '/project', + getAllFiles: () => [...new Set(nodes.map((node) => node.filePath))], + getNodesByLowerName: (name) => + nodes.filter((node) => node.name.toLowerCase() === name), + getImportMappings: () => [], + }; +} + +describe('PostgreSQL object-reference intent matching', () => { + it('keeps ordinary relation, type, and sequence targets disjoint', () => { + const table = makeNode({ + id: 'table', + name: 'status', + qualifiedName: 'public.status', + kind: 'struct', + decorators: ['postgres:table'], + }); + const type = makeNode({ + id: 'type', + name: 'status', + qualifiedName: 'public.status', + kind: 'enum', + decorators: ['postgres:enum'], + }); + const sequence = makeNode({ + id: 'sequence', + name: 'status', + qualifiedName: 'public.status', + kind: 'variable', + decorators: ['postgres:sequence'], + }); + const context = contextFor([table, type, sequence]); + + expect(matchReference(makeRef('public.status', 'references'), context)) + .toMatchObject({ targetNodeId: table.id }); + expect(matchReference(makeRef('public.status', POSTGRES_TYPE_REFERENCE_KIND), context)) + .toMatchObject({ targetNodeId: type.id }); + expect(matchReference(makeRef('public.status', POSTGRES_SEQUENCE_REFERENCE_KIND), context)) + .toMatchObject({ targetNodeId: sequence.id }); + }); + + it.each([ + ['enum', 'enum', 'postgres:enum'], + ['domain', 'type_alias', 'postgres:domain'], + ['composite type', 'type_alias', 'postgres:type'], + ] as const)('accepts a decorated PostgreSQL %s', (_label, kind, decorator) => { + const target = makeNode({ + id: decorator, + name: 'status', + qualifiedName: 'app.status', + kind, + decorators: [decorator], + }); + const decoy = makeNode({ + id: `${decorator}:decoy`, + name: 'status', + qualifiedName: 'app.status', + kind, + language: 'typescript', + decorators: [decorator], + }); + + expect(matchReference( + makeRef('app.status', POSTGRES_TYPE_REFERENCE_KIND), + contextFor([target, decoy]) + )).toMatchObject({ targetNodeId: target.id }); + }); + + it('preserves quoted identifiers, search_path order, and duplicate ambiguity', () => { + const filePath = 'migrations/consumer.sql'; + const source = 'SET search_path = app, public;\nSELECT 1;'; + const appType = makeNode({ + id: 'app-type', + name: 'Status.Type', + qualifiedName: 'app."Status.Type"', + kind: 'enum', + decorators: ['postgres:enum'], + }); + const publicType = makeNode({ + id: 'public-type', + name: 'Status.Type', + qualifiedName: 'public."Status.Type"', + kind: 'enum', + decorators: ['postgres:enum'], + }); + const context = contextFor([appType, publicType], { [filePath]: source }); + + expect(matchReference( + makeRef('"Status.Type"', POSTGRES_TYPE_REFERENCE_KIND, filePath), + context + )).toMatchObject({ targetNodeId: appType.id }); + + const duplicate = makeNode({ + ...appType, + id: 'app-type-v2', + filePath: 'migrations/002.sql', + }); + expect(matchReference( + makeRef('app."Status.Type"', POSTGRES_TYPE_REFERENCE_KIND, filePath), + contextFor([appType, duplicate]) + )).toBeNull(); + }); + + it('rejects undecorated and wrong-class PostgreSQL nodes', () => { + const nodes = [ + makeNode({ + id: 'undecorated-type', + name: 'status', + qualifiedName: 'public.status', + kind: 'enum', + }), + makeNode({ + id: 'wrong-type', + name: 'status', + qualifiedName: 'public.status', + kind: 'enum', + decorators: ['postgres:sequence'], + }), + makeNode({ + id: 'wrong-sequence', + name: 'events_id_seq', + qualifiedName: 'public.events_id_seq', + kind: 'variable', + decorators: ['postgres:type'], + }), + ]; + const context = contextFor(nodes); + + expect(matchReference( + makeRef('public.status', POSTGRES_TYPE_REFERENCE_KIND), + context + )).toBeNull(); + expect(matchReference( + makeRef('public.events_id_seq', POSTGRES_SEQUENCE_REFERENCE_KIND), + context + )).toBeNull(); + }); +}); + +describe('PostgreSQL object-reference intent persistence', () => { + const cleanup: Array<{ dir: string; db: DatabaseConnection }> = []; + + afterEach(() => { + for (const item of cleanup.splice(0)) { + item.db.close(); + fs.rmSync(item.dir, { recursive: true, force: true }); + } + }); + + it('materializes quoted search_path matches as ordinary references edges', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pg-object-ref-')); + const db = DatabaseConnection.initialize(path.join(dir, 'test.db')); + cleanup.push({ dir, db }); + const queries = new QueryBuilder(db.getDb()); + const consumerPath = 'consumer.sql'; + fs.writeFileSync( + path.join(dir, consumerPath), + 'SET search_path = app;\nSELECT 1;\nSELECT 2;\n' + ); + fs.writeFileSync(path.join(dir, 'types.sql'), ''); + fs.writeFileSync(path.join(dir, 'sequences.sql'), ''); + + const consumer = makeNode({ + id: 'consumer', + name: 'consumer.sql', + qualifiedName: consumerPath, + filePath: consumerPath, + kind: 'file', + }); + const type = makeNode({ + id: 'type', + name: 'Status.Type', + qualifiedName: 'app."Status.Type"', + filePath: 'types.sql', + kind: 'enum', + decorators: ['postgres:enum'], + }); + const typeDecoy = makeNode({ + id: 'type-decoy-table', + name: 'Status.Type', + qualifiedName: 'app."Status.Type"', + filePath: 'types.sql', + kind: 'struct', + decorators: ['postgres:table'], + }); + const sequence = makeNode({ + id: 'sequence', + name: 'Seq.Name', + qualifiedName: 'app."Seq.Name"', + filePath: 'sequences.sql', + kind: 'variable', + decorators: ['postgres:sequence'], + }); + const sequenceDecoy = makeNode({ + id: 'sequence-decoy-type', + name: 'Seq.Name', + qualifiedName: 'app."Seq.Name"', + filePath: 'sequences.sql', + kind: 'type_alias', + decorators: ['postgres:type'], + }); + for (const node of [consumer, type, typeDecoy, sequence, sequenceDecoy]) { + queries.insertNode(node); + } + + const refs: UnresolvedReference[] = [ + { + fromNodeId: consumer.id, + referenceName: '"Status.Type"', + referenceKind: POSTGRES_TYPE_REFERENCE_KIND, + line: 2, + column: 1, + filePath: consumerPath, + language: 'postgres', + }, + { + fromNodeId: consumer.id, + referenceName: '"Seq.Name"', + referenceKind: POSTGRES_SEQUENCE_REFERENCE_KIND, + line: 3, + column: 1, + filePath: consumerPath, + language: 'postgres', + }, + ]; + queries.insertUnresolvedRefsBatch(refs); + + const resolver = createResolver(dir, queries); + const result = resolver.resolveAndPersist(queries.getUnresolvedReferences()); + expect(result.resolved).toHaveLength(2); + expect(result.unresolved).toEqual([]); + + const edges = queries.getOutgoingEdges(consumer.id); + expect(edges).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: type.id, + kind: 'references', + metadata: expect.objectContaining({ + refKind: POSTGRES_TYPE_REFERENCE_KIND, + refName: '"Status.Type"', + }), + }), + expect.objectContaining({ + target: sequence.id, + kind: 'references', + metadata: expect.objectContaining({ + refKind: POSTGRES_SEQUENCE_REFERENCE_KIND, + refName: '"Seq.Name"', + }), + }), + ])); + expect(edges.some((edge) => + edge.target === typeDecoy.id || edge.target === sequenceDecoy.id + )).toBe(false); + expect(queries.getUnresolvedReferencesCount()).toBe(0); + }); +}); diff --git a/__tests__/postgres-plpgsql-grammar.test.ts b/__tests__/postgres-plpgsql-grammar.test.ts new file mode 100644 index 000000000..e71d42a5f --- /dev/null +++ b/__tests__/postgres-plpgsql-grammar.test.ts @@ -0,0 +1,47 @@ +import { createHash } from 'node:crypto'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + getPostgresPlpgsqlParser, + initGrammars, + loadGrammarsForLanguages, + readGrammarWasmBytes, +} from '../src/extraction/grammars'; + +describe('PostgreSQL PL/pgSQL companion grammar', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); + }); + + it('pre-reads the pinned companion bytes with the PostgreSQL grammar', async () => { + const bytes = await readGrammarWasmBytes(['postgres']); + + expect(bytes.postgres).toBeInstanceOf(Uint8Array); + expect(bytes.plpgsql).toBeInstanceOf(Uint8Array); + expect(bytes.plpgsql.byteLength).toBe(137_462); + expect(createHash('sha256').update(bytes.plpgsql).digest('hex')).toBe( + '24a4b44b5263cbf78716d5a7301b5ade6f8696dbce08365421e785382427694e' + ); + }); + + it('loads a dedicated parser that structurally separates embedded SQL', () => { + const parser = getPostgresPlpgsqlParser(); + expect(parser).not.toBeNull(); + + const tree = parser!.parse([ + 'DECLARE', + ' selected_id uuid;', + 'BEGIN', + ' SELECT id INTO selected_id FROM public.users WHERE active;', + ' PERFORM public.touch_user(selected_id);', + 'END;', + ].join('\n')); + + expect(tree.rootNode.hasError).toBe(false); + const syntax = tree.rootNode.toString(); + expect(syntax).toContain('(pl_block '); + expect(syntax).toContain('(stmt_execsql (sql_expression))'); + expect(syntax).toContain('(stmt_perform (kw_perform) (sql_expression))'); + tree.delete(); + }); +}); diff --git a/__tests__/postgres-preparse-resolution.test.ts b/__tests__/postgres-preparse-resolution.test.ts new file mode 100644 index 000000000..aba7bec35 --- /dev/null +++ b/__tests__/postgres-preparse-resolution.test.ts @@ -0,0 +1,68 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); +}); + +async function indexedPostgres(source: string): Promise<{ + graph: CodeGraph; + projectDir: string; +}> { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pg-preparse-')); + fs.writeFileSync(path.join(projectDir, 'schema.sql'), `${source.trim()}\n`); + const graph = CodeGraph.initSync(projectDir); + await graph.indexAll(); + return { graph, projectDir }; +} + +function referencedTableNames(graph: CodeGraph, line: number): string[] { + const file = graph.getNodesByQualifiedName('schema.sql') + .find((node) => node.kind === 'file')!; + return graph.getOutgoingEdges(file.id, ['references']) + .filter((edge) => edge.line === line) + .map((edge) => graph.getNode(edge.target)) + .filter((node) => node?.language === 'postgres' && node.kind === 'struct') + .map((node) => node!.qualifiedName); +} + +describe('PostgreSQL prepared-source resolution parity', () => { + it('does not let a psql meta-command swallow the following SET search_path', async () => { + const { graph, projectDir } = await indexedPostgres([ + '\\set tenant app', + 'SET search_path = app;', + 'CREATE TABLE app.users (id bigint);', + 'SELECT * FROM users;', + ].join('\n')); + try { + expect(referencedTableNames(graph, 4)).toEqual(['app.users']); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not treat COPY FROM STDIN payload text as a search_path change', async () => { + const { graph, projectDir } = await indexedPostgres([ + 'CREATE TABLE app.users (id bigint);', + 'CREATE TABLE history.users (id bigint);', + 'CREATE TABLE public.seed (value text);', + 'SET search_path = app;', + 'COPY public.seed FROM STDIN WITH (FORMAT csv);', + 'SET search_path = history;', + '\\.', + 'SELECT * FROM users;', + ].join('\n')); + try { + expect(referencedTableNames(graph, 8)).toEqual(['app.users']); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/postgres-relation-lifecycle-resolution.test.ts b/__tests__/postgres-relation-lifecycle-resolution.test.ts new file mode 100644 index 000000000..9dee42b7b --- /dev/null +++ b/__tests__/postgres-relation-lifecycle-resolution.test.ts @@ -0,0 +1,165 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { POSTGRES_DROP_RELATION_DECORATOR } from '../src/postgres/relation-lifecycle'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); +}); + +function withMigrationProject(files: Record): string { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-pg-lifecycle-')); + for (const [file, source] of Object.entries(files)) { + fs.writeFileSync(path.join(projectDir, file), `${source.trim()}\n`); + } + return projectDir; +} + +describe('PostgreSQL relation lifecycle resolution', () => { + it('attaches a later-file FK to the table declaration after DROP and re-CREATE', async () => { + const projectDir = withMigrationProject({ + '001_users.sql': 'CREATE TABLE public.users (id bigint PRIMARY KEY);', + '002_orders.sql': [ + 'CREATE TABLE public.orders (', + ' id bigint PRIMARY KEY,', + ' user_id bigint', + ');', + ].join('\n'), + '003_drop_orders.sql': 'DROP TABLE public.orders;', + '004_recreate_orders.sql': [ + 'CREATE TABLE public.orders (', + ' id bigint PRIMARY KEY,', + ' user_id bigint', + ');', + ].join('\n'), + '005_orders_fk.sql': [ + 'ALTER TABLE public.orders', + ' ADD CONSTRAINT orders_user_fk', + ' FOREIGN KEY (user_id) REFERENCES public.users(id);', + ].join('\n'), + }); + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const users = graph.getNodesByQualifiedName('public.users')[0]!; + const orders = graph.getNodesByQualifiedName('public.orders'); + const oldOrders = orders.find((node) => node.filePath === '002_orders.sql')!; + const recreatedOrders = orders.find((node) => node.filePath === '004_recreate_orders.sql')!; + expect(users).toBeTruthy(); + expect(oldOrders).toBeTruthy(); + expect(recreatedOrders).toBeTruthy(); + expect(graph.getOutgoingEdges(oldOrders.id)).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: users.id, + metadata: expect.objectContaining({ postgresRelation: 'foreign-key' }), + }), + ])); + expect(graph.getOutgoingEdges(recreatedOrders.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: users.id, + kind: 'references', + metadata: expect.objectContaining({ postgresRelation: 'foreign-key' }), + }), + ])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('resolves each DROP VIEW to the latest preceding historical definition', async () => { + const projectDir = withMigrationProject({ + '001_create_view.sql': 'CREATE VIEW app.summary AS SELECT 1 AS value;', + '002_drop_view.sql': 'DROP VIEW app.summary;', + '003_recreate_view.sql': 'CREATE VIEW app.summary AS SELECT 2 AS value;', + '004_drop_view.sql': 'DROP VIEW app.summary;', + }); + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const views = graph.getNodesByQualifiedName('app.summary'); + const original = views.find((node) => node.filePath === '001_create_view.sql')!; + const recreated = views.find((node) => node.filePath === '003_recreate_view.sql')!; + const drops = graph.getNodesByName('DROP VIEW app.summary') + .filter((node) => node.decorators?.includes(POSTGRES_DROP_RELATION_DECORATOR)); + const firstDrop = drops.find((node) => node.filePath === '002_drop_view.sql')!; + const secondDrop = drops.find((node) => node.filePath === '004_drop_view.sql')!; + expect(graph.getOutgoingEdges(firstDrop.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: original.id, kind: 'references' }), + ])); + expect(graph.getOutgoingEdges(secondDrop.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: recreated.id, kind: 'references' }), + ])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('does not resolve a DROP to a later declaration in the same file', async () => { + const projectDir = withMigrationProject({ + '001_create_orders.sql': 'CREATE TABLE public.orders (id bigint);', + '002_replace_orders.sql': [ + 'DROP TABLE public.orders;', + 'CREATE TABLE public.orders (id bigint);', + ].join('\n'), + }); + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const orders = graph.getNodesByQualifiedName('public.orders'); + const original = orders.find((node) => node.filePath === '001_create_orders.sql')!; + const recreated = orders.find((node) => node.filePath === '002_replace_orders.sql')!; + const drop = graph.getNodesByName('DROP TABLE public.orders') + .find((node) => node.decorators?.includes(POSTGRES_DROP_RELATION_DECORATOR))!; + const targets = graph.getOutgoingEdges(drop.id) + .filter((edge) => edge.kind === 'references') + .map((edge) => edge.target); + + expect(original).toBeTruthy(); + expect(recreated).toBeTruthy(); + expect(drop).toBeTruthy(); + expect(targets).toContain(original.id); + expect(targets).not.toContain(recreated.id); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); + + it('renames the recreated relation after a DROP boundary', async () => { + const projectDir = withMigrationProject({ + '001_create_orders.sql': 'CREATE TABLE public.orders (id bigint);', + '002_drop_orders.sql': 'DROP TABLE public.orders;', + '003_recreate_orders.sql': 'CREATE TABLE public.orders (id bigint);', + '004_rename_orders.sql': 'ALTER TABLE public.orders RENAME TO purchases;', + }); + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const orders = graph.getNodesByQualifiedName('public.orders'); + const original = orders.find((node) => node.filePath === '001_create_orders.sql')!; + const recreated = orders.find((node) => node.filePath === '003_recreate_orders.sql')!; + const purchases = graph.getNodesByQualifiedName('public.purchases')[0]!; + const isRenameToPurchases = (edge: ReturnType[number]) => + edge.target === purchases.id && edge.metadata?.postgresRelation === 'rename'; + + expect(original).toBeTruthy(); + expect(recreated).toBeTruthy(); + expect(purchases).toBeTruthy(); + expect(graph.getOutgoingEdges(recreated.id).some(isRenameToPurchases)).toBe(true); + expect(graph.getOutgoingEdges(original.id).some(isRenameToPurchases)).toBe(false); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/postgres-rename-regressions.test.ts b/__tests__/postgres-rename-regressions.test.ts new file mode 100644 index 000000000..7b96296b2 --- /dev/null +++ b/__tests__/postgres-rename-regressions.test.ts @@ -0,0 +1,163 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + decodePostgresTableRelationDescriptor, +} from '../src/postgres/table-relation'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); +}); + +function extract(source: string) { + return extractFromSource('db/renames.sql', source, 'postgres'); +} + +describe('PostgreSQL rename regressions', () => { + it('extracts view and materialized-view renames as relation aliases', () => { + const result = extract([ + 'CREATE VIEW app.old_view AS SELECT 1 AS id;', + 'CREATE MATERIALIZED VIEW app.old_mv AS SELECT 1 AS id;', + 'ALTER VIEW app.old_view RENAME TO active_view;', + 'ALTER MATERIALIZED VIEW app.old_mv RENAME TO active_mv;', + ].join('\n')); + + expect(result.errors).toEqual([]); + expect(result.nodes.find((node) => node.qualifiedName === 'app.active_view')) + .toMatchObject({ + kind: 'struct', + decorators: expect.arrayContaining(['postgres:view', 'postgres:renamed-relation']), + }); + expect(result.nodes.find((node) => node.qualifiedName === 'app.active_mv')) + .toMatchObject({ + kind: 'struct', + decorators: expect.arrayContaining([ + 'postgres:materialized-view', + 'postgres:renamed-relation', + ]), + }); + expect(result.nodes.filter((node) => + node.decorators?.includes('postgres:renamed-type') + )).toEqual([]); + + const renames = result.nodes + .filter((node) => node.decorators?.includes(POSTGRES_TABLE_RELATION_DECORATOR)) + .map((node) => decodePostgresTableRelationDescriptor(node.decorators)); + expect(renames).toEqual(expect.arrayContaining([ + { + relation: 'rename', + sourceTable: 'app.old_view', + targetTable: 'app.active_view', + }, + { + relation: 'rename', + sourceTable: 'app.old_mv', + targetTable: 'app.active_mv', + }, + ])); + }); + + it('models ALTER TYPE RENAME VALUE at the new label, separately from ADD VALUE', () => { + const result = extract([ + "CREATE TYPE app.status AS ENUM ('old');", + "ALTER TYPE app.status ADD VALUE 'added';", + "ALTER TYPE app.status RENAME VALUE 'old' TO 'new';", + ].join('\n')); + + expect(result.errors).toEqual([]); + const added = result.nodes.find((node) => + node.qualifiedName === 'app.status.added' && + node.decorators?.includes('postgres:alter-enum-add-value') + ); + const renamed = result.nodes.find((node) => + node.qualifiedName === 'app.status.new' && + node.decorators?.includes('postgres:renamed-enum-value') + ); + expect(added).toBeTruthy(); + expect(renamed).toMatchObject({ + name: 'new', + kind: 'enum_member', + decorators: expect.arrayContaining([ + 'postgres:enum-value', + 'postgres:renamed-enum-value', + ]), + }); + expect(renamed?.decorators).not.toContain('postgres:alter-enum-add-value'); + expect(result.nodes.filter((node) => node.qualifiedName === 'app.status.old')) + .toHaveLength(1); + }); + + it('resolves renamed relation aliases and synthesizes their rename trails', async () => { + const projectDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-postgres-renames-')); + fs.writeFileSync( + path.join(projectDir, '001_relations.sql'), + [ + 'CREATE VIEW app.old_view AS SELECT 1 AS id;', + 'CREATE MATERIALIZED VIEW app.old_mv AS SELECT 1 AS id;', + "CREATE TYPE app.status AS ENUM ('old');", + ].join('\n') + '\n' + ); + fs.writeFileSync( + path.join(projectDir, '002_renames.sql'), + [ + 'ALTER VIEW app.old_view RENAME TO active_view;', + 'ALTER MATERIALIZED VIEW app.old_mv RENAME TO active_mv;', + "ALTER TYPE app.status RENAME VALUE 'old' TO 'new';", + ].join('\n') + '\n' + ); + fs.writeFileSync( + path.join(projectDir, '003_consumers.sql'), + [ + 'CREATE VIEW app.view_consumer AS SELECT * FROM app.active_view;', + 'CREATE VIEW app.mv_consumer AS SELECT * FROM app.active_mv;', + ].join('\n') + '\n' + ); + + const graph = CodeGraph.initSync(projectDir); + try { + await graph.indexAll(); + + const oldView = graph.getNodesByQualifiedName('app.old_view')[0]!; + const activeView = graph.getNodesByQualifiedName('app.active_view')[0]!; + const oldMv = graph.getNodesByQualifiedName('app.old_mv')[0]!; + const activeMv = graph.getNodesByQualifiedName('app.active_mv')[0]!; + const viewConsumer = graph.getNodesByQualifiedName('app.view_consumer')[0]!; + const mvConsumer = graph.getNodesByQualifiedName('app.mv_consumer')[0]!; + + expect(graph.getOutgoingEdges(oldView.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: activeView.id, + metadata: expect.objectContaining({ postgresRelation: 'rename' }), + }), + ])); + expect(graph.getOutgoingEdges(oldMv.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ + target: activeMv.id, + metadata: expect.objectContaining({ postgresRelation: 'rename' }), + }), + ])); + expect(graph.getOutgoingEdges(viewConsumer.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: activeView.id, kind: 'references' }), + ])); + expect(graph.getOutgoingEdges(mvConsumer.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: activeMv.id, kind: 'references' }), + ])); + + const status = graph.getNodesByQualifiedName('app.status')[0]!; + const renamedValue = graph.getNodesByQualifiedName('app.status.new')[0]!; + expect(renamedValue.decorators).toContain('postgres:renamed-enum-value'); + expect(graph.getOutgoingEdges(status.id)).toEqual(expect.arrayContaining([ + expect.objectContaining({ target: renamedValue.id, kind: 'contains' }), + ])); + } finally { + graph.destroy(); + fs.rmSync(projectDir, { recursive: true, force: true }); + } + }); +}); diff --git a/__tests__/postgres-routine-body.test.ts b/__tests__/postgres-routine-body.test.ts new file mode 100644 index 000000000..859abc630 --- /dev/null +++ b/__tests__/postgres-routine-body.test.ts @@ -0,0 +1,194 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { + getParser, + getPostgresPlpgsqlParser, + initGrammars, + loadGrammarsForLanguages, +} from '../src/extraction/grammars'; +import { + discoverPostgresRoutineBodyReferences, + type PostgresRoutineBodyReference, +} from '../src/postgres/routine-body'; + +function firstDescendant(node: SyntaxNode, type: string): SyntaxNode | null { + for (let index = 0; index < node.namedChildCount; index++) { + const child = node.namedChild(index); + if (!child) continue; + if (child.type === type) return child; + const nested = firstDescendant(child, type); + if (nested) return nested; + } + return null; +} + +function expectedPosition(source: string, startIndex: number) { + const before = source.slice(0, startIndex); + const lineStart = before.lastIndexOf('\n') + 1; + return { + startIndex, + line: before.split('\n').length, + column: startIndex - lineStart, + }; +} + +function findFact( + facts: PostgresRoutineBodyReference[], + kind: PostgresRoutineBodyReference['kind'], + qualifiedName: string +) { + return facts.find((fact) => fact.kind === kind && fact.qualifiedName === qualifiedName); +} + +describe('PostgreSQL routine body reference discovery', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); + }); + + function analyze(source: string, statementType: 'CreateFunctionStmt' | 'DoStmt') { + const postgres = getParser('postgres')!; + const outerTree = postgres.parse(source); + const statement = firstDescendant(outerTree.rootNode, statementType); + expect(statement).not.toBeNull(); + const result = discoverPostgresRoutineBodyReferences(statement!, source, { + postgres, + plpgsql: getPostgresPlpgsqlParser(), + }); + outerTree.delete(); + return result; + } + + it('indexes SQL-language bodies with CTE suppression, de-duplication, and outer offsets', () => { + const source = [ + '-- leading line keeps nested coordinates honest', + 'CREATE FUNCTION app.find_users() RETURNS SETOF uuid', + 'LANGUAGE sql', + 'AS $body$', + 'WITH selected AS (', + ' SELECT id FROM "App"."Users"', + ')', + "SELECT public.touch_user(id), nextval('public.job_seq'::regclass)", + 'FROM selected', + 'JOIN public.accounts ON true', + 'JOIN "App"."Users" again ON true;', + '$body$;', + ].join('\n'); + + const result = analyze(source, 'CreateFunctionStmt'); + expect(result.status).toBe('analyzed'); + expect(result.language).toBe('sql'); + expect(result.facts.map((fact) => [fact.kind, fact.qualifiedName])).toEqual([ + ['references', '"App"."Users"'], + ['calls', 'public.touch_user'], + ['calls', 'nextval'], + ['sequence', 'public.job_seq'], + ['references', 'public.accounts'], + ]); + expect(result.facts.some((fact) => fact.qualifiedName === 'selected')).toBe(false); + + const usersIndex = source.indexOf('"App"."Users"'); + expect(findFact(result.facts, 'references', '"App"."Users"')).toMatchObject( + expectedPosition(source, usersIndex) + ); + }); + + it('uses PL/pgSQL regions, safely recovers INTO, and skips dynamic SQL and record fields', () => { + const source = [ + 'CREATE FUNCTION public.touch_submission() RETURNS trigger', + 'AS $fn$', + 'DECLARE selected_id uuid;', + 'BEGIN', + ' WITH chosen AS (SELECT id FROM public.users)', + ' SELECT id INTO selected_id FROM chosen;', + ' UPDATE public.accounts SET active = true', + ' WHERE user_id = selected_id RETURNING id INTO selected_id;', + ' PERFORM public.touch_user(selected_id);', + " EXECUTE format('DELETE FROM public.secret_table WHERE id = %L', selected_id);", + ' NEW.updated_at := now();', + ' RETURN NEW;', + 'END;', + '$fn$', + 'LANGUAGE plpgsql;', + ].join('\n'); + + const result = analyze(source, 'CreateFunctionStmt'); + expect(result.status).toBe('analyzed'); + expect(result.language).toBe('plpgsql'); + expect(result.recoveredFragments).toBeGreaterThan(0); + expect(result.skippedDynamicFragments).toBeGreaterThan(0); + expect(result.facts.map((fact) => [fact.kind, fact.qualifiedName])).toEqual([ + ['references', 'public.users'], + ['references', 'public.accounts'], + ['calls', 'public.touch_user'], + ['calls', 'now'], + ]); + expect(result.facts.some((fact) => fact.qualifiedName === 'chosen')).toBe(false); + expect(result.facts.some((fact) => fact.qualifiedName.includes('secret_table'))).toBe(false); + expect(result.facts.some((fact) => fact.qualifiedName.startsWith('new.'))).toBe(false); + + const accountsIndex = source.indexOf('public.accounts'); + expect(findFact(result.facts, 'references', 'public.accounts')).toMatchObject( + expectedPosition(source, accountsIndex) + ); + const touchIndex = source.indexOf('public.touch_user'); + expect(findFact(result.facts, 'calls', 'public.touch_user')).toMatchObject( + expectedPosition(source, touchIndex) + ); + }); + + it('treats DO as PL/pgSQL by default and maps tagged body coordinates', () => { + const source = [ + 'DO $migration$', + 'BEGIN', + ' INSERT INTO audit.events(id) VALUES (gen_random_uuid());', + 'END;', + '$migration$;', + ].join('\n'); + + const result = analyze(source, 'DoStmt'); + expect(result.status).toBe('analyzed'); + expect(result.language).toBe('plpgsql'); + expect(result.facts.map((fact) => [fact.kind, fact.qualifiedName])).toEqual([ + ['references', 'audit.events'], + ['calls', 'gen_random_uuid'], + ]); + const eventsIndex = source.indexOf('audit.events'); + expect(findFact(result.facts, 'references', 'audit.events')).toMatchObject( + expectedPosition(source, eventsIndex) + ); + }); + + it('rejects an invalid SQL body instead of trusting error recovery', () => { + const source = [ + 'CREATE FUNCTION public.broken() RETURNS void', + 'LANGUAGE sql AS $$', + 'SELECT (((;', + '$$;', + ].join('\n'); + + const result = analyze(source, 'CreateFunctionStmt'); + expect(result.status).toBe('parse-error'); + expect(result.facts).toEqual([]); + }); + + it('accepts only literal sequence arguments through parentheses and casts', () => { + const source = [ + 'CREATE FUNCTION public.sequence_values(tenant text) RETURNS bigint', + 'LANGUAGE sql AS $$', + "SELECT nextval((('public.literal_seq'))::regclass),", + " setval(CAST('public.cast_seq' AS regclass), 1),", + " nextval(('public.foo_' || tenant)::regclass),", + " nextval(format('public.%s_seq', tenant)::regclass);", + '$$;', + ].join('\n'); + + const result = analyze(source, 'CreateFunctionStmt'); + expect(result.status).toBe('analyzed'); + expect(result.facts + .filter((fact) => fact.kind === 'sequence') + .map((fact) => fact.qualifiedName) + .sort() + ).toEqual(['public.cast_seq', 'public.literal_seq']); + }); +}); diff --git a/__tests__/postgres-routine-search-path.test.ts b/__tests__/postgres-routine-search-path.test.ts new file mode 100644 index 000000000..02db733de --- /dev/null +++ b/__tests__/postgres-routine-search-path.test.ts @@ -0,0 +1,239 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { extractFromSource } from '../src/extraction'; +import { + getParser, + getPostgresPlpgsqlParser, + initGrammars, + loadGrammarsForLanguages, +} from '../src/extraction/grammars'; +import { discoverPostgresRoutineBodyReferences } from '../src/postgres/routine-body'; +import { matchReference } from '../src/resolution/name-matcher'; +import type { ResolutionContext, UnresolvedRef } from '../src/resolution/types'; +import type { Node } from '../src/types'; + +function firstDescendant(node: SyntaxNode, type: string): SyntaxNode | null { + for (let index = 0; index < node.namedChildCount; index++) { + const child = node.namedChild(index); + if (!child) continue; + if (child.type === type) return child; + const nested = firstDescendant(child, type); + if (nested) return nested; + } + return null; +} + +function analyze(source: string) { + const postgres = getParser('postgres')!; + const tree = postgres.parse(source); + const statement = firstDescendant(tree.rootNode, 'CreateFunctionStmt'); + expect(statement).not.toBeNull(); + const discovery = discoverPostgresRoutineBodyReferences(statement!, source, { + postgres, + plpgsql: getPostgresPlpgsqlParser(), + }); + tree.delete(); + return discovery; +} + +function table(id: string, qualifiedName: string, filePath = `${id}.sql`): Node { + const name = qualifiedName.slice(qualifiedName.lastIndexOf('.') + 1).replace(/^"|"$/g, ''); + return { + id, + kind: 'struct', + name, + qualifiedName, + filePath, + language: 'postgres', + decorators: ['postgres:table'], + startLine: 1, + endLine: 1, + startColumn: 0, + endColumn: 1, + updatedAt: 0, + }; +} + +function contextFor(nodes: Node[], source = 'SET search_path = outer;\nSELECT 1;'): ResolutionContext { + return { + getNodesInFile: (filePath) => nodes.filter((node) => node.filePath === filePath), + getNodesByName: (name) => nodes.filter((node) => node.name === name), + getNodesByQualifiedName: (qualifiedName) => + nodes.filter((node) => node.qualifiedName === qualifiedName), + getNodesByKind: (kind) => nodes.filter((node) => node.kind === kind), + fileExists: (filePath) => nodes.some((node) => node.filePath === filePath), + readFile: (filePath) => filePath === 'routine.sql' ? source : '', + getProjectRoot: () => '/project', + getAllFiles: () => [...new Set(nodes.map((node) => node.filePath))], + getNodesByLowerName: (lowerName) => + nodes.filter((node) => node.name.toLowerCase() === lowerName), + getImportMappings: () => [], + }; +} + +function routineRef(candidates: string[]): UnresolvedRef { + return { + fromNodeId: 'routine', + referenceName: 'jobs', + referenceKind: 'references', + line: 2, + column: 1, + filePath: 'routine.sql', + language: 'postgres', + candidates, + }; +} + +describe('PostgreSQL routine-local search_path', () => { + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['postgres']); + }); + + it('qualifies SQL-body references with a single quoted schema', () => { + const source = [ + 'CREATE FUNCTION app.read_users() RETURNS SETOF record', + 'AS $$ SELECT touch_user(id) FROM users JOIN public.audit ON true $$', + 'LANGUAGE sql', + 'SET "search_path" TO "Tenant.Schema";', + ].join('\n'); + + const result = analyze(source); + expect(result.status).toBe('analyzed'); + expect(result.facts.map((fact) => [fact.kind, fact.qualifiedName])).toEqual([ + ['calls', '"Tenant.Schema".touch_user'], + ['references', '"Tenant.Schema".users'], + ['references', 'public.audit'], + ]); + expect(result.facts.every((fact) => fact.searchPathCandidates === undefined)).toBe(true); + }); + + it('keeps PL/pgSQL procedure alternatives ordered instead of linking every schema', () => { + const source = [ + 'CREATE PROCEDURE run_jobs()', + 'LANGUAGE plpgsql', + 'SET search_path = app, public', + 'AS $$', + 'BEGIN', + " INSERT INTO jobs(id) VALUES (nextval('job_seq'));", + ' PERFORM notify_job();', + 'END', + '$$;', + ].join('\n'); + + const result = analyze(source); + expect(result.status).toBe('analyzed'); + expect(result.facts.map((fact) => ({ + kind: fact.kind, + name: fact.qualifiedName, + candidates: fact.searchPathCandidates, + }))).toEqual([ + { kind: 'references', name: 'jobs', candidates: ['app.jobs', 'public.jobs'] }, + { kind: 'calls', name: 'nextval', candidates: ['app.nextval', 'public.nextval'] }, + { kind: 'sequence', name: 'job_seq', candidates: ['app.job_seq', 'public.job_seq'] }, + { kind: 'calls', name: 'notify_job', candidates: ['app.notify_job', 'public.notify_job'] }, + ]); + }); + + it('captures FROM CURRENT without changing the surrounding file path', () => { + const source = [ + 'SET search_path = "Creation.Schema";', + 'CREATE FUNCTION read_users() RETURNS SETOF record', + 'SET search_path FROM CURRENT', + 'LANGUAGE sql', + 'AS $$ SELECT * FROM users $$;', + 'SELECT * FROM users;', + ].join('\n'); + + const facts = analyze(source).facts; + expect(facts).toHaveLength(1); + expect(facts[0]).toMatchObject({ qualifiedName: '"Creation.Schema".users' }); + expect(facts[0]).not.toHaveProperty('searchPathCandidates'); + + const extracted = extractFromSource('db/schema.sql', source, 'postgres'); + const routine = extracted.nodes.find((node) => node.decorators?.includes('postgres:function'))!; + const routineReference = extracted.unresolvedReferences.find( + (reference) => reference.fromNodeId === routine.id && reference.referenceName.endsWith('.users') + ); + const fileReference = extracted.unresolvedReferences.find( + (reference) => reference.fromNodeId !== routine.id && reference.referenceName === 'users' + ); + expect(routineReference).toMatchObject({ + referenceName: '"Creation.Schema".users', + }); + expect(fileReference).toMatchObject({ referenceName: 'users' }); + expect(routineReference).not.toHaveProperty('candidates'); + expect(fileReference).not.toHaveProperty('candidates'); + }); + + it('persists multiple and empty routine paths without altering the ambient path', () => { + const source = [ + 'SET search_path = outer;', + 'CREATE PROCEDURE run_jobs()', + 'LANGUAGE plpgsql SET search_path = app, public', + 'AS $$ BEGIN INSERT INTO jobs VALUES (1); END $$;', + 'CREATE FUNCTION isolated() RETURNS SETOF record', + "LANGUAGE sql SET search_path = ''", + 'AS $$ SELECT * FROM jobs $$;', + 'CREATE FUNCTION defaulted() RETURNS SETOF record', + 'LANGUAGE sql SET search_path TO DEFAULT', + 'AS $$ SELECT * FROM jobs $$;', + 'SELECT * FROM jobs;', + ].join('\n'); + const extracted = extractFromSource('db/schema.sql', source, 'postgres'); + const routines = extracted.nodes.filter((node) => + node.decorators?.includes('postgres:function') || node.decorators?.includes('postgres:procedure') + ); + const byRoutine = new Map(routines.map((routine) => [ + routine.name, + extracted.unresolvedReferences.filter((reference) => reference.fromNodeId === routine.id), + ])); + + expect(byRoutine.get('run_jobs')).toEqual(expect.arrayContaining([ + expect.objectContaining({ + referenceName: 'jobs', + candidates: ['app.jobs', 'public.jobs'], + }), + ])); + expect(byRoutine.get('isolated')).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceName: 'jobs', candidates: [] }), + ])); + expect(byRoutine.get('defaulted')).toEqual(expect.arrayContaining([ + expect.objectContaining({ referenceName: 'public.jobs' }), + ])); + expect(extracted.unresolvedReferences).toEqual(expect.arrayContaining([ + expect.objectContaining({ + fromNodeId: 'file:db/schema.sql', + referenceName: 'jobs', + }), + ])); + const ambientReference = extracted.unresolvedReferences.find( + (reference) => reference.fromNodeId === 'file:db/schema.sql' && + reference.referenceName === 'jobs' + ); + expect(ambientReference).not.toHaveProperty('candidates'); + }); + + it('resolves ordered candidates with first-existing-schema and ambiguity semantics', () => { + const app = table('app-jobs', 'app.jobs'); + const publicJobs = table('public-jobs', 'public.jobs'); + const outer = table('outer-jobs', 'outer.jobs'); + const candidates = ['app.jobs', 'public.jobs']; + + expect(matchReference(routineRef(candidates), contextFor([app, publicJobs, outer]))) + .toMatchObject({ targetNodeId: app.id }); + expect(matchReference(routineRef(candidates), contextFor([publicJobs, outer]))) + .toMatchObject({ targetNodeId: publicJobs.id }); + + const duplicateApp = table('app-jobs-v2', 'app.jobs', 'app-v2.sql'); + expect(matchReference( + routineRef(candidates), + contextFor([app, duplicateApp, publicJobs, outer]) + )).toBeNull(); + expect(matchReference(routineRef([]), contextFor([outer]))).toBeNull(); + expect(matchReference( + routineRef(['"$user".jobs', 'public.jobs']), + contextFor([publicJobs]) + )).toBeNull(); + }); +}); diff --git a/__tests__/pr19-improvements.test.ts b/__tests__/pr19-improvements.test.ts index 6dbd20738..e233095eb 100644 --- a/__tests__/pr19-improvements.test.ts +++ b/__tests__/pr19-improvements.test.ts @@ -299,7 +299,7 @@ describe('Best-Candidate Resolution', () => { describe('Schema v2 Migration', () => { it.skipIf(!HAS_SQLITE)('should have correct current schema version', async () => { const { CURRENT_SCHEMA_VERSION } = await import('../src/db/migrations'); - expect(CURRENT_SCHEMA_VERSION).toBe(8); + expect(CURRENT_SCHEMA_VERSION).toBe(9); }); it.skipIf(!HAS_SQLITE)('should have migration for version 2', async () => { diff --git a/__tests__/security.test.ts b/__tests__/security.test.ts index 3b3171782..a4e90963b 100644 --- a/__tests__/security.test.ts +++ b/__tests__/security.test.ts @@ -544,7 +544,7 @@ describe('JSON.parse Error Boundaries in DB', () => { INSERT INTO nodes (id, kind, name, qualified_name, file_path, language, start_line, end_line, start_column, end_column, decorators, is_exported, is_async, is_static, is_abstract, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - 'test-node-1', 'function', 'myFunc', 'myFunc', 'test.ts', 'typescript', + 'test-node-1', 'function', 'myFunc', 'myFunc', 'test.sql', 'postgres', 1, 5, 0, 0, '{not valid json!!!}', // malformed decorators 0, 0, 0, 0, Date.now() @@ -555,6 +555,9 @@ describe('JSON.parse Error Boundaries in DB', () => { expect(node).not.toBeNull(); expect(node!.name).toBe('myFunc'); expect(node!.decorators).toBeUndefined(); + expect([ + ...queries.iterateDecoratorReferenceState('postgres', 'postgres:foreign-key'), + ]).toEqual([]); db.close(); }); @@ -578,6 +581,10 @@ describe('JSON.parse Error Boundaries in DB', () => { VALUES (?, ?, ?, ?) `).run('node-a', 'node-b', 'calls', 'broken json {{{'); + // Whole-graph relationship refreshes must skip malformed legacy metadata + // while deleting only edges explicitly owned by that synthesizer. + expect(() => queries.replaceEdgesBySynthesizer('postgres-foreign-key', [])).not.toThrow(); + // Should not throw - should return edge with undefined metadata const edges = queries.getOutgoingEdges('node-a'); expect(edges.length).toBe(1); diff --git a/__tests__/symbol-lookup.test.ts b/__tests__/symbol-lookup.test.ts index c81aaabd4..9c3e079c7 100644 --- a/__tests__/symbol-lookup.test.ts +++ b/__tests__/symbol-lookup.test.ts @@ -220,3 +220,177 @@ describe.skipIf(!HAS_SQLITE)('matchesSymbol — dotted lookups (regression for # expect((text.match(/\*\*Location:\*\*/g) || []).length).toBeGreaterThanOrEqual(2); }); }); + +describe.skipIf(!HAS_SQLITE)('PostgreSQL qualified relation lookup', () => { + let projectRoot: string; + let cg: any; + let handler: any; + let findSymbolMatches: (cg: any, s: string) => any[]; + let findAllSymbols: (cg: any, s: string) => { nodes: any[]; note: string }; + let matchesSymbol: (node: any, s: string) => boolean; + + beforeEach(async () => { + projectRoot = tmpRoot(); + fs.writeFileSync( + path.join(projectRoot, 'schema.sql'), + [ + 'CREATE TABLE public.users (id bigint PRIMARY KEY);', + 'CREATE TABLE history.users (id bigint PRIMARY KEY);', + 'CREATE TABLE app.users (id bigint PRIMARY KEY);', + 'CREATE TABLE "App"."Users" (id bigint PRIMARY KEY);', + 'CREATE TABLE public.orders (id bigint PRIMARY KEY, user_id bigint);', + "CREATE FUNCTION public.touch_user() RETURNS trigger LANGUAGE plpgsql AS 'BEGIN RETURN NEW; END';", + 'ALTER TABLE public.orders ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES public.users(id);', + 'CREATE TRIGGER users_touch AFTER UPDATE ON users EXECUTE FUNCTION public.touch_user();', + ].join('\n') + ); + fs.writeFileSync(path.join(projectRoot, 'users.ts'), 'export const users = [];\n'); + + const CodeGraph = (await import('../src/index')).default; + const { ToolHandler } = await import('../src/mcp/tools'); + cg = CodeGraph.initSync(projectRoot, { + config: { include: ['**/*.sql', '**/*.ts'], exclude: [] }, + }); + await cg.indexAll(); + handler = new ToolHandler(cg); + findSymbolMatches = (handler as any).findSymbolMatches.bind(handler); + findAllSymbols = (handler as any).findAllSymbols.bind(handler); + matchesSymbol = (handler as any).matchesSymbol.bind(handler); + }); + + afterEach(() => { + handler?.closeAll(); + cg?.destroy(); + rmTree(projectRoot); + }); + + it('uses the exact qualified-name index for schema.table', async () => { + expect(cg.getNodesByQualifiedName('public.users')).toHaveLength(1); + const matches = findSymbolMatches(cg, 'public.users'); + expect(matches.map((node) => node.qualifiedName)).toEqual(['public.users']); + expect(findAllSymbols(cg, 'public.users').nodes.map((node) => node.qualifiedName)) + .toEqual(['public.users']); + expect(findSymbolMatches(cg, 'missing.users')).toEqual([]); + + const response = await handler.execute('codegraph_node', { symbol: 'public.users' }); + const text = response.content?.[0]?.text ?? ''; + expect(text).toContain('**public.users** (table)'); + expect(text).not.toContain('Symbol "public.users" not found'); + expect(text).toContain('**Referenced by foreign keys ←** public.orders'); + expect(text).toContain('[orders_user_fk]'); + expect(text).toContain('**Triggers ←** users_touch → public.touch_user'); + }); + + it('folds unquoted qualified inputs without folding quoted identifiers', async () => { + expect(findSymbolMatches(cg, 'PUBLIC.USERS').map((node) => node.qualifiedName)) + .toEqual(['public.users']); + expect(findAllSymbols(cg, 'PUBLIC.USERS').nodes.map((node) => node.qualifiedName)) + .toEqual(['public.users']); + + expect(findSymbolMatches(cg, 'APP.USERS').map((node) => node.qualifiedName)) + .toEqual(['app.users']); + expect(findSymbolMatches(cg, '"App"."Users"').map((node) => node.qualifiedName)) + .toEqual(['"App"."Users"']); + expect(findAllSymbols(cg, '"App"."Users"').nodes.map((node) => node.qualifiedName)) + .toEqual(['"App"."Users"']); + + const folded = cg.getNodesByQualifiedName('app.users')[0]; + const quoted = cg.getNodesByQualifiedName('"App"."Users"')[0]; + expect(matchesSymbol(folded, 'APP.USERS')).toBe(true); + expect(matchesSymbol(quoted, '"App"."Users"')).toBe(true); + expect(matchesSymbol(quoted, 'App.Users')).toBe(false); + + const response = await handler.execute('codegraph_node', { symbol: 'PUBLIC.USERS' }); + const text = response.content?.[0]?.text ?? ''; + expect(text).toContain('**public.users** (table)'); + expect(text).not.toContain('Symbol "PUBLIC.USERS" not found'); + }); + + it('ranks an exact qualified relation above its triggers', () => { + const results = cg.searchNodes('public.users', { limit: 10 }); + expect(results[0]?.node.qualifiedName).toBe('public.users'); + }); + + it('keeps exact-qualified ranking stable across search pages', () => { + const full = cg.searchNodes('public.users', { limit: 10, offset: 0 }); + const paged = full.map((_: unknown, offset: number) => + cg.searchNodes('public.users', { limit: 1, offset })[0] + ); + expect(full[0]?.node.qualifiedName).toBe('public.users'); + expect(paged.map((result: any) => result?.node.id)) + .toEqual(full.map((result: any) => result.node.id)); + }); + + it('exposes PostgreSQL relation filters through MCP search', async () => { + const response = await handler.execute('codegraph_search', { + query: 'users', + kind: 'struct', + language: 'postgres', + limit: 10, + }); + const text = response.content?.[0]?.text ?? ''; + expect(text).toContain('**public.users** (table)'); + expect(text).toContain('**history.users** (table)'); + expect(text).not.toContain('users.ts'); + }); +}); + +describe.skipIf(!HAS_SQLITE)('search result pagination', () => { + it('paginates one stable globally-rescored candidate set', async () => { + const projectRoot = tmpRoot(); + const { DatabaseConnection } = await import('../src/db'); + const { QueryBuilder } = await import('../src/db/queries'); + const db = DatabaseConnection.initialize(path.join(projectRoot, 'codegraph.db')); + + try { + const queries = new QueryBuilder(db.getDb()); + const variables = Array.from({ length: 101 }, (_, index) => { + const suffix = String(index).padStart(3, '0'); + return { + id: `variable-${suffix}`, + kind: 'variable' as const, + name: `needle${suffix}`, + qualifiedName: `fixture.needle${suffix}`, + filePath: 'fixture.ts', + language: 'typescript' as const, + startLine: index + 1, + endLine: index + 1, + startColumn: 0, + endColumn: 9, + updatedAt: Date.now(), + }; + }); + queries.insertNodes([ + ...variables, + { + id: 'promoted-function', + kind: 'function', + name: 'needle101', + qualifiedName: 'fixture.needle101', + filePath: 'fixture.ts', + language: 'typescript', + startLine: 102, + endLine: 102, + startColumn: 0, + endColumn: 9, + updatedAt: Date.now(), + }, + ]); + + // The function is deliberately inserted after more than the FTS + // minimum candidate count. Its kind bonus should promote it only after + // every page has selected the same raw candidate universe. + const full = queries.searchNodes('needle', { limit: 30 }); + const paged = Array.from({ length: full.length }, (_, offset) => + queries.searchNodes('needle', { limit: 1, offset })[0] + ); + + expect(full[0]?.node.id).toBe('promoted-function'); + expect(paged.map((result) => result?.node.id)) + .toEqual(full.map((result) => result.node.id)); + } finally { + db.close(); + rmTree(projectRoot); + } + }); +}); diff --git a/package.json b/package.json index e661e803d..b0ce5efc0 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "scripts": { "build": "tsc && npm run copy-assets && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"", "preuninstall": "node dist/bin/uninstall.js", - "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", + "copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')||f.endsWith('.LICENSE')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"", "dev": "tsc --watch", "build:kernel": "bash scripts/build-kernel.sh", "cli": "npm run build && node dist/bin/codegraph.js", diff --git a/src/bin/codegraph.ts b/src/bin/codegraph.ts index eefb5d907..852a35a7f 100644 --- a/src/bin/codegraph.ts +++ b/src/bin/codegraph.ts @@ -1095,8 +1095,9 @@ program .option('-p, --path ', 'Project path') .option('-l, --limit ', 'Maximum results', '10') .option('-k, --kind ', 'Filter by node kind (function, class, etc.)') + .option('--language ', 'Filter by source language (for example postgres)') .option('-j, --json', 'Output as JSON') - .action(async (search: string, options: { path?: string; limit?: string; kind?: string; json?: boolean }) => { + .action(async (search: string, options: { path?: string; limit?: string; kind?: string; language?: string; json?: boolean }) => { const projectPath = resolveProjectPath(options.path); try { @@ -1112,6 +1113,7 @@ program const rawResults = cg.searchNodes(search, { limit, kinds: options.kind ? [options.kind as any] : undefined, + languages: options.language ? [options.language as any] : undefined, }); // Mirror the MCP search down-rank so the CLI also surfaces the @@ -1140,10 +1142,18 @@ program for (const result of results) { const node = result.node; const location = `${node.filePath}:${node.startLine}`; + const postgresKind = node.language === 'postgres' + ? node.decorators?.find((decorator: string) => + decorator.startsWith('postgres:') && + !decorator.startsWith('postgres:foreign-key-data:') && + !decorator.startsWith('postgres:table-relation-data:') + )?.slice('postgres:'.length) + : undefined; + const displayName = node.language === 'postgres' ? node.qualifiedName : node.name; console.log( - chalk.cyan(node.kind.padEnd(12)) + - chalk.white(node.name) + chalk.cyan((postgresKind ?? node.kind).padEnd(12)) + + chalk.white(displayName) ); console.log(chalk.dim(` ${location}`)); if (node.signature) { diff --git a/src/db/index.ts b/src/db/index.ts index 1b7a5883b..233f156a6 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -112,9 +112,12 @@ export class DatabaseConnection { runMigrations(db, currentVersion); } - // Self-heal a bulk-load window that never closed (crash between - // beginBulkNodeLoad and endBulkNodeLoad): the FTS triggers are missing and - // nodes_fts is stale. Rebuild + recreate so search stays in sync. + // Self-heal bulk-load windows that never closed. A process can die after + // dropping secondary indexes or FTS triggers but before the corresponding + // endBulk*Load call recreates them. Schema migrations will not help when + // the database already records the current version, so repair the missing + // schema objects explicitly on every reopen. + conn.healBulkLoadIndexes(); conn.healBulkNodeLoad(); return conn; @@ -279,12 +282,13 @@ export class DatabaseConnection { 'idx_edges_source_kind', 'idx_edges_target_kind', 'idx_edges_provenance', + 'idx_edges_synthesized_by', ] as const; /** * Enter bulk-edge-load mode: drop the non-unique edge indexes so the mass * INSERT OR IGNORE stream pays one B-tree (the identity index) instead of - * five — measured 2.8s → 1.1s inserting a 224k-edge resolution set, with + * six — measured 2.8s → 1.1s inserting a 224k-edge resolution set, with * recreation costing ~0.3s. MUST be paired with endBulkEdgeLoad(); a crash * inside the window is healed on the next DatabaseConnection open (schema.sql * re-applies CREATE INDEX IF NOT EXISTS). @@ -319,6 +323,34 @@ export class DatabaseConnection { } } + /** + * Recreate secondary indexes left missing by any interrupted bulk-load + * window. Only indexes deliberately dropped by beginBulk*Load are eligible: + * this keeps open() from silently masking unrelated schema corruption while + * covering the parse, reference-resolution, and edge-load windows together. + */ + private healBulkLoadIndexes(): void { + const expected = new Set([ + ...DatabaseConnection.BULK_PARSE_INDEX_NAMES, + ...DatabaseConnection.BULK_REF_INDEX_NAMES, + ...DatabaseConnection.BULK_EDGE_INDEX_NAMES, + ]); + const rows = this.db + .prepare("SELECT name FROM sqlite_master WHERE type = 'index'") + .all() as Array<{ name: string }>; + const existing = new Set(rows.map((row) => row.name)); + const missing = [...expected].filter((name) => !existing.has(name)); + if (missing.length === 0) return; + + const schemaPath = path.join(__dirname, 'schema.sql'); + const schema = fs.readFileSync(schemaPath, 'utf-8'); + for (const idx of missing) { + const m = schema.match(new RegExp(`CREATE INDEX IF NOT EXISTS ${idx}\\b[^;]*;`)); + if (!m) throw new Error(`schema.sql: index ${idx} not found for bulk-load recovery`); + this.db.exec(m[0]); + } + } + /** Recreate the FTS triggers + rebuild if a bulk-load window never closed. */ private healBulkNodeLoad(): void { const row = this.db diff --git a/src/db/migrations.ts b/src/db/migrations.ts index d083095bb..ef0124d6d 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -9,7 +9,7 @@ import { SqliteDatabase } from './sqlite-adapter'; /** * Current schema version */ -export const CURRENT_SCHEMA_VERSION = 8; +export const CURRENT_SCHEMA_VERSION = 9; /** * Migration definition @@ -150,6 +150,18 @@ const migrations: Migration[] = [ `); }, }, + { + version: 9, + description: + 'Index synthesized edge ownership so incremental relationship refreshes avoid full edge-table scans', + up: (db) => { + db.exec(` + CREATE INDEX IF NOT EXISTS idx_edges_synthesized_by + ON edges(json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy')) + WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL; + `); + }, + }, ]; /** diff --git a/src/db/queries.ts b/src/db/queries.ts index 16b9d5f91..c47805a71 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -12,6 +12,7 @@ import { UnresolvedReference, NodeKind, EdgeKind, + ReferenceKind, Language, GraphStats, SearchOptions, @@ -50,6 +51,19 @@ function isLowValueFile(filePath: string): boolean { const SQLITE_PARAM_CHUNK_SIZE = 500; +/** + * Non-empty searches apply JS-only scoring bonuses after SQLite returns the + * lexical matches. Every page must therefore rank the same raw candidate set: + * growing that set with `offset + limit` lets a later page discover a newly + * promoted result and causes duplicates/skips. Keep one fixed top-100 result + * window (the same maximum exposed by the MCP search tool); offsets at or past + * the window intentionally return no results. + * + * `searchNodesFTS` over-fetches this by 5x, so the raw FTS universe is also + * fixed (500 rows) before global rescoring. + */ +const SEARCH_RANKING_WINDOW = 100; + /** * Database row types (snake_case from SQLite) */ @@ -213,6 +227,7 @@ export class QueryBuilder { insertEdge?: SqliteStatement; upsertFile?: SqliteStatement; deleteEdgesBySource?: SqliteStatement; + deleteEdgesBySynthesizer?: SqliteStatement; deleteEdgesByTarget?: SqliteStatement; getEdgesBySource?: SqliteStatement; getEdgesByTarget?: SqliteStatement; @@ -1062,6 +1077,14 @@ export class QueryBuilder { } } + /** Stream every node for one language in stable ID order. */ + *iterateNodesByLanguage(language: Language): IterableIterator { + const stmt = this.db.prepare('SELECT * FROM nodes WHERE language = ? ORDER BY id'); + for (const row of stmt.iterate(language)) { + yield rowToNode(row as NodeRow); + } + } + /** * Get all nodes in the database */ @@ -1090,6 +1113,100 @@ export class QueryBuilder { } } + /** + * Stable, single-query state stream for a decorator-owned relationship + * synthesizer. Includes each fact's encoded data and its currently resolved + * reference endpoints, avoiding an N+1 edge lookup merely to decide whether + * an incremental refresh has any work to do. + */ + *iterateDecoratorReferenceState( + language: Language, + decorator: string + ): IterableIterator<{ + nodeId: string; + decorators: string; + targetId: string | null; + edgeMetadata: string | null; + targetQualifiedName: string | null; + targetKind: string | null; + targetLanguage: string | null; + targetDecorators: string | null; + }> { + const stmt = this.db.prepare(` + SELECT + n.id AS node_id, + n.decorators AS decorators, + e.target AS target_id, + e.metadata AS edge_metadata, + target.qualified_name AS target_qualified_name, + target.kind AS target_kind, + target.language AS target_language, + target.decorators AS target_decorators + FROM nodes n + LEFT JOIN edges e ON e.source = n.id AND e.kind = 'references' + LEFT JOIN nodes target ON target.id = e.target + WHERE n.language = ? + AND EXISTS ( + SELECT 1 FROM json_each( + CASE WHEN json_valid(n.decorators) THEN n.decorators ELSE '[]' END + ) + WHERE json_each.value = ? + ) + ORDER BY n.id, e.target, e.id + `); + for (const row of stmt.iterate(language, decorator) as Iterable<{ + node_id: string; + decorators: string; + target_id: string | null; + edge_metadata: string | null; + target_qualified_name: string | null; + target_kind: string | null; + target_language: string | null; + target_decorators: string | null; + }>) { + yield { + nodeId: row.node_id, + decorators: row.decorators, + targetId: row.target_id, + edgeMetadata: row.edge_metadata, + targetQualifiedName: row.target_qualified_name, + targetKind: row.target_kind, + targetLanguage: row.target_language, + targetDecorators: row.target_decorators, + }; + } + } + + /** Stream a synthesizer's indexed owned edge set in stable order. */ + *iterateEdgesBySynthesizer(synthesizedBy: string): IterableIterator<{ + source: string; + target: string; + kind: string; + metadata: string | null; + line: number | null; + col: number | null; + provenance: string | null; + }> { + const stmt = this.db.prepare(` + SELECT source, target, kind, metadata, line, col, provenance + FROM edges + WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') = ? + AND json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL + ORDER BY source, target, kind, IFNULL(line, -1), IFNULL(col, -1) + `); + for (const row of stmt.iterate(synthesizedBy)) { + yield row as { + source: string; + target: string; + kind: string; + metadata: string | null; + line: number | null; + col: number | null; + provenance: string | null; + }; + } + } + /** * Distinct languages present in the files table. One indexed aggregate — * lets the dynamic-edge synthesizers skip passes for languages the project @@ -1162,6 +1279,14 @@ export class QueryBuilder { */ searchNodes(query: string, options: SearchOptions = {}): SearchResult[] { const { limit = 100, offset = 0 } = options; + // Ranking and exact-qualified supplementation happen in this method, after + // the raw FTS query. Non-empty queries must use the same bounded candidate + // universe on every page. A truly empty query is not rescored, so it can + // retain unbounded, name-ordered pagination for callers that enumerate the + // graph (for example sync parity checks). + const candidateLimit = query + ? SEARCH_RANKING_WINDOW + : Math.max(limit, offset + limit); // Parse field-qualified bits out of the raw query (kind:, lang:, // path:, name:). Anything not recognised stays in `text` and goes @@ -1188,16 +1313,16 @@ export class QueryBuilder { // First try FTS5 with prefix matching let results = text - ? this.searchNodesFTS(text, { kinds, languages, limit, offset }) + ? this.searchNodesFTS(text, { kinds, languages, limit: candidateLimit, offset: 0 }) // Over-fetch by 5× when running filter-only (no text). The // post-scoring path: + name: filters can be very selective, so // a smaller multiplier risks returning fewer than `limit` // results despite the DB having plenty of matches. - : this.searchAllByFilters({ kinds, languages, limit: limit * 5 }); + : this.searchAllByFilters({ kinds, languages, limit: candidateLimit * 5 }); // If no FTS results, try LIKE-based substring search if (results.length === 0 && text.length >= 2) { - results = this.searchNodesLike(text, { kinds, languages, limit, offset }); + results = this.searchNodesLike(text, { kinds, languages, limit: candidateLimit, offset: 0 }); } // Final fuzzy fallback: scan all known names and keep those within @@ -1205,7 +1330,7 @@ export class QueryBuilder { // returned nothing AND there's a text portion long enough to be // worth fuzzing (1-char queries would match too much). if (results.length === 0 && text.length >= 3) { - results = this.searchNodesFuzzy(text, { kinds, languages, limit }); + results = this.searchNodesFuzzy(text, { kinds, languages, limit: candidateLimit }); } // Supplement: ensure exact name matches are always candidates. @@ -1229,7 +1354,7 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' LIMIT 20'; + sql += ' ORDER BY id LIMIT 20'; const rows = this.db.prepare(sql).all(...params) as NodeRow[]; for (const row of rows) { if (!existingIds.has(row.id)) { @@ -1240,6 +1365,28 @@ export class QueryBuilder { } } + // A fully-qualified symbol is the strongest possible lexical match, but + // FTS tokenizes its separators and heavily weights the simple-name column. + // That can rank `public.users.some_trigger` above the exact `public.users` + // table, or cap the exact node out entirely. Probe the indexed qualified + // name literally and retain its IDs for an explicit scoring bonus. + const exactQualifiedIds = new Set(); + if (text && /[.\/]|::/.test(text)) { + const exactQualified = this.getNodesByQualifiedNameExact(text).filter((node) => + (!kinds || kinds.length === 0 || kinds.includes(node.kind)) && + (!languages || languages.length === 0 || languages.includes(node.language)) + ); + const existingIds = new Set(results.map((result) => result.node.id)); + const maxScore = results.length > 0 ? Math.max(...results.map((result) => result.score)) : 1; + for (const node of exactQualified) { + exactQualifiedIds.add(node.id); + if (!existingIds.has(node.id)) { + results.push({ node, score: maxScore }); + existingIds.add(node.id); + } + } + } + // Apply multi-signal scoring if (results.length > 0 && (text || query)) { const scoringQuery = text || query; @@ -1248,12 +1395,13 @@ export class QueryBuilder { score: r.score + kindBonus(r.node.kind) + scorePathRelevance(r.node.filePath, scoringQuery, this.projectNameTokens) - + nameMatchBonus(r.node.name, scoringQuery), + + nameMatchBonus(r.node.name, scoringQuery) + + (exactQualifiedIds.has(r.node.id) ? 50 : 0), })); - results.sort((a, b) => b.score - a.score); - // Trim to requested limit after rescoring - if (results.length > limit) { - results = results.slice(0, limit); + results.sort((a, b) => b.score - a.score || a.node.id.localeCompare(b.node.id)); + // Trim to the fixed ranking window after rescoring. + if (results.length > candidateLimit) { + results = results.slice(0, candidateLimit); } } @@ -1276,7 +1424,7 @@ export class QueryBuilder { }); } - return results; + return results.slice(offset, offset + limit); } /** @@ -1301,7 +1449,7 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' ORDER BY name LIMIT ?'; + sql += ' ORDER BY name, id LIMIT ?'; params.push(limit); const rows = this.db.prepare(sql).all(...params) as NodeRow[]; return rows.map((row) => ({ node: rowToNode(row), score: 1 })); @@ -1333,7 +1481,7 @@ export class QueryBuilder { const dist = boundedEditDistance(name.toLowerCase(), lowered, maxDist); if (dist <= maxDist) candidates.push({ name, dist }); } - candidates.sort((a, b) => a.dist - b.dist); + candidates.sort((a, b) => a.dist - b.dist || a.name.localeCompare(b.name)); // Cap the per-name follow-up queries. Each survivor triggers a // separate `SELECT * FROM nodes WHERE name = ?`; without this cap @@ -1357,7 +1505,7 @@ export class QueryBuilder { sql += ` AND language IN (${languages.map(() => '?').join(',')})`; params.push(...languages); } - sql += ' LIMIT 5'; + sql += ' ORDER BY id LIMIT 5'; const rows = this.db.prepare(sql).all(...params) as NodeRow[]; for (const row of rows) { if (seen.has(row.id)) continue; @@ -1424,7 +1572,7 @@ export class QueryBuilder { params.push(...languages); } - sql += ' ORDER BY score LIMIT ? OFFSET ?'; + sql += ' ORDER BY score, nodes.id LIMIT ? OFFSET ?'; params.push(ftsLimit, offset); try { @@ -1488,7 +1636,7 @@ export class QueryBuilder { params.push(...languages); } - sql += ' ORDER BY score DESC, length(name) ASC LIMIT ? OFFSET ?'; + sql += ' ORDER BY score DESC, length(name) ASC, nodes.id ASC LIMIT ? OFFSET ?'; params.push(limit, offset); const rows = this.db.prepare(sql).all(...params) as (NodeRow & { score: number })[]; @@ -1710,6 +1858,50 @@ export class QueryBuilder { this.stmts.deleteEdgesBySource.run(sourceId); } + /** + * Replace one synthesizer's complete edge set in a transaction. Cross-file + * edges are not owned by either endpoint file, so incremental sync rebuilds + * them as a set; the transaction prevents a failed insert leaving a partial + * graph that a no-change sync would not revisit. + */ + replaceEdgesBySynthesizer(synthesizedBy: string, edges: Edge[]): void { + if (!this.stmts.deleteEdgesBySynthesizer) { + this.stmts.deleteEdgesBySynthesizer = this.db.prepare( + "DELETE FROM edges WHERE " + + "json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') = ? " + + "AND json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL" + ); + } + this.db.transaction(() => { + this.stmts.deleteEdgesBySynthesizer!.run(synthesizedBy); + const endpointIds = new Set(); + for (const edge of edges) { + endpointIds.add(edge.source); + endpointIds.add(edge.target); + } + const existingNodeIds = this.getExistingNodeIds([...endpointIds]); + const rows: unknown[][] = []; + for (const edge of edges) { + if (!existingNodeIds.has(edge.source) || !existingNodeIds.has(edge.target)) continue; + rows.push([ + edge.source, + edge.target, + edge.kind, + edge.metadata ? JSON.stringify(edge.metadata) : null, + edge.line ?? null, + edge.column ?? null, + edge.provenance ?? null, + ]); + } + this.runBatched( + 'replaceEdgesBySynthesizer', + 'INSERT OR IGNORE INTO edges (source, target, kind, metadata, line, col, provenance) VALUES ', + '(?,?,?,?,?,?,?)', + rows + ); + })(); + } + /** * Get outgoing edges from a node */ @@ -2028,7 +2220,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2046,7 +2238,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2094,7 +2286,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2122,7 +2314,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2191,7 +2383,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2373,7 +2565,7 @@ export class QueryBuilder { return rows.map((row) => ({ fromNodeId: row.from_node_id, referenceName: row.reference_name, - referenceKind: row.reference_kind as EdgeKind, + referenceKind: row.reference_kind as ReferenceKind, line: row.line, column: row.col, candidates: row.candidates ? safeJsonParse(row.candidates, undefined) : undefined, @@ -2487,6 +2679,11 @@ export class QueryBuilder { ).run(key, value, Date.now()); } + /** Delete one project metadata key. */ + deleteMetadata(key: string): void { + this.db.prepare('DELETE FROM project_metadata WHERE key = ?').run(key); + } + /** * Get all metadata as a key-value record */ diff --git a/src/db/schema.sql b/src/db/schema.sql index 75df8c05c..761abef59 100644 --- a/src/db/schema.sql +++ b/src/db/schema.sql @@ -185,6 +185,14 @@ CREATE INDEX IF NOT EXISTS idx_unresolved_from_name ON unresolved_refs(from_node CREATE INDEX IF NOT EXISTS idx_unresolved_status ON unresolved_refs(status); CREATE INDEX IF NOT EXISTS idx_unresolved_failed_tail ON unresolved_refs(name_tail) WHERE status = 'failed'; CREATE INDEX IF NOT EXISTS idx_edges_provenance ON edges(provenance); +-- Whole-graph synthesizers replace their owned edge sets by the metadata key. +-- Keep that ownership lookup indexed; without this expression index every +-- incremental refresh scans the complete edge table. Migration v9 adds it to +-- existing databases. The guarded expression preserves the database's legacy +-- behavior of tolerating malformed metadata JSON at read boundaries. +CREATE INDEX IF NOT EXISTS idx_edges_synthesized_by + ON edges(json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy')) + WHERE json_extract(CASE WHEN json_valid(metadata) THEN metadata ELSE '{}' END, '$.synthesizedBy') IS NOT NULL; -- Project metadata for version/provenance tracking CREATE TABLE IF NOT EXISTS project_metadata ( diff --git a/src/extraction/extraction-version.ts b/src/extraction/extraction-version.ts index 618a1b1c3..3691e95f0 100644 --- a/src/extraction/extraction-version.ts +++ b/src/extraction/extraction-version.ts @@ -21,4 +21,4 @@ * turns the re-index hint into noise — keep it honest (see CLAUDE.md, "Honesty * in the product is load-bearing"). */ -export const EXTRACTION_VERSION = 24; +export const EXTRACTION_VERSION = 27; diff --git a/src/extraction/grammars.ts b/src/extraction/grammars.ts index d4127631d..e509b110f 100644 --- a/src/extraction/grammars.ts +++ b/src/extraction/grammars.ts @@ -50,8 +50,18 @@ const WASM_GRAMMAR_FILES: Record = { terraform: 'tree-sitter-terraform.wasm', arkts: 'tree-sitter-arkts.wasm', nix: 'tree-sitter-nix.wasm', + postgres: 'tree-sitter-postgres.wasm', }; +/** + * PL/pgSQL is an embedded grammar, not a file language: PostgreSQL files use + * the `postgres` grammar and inject routine/DO bodies into this companion. + * Keep it out of Language/EXTENSION_MAP while still preloading it whenever a + * PostgreSQL index is loaded. + */ +const POSTGRES_PLPGSQL_GRAMMAR_KEY = 'plpgsql'; +const POSTGRES_PLPGSQL_WASM_FILE = 'tree-sitter-plpgsql.wasm'; + /** * File extension to Language mapping */ @@ -170,6 +180,11 @@ export const EXTENSION_MAP: Record = { '.tf': 'terraform', '.tfvars': 'terraform', '.tofu': 'terraform', + // PostgreSQL SQL and migration files. Projects using other PostgreSQL file + // extensions can opt into the grammar through a codegraph.json override. + '.sql': 'postgres', + '.psql': 'postgres', + '.pgsql': 'postgres', }; /** @@ -233,6 +248,9 @@ export function isPlayRoutesFile(filePath: string): boolean { const parserCache = new Map(); const languageCache = new Map(); const unavailableGrammarErrors = new Map(); +let postgresPlpgsqlParser: Parser | null = null; +let postgresPlpgsqlLanguage: WasmLanguage | null = null; +let postgresPlpgsqlUnavailableError: string | null = null; let parserInitialized = false; @@ -287,10 +305,19 @@ export async function initGrammars(): Promise { * (parser.c sha-matched against the crates.io tarball). * The kernel-grammar-parity test asserts this alignment; bump the crate and * the vendored wasm together. + * + * PostgreSQL: gmr/tree-sitter-postgres v1.2.4 (BSD-3-Clause), exact commit + * 9b27ba5c8700f9bf808221a0f6d17fe6515da787. The SQL grammar uses the + * release's prebuilt ABI-15 tree-sitter-postgres.wasm (SHA-256 + * 084883e58414c407dfac6f37f0facc983afdfe8103e17f5fd2ca138b79a22b92). + * The companion PL/pgSQL grammar is built from that tag's checked-in + * plpgsql/src/parser.c + scanner.c with tree-sitter-cli 0.26.7; its WASM + * SHA-256 is 24a4b44b5263cbf78716d5a7301b5ade6f8696dbce08365421e785382427694e. */ const VENDORED_WASM_LANGS: ReadonlySet = new Set([ 'pascal', 'scala', 'lua', 'luau', 'csharp', 'r', 'cfml', 'cfscript', 'cfquery', 'cobol', 'vbnet', 'erlang', 'terraform', 'arkts', 'nix', + 'postgres', 'typescript', 'tsx', 'javascript', 'jsx', 'java', 'python', 'go', // R7a (C/C++ kernel port prep): tree-sitter-c v0.24.2 (b780e47) + // tree-sitter-cpp v0.23.4 (f41e1a0), parser.c/scanner.c sha-matched against @@ -348,6 +375,11 @@ function resolveWasmPath(lang: GrammarLanguage): string { : require.resolve(`tree-sitter-wasms/out/${wasmFile}`); } +/** Absolute path of the PostgreSQL companion PL/pgSQL grammar. */ +function resolvePostgresPlpgsqlWasmPath(): string { + return path.join(__dirname, 'wasm', POSTGRES_PLPGSQL_WASM_FILE); +} + /** * Expand an index set's languages to the grammars actually needed to parse it. * SFC languages (svelte/vue/astro) have no grammar of their own — their @@ -389,9 +421,36 @@ export async function readGrammarWasmBytes(languages: Language[]): Promise +): Promise { + if (postgresPlpgsqlLanguage || postgresPlpgsqlUnavailableError) return; + try { + const bytes = wasmBytes?.[POSTGRES_PLPGSQL_GRAMMAR_KEY]; + postgresPlpgsqlLanguage = await WasmLanguage.load( + bytes ?? resolvePostgresPlpgsqlWasmPath() + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn( + '[CodeGraph] Failed to load PostgreSQL PL/pgSQL companion grammar — ' + + `routine-body parsing will be unavailable: ${message}` + ); + postgresPlpgsqlUnavailableError = message; + } +} + /** * Load grammar WASM files for specific languages only. * Skips languages that are already loaded or have no WASM grammar. @@ -429,6 +488,12 @@ export async function loadGrammarsForLanguages(languages: Language[], wasmBytes? unavailableGrammarErrors.set(lang, message); } } + + // Keep the embedded grammar out of the public Language set, but guarantee + // it is ready before synchronous PostgreSQL extraction begins. + if (languages.includes('postgres')) { + await loadPostgresPlpgsqlGrammar(wasmBytes); + } } /** @@ -467,6 +532,24 @@ export function getParser(language: Language): Parser | null { return parser; } +/** + * Return the preloaded parser for a PL/pgSQL routine/DO body. + * + * This is intentionally separate from getParser(): PL/pgSQL is embedded in a + * PostgreSQL file and must never become an extension-detected file language. + * The accessor is synchronous for use during extraction and safely returns + * null when the companion failed to load. + */ +export function getPostgresPlpgsqlParser(): Parser | null { + if (postgresPlpgsqlParser) return postgresPlpgsqlParser; + if (!postgresPlpgsqlLanguage) return null; + + const parser = new Parser(); + parser.setLanguage(postgresPlpgsqlLanguage); + postgresPlpgsqlParser = parser; + return parser; +} + /** * Detect language from file extension. * @@ -583,6 +666,10 @@ export function resetParser(language: Language): void { old.delete(); parserCache.delete(language); } + if (language === 'postgres' && postgresPlpgsqlParser) { + postgresPlpgsqlParser.delete(); + postgresPlpgsqlParser = null; + } } /** @@ -593,9 +680,14 @@ export function clearParserCache(): void { parser.delete(); } parserCache.clear(); + if (postgresPlpgsqlParser) { + postgresPlpgsqlParser.delete(); + postgresPlpgsqlParser = null; + } // Note: languageCache is NOT cleared — WASM languages persist. // To fully re-init, set parserInitialized = false and call initGrammars() again. unavailableGrammarErrors.clear(); + postgresPlpgsqlUnavailableError = null; } /** @@ -654,6 +746,7 @@ export function getLanguageDisplayName(language: Language): string { vbnet: 'Visual Basic .NET', erlang: 'Erlang', terraform: 'Terraform', + postgres: 'PostgreSQL', arkts: 'ArkTS', unknown: 'Unknown', }; diff --git a/src/extraction/languages/index.ts b/src/extraction/languages/index.ts index 6b760b01d..f0cd3e1ff 100644 --- a/src/extraction/languages/index.ts +++ b/src/extraction/languages/index.ts @@ -36,6 +36,7 @@ import { solidityExtractor } from './solidity'; import { terraformExtractor } from './terraform'; import { arktsExtractor } from './arkts'; import { nixExtractor } from './nix'; +import { postgresExtractor } from './postgres'; export const EXTRACTORS: Partial> = { typescript: typescriptExtractor, @@ -69,4 +70,5 @@ export const EXTRACTORS: Partial> = { terraform: terraformExtractor, arkts: arktsExtractor, nix: nixExtractor, + postgres: postgresExtractor, }; diff --git a/src/extraction/languages/postgres.ts b/src/extraction/languages/postgres.ts new file mode 100644 index 000000000..72ec174ea --- /dev/null +++ b/src/extraction/languages/postgres.ts @@ -0,0 +1,2206 @@ +import type { Node as SyntaxNode } from 'web-tree-sitter'; +import type { Node, NodeKind } from '../../types'; +import { + POSTGRES_DROP_CONSTRAINT_DECORATOR, + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + encodePostgresDropConstraintDescriptor, + encodePostgresRenameConstraintDescriptor, +} from '../../postgres/constraint-mutation'; +import { + POSTGRES_FOREIGN_KEY_DECORATOR, + encodePostgresForeignKeyDescriptor, + type PostgresForeignKeyDescriptor, +} from '../../postgres/foreign-key'; +import { + appendPostgresIdentifier, + parsePostgresQualifiedName, + serializePostgresQualifiedName, +} from '../../postgres/identifiers'; +import { + POSTGRES_SEQUENCE_REFERENCE_KIND, + POSTGRES_TYPE_REFERENCE_KIND, + type PostgresObjectReferenceKind, +} from '../../postgres/reference-intent'; +import { + POSTGRES_DROP_RELATION_DECORATOR, + encodePostgresDropRelationDescriptor, + type PostgresDroppedRelationKind, +} from '../../postgres/relation-lifecycle'; +import { + analyzePostgresSearchPath, + postgresSearchPathAtOffset, + type PostgresSearchPathState, +} from '../../postgres/search-path'; +import { + analyzePostgresTemporaryRelationVisibility, + postgresTemporaryRelationVisibleAt, + type PostgresTemporaryRelationVisibilityState, +} from '../../postgres/temporary-relations'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + encodePostgresTableRelationDescriptor, + type PostgresTableRelationDescriptor, + type PostgresTableRelationKind, +} from '../../postgres/table-relation'; +import { + POSTGRES_ENUM_VALUE_MUTATION_DECORATOR, + POSTGRES_TYPE_RENAME_DECORATOR, + encodePostgresEnumValueMutationDescriptor, + encodePostgresTypeRenameDescriptor, +} from '../../postgres/type-lifecycle'; +import { + discoverPostgresRoutineBodyReferences, + postgresStaticSequenceLiteral, +} from '../../postgres/routine-body'; +import { getParser, getPostgresPlpgsqlParser } from '../grammars'; +import { getNodeText } from '../tree-sitter-helpers'; +import type { ExtractorContext, LanguageExtractor } from '../tree-sitter-types'; + +/** + * PostgreSQL extractor for gmr/tree-sitter-postgres. + * + * The generated PostgreSQL grammar intentionally has no field map, so this + * extractor cannot use the generic `nameField` / `bodyField` dispatcher. It + * recognizes statement shapes explicitly and keeps SQL's own dotted names as + * qualified names (`schema.object`). Generic CodeGraph node kinds are reused; + * the precise database-object kind is retained as a `postgres:*` decorator. + * + * Routine bodies are opaque leaves in the outer grammar. Static dollar-quoted + * SQL bodies are reparsed with PostgreSQL; PL/pgSQL and DO blocks are first + * parsed with the pinned companion grammar and only its explicit SQL regions + * are injected back into PostgreSQL. Dynamic EXECUTE remains deliberately + * opaque because indexing must never guess or execute generated source. + */ + +interface SqlName { + parts: string[]; + qualified: string; + simple: string; +} + +type ReferenceKind = 'references' | 'calls' | PostgresObjectReferenceKind; + +const NAME_SEGMENT_TYPES = new Set(['ColId', 'ColLabel', 'type_function_name']); + +// GenericType also covers built-ins such as text/jsonb/uuid. Filter the core +// catalog spellings so typed object references stay useful instead of adding a +// failed unresolved row for nearly every column in a schema dump. +const POSTGRES_BUILTIN_TYPES = new Set([ + 'aclitem', 'any', 'anyarray', 'anycompatible', 'anycompatiblearray', 'anycompatiblenonarray', + 'anycompatiblemultirange', 'anycompatiblerange', 'anyelement', 'anyenum', + 'anymultirange', 'anynonarray', 'anyrange', 'bigint', 'bigserial', 'bit', 'bool', + 'boolean', 'box', 'bpchar', 'bytea', 'char', 'character', 'cidr', 'circle', 'cstring', + 'date', 'datemultirange', 'daterange', 'decimal', 'double precision', 'event_trigger', + 'fdw_handler', 'float4', 'float8', 'gtsvector', 'index_am_handler', 'inet', 'int', + 'int2', 'int2vector', 'int4', 'int4multirange', 'int4range', 'int8', 'int8multirange', + 'int8range', 'integer', 'internal', 'interval', 'json', 'jsonb', 'language_handler', + 'line', 'lseg', 'macaddr', 'macaddr8', 'money', 'name', 'numeric', 'nummultirange', + 'numrange', 'oid', 'oidvector', 'opaque', 'path', 'pg_dependencies', 'pg_lsn', + 'pg_mcv_list', 'pg_ndistinct', 'pg_node_tree', 'pg_snapshot', 'point', 'polygon', + 'real', 'record', 'refcursor', 'regclass', 'regcollation', 'regconfig', 'regdictionary', + 'regnamespace', 'regoper', 'regoperator', 'regproc', 'regprocedure', 'regrole', + 'regtype', 'serial', 'serial2', 'serial4', 'serial8', 'smallint', 'smallserial', + 'table_am_handler', 'text', 'tid', 'time', 'timestamp', 'timestamptz', 'timetz', + 'trigger', 'tsm_handler', 'tsmultirange', 'tsquery', 'tsrange', 'tstzmultirange', + 'tstzrange', 'tsvector', 'txid_snapshot', 'unknown', 'uuid', 'varbit', 'varchar', + 'void', 'xid', 'xid8', 'xml', +]); + +/** Per-extraction reference de-duplication. `ctx.nodes` is stable for a file. */ +const emittedReferenceKeys = new WeakMap>(); +const extractionSearchPaths = new WeakMap(); +interface ExtractionTemporaryVisibilityCache { + scannedNodeCount: number; + candidates: Node[]; + state: PostgresTemporaryRelationVisibilityState; +} +const extractionTemporaryVisibility = new WeakMap< + readonly object[], + ExtractionTemporaryVisibilityCache +>(); + +function postgresDollarTagAt(source: string, offset: number): string | null { + if (source[offset] !== '$') return null; + let end = offset + 1; + if (source[end] === '$') return '$$'; + if (!/[A-Za-z_]/.test(source[end] ?? '')) return null; + end++; + while (/[A-Za-z0-9_]/.test(source[end] ?? '')) end++; + return source[end] === '$' ? source.slice(offset, end + 1) : null; +} + +function isPostgresEscapeStringStart(source: string, quoteOffset: number): boolean { + const prefixOffset = quoteOffset - 1; + const prefix = source[prefixOffset]; + if (prefix !== 'E' && prefix !== 'e') return false; + // E/e is a literal prefix only at a token boundary, not the tail of an + // identifier such as `some' text'`. + return prefixOffset === 0 || !/[A-Za-z0-9_$]/.test(source[prefixOffset - 1] ?? ''); +} + +function postgresCodeMask(source: string): string { + // split('') retains UTF-16 code-unit indexes; a code-point spread would make + // every replacement after an astral character address the wrong position. + const masked = source.split(''); + let single = false; + let singleBackslashEscapes = false; + let double = false; + let dollarTag: string | null = null; + let lineComment = false; + let blockDepth = 0; + for (let index = 0; index < source.length; index++) { + const char = source[index]!; + const next = source[index + 1]; + if (lineComment) { + if (char !== '\n') masked[index] = ' '; + else lineComment = false; + continue; + } + if (blockDepth > 0) { + if (char !== '\n') masked[index] = ' '; + if (char === '/' && next === '*') { + masked[++index] = ' '; + blockDepth++; + } else if (char === '*' && next === '/') { + masked[++index] = ' '; + blockDepth--; + } + continue; + } + if (dollarTag) { + if (char !== '\n') masked[index] = ' '; + if (source.startsWith(dollarTag, index)) { + for (let cursor = index; cursor < index + dollarTag.length; cursor++) masked[cursor] = ' '; + index += dollarTag.length - 1; + dollarTag = null; + } + continue; + } + if (single) { + if (char !== '\n') masked[index] = ' '; + if (char === "'" && next === "'") masked[++index] = ' '; + else if (char === "'") { + single = false; + singleBackslashEscapes = false; + } else if ( + singleBackslashEscapes && char === '\\' && index + 1 < source.length + ) masked[++index] = ' '; + continue; + } + if (double) { + if (char !== '\n') masked[index] = ' '; + if (char === '"' && next === '"') masked[++index] = ' '; + else if (char === '"') double = false; + continue; + } + if (char === '-' && next === '-') { + masked[index] = masked[index + 1] = ' '; + lineComment = true; + index++; + } else if (char === '/' && next === '*') { + masked[index] = masked[index + 1] = ' '; + blockDepth = 1; + index++; + } else if (char === "'") { + masked[index] = ' '; + single = true; + singleBackslashEscapes = isPostgresEscapeStringStart(source, index); + } else if (char === '"') { + masked[index] = ' '; + double = true; + } else if (char === '$') { + const tag = postgresDollarTagAt(source, index); + if (tag) { + for (let cursor = index; cursor < index + tag.length; cursor++) masked[cursor] = ' '; + dollarTag = tag; + index += tag.length - 1; + } + } + } + return masked.join(''); +} + +/** Match COPY's top-level direction without mistaking a query's inner FROM. */ +function isCopyFromStdinStatement(statement: string): boolean { + const copy = /^\s*COPY\b/i.exec(statement); + if (!copy) return false; + let depth = 0; + for (let index = copy[0].length; index < statement.length;) { + const char = statement[index]!; + if (char === '(') { + depth++; + index++; + continue; + } + if (char === ')') { + depth = Math.max(0, depth - 1); + index++; + continue; + } + if (depth === 0 && /[A-Za-z_]/.test(char)) { + const start = index++; + while (/[A-Za-z0-9_$]/.test(statement[index] ?? '')) index++; + const word = statement.slice(start, index).toUpperCase(); + if (word === 'TO') return false; + if (word === 'FROM') { + while (/\s/.test(statement[index] ?? '')) index++; + const source = /^[A-Za-z_][A-Za-z0-9_$]*/.exec(statement.slice(index))?.[0]; + return source?.toUpperCase() === 'STDIN'; + } + continue; + } + index++; + } + return false; +} + +/** + * Offset-preserving PostgreSQL dialect cleanup for constructs intentionally + * outside the core server grammar: + * + * - psql meta-command lines are blanked; + * - psql variables become equivalent parser-safe expressions; + * - a valid CHECK-expression shape that gmr/tree-sitter-postgres currently + * mis-parses receives redundant parentheses in place of existing spaces. + * + * Every replacement is ASCII-for-ASCII and retains every newline, so the AST + * still addresses the original file's byte offsets and source locations. + */ +export function preParsePostgresSource(source: string): string { + const output = source.split(''); + const psqlCopyPayloadStarts: number[] = []; + let single = false; + let singleBackslashEscapes = false; + let double = false; + let dollarTag: string | null = null; + let lineComment = false; + let blockDepth = 0; + let atLineStart = true; + + for (let index = 0; index < source.length; index++) { + if (atLineStart) { + atLineStart = false; + if (!single && !double && !dollarTag && blockDepth === 0 && !lineComment) { + let commandStart = index; + while (source[commandStart] === ' ' || source[commandStart] === '\t') commandStart++; + if (source[commandStart] === '\\') { + const lineEnd = source.indexOf('\n', commandStart); + const end = lineEnd < 0 ? source.length : lineEnd; + const command = source.slice(commandStart, end).replace(/\r$/, ''); + const copyStatement = `${command.slice(1).replace(/;?\s*$/, '')};`; + if (isCopyFromStdinStatement(copyStatement) && lineEnd >= 0) { + psqlCopyPayloadStarts.push(lineEnd + 1); + } + for (let cursor = commandStart; cursor < end; cursor++) output[cursor] = ' '; + index = end - 1; + continue; + } + } + } + + const char = source[index]!; + const next = source[index + 1]; + if (lineComment) { + if (char === '\n') { + lineComment = false; + atLineStart = true; + } + continue; + } + if (blockDepth > 0) { + if (char === '/' && next === '*') { + blockDepth++; + index++; + } else if (char === '*' && next === '/') { + blockDepth--; + index++; + } else if (char === '\n') { + atLineStart = true; + } + continue; + } + if (dollarTag) { + if (source.startsWith(dollarTag, index)) { + index += dollarTag.length - 1; + dollarTag = null; + } else if (char === '\n') { + atLineStart = true; + } + continue; + } + if (single) { + if (char === "'" && next === "'") { + index++; + } else if (char === "'") { + single = false; + singleBackslashEscapes = false; + } else if ( + singleBackslashEscapes && char === '\\' && index + 1 < source.length + ) { + index++; + } else if (char === '\n') { + atLineStart = true; + } + continue; + } + if (double) { + if (char === '"' && next === '"') { + index++; + } else if (char === '"') { + double = false; + } else if (char === '\n') { + atLineStart = true; + } + continue; + } + + if (char === '-' && next === '-') { + lineComment = true; + index++; + } else if (char === '/' && next === '*') { + blockDepth = 1; + index++; + } else if (char === "'") { + single = true; + singleBackslashEscapes = isPostgresEscapeStringStart(source, index); + } else if (char === '"') { + double = true; + } else if (char === '$') { + const tag = postgresDollarTagAt(source, index); + if (tag) { + dollarTag = tag; + index += tag.length - 1; + } + } else if (char === ':' && source[index - 1] !== ':' && (next === "'" || next === '"')) { + // `:'name'` / `:"name"`: removing only the psql interpolation marker + // leaves a valid string literal / quoted identifier of identical length. + output[index] = ' '; + } else if (char === ':' && source[index - 1] !== ':' && /[A-Za-z_]/.test(next ?? '')) { + let end = index + 2; + while (/[A-Za-z0-9_]/.test(source[end] ?? '')) end++; + output[index] = '$'; + output[index + 1] = '1'; + for (let cursor = index + 2; cursor < end; cursor++) output[cursor] = ' '; + index = end - 1; + } else if (char === '\n') { + atLineStart = true; + } + } + + // gmr parses a server COPY statement itself but intentionally does not + // consume psql's following text-format payload. psql's `\copy` uses the same + // payload protocol but normally has no semicolon and its command line was + // blanked above. Only erase a payload when an exact `\.` terminator exists; + // without one, subsequent SQL must remain visible instead of being silently + // treated as data. Every erased byte keeps its original newline/offset. + const blankCopyPayload = (firstDataOffset: number): number | null => { + let lineStart = firstDataOffset; + let terminatorEnd: number | null = null; + while (lineStart < source.length) { + const newline = source.indexOf('\n', lineStart); + const lineEnd = newline < 0 ? source.length : newline; + const line = source.slice(lineStart, lineEnd).replace(/\r$/, ''); + if (line === '\\.') { + terminatorEnd = lineEnd; + break; + } + if (newline < 0) break; + lineStart = newline + 1; + } + if (terminatorEnd === null) return null; + for (let cursor = firstDataOffset; cursor < terminatorEnd; cursor++) { + if (source[cursor] !== '\n') output[cursor] = ' '; + } + const newline = source.indexOf('\n', terminatorEnd); + return newline < 0 ? source.length : newline + 1; + }; + + for (const firstDataOffset of psqlCopyPayloadStarts) { + blankCopyPayload(firstDataOffset); + } + + // Inspect complete semicolon-delimited statements rather than searching for + // the words COPY/FROM/STDIN anywhere. This keeps valid identifiers in e.g. + // `SELECT copy FROM stdin` from erasing every later line in the file. + const preparedCode = postgresCodeMask(output.join('')); + let statementStart = 0; + for (let cursor = 0; cursor < preparedCode.length; cursor++) { + if (preparedCode[cursor] !== ';') continue; + const statement = preparedCode.slice(statementStart, cursor + 1); + const isCopyFromStdin = isCopyFromStdinStatement(statement); + if (isCopyFromStdin) { + const headerLineEnd = source.indexOf('\n', cursor + 1); + const resume = headerLineEnd < 0 ? null : blankCopyPayload(headerLineEnd + 1); + if (resume !== null) { + cursor = resume - 1; + statementStart = resume; + continue; + } + } + statementStart = cursor + 1; + } + + const checkPattern = /(\bCHECK)(\s+)\(([^;\n]*?\bIN\s*\([^()\n]*\))(\s+)=/gi; + const code = postgresCodeMask(output.join('')); + for (let match = checkPattern.exec(code); match; match = checkPattern.exec(code)) { + const afterKeyword = match.index + (match[1]?.length ?? 0); + const beforeEquals = match.index + match[0].length - 1 - (match[4]?.length ?? 0); + output[afterKeyword] = '('; + output[beforeEquals] = ')'; + } + return output.join(''); +} + +function children(node: SyntaxNode): SyntaxNode[] { + const result: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child) result.push(child); + } + return result; +} + +function directChild(node: SyntaxNode, ...types: string[]): SyntaxNode | null { + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && types.includes(child.type)) return child; + } + return null; +} + +function directChildren(node: SyntaxNode, ...types: string[]): SyntaxNode[] { + return children(node).filter((child) => types.includes(child.type)); +} + +function firstDescendant(node: SyntaxNode, ...types: string[]): SyntaxNode | null { + for (const child of children(node)) { + if (types.includes(child.type)) return child; + const nested = firstDescendant(child, ...types); + if (nested) return nested; + } + return null; +} + +function allDescendants(node: SyntaxNode, type: string): SyntaxNode[] { + const result: SyntaxNode[] = []; + const visit = (current: SyntaxNode): void => { + for (const child of children(current)) { + if (child.type === type) result.push(child); + visit(child); + } + }; + visit(node); + return result; +} + +function nearestAncestor(node: SyntaxNode, ...types: string[]): SyntaxNode | null { + let ancestor = node.parent; + while (ancestor) { + if (types.includes(ancestor.type)) return ancestor; + ancestor = ancestor.parent; + } + return null; +} + +function hasDirectChild(node: SyntaxNode, type: string): boolean { + return directChild(node, type) !== null; +} + +/** PostgreSQL folds unquoted identifiers to lower case; quoted names do not. */ +function canonicalSegment(node: SyntaxNode, source: string): string { + const raw = getNodeText(node, source).trim(); + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + return raw.slice(1, -1).replace(/""/g, '"'); + } + return raw.toLowerCase(); +} + +/** + * Read a grammar name wrapper (`qualified_name`, `func_name`, `any_name`, + * `name`, ...). Segment wrappers are used rather than identifier leaves so + * keyword-shaped legal identifiers (for example `source`) are retained. + */ +function readSqlName(node: SyntaxNode | null, source: string): SqlName | null { + if (!node) return null; + const parts: string[] = []; + + const visit = (current: SyntaxNode): void => { + if (NAME_SEGMENT_TYPES.has(current.type)) { + const part = canonicalSegment(current, source); + if (part) parts.push(part); + return; + } + if (current.type === 'identifier' || current.type === 'quoted_identifier') { + const part = canonicalSegment(current, source); + if (part) parts.push(part); + return; + } + for (const child of children(current)) visit(child); + }; + + visit(node); + if (parts.length === 0) return null; + return sqlNameFromParts(parts); +} + +function sqlNameFromParts(parts: string[]): SqlName { + return { + parts, + qualified: serializePostgresQualifiedName(parts), + simple: parts[parts.length - 1]!, + }; +} + +function isUserDefinedPostgresType(name: SqlName): boolean { + if (name.parts.length > 1) { + return name.parts[0] !== 'pg_catalog' && name.parts[0] !== 'information_schema'; + } + return !POSTGRES_BUILTIN_TYPES.has(name.simple.toLowerCase()); +} + +interface NestedSchema { + found: boolean; + name: SqlName | null; +} + +function schemaName(node: SyntaxNode, source: string): SqlName | null { + return readSqlName(directChild(node, 'ColId', 'opt_single_name', 'RoleSpec'), source); +} + +function nestedSchema(node: SyntaxNode, source: string): NestedSchema { + let parent = node.parent; + while (parent) { + if (parent.type === 'CreateSchemaStmt') { + return { found: true, name: schemaName(parent, source) }; + } + parent = parent.parent; + } + return { found: false, name: null }; +} + +function searchPathState(ctx: ExtractorContext): PostgresSearchPathState { + let state = extractionSearchPaths.get(ctx.nodes); + if (!state) { + state = analyzePostgresSearchPath(ctx.source, { copyPayloadsMasked: true }); + extractionSearchPaths.set(ctx.nodes, state); + } + return state; +} + +/** + * Extraction appends to `ctx.nodes`. Scan each appended node once and rebuild + * the source timeline only when a new temporary declaration appears; relation + * lookups must not rescan a large SQL file for every statement. + */ +function temporaryVisibilityState(ctx: ExtractorContext): ExtractionTemporaryVisibilityCache { + let cached = extractionTemporaryVisibility.get(ctx.nodes); + if (!cached) { + cached = { + scannedNodeCount: 0, + candidates: [], + state: { changesByNodeId: new Map() }, + }; + extractionTemporaryVisibility.set(ctx.nodes, cached); + } + + let addedTemporary = false; + for (let index = cached.scannedNodeCount; index < ctx.nodes.length; index++) { + const candidate = ctx.nodes[index]!; + if (candidate.language === 'postgres' && + candidate.filePath === ctx.filePath && + candidate.decorators?.includes('postgres:temporary') === true) { + cached.candidates.push(candidate); + addedTemporary = true; + } + } + cached.scannedNodeCount = ctx.nodes.length; + if (addedTemporary) { + cached.state = analyzePostgresTemporaryRelationVisibility( + ctx.source, + cached.candidates, + searchPathState(ctx), + true + ); + } + return cached; +} + +function visibleTemporaryCandidates( + ctx: ExtractorContext, + name: SqlName, + offset: number +): Node[] { + const cached = temporaryVisibilityState(ctx); + if (cached.candidates.length === 0) return []; + const targetSchema = name.parts.length > 1 ? name.parts[0]! : null; + if (targetSchema !== null && targetSchema !== 'pg_temp' && + !/^pg_temp_[0-9]+$/.test(targetSchema)) return []; + + return cached.candidates.filter((candidate) => { + if (candidate.name !== name.simple) return false; + const parts = parsePostgresQualifiedName(candidate.qualifiedName); + if (!parts || parts.length < 2) return false; + if (targetSchema !== null && + name.parts.slice(1).some((part, index) => part !== parts[index + 1])) return false; + return postgresTemporaryRelationVisibleAt(cached.state, candidate.id, offset); + }); +} + +function nameInSearchPathSchema( + name: SqlName, + positionNode: SyntaxNode, + ctx: ExtractorContext +): SqlName { + if (name.parts.length > 1) return name; + const positions = searchPathState(ctx); + const path = postgresSearchPathAtOffset(positions, positionNode.startIndex); + const hasSameFileTemporary = visibleTemporaryCandidates( + ctx, + name, + positionNode.startIndex + ).length > 0; + if (hasSameFileTemporary) return sqlNameFromParts(['pg_temp', ...name.parts]); + if (path.schemas.length !== 1 || path.schemas[0] === '$user') return name; + const schema = path.schemas[0]!; + return sqlNameFromParts([schema, ...name.parts]); +} + +function objectName( + node: SyntaxNode, + nameNode: SyntaxNode | null, + ctx: ExtractorContext +): SqlName | null { + const name = readSqlName(nameNode, ctx.source); + if (!name || name.parts.length > 1 || node.type === 'CreateSchemaStmt') return name; + const nested = nestedSchema(node, ctx.source); + if (nested.found) { + // AUTHORIZATION CURRENT_USER has a runtime-only schema name. Keep nested + // declarations searchable but unqualified rather than falsely assigning + // them to the ambient/default search_path. + return nested.name + ? sqlNameFromParts([...nested.name.parts, ...name.parts]) + : name; + } + return nameInSearchPathSchema(name, node, ctx); +} + +function isTemporaryRelationName(name: SqlName | null): boolean { + const schema = name && name.parts.length > 1 ? name.parts[0]! : null; + return schema === 'pg_temp' || (schema !== null && /^pg_temp_[0-9]+$/.test(schema)); +} + +function hasTemporaryOption(node: SyntaxNode): boolean { + const options = directChild(node, 'OptTemp'); + return options !== null && ( + hasDirectChild(options, 'kw_temp') || hasDirectChild(options, 'kw_temporary') + ); +} + +/** + * PostgreSQL tables, views, and sequences share the temporary relation + * namespace. TEMP declarations with an unqualified name live in the + * session's pg_temp schema; explicitly targeting pg_temp has the same + * semantics even when the TEMP keyword is omitted. + */ +function relationDeclarationName( + node: SyntaxNode, + nameNode: SyntaxNode | null, + ctx: ExtractorContext +): { name: SqlName | null; temporary: boolean } { + const rawName = readSqlName(nameNode, ctx.source); + const temporary = hasTemporaryOption(node) || isTemporaryRelationName(rawName); + const name = temporary && rawName?.parts.length === 1 + ? sqlNameFromParts(['pg_temp', ...rawName.parts]) + : objectName(node, nameNode, ctx); + return { name, temporary }; +} + +function markTemporary(created: Node | null | undefined, temporary: boolean): void { + if (created && temporary && !created.decorators?.includes('postgres:temporary')) { + created.decorators = [...(created.decorators ?? []), 'postgres:temporary']; + } +} + +function compactText(node: SyntaxNode, source: string, maxLength = 320): string { + const compact = getNodeText(node, source).replace(/\s+/g, ' ').trim(); + return compact.length <= maxLength ? compact : `${compact.slice(0, maxLength - 1)}…`; +} + +function currentScopeId(ctx: ExtractorContext): string | null { + return ctx.nodeStack[ctx.nodeStack.length - 1] ?? null; +} + +function currentScopeNode(ctx: ExtractorContext): Node | null { + const id = currentScopeId(ctx); + return id ? ctx.nodes.find((node) => node.id === id) ?? null : null; +} + +function currentScopeQualifiedName(ctx: ExtractorContext): string | null { + return currentScopeNode(ctx)?.qualifiedName ?? null; +} + +function addReference( + ctx: ExtractorContext, + fromNodeId: string, + target: SqlName, + kind: ReferenceKind, + positionNode: SyntaxNode +): void { + addReferenceAt(ctx, fromNodeId, target, kind, { + startIndex: positionNode.startIndex, + line: positionNode.startPosition.row + 1, + column: positionNode.startPosition.column, + }); +} + +function addReferenceAt( + ctx: ExtractorContext, + fromNodeId: string, + target: SqlName, + kind: ReferenceKind, + position: { startIndex: number; line: number; column: number }, + searchPathCandidates?: readonly string[] +): void { + let keys = emittedReferenceKeys.get(ctx.nodes); + if (!keys) { + keys = new Set(); + emittedReferenceKeys.set(ctx.nodes, keys); + } + // Search-path segment is semantic in SQL: the same unqualified name on + // opposite sides of SET search_path can resolve to different schemas, while + // ordinary repeated reads within one segment remain de-duplicated. + const activePath = postgresSearchPathAtOffset( + searchPathState(ctx), + position.startIndex + ); + const temporaryBinding = visibleTemporaryCandidates( + ctx, + target, + position.startIndex + ).map((candidate) => candidate.id).sort().join('\u001f'); + const key = `${fromNodeId}\u0000${kind}\u0000${target.qualified}` + + `\u0000${activePath.explicit ? 'explicit' : 'default'}` + + `\u0000${activePath.schemas.join('\u001f')}` + + `\u0000routine:${searchPathCandidates === undefined + ? 'ambient' + : searchPathCandidates.join('\u001f')}` + + `\u0000temp:${temporaryBinding}`; + if (keys.has(key)) return; + keys.add(key); + + const reference = { + fromNodeId, + referenceName: target.qualified, + referenceKind: kind, + line: position.line, + column: position.column, + ...(searchPathCandidates === undefined + ? {} + : { candidates: [...searchPathCandidates] }), + }; + ctx.addUnresolvedReference(reference); +} + +function createDatabaseNode( + ctx: ExtractorContext, + node: SyntaxNode, + name: SqlName, + kind: NodeKind, + objectKind: string, + qualifiedName = name.qualified, + signature = compactText(node, ctx.source) +) { + // Node IDs use the createNode `name` argument plus file/line. PostgreSQL can + // switch search_path and declare same-named objects more than once on a + // generated/minified line, so simple names are not a safe discriminator. + const idDiscriminator = `${qualifiedName}@${node.startPosition.row + 1}:` + + `${node.startPosition.column + 1}`; + return ctx.createNode(kind, idDiscriminator, node, { + name: name.simple, + qualifiedName, + signature, + decorators: [`postgres:${objectKind}`], + isExported: true, + }); +} + +function addDecorators(created: Node | null | undefined, ...decorators: string[]): void { + if (!created) return; + const merged = new Set(created.decorators ?? []); + for (const decorator of decorators) merged.add(decorator); + created.decorators = [...merged]; +} + +function walkChildren(node: SyntaxNode, ctx: ExtractorContext): void { + for (const child of children(node)) ctx.visitNode(child); +} + +function createColumn( + ctx: ExtractorContext, + node: SyntaxNode, + nameNode: SyntaxNode, + parentQualifiedOverride?: string +): Node | null { + const name = readSqlName(nameNode, ctx.source); + if (!name) return null; + const parentQualified = parentQualifiedOverride ?? currentScopeQualifiedName(ctx); + const qualifiedName = parentQualified + ? appendPostgresIdentifier(parentQualified, name.simple) + : name.qualified; + const idDiscriminator = `${qualifiedName}@${node.startPosition.row + 1}:` + + `${node.startPosition.column + 1}`; + const created = ctx.createNode('field', idDiscriminator, node, { + name: name.simple, + qualifiedName, + signature: compactText(node, ctx.source, 200), + decorators: ['postgres:column'], + isExported: true, + }); + const typeNode = directChild(node, 'Typename'); + const typeName = readSqlName(typeNode, ctx.source); + if (created && typeNode && typeName && isUserDefinedPostgresType(typeName)) { + addReference( + ctx, + created.id, + typeName, + POSTGRES_TYPE_REFERENCE_KIND, + typeNode + ); + } + if (created) createStaticSequenceReferences(node, ctx, created.id); + return created; +} + +function createExplicitColumns(container: SyntaxNode | null, ctx: ExtractorContext): void { + if (!container) return; + const columnList = directChild(container, 'opt_column_list', 'columnList'); + if (!columnList) return; + for (const column of allDescendants(columnList, 'columnElem')) { + const nameNode = directChild(column, 'ColId'); + if (nameNode) createColumn(ctx, column, nameNode); + } +} + +function createEnumMembers(node: SyntaxNode, ctx: ExtractorContext): void { + const values = directChild(node, 'opt_enum_val_list'); + if (!values) return; + const parentQualified = currentScopeQualifiedName(ctx); + for (const literal of allDescendants(values, 'string_literal')) { + const raw = getNodeText(literal, ctx.source); + const value = raw.startsWith("'") && raw.endsWith("'") + ? raw.slice(1, -1).replace(/''/g, "'") + : raw; + if (!value) continue; + const qualifiedName = parentQualified + ? appendPostgresIdentifier(parentQualified, value) + : serializePostgresQualifiedName([value]); + const idDiscriminator = `${qualifiedName}@${literal.startPosition.row + 1}:` + + `${literal.startPosition.column + 1}`; + ctx.createNode('enum_member', idDiscriminator, literal, { + name: value, + qualifiedName, + signature: raw, + decorators: ['postgres:enum-value'], + isExported: true, + }); + } +} + +function withCreatedScope( + ctx: ExtractorContext, + created: ReturnType, + body: () => void +): void { + if (!created) return; + ctx.pushScope(created.id); + try { + body(); + } finally { + ctx.popScope(); + } +} + +function createTableRelationFact( + ctx: ExtractorContext, + clause: SyntaxNode, + sourceTable: SqlName, + targetTable: SqlName, + relation: PostgresTableRelationKind, + sourcePosition: SyntaxNode, + targetPosition: SyntaxNode, + extra: Pick = {} +): void { + const descriptor: PostgresTableRelationDescriptor = { + relation, + sourceTable: sourceTable.qualified, + targetTable: targetTable.qualified, + ...extra, + }; + const label = `${relation.toUpperCase()} → ${targetTable.qualified}`; + const identity = `${label} @${clause.startPosition.row + 1}:` + + `${clause.startPosition.column + 1}`; + const created = ctx.createNode('constant', `${sourceTable.qualified}.${identity}`, clause, { + name: label, + qualifiedName: appendPostgresIdentifier(sourceTable.qualified, identity), + signature: compactText(clause, ctx.source), + decorators: [ + POSTGRES_TABLE_RELATION_DECORATOR, + encodePostgresTableRelationDescriptor(descriptor), + ], + isExported: true, + }); + withCreatedScope(ctx, created, () => { + if (!created) return; + addReference(ctx, created.id, sourceTable, 'references', sourcePosition); + addReference(ctx, created.id, targetTable, 'references', targetPosition); + }); +} + +function createTableDeclarationRelations( + node: SyntaxNode, + ctx: ExtractorContext, + sourceTable: SqlName, + sourcePosition: SyntaxNode +): void { + const directNames = children(node).filter((child) => child.type === 'qualified_name'); + if (hasDirectChild(node, 'kw_partition') && hasDirectChild(node, 'kw_of')) { + const targetNode = directNames.find((candidate) => candidate.startIndex > sourcePosition.endIndex); + const target = readSqlName(targetNode ?? null, ctx.source); + if (targetNode && target) { + createTableRelationFact( + ctx, node, sourceTable, target, 'partition-of', sourcePosition, targetNode + ); + } + } + + const inheritance = directChild(node, 'OptInherit'); + if (inheritance) { + for (const targetNode of allDescendants(inheritance, 'qualified_name')) { + const target = readSqlName(targetNode, ctx.source); + if (target) { + createTableRelationFact( + ctx, targetNode, sourceTable, target, 'inherits', sourcePosition, targetNode + ); + } + } + } + + for (const like of allDescendants(node, 'TableLikeClause')) { + const targetNode = directChild(like, 'qualified_name'); + const target = readSqlName(targetNode, ctx.source); + if (targetNode && target) { + createTableRelationFact(ctx, like, sourceTable, target, 'like', sourcePosition, targetNode); + } + } +} + +function createTableLike( + node: SyntaxNode, + ctx: ExtractorContext, + nameNode: SyntaxNode | null, + objectKind: string, + explicitColumnsContainer?: SyntaxNode | null +): boolean { + const { name, temporary } = relationDeclarationName(node, nameNode, ctx); + if (!name) return true; + const created = createDatabaseNode(ctx, node, name, 'struct', objectKind); + markTemporary(created, temporary); + withCreatedScope(ctx, created, () => { + if (nameNode) createTableDeclarationRelations(node, ctx, name, nameNode); + createExplicitColumns(explicitColumnsContainer ?? node, ctx); + walkChildren(node, ctx); + }); + return true; +} + +function routineSignature(node: SyntaxNode, name: SqlName, source: string): string { + const args = directChild(node, 'func_args_with_defaults'); + const returns = directChild(node, 'func_return', 'table_func_column_list'); + const isProcedure = hasDirectChild(node, 'kw_procedure'); + const prefix = isProcedure ? 'CREATE PROCEDURE' : 'CREATE FUNCTION'; + const argsText = args ? compactText(args, source, 180) : '()'; + const returnText = returns ? ` RETURNS ${compactText(returns, source, 100)}` : ''; + return `${prefix} ${name.qualified}${argsText}${returnText}`; +} + +function createRoutineBodyReferences( + node: SyntaxNode, + ctx: ExtractorContext, + ownerId: string +): void { + const postgres = getParser('postgres'); + if (!postgres) return; + const discovery = discoverPostgresRoutineBodyReferences(node, ctx.source, { + postgres, + plpgsql: getPostgresPlpgsqlParser(), + }); + if (discovery.status !== 'analyzed') return; + for (const fact of discovery.facts) { + addReferenceAt( + ctx, + ownerId, + sqlNameFromParts(fact.parts), + fact.kind === 'sequence' ? POSTGRES_SEQUENCE_REFERENCE_KIND : fact.kind, + fact, + fact.searchPathCandidates + ); + } +} + +function createRoutine(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = objectName(node, directChild(node, 'func_name'), ctx); + if (!name) return true; + const objectKind = hasDirectChild(node, 'kw_procedure') ? 'procedure' : 'function'; + const created = createDatabaseNode( + ctx, + node, + name, + 'function', + objectKind, + name.qualified, + routineSignature(node, name, ctx.source) + ); + withCreatedScope(ctx, created, () => { + walkChildren(node, ctx); + if (created) createRoutineBodyReferences(node, ctx, created.id); + }); + return true; +} + +function createDoBlock(node: SyntaxNode, ctx: ExtractorContext): boolean { + const identity = `DO@${node.startPosition.row + 1}:${node.startPosition.column + 1}`; + const created = ctx.createNode('function', identity, node, { + name: 'DO block', + qualifiedName: `${ctx.filePath}::${identity}`, + signature: 'DO block', + decorators: ['postgres:do-block'], + isExported: false, + }); + withCreatedScope(ctx, created, () => { + walkChildren(node, ctx); + if (created) createRoutineBodyReferences(node, ctx, created.id); + }); + return true; +} + +function createType(node: SyntaxNode, ctx: ExtractorContext): boolean { + if (!hasDirectChild(node, 'kw_type')) return false; + const name = objectName(node, directChild(node, 'any_name'), ctx); + if (!name) return true; + const isEnum = hasDirectChild(node, 'kw_enum'); + const created = createDatabaseNode( + ctx, + node, + name, + isEnum ? 'enum' : 'type_alias', + isEnum ? 'enum' : 'type' + ); + withCreatedScope(ctx, created, () => { + if (isEnum) createEnumMembers(node, ctx); + walkChildren(node, ctx); + }); + return true; +} + +function createDomain(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = objectName(node, directChild(node, 'any_name'), ctx); + if (!name) return true; + const created = createDatabaseNode(ctx, node, name, 'type_alias', 'domain'); + withCreatedScope(ctx, created, () => walkChildren(node, ctx)); + return true; +} + +function createSchema(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = schemaName(node, ctx.source); + if (!name) { + walkChildren(node, ctx); + return true; + } + const created = createDatabaseNode(ctx, node, name, 'namespace', 'schema'); + withCreatedScope(ctx, created, () => walkChildren(node, ctx)); + return true; +} + +function createTrigger(node: SyntaxNode, ctx: ExtractorContext): boolean { + const triggerName = readSqlName(directChild(node, 'name'), ctx.source); + const tableNode = directChild(node, 'qualified_name'); + const tableName = readSqlName(tableNode, ctx.source); + if (!triggerName) return true; + + const displayTableName = tableName ? nameInSearchPathSchema(tableName, node, ctx) : null; + const qualifiedName = displayTableName + ? appendPostgresIdentifier(displayTableName.qualified, triggerName.simple) + : triggerName.qualified; + const created = createDatabaseNode( + ctx, + node, + triggerName, + 'function', + node.type === 'CreateEventTrigStmt' ? 'event-trigger' : 'trigger', + qualifiedName + ); + withCreatedScope(ctx, created, () => { + if (created && tableNode && tableName) { + addReference(ctx, created.id, tableName, 'references', tableNode); + } + const constraintSource = directChild(node, 'OptConstrFromTable'); + const sourceTableNode = constraintSource + ? directChild(constraintSource, 'qualified_name') + : null; + const sourceTable = readSqlName(sourceTableNode, ctx.source); + if (created && sourceTableNode && sourceTable) { + addReference(ctx, created.id, sourceTable, 'references', sourceTableNode); + } + if (tableNode && displayTableName && sourceTableNode && sourceTable) { + createTableRelationFact( + ctx, + constraintSource ?? node, + displayTableName, + sourceTable, + 'constraint-trigger', + tableNode, + sourceTableNode, + { triggerName: triggerName.simple } + ); + } + const routineNode = directChild(node, 'func_name'); + const routineName = readSqlName(routineNode, ctx.source); + if (created && routineNode && routineName) { + addReference(ctx, created.id, routineName, 'calls', routineNode); + } + walkChildren(node, ctx); + }); + return true; +} + +function createPolicy(node: SyntaxNode, ctx: ExtractorContext): boolean { + const policyName = readSqlName(directChild(node, 'name'), ctx.source); + const tableNode = directChild(node, 'qualified_name'); + const tableName = readSqlName(tableNode, ctx.source); + if (!policyName) return true; + const displayTableName = tableName ? nameInSearchPathSchema(tableName, node, ctx) : null; + const qualifiedName = displayTableName + ? appendPostgresIdentifier(displayTableName.qualified, policyName.simple) + : policyName.qualified; + const created = createDatabaseNode( + ctx, + node, + policyName, + 'constant', + 'policy', + qualifiedName + ); + if (node.type === 'AlterPolicyStmt') addDecorators(created, 'postgres:alter-policy'); + withCreatedScope(ctx, created, () => { + if (created && tableNode && tableName) { + addReference(ctx, created.id, tableName, 'references', tableNode); + } + walkChildren(node, ctx); + }); + return true; +} + +function renamedName(previous: SqlName, replacement: SqlName): SqlName { + return sqlNameFromParts([...previous.parts.slice(0, -1), replacement.simple]); +} + +function createDrop(node: SyntaxNode, ctx: ExtractorContext): boolean { + const objectType = directChild(node, 'object_type_any_name'); + if (!objectType) return false; + let relationKind: PostgresDroppedRelationKind | null = null; + if (hasDirectChild(objectType, 'kw_table')) { + relationKind = hasDirectChild(objectType, 'kw_foreign') ? 'foreign-table' : 'table'; + } else if (hasDirectChild(objectType, 'kw_view')) { + relationKind = hasDirectChild(objectType, 'kw_materialized') + ? 'materialized-view' + : 'view'; + } + if (!relationKind) return false; + + const objectLabel = relationKind.replace('-', ' ').toUpperCase(); + for (const nameNode of allDescendants(node, 'any_name')) { + const rawName = readSqlName(nameNode, ctx.source); + const name = rawName ? nameInSearchPathSchema(rawName, node, ctx) : null; + if (!name) continue; + const displayName = `DROP ${objectLabel} ${name.qualified}`; + const created = ctx.createNode('constant', `${displayName}@${node.startIndex}`, node, { + name: displayName, + qualifiedName: appendPostgresIdentifier(name.qualified, displayName), + signature: compactText(node, ctx.source), + decorators: [ + POSTGRES_DROP_RELATION_DECORATOR, + encodePostgresDropRelationDescriptor({ + relationName: name.qualified, + relationKind, + }), + ], + isExported: true, + }); + if (created) addReference(ctx, created.id, name, 'references', nameNode); + } + return true; +} + +function createRename(node: SyntaxNode, ctx: ExtractorContext): boolean { + const replacementNodes = directChildren(node, 'name'); + + if (hasDirectChild(node, 'kw_policy')) { + const tableNode = directChild(node, 'qualified_name'); + const rawTable = readSqlName(tableNode, ctx.source); + const table = rawTable ? nameInSearchPathSchema(rawTable, node, ctx) : null; + const replacement = readSqlName(replacementNodes[replacementNodes.length - 1] ?? null, ctx.source); + if (!tableNode || !table || !replacement) return true; + const qualified = appendPostgresIdentifier(table.qualified, replacement.simple); + const created = createDatabaseNode( + ctx, + node, + replacement, + 'constant', + 'policy', + qualified + ); + addDecorators(created, 'postgres:renamed-policy'); + if (created) addReference(ctx, created.id, table, 'references', tableNode); + return true; + } + + const relation = directChild(node, 'relation_expr'); + const tableNode = relation ? firstDescendant(relation, 'qualified_name') : null; + const rawTable = readSqlName(tableNode, ctx.source); + const table = rawTable ? nameInSearchPathSchema(rawTable, node, ctx) : null; + + if (relation && tableNode && table && hasDirectChild(node, 'opt_column')) { + const names = replacementNodes.map((candidate) => readSqlName(candidate, ctx.source)); + const previous = names[0]; + const replacement = names[names.length - 1]; + if (!previous || !replacement) return true; + const qualifiedName = appendPostgresIdentifier(table.qualified, replacement.simple); + const idDiscriminator = `${qualifiedName}@${node.startPosition.row + 1}:` + + `${node.startPosition.column + 1}`; + const created = ctx.createNode('field', idDiscriminator, node, { + name: replacement.simple, + qualifiedName, + signature: compactText(node, ctx.source), + decorators: ['postgres:column', 'postgres:renamed-column'], + isExported: true, + }); + if (created) addReference(ctx, created.id, table, 'references', tableNode); + return true; + } + + if (relation && tableNode && table && hasDirectChild(node, 'kw_constraint')) { + const names = replacementNodes.map((candidate) => readSqlName(candidate, ctx.source)); + const previous = names[0]; + const replacement = names[names.length - 1]; + if (!previous || !replacement) return true; + const qualifiedName = appendPostgresIdentifier(table.qualified, replacement.simple); + const created = createDatabaseNode( + ctx, + node, + replacement, + 'constant', + 'constraint', + qualifiedName + ); + addDecorators( + created, + 'postgres:renamed-constraint', + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + encodePostgresRenameConstraintDescriptor({ + table: table.qualified, + sourceConstraint: previous.simple, + targetConstraint: replacement.simple, + }) + ); + if (created) addReference(ctx, created.id, table, 'references', tableNode); + return true; + } + + if (relation && tableNode && table && hasDirectChild(node, 'kw_table')) { + const replacement = readSqlName(replacementNodes[replacementNodes.length - 1] ?? null, ctx.source); + if (!replacement) return true; + const next = renamedName(table, replacement); + const created = createDatabaseNode(ctx, node, next, 'struct', 'table'); + addDecorators(created, 'postgres:renamed-relation'); + markTemporary(created, isTemporaryRelationName(table)); + if (created) addReference(ctx, created.id, table, 'references', tableNode); + createTableRelationFact( + ctx, + node, + table, + next, + 'rename' as PostgresTableRelationKind, + tableNode, + replacementNodes[replacementNodes.length - 1] ?? node + ); + return true; + } + + const sourceNode = directChild(node, 'qualified_name', 'any_name'); + const rawSource = readSqlName(sourceNode, ctx.source); + const source = rawSource ? nameInSearchPathSchema(rawSource, node, ctx) : null; + const replacement = readSqlName(replacementNodes[replacementNodes.length - 1] ?? null, ctx.source); + if (!sourceNode || !source || !replacement) return true; + const next = renamedName(source, replacement); + + if (hasDirectChild(node, 'kw_view')) { + const objectKind = hasDirectChild(node, 'kw_materialized') + ? 'materialized-view' + : 'view'; + const created = createDatabaseNode(ctx, node, next, 'struct', objectKind); + addDecorators(created, 'postgres:renamed-relation'); + markTemporary(created, isTemporaryRelationName(source)); + if (created) addReference(ctx, created.id, source, 'references', sourceNode); + createTableRelationFact( + ctx, + node, + source, + next, + 'rename' as PostgresTableRelationKind, + sourceNode, + replacementNodes[replacementNodes.length - 1] ?? node + ); + return true; + } + + if (hasDirectChild(node, 'kw_type') && !hasDirectChild(node, 'kw_attribute')) { + const created = createDatabaseNode(ctx, node, next, 'type_alias', 'type'); + addDecorators( + created, + 'postgres:renamed-type', + POSTGRES_TYPE_RENAME_DECORATOR, + encodePostgresTypeRenameDescriptor({ + sourceType: source.qualified, + targetType: next.qualified, + }) + ); + if (created) { + addReference(ctx, created.id, source, POSTGRES_TYPE_REFERENCE_KIND, sourceNode); + } + return true; + } + + const objectKind = hasDirectChild(node, 'kw_index') + ? 'index' + : hasDirectChild(node, 'kw_sequence') + ? 'sequence' + : 'type'; + const kind: NodeKind = objectKind === 'index' + ? 'constant' + : objectKind === 'sequence' + ? 'variable' + : 'type_alias'; + const created = createDatabaseNode(ctx, node, next, kind, objectKind); + addDecorators(created, `postgres:renamed-${objectKind}`); + return true; +} + +function postgresStringLiteral(node: SyntaxNode | null, source: string): string | null { + if (!node) return null; + const raw = getNodeText(node, source); + if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) { + return raw.slice(1, -1).replace(/''/g, "'"); + } + return raw || null; +} + +function createAlterEnum(node: SyntaxNode, ctx: ExtractorContext): boolean { + const typeNode = directChild(node, 'any_name'); + const rawType = readSqlName(typeNode, ctx.source); + const typeName = rawType ? nameInSearchPathSchema(rawType, node, ctx) : null; + const values = directChildren(node, 'Sconst') + .map((value) => firstDescendant(value, 'string_literal')) + .filter((value): value is SyntaxNode => value !== null); + const renamed = hasDirectChild(node, 'kw_rename'); + const sourceValueNode = renamed ? values[0] ?? null : null; + const valueNode = renamed ? values[values.length - 1] ?? null : values[0] ?? null; + const sourceValue = postgresStringLiteral(sourceValueNode, ctx.source); + const value = postgresStringLiteral(valueNode, ctx.source); + if (!typeName || !valueNode || !value || (renamed && !sourceValue)) return true; + const qualifiedName = appendPostgresIdentifier(typeName.qualified, value); + const idDiscriminator = `${qualifiedName}@${valueNode.startPosition.row + 1}:` + + `${valueNode.startPosition.column + 1}`; + ctx.createNode('enum_member', idDiscriminator, valueNode, { + name: value, + qualifiedName, + signature: compactText(node, ctx.source), + decorators: [ + 'postgres:enum-value', + renamed ? 'postgres:renamed-enum-value' : 'postgres:alter-enum-add-value', + POSTGRES_ENUM_VALUE_MUTATION_DECORATOR, + encodePostgresEnumValueMutationDescriptor(renamed ? { + mutation: 'rename', + enumType: typeName.qualified, + sourceValue: sourceValue!, + targetValue: value, + } : { + mutation: 'add', + enumType: typeName.qualified, + targetValue: value, + }), + ], + isExported: true, + }); + return true; +} + +function createAlterSequence(node: SyntaxNode, ctx: ExtractorContext): boolean { + const sequenceNode = directChild(node, 'qualified_name'); + const rawSequence = readSqlName(sequenceNode, ctx.source); + const sequence = rawSequence ? nameInSearchPathSchema(rawSequence, node, ctx) : null; + const ownerNode = firstDescendant(node, 'any_name'); + const owner = readSqlName(ownerNode, ctx.source); + if (!sequence || !ownerNode || !owner || owner.parts.length < 2) return true; + const table = sqlNameFromParts(owner.parts.slice(0, -1)); + const identity = `OWNED BY ${owner.qualified}`; + const deltaName: SqlName = { + parts: [...sequence.parts, identity], + qualified: appendPostgresIdentifier(sequence.qualified, identity), + simple: sequence.simple, + }; + const created = createDatabaseNode( + ctx, + node, + deltaName, + 'constant', + 'sequence-ownership' + ); + if (created) { + addReference(ctx, created.id, table, 'references', ownerNode); + if (sequenceNode) { + addReference( + ctx, + created.id, + sequence, + POSTGRES_SEQUENCE_REFERENCE_KIND, + sequenceNode + ); + } + } + return true; +} + +function createIndex(node: SyntaxNode, ctx: ExtractorContext): boolean { + const rawName = readSqlName(directChild(node, 'opt_single_name', 'name'), ctx.source); + const relation = directChild(node, 'relation_expr'); + const tableNode = relation ? firstDescendant(relation, 'qualified_name') : null; + const tableName = readSqlName(tableNode, ctx.source); + if (!rawName) { + walkChildren(node, ctx); + return true; + } + const displayTableName = tableName ? nameInSearchPathSchema(tableName, node, ctx) : null; + const schema = displayTableName && displayTableName.parts.length > 1 + ? serializePostgresQualifiedName(displayTableName.parts.slice(0, -1)) + : null; + const qualifiedName = schema + ? appendPostgresIdentifier(schema, rawName.simple) + : rawName.qualified; + const created = createDatabaseNode( + ctx, + node, + rawName, + 'constant', + 'index', + qualifiedName + ); + withCreatedScope(ctx, created, () => walkChildren(node, ctx)); + return true; +} + +function createSequence(node: SyntaxNode, ctx: ExtractorContext): boolean { + const { name, temporary } = relationDeclarationName( + node, + directChild(node, 'qualified_name'), + ctx + ); + if (!name) return true; + const created = createDatabaseNode(ctx, node, name, 'variable', 'sequence'); + markTemporary(created, temporary); + withCreatedScope(ctx, created, () => walkChildren(node, ctx)); + return true; +} + +function createExtension(node: SyntaxNode, ctx: ExtractorContext): boolean { + const name = readSqlName(directChild(node, 'name'), ctx.source); + if (!name) return true; + const created = createDatabaseNode(ctx, node, name, 'constant', 'extension'); + withCreatedScope(ctx, created, () => walkChildren(node, ctx)); + return true; +} + +function columnNames(container: SyntaxNode | null, source: string): string[] { + if (!container) return []; + const result: string[] = []; + for (const column of allDescendants(container, 'columnElem')) { + const name = readSqlName(column, source); + if (name) result.push(name.simple); + } + return result; +} + +function foreignKeyAction( + text: string, + event: 'DELETE' | 'UPDATE' +): PostgresForeignKeyDescriptor['onDelete'] { + const match = new RegExp( + `\\bON\\s+${event}\\s+(NO\\s+ACTION|RESTRICT|CASCADE|SET\\s+NULL|SET\\s+DEFAULT)\\b`, + 'i' + ).exec(text); + return match?.[1] + ? match[1].replace(/\s+/g, ' ').toLowerCase() as PostgresForeignKeyDescriptor['onDelete'] + : undefined; +} + +/** + * Create a durable constraint fact instead of attributing REFERENCES to the + * migration file. The constraint points at both endpoint relations; a + * post-resolution pass turns those two exact facts into a direct table edge. + */ +function createForeignKey(node: SyntaxNode, ctx: ExtractorContext): boolean { + const marker = directChild(node, 'kw_references'); + if (!marker || (node.type === 'ConstraintElem' && !hasDirectChild(node, 'kw_foreign'))) { + walkChildren(node, ctx); + return true; + } + + const targetNode = children(node).find( + (child) => child.type === 'qualified_name' && child.startIndex > marker.endIndex + ) ?? null; + const target = readSqlName(targetNode, ctx.source); + if (!targetNode || !target) { + walkChildren(node, ctx); + return true; + } + + const alter = nearestAncestor(node, 'AlterTableStmt'); + const alterRelation = alter ? directChild(alter, 'relation_expr') : null; + const alterTableNode = alterRelation ? firstDescendant(alterRelation, 'qualified_name') : null; + const rawAlterTable = readSqlName(alterTableNode, ctx.source); + const alterTable = rawAlterTable && alter + ? nameInSearchPathSchema(rawAlterTable, alter, ctx) + : rawAlterTable; + const scope = currentScopeNode(ctx); + const scopeIsTable = scope?.language === 'postgres' && scope.kind === 'struct' && + (scope.decorators?.includes('postgres:table') === true || + scope.decorators?.includes('postgres:foreign-table') === true); + const sourceTable: SqlName | null = alterTable ?? (scopeIsTable && scope ? { + parts: parsePostgresQualifiedName(scope.qualifiedName) ?? [scope.qualifiedName], + qualified: scope.qualifiedName, + simple: scope.name, + } : null); + if (!sourceTable) { + walkChildren(node, ctx); + return true; + } + + const wrapper = nearestAncestor(node, 'TableConstraint', 'ColConstraint'); + const constraintName = readSqlName(wrapper ? directChild(wrapper, 'name') : null, ctx.source); + let sourceColumns: string[]; + if (node.type === 'ConstraintElem') { + sourceColumns = columnNames(directChild(node, 'columnList'), ctx.source); + } else { + const column = nearestAncestor(node, 'columnDef'); + const columnName = readSqlName(column ? directChild(column, 'ColId') : null, ctx.source); + sourceColumns = columnName ? [columnName.simple] : []; + } + + const targetColumnsContainer = children(node).find( + (child) => child.startIndex >= targetNode.endIndex && + (child.type === 'opt_column_and_period_list' || child.type === 'opt_column_list') + ) ?? null; + const targetColumns = columnNames(targetColumnsContainer, ctx.source); + const raw = getNodeText(nearestAncestor(node, 'alter_table_cmd') ?? wrapper ?? node, ctx.source); + const compact = raw.replace(/\s+/g, ' ').trim(); + const matchType = /\bMATCH\s+(FULL|PARTIAL|SIMPLE)\b/i.exec(compact)?.[1]?.toLowerCase() as + PostgresForeignKeyDescriptor['match']; + const initially = /\bINITIALLY\s+(DEFERRED|IMMEDIATE)\b/i.exec(compact)?.[1]?.toLowerCase() as + PostgresForeignKeyDescriptor['initially']; + const onDelete = foreignKeyAction(compact, 'DELETE'); + const onUpdate = foreignKeyAction(compact, 'UPDATE'); + const descriptor: PostgresForeignKeyDescriptor = { + sourceTable: sourceTable.qualified, + targetTable: target.qualified, + ...(constraintName ? { constraintName: constraintName.simple } : {}), + sourceColumns, + targetColumns, + ...(matchType ? { match: matchType } : {}), + ...(onDelete ? { onDelete } : {}), + ...(onUpdate ? { onUpdate } : {}), + ...(/\bNOT\s+DEFERRABLE\b/i.test(compact) + ? { deferrable: false } + : /\bDEFERRABLE\b/i.test(compact) + ? { deferrable: true } + : {}), + ...(initially ? { initially } : {}), + ...(/\bNOT\s+VALID\b/i.test(compact) ? { notValid: true } : {}), + }; + + const sourceLabel = sourceColumns.length > 0 ? sourceColumns.join(', ') : '?'; + const simple = constraintName?.simple ?? `FOREIGN KEY (${sourceLabel})`; + const qualifiedIdentity = constraintName?.simple ?? + `FOREIGN KEY (${sourceLabel}) -> ${target.qualified} ` + + `@${node.startPosition.row + 1}:${node.startPosition.column + 1}`; + const name: SqlName = { + parts: [...sourceTable.parts, qualifiedIdentity], + qualified: appendPostgresIdentifier(sourceTable.qualified, qualifiedIdentity), + simple, + }; + const idDiscriminator = `${sourceTable.qualified}.${qualifiedIdentity} ` + + `@${node.startPosition.row + 1}:${node.startPosition.column + 1}`; + // createNode's stable ID is based on its `name` argument and start line. Use + // the disambiguated identity there, then retain the human-facing name in the + // stored node. Generated/minified DDL can declare two anonymous constraints + // on one line; using `simple` for the ID would silently collapse one of them. + const created = ctx.createNode('constant', idDiscriminator, wrapper ?? node, { + name: simple, + qualifiedName: name.qualified, + signature: compactText(wrapper ?? node, ctx.source), + decorators: [ + POSTGRES_FOREIGN_KEY_DECORATOR, + encodePostgresForeignKeyDescriptor(descriptor), + ], + isExported: true, + }); + withCreatedScope(ctx, created, () => { + if (!created) return; + addReference(ctx, created.id, sourceTable, 'references', alterTableNode ?? node); + addReference(ctx, created.id, target, 'references', targetNode); + }); + return true; +} + +function createConstraint(node: SyntaxNode, ctx: ExtractorContext): boolean { + const references = directChild(node, 'kw_references'); + if (references && (node.type === 'ColConstraintElem' || hasDirectChild(node, 'kw_foreign'))) { + return createForeignKey(node, ctx); + } + + const constraintKind = hasDirectChild(node, 'kw_primary') + ? 'primary-key' + : hasDirectChild(node, 'kw_unique') + ? 'unique' + : hasDirectChild(node, 'kw_check') + ? 'check' + : null; + if (!constraintKind) { + walkChildren(node, ctx); + return true; + } + + const alter = nearestAncestor(node, 'AlterTableStmt'); + const alterRelation = alter ? directChild(alter, 'relation_expr') : null; + const alterTableNode = alterRelation ? firstDescendant(alterRelation, 'qualified_name') : null; + const rawAlterTable = readSqlName(alterTableNode, ctx.source); + const alterTable = rawAlterTable && alter + ? nameInSearchPathSchema(rawAlterTable, alter, ctx) + : rawAlterTable; + const scope = currentScopeNode(ctx); + const scopeIsTable = scope?.language === 'postgres' && scope.kind === 'struct' && + (scope.decorators?.includes('postgres:table') === true || + scope.decorators?.includes('postgres:foreign-table') === true); + const sourceTable: SqlName | null = alterTable ?? (scopeIsTable && scope ? { + parts: parsePostgresQualifiedName(scope.qualifiedName) ?? [scope.qualifiedName], + qualified: scope.qualifiedName, + simple: scope.name, + } : null); + if (!sourceTable) { + walkChildren(node, ctx); + return true; + } + + const wrapper = nearestAncestor(node, 'TableConstraint', 'ColConstraint'); + const constraintName = readSqlName(wrapper ? directChild(wrapper, 'name') : null, ctx.source); + const columns = node.type === 'ConstraintElem' + ? columnNames(directChild(node, 'columnList'), ctx.source) + : (() => { + const column = nearestAncestor(node, 'columnDef'); + const name = readSqlName(column ? directChild(column, 'ColId') : null, ctx.source); + return name ? [name.simple] : []; + })(); + const label = constraintName?.simple ?? `${constraintKind.toUpperCase()} (${columns.join(', ')})`; + const identity = constraintName?.simple ?? + `${label} @${node.startPosition.row + 1}:${node.startPosition.column + 1}`; + const qualifiedName = appendPostgresIdentifier(sourceTable.qualified, identity); + const idDiscriminator = `${qualifiedName}@${node.startPosition.row + 1}:` + + `${node.startPosition.column + 1}`; + const created = ctx.createNode('constant', idDiscriminator, wrapper ?? node, { + name: label, + qualifiedName, + signature: compactText(wrapper ?? node, ctx.source), + decorators: ['postgres:constraint', `postgres:${constraintKind}`], + isExported: true, + }); + withCreatedScope(ctx, created, () => { + if (created) { + addReference(ctx, created.id, sourceTable, 'references', alterTableNode ?? node); + } + walkChildren(node, ctx); + }); + return true; +} + +function createDropConstraint( + command: SyntaxNode, + ctx: ExtractorContext, + tableName: SqlName, + tableNode: SyntaxNode +): boolean { + if (!hasDirectChild(command, 'kw_drop') || !hasDirectChild(command, 'kw_constraint')) { + return false; + } + const constraintNode = directChild(command, 'name'); + const constraint = readSqlName(constraintNode, ctx.source); + if (!constraintNode || !constraint) return false; + const identity = `DROP CONSTRAINT ${constraint.simple}`; + const qualifiedName = appendPostgresIdentifier(tableName.qualified, identity); + const idDiscriminator = `${qualifiedName}@${command.startPosition.row + 1}:` + + `${command.startPosition.column + 1}`; + const created = ctx.createNode('constant', idDiscriminator, command, { + name: identity, + qualifiedName, + signature: compactText(command, ctx.source), + decorators: [ + POSTGRES_DROP_CONSTRAINT_DECORATOR, + encodePostgresDropConstraintDescriptor({ + table: tableName.qualified, + constraintName: constraint.simple, + }), + ], + isExported: true, + }); + if (created) addReference(ctx, created.id, tableName, 'references', tableNode); + return true; +} + +function createIdentitySequence( + command: SyntaxNode, + ctx: ExtractorContext, + tableName: SqlName, + tableNode: SyntaxNode +): void { + const identity = firstDescendant(command, 'kw_identity'); + if (!identity) return; + const sequenceNode = allDescendants(command, 'any_name') + .find((candidate) => candidate.startIndex > identity.startIndex) ?? null; + const rawSequence = readSqlName(sequenceNode, ctx.source); + const sequence = rawSequence ? nameInSearchPathSchema(rawSequence, command, ctx) : null; + if (!sequenceNode || !sequence) return; + const created = createDatabaseNode(ctx, command, sequence, 'variable', 'sequence'); + addDecorators(created, 'postgres:identity-sequence'); + if (created) addReference(ctx, created.id, tableName, 'references', tableNode); +} + +function createAlterColumnType( + command: SyntaxNode, + ctx: ExtractorContext, + tableName: SqlName, + tableNode: SyntaxNode +): boolean { + const typeNode = directChild(command, 'Typename'); + const typeName = readSqlName(typeNode, ctx.source); + const columnNode = directChild(command, 'ColId'); + const columnName = readSqlName(columnNode, ctx.source); + if (!typeNode || !typeName || !columnName || !isUserDefinedPostgresType(typeName)) return false; + const identity = `ALTER COLUMN ${columnName.simple} TYPE ${typeName.qualified}`; + const qualifiedName = appendPostgresIdentifier(tableName.qualified, identity); + const idDiscriminator = `${qualifiedName}@${command.startPosition.row + 1}:` + + `${command.startPosition.column + 1}`; + const created = ctx.createNode('constant', idDiscriminator, command, { + name: identity, + qualifiedName, + signature: compactText(command, ctx.source), + decorators: ['postgres:alter-table-column-type'], + isExported: true, + }); + withCreatedScope(ctx, created, () => { + if (!created) return; + addReference(ctx, created.id, tableName, 'references', tableNode); + addReference(ctx, created.id, typeName, POSTGRES_TYPE_REFERENCE_KIND, typeNode); + walkChildren(command, ctx); + }); + return true; +} + +/** + * Model ALTER TABLE ADD COLUMN as a migration delta rather than attaching the + * new field to an arbitrary historical CREATE TABLE node. The delta references + * its target relation and contains a normally searchable table-qualified field; + * a future ordered-migration layer can fold these deltas into an effective + * schema without changing the extracted facts. + */ +function createAlterTableRelations( + node: SyntaxNode, + ctx: ExtractorContext, + tableName: SqlName, + tableNode: SyntaxNode +): void { + const partition = directChild(node, 'partition_cmd'); + if (partition) { + const childNode = directChild(partition, 'qualified_name'); + const rawChild = readSqlName(childNode, ctx.source); + const child = rawChild ? nameInSearchPathSchema(rawChild, partition, ctx) : null; + const attach = hasDirectChild(partition, 'kw_attach'); + const detach = hasDirectChild(partition, 'kw_detach'); + if (childNode && child && (attach || detach)) { + const compact = compactText(partition, ctx.source).toLowerCase(); + const mode = /\bconcurrently\b/.test(compact) + ? 'concurrently' as const + : /\bfinalize\b/.test(compact) + ? 'finalize' as const + : undefined; + createTableRelationFact( + ctx, + partition, + child, + tableName, + attach ? 'attach-partition' : 'detach-partition', + childNode, + tableNode, + mode ? { mode } : {} + ); + } + } + + const commands = directChild(node, 'alter_table_cmds'); + if (!commands) return; + for (const command of allDescendants(commands, 'alter_table_cmd')) { + if (!hasDirectChild(command, 'kw_inherit')) continue; + const parentNode = directChild(command, 'qualified_name'); + const parent = readSqlName(parentNode, ctx.source); + if (parentNode && parent) { + createTableRelationFact( + ctx, + command, + tableName, + parent, + hasDirectChild(command, 'kw_no') ? 'no-inherit' : 'inherit', + tableNode, + parentNode + ); + } + } +} + +function createAlterTableDeltas(node: SyntaxNode, ctx: ExtractorContext): boolean { + const relation = directChild(node, 'relation_expr'); + const tableNode = relation ? firstDescendant(relation, 'qualified_name') : null; + const rawTableName = readSqlName(tableNode, ctx.source); + const tableName = rawTableName ? nameInSearchPathSchema(rawTableName, node, ctx) : null; + const ownerId = currentScopeId(ctx); + if (!tableNode || !tableName || !ownerId) { + walkChildren(node, ctx); + return true; + } + + createAlterTableRelations(node, ctx, tableName, tableNode); + + // Retain the statement-level dependency that ALTER TABLE emitted before + // column deltas were modeled. + addReference(ctx, ownerId, tableName, 'references', tableNode); + + const commands = directChild(node, 'alter_table_cmds'); + if (!commands) return true; + for (const command of allDescendants(commands, 'alter_table_cmd')) { + createIdentitySequence(command, ctx, tableName, tableNode); + if (createDropConstraint(command, ctx, tableName, tableNode)) { + walkChildren(command, ctx); + continue; + } + if (createAlterColumnType(command, ctx, tableName, tableNode)) continue; + const column = directChild(command, 'columnDef'); + const columnNameNode = column ? directChild(column, 'ColId') : null; + const columnName = readSqlName(columnNameNode, ctx.source); + if (!hasDirectChild(command, 'kw_add') || !column || !columnNameNode || !columnName) { + ctx.visitNode(command); + continue; + } + + const deltaSimple = `ADD COLUMN ${columnName.simple}`; + const deltaName: SqlName = { + parts: [...tableName.parts, deltaSimple], + qualified: appendPostgresIdentifier(tableName.qualified, deltaSimple), + simple: deltaSimple, + }; + const created = createDatabaseNode( + ctx, + command, + deltaName, + 'constant', + 'alter-table-add-column' + ); + withCreatedScope(ctx, created, () => { + if (created) addReference(ctx, created.id, tableName, 'references', tableNode); + createColumn(ctx, column, columnNameNode, tableName.qualified); + walkChildren(command, ctx); + }); + } + return true; +} + +const QUERY_SCOPE_TYPES = new Set([ + 'select_no_parens', + 'InsertStmt', + 'UpdateStmt', + 'DeleteStmt', +]); + +const QUERY_BOUNDARY_TYPES = new Set([ + ...QUERY_SCOPE_TYPES, + 'SelectStmt', + 'select_with_parens', +]); + +/** + * Find the WITH clause owned by one query level. The grammar wraps it in nodes + * such as `select_no_parens`, so it is not necessarily a direct child. Nested + * query statements are hard boundaries: their WITH clauses are not visible to + * an outer relation that happens to share the same alias. + */ +function ownWithClause(query: SyntaxNode): SyntaxNode | null { + const visit = (node: SyntaxNode): SyntaxNode | null => { + for (const child of children(node)) { + if (child.type === 'with_clause') return child; + if (QUERY_BOUNDARY_TYPES.has(child.type)) continue; + const found = visit(child); + if (found) return found; + } + return null; + }; + return visit(query); +} + +function cteNamesInClause(withClause: SyntaxNode, source: string): Set { + const names = new Set(); + const visit = (node: SyntaxNode): void => { + for (const child of children(node)) { + if (child.type === 'common_table_expr') { + const name = readSqlName(directChild(child, 'name'), source); + if (name) names.add(name.qualified); + // Do not enter the CTE body: a nested query may own another WITH clause. + continue; + } + visit(child); + } + }; + visit(withClause); + return names; +} + +function cteNamesVisibleFrom(node: SyntaxNode, source: string): Set { + const names = new Set(); + let ancestor: SyntaxNode | null = node; + while (ancestor) { + if (QUERY_SCOPE_TYPES.has(ancestor.type)) { + const withClause = ownWithClause(ancestor); + if (withClause) { + for (const name of cteNamesInClause(withClause, source)) names.add(name); + } + } + ancestor = ancestor.parent; + } + return names; +} + +function emitRelationReference(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = firstDescendant(node, 'qualified_name'); + const name = readSqlName(nameNode, ctx.source); + const fromNodeId = currentScopeId(ctx); + if (!nameNode || !name || !fromNodeId) return true; + if (name.parts.length === 1 && cteNamesVisibleFrom(node, ctx.source).has(name.qualified)) { + return true; + } + addReference(ctx, fromNodeId, name, 'references', nameNode); + walkChildren(node, ctx); + return true; +} + +function createStaticSequenceReferences( + container: SyntaxNode, + ctx: ExtractorContext, + ownerId: string +): void { + const applications = container.type === 'func_application' + ? [container] + : allDescendants(container, 'func_application'); + for (const application of applications) { + const functionName = readSqlName(directChild(application, 'func_name'), ctx.source); + if (!functionName || !['nextval', 'currval', 'setval'].includes(functionName.simple)) continue; + const literal = postgresStaticSequenceLiteral(application); + const rawSequence = postgresStringLiteral(literal, ctx.source); + const parts = rawSequence ? parsePostgresQualifiedName(rawSequence) : null; + if (!literal || !parts || parts.length === 0) continue; + addReference( + ctx, + ownerId, + sqlNameFromParts(parts), + POSTGRES_SEQUENCE_REFERENCE_KIND, + literal + ); + } +} + +function emitTypeReference(node: SyntaxNode, ctx: ExtractorContext): boolean { + if (nearestAncestor(node, 'columnDef', 'alter_table_cmd')) return true; + const name = readSqlName(node, ctx.source); + const ownerId = currentScopeId(ctx); + if (name && ownerId && isUserDefinedPostgresType(name)) { + addReference(ctx, ownerId, name, POSTGRES_TYPE_REFERENCE_KIND, node); + } + return true; +} + +function emitRoutineCall(node: SyntaxNode, ctx: ExtractorContext): boolean { + const nameNode = directChild(node, 'func_name'); + const name = readSqlName(nameNode, ctx.source); + const fromNodeId = currentScopeId(ctx); + // Emit every routine-shaped call. PostgreSQL permits user routines to share + // names with pg_catalog routines (overload resolution and search_path decide + // the target), so filtering an unqualified name here would erase valid + // project edges before the resolver can inspect the indexed declarations. + if (nameNode && name && fromNodeId) { + addReference(ctx, fromNodeId, name, 'calls', nameNode); + if (!nearestAncestor(node, 'columnDef')) { + createStaticSequenceReferences(node, ctx, fromNodeId); + } + } + walkChildren(node, ctx); + return true; +} + +export const postgresExtractor: LanguageExtractor = { + preParse: preParsePostgresSource, + reportParseErrors: true, + // All relevant grammar nodes have empty field maps, so visitNode owns the + // PostgreSQL dispatch. Empty generic mappings prevent accidental extraction + // from wrapper names such as `name` / `ColId`. + functionTypes: [], + classTypes: [], + methodTypes: [], + interfaceTypes: [], + structTypes: [], + enumTypes: [], + enumMemberTypes: [], + typeAliasTypes: [], + importTypes: [], + callTypes: [], + variableTypes: [], + fieldTypes: [], + nameField: '', + bodyField: '', + paramsField: '', + + visitNode: (node, ctx) => { + switch (node.type) { + case 'CreateStmt': + case 'CreateForeignTableStmt': + return createTableLike( + node, + ctx, + directChild(node, 'qualified_name'), + node.type === 'CreateForeignTableStmt' ? 'foreign-table' : 'table' + ); + + case 'CreateAsStmt': { + const target = directChild(node, 'create_as_target'); + return createTableLike( + node, + ctx, + target ? firstDescendant(target, 'qualified_name') : null, + 'table', + target + ); + } + + case 'ViewStmt': + return createTableLike(node, ctx, directChild(node, 'qualified_name'), 'view', node); + + case 'CreateMatViewStmt': { + const target = directChild(node, 'create_mv_target'); + return createTableLike( + node, + ctx, + target ? firstDescendant(target, 'qualified_name') : null, + 'materialized-view', + target + ); + } + + case 'CreateFunctionStmt': + return createRoutine(node, ctx); + + case 'DoStmt': + return createDoBlock(node, ctx); + + case 'CreateSchemaStmt': + return createSchema(node, ctx); + + case 'DefineStmt': + return createType(node, ctx); + + case 'CreateDomainStmt': + return createDomain(node, ctx); + + case 'CreateTrigStmt': + case 'CreateEventTrigStmt': + return createTrigger(node, ctx); + + case 'CreatePolicyStmt': + case 'AlterPolicyStmt': + return createPolicy(node, ctx); + + case 'RenameStmt': + return createRename(node, ctx); + + case 'DropStmt': + return createDrop(node, ctx); + + case 'AlterEnumStmt': + return createAlterEnum(node, ctx); + + case 'IndexStmt': + return createIndex(node, ctx); + + case 'CreateSeqStmt': + return createSequence(node, ctx); + + case 'AlterSeqStmt': + return createAlterSequence(node, ctx); + + case 'CreateExtensionStmt': + return createExtension(node, ctx); + + case 'AlterTableStmt': + return createAlterTableDeltas(node, ctx); + + case 'columnDef': { + const nameNode = directChild(node, 'ColId'); + const scope = currentScopeNode(ctx); + const isTableColumn = scope?.decorators?.some( + (decorator) => decorator === 'postgres:table' || + decorator === 'postgres:foreign-table' + ) ?? false; + if (nameNode && isTableColumn) createColumn(ctx, node, nameNode); + walkChildren(node, ctx); + return true; + } + + case 'ConstraintElem': + case 'ColConstraintElem': + return createConstraint(node, ctx); + + case 'Typename': + return emitTypeReference(node, ctx); + + case 'relation_expr': + case 'insert_target': + return emitRelationReference(node, ctx); + + case 'func_application': + return emitRoutineCall(node, ctx); + + default: + return false; + } + }, +}; diff --git a/src/extraction/tree-sitter-types.ts b/src/extraction/tree-sitter-types.ts index 2a02b47b7..0075050f7 100644 --- a/src/extraction/tree-sitter-types.ts +++ b/src/extraction/tree-sitter-types.ts @@ -90,6 +90,15 @@ export interface LanguageExtractor { */ preParse?: (source: string, filePath?: string) => string; + /** + * Record recovered tree-sitter ERROR/MISSING nodes in ExtractionResult while + * still walking the recoverable tree. Most programming-language grammars are + * intentionally tolerant of incomplete editor buffers; strict source formats + * such as migration SQL can opt in so indexing does not silently report a + * malformed file as clean. + */ + reportParseErrors?: boolean; + // --- Node type mappings --- /** Node types that represent functions */ diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 05b13a9c1..dde021411 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -503,6 +503,29 @@ export class TreeSitterExtractor { if (!this.tree) { throw new Error('Parser returned null tree'); } + if (this.extractor?.reportParseErrors && this.tree.rootNode.hasError) { + const findFirstProblem = (node: SyntaxNode): SyntaxNode | null => { + if (node.isError || node.isMissing) return node; + for (let index = 0; index < node.namedChildCount; index++) { + const child = node.namedChild(index); + if (!child) continue; + const problem = findFirstProblem(child); + if (problem) return problem; + } + return null; + }; + const problem = findFirstProblem(this.tree.rootNode); + this.errors.push({ + message: problem?.isMissing + ? `Parser recovered a missing ${problem.type}` + : 'Parser recovered an invalid syntax region', + filePath: this.filePath, + line: (problem?.startPosition.row ?? 0) + 1, + column: problem?.startPosition.column ?? 0, + severity: 'error', + code: 'parse_error', + }); + } // Create file node representing the source file const fileNode: Node = { diff --git a/src/extraction/wasm/tree-sitter-plpgsql.LICENSE b/src/extraction/wasm/tree-sitter-plpgsql.LICENSE new file mode 100644 index 000000000..bb61fcdac --- /dev/null +++ b/src/extraction/wasm/tree-sitter-plpgsql.LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2026 Gavin M. Roy, AWeber +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/extraction/wasm/tree-sitter-plpgsql.wasm b/src/extraction/wasm/tree-sitter-plpgsql.wasm new file mode 100644 index 000000000..a34e955b3 Binary files /dev/null and b/src/extraction/wasm/tree-sitter-plpgsql.wasm differ diff --git a/src/extraction/wasm/tree-sitter-postgres.LICENSE b/src/extraction/wasm/tree-sitter-postgres.LICENSE new file mode 100644 index 000000000..bb61fcdac --- /dev/null +++ b/src/extraction/wasm/tree-sitter-postgres.LICENSE @@ -0,0 +1,25 @@ +Copyright (c) 2026 Gavin M. Roy, AWeber +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + * Neither the name of the copyright holder nor the names of its contributors may + be used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/src/extraction/wasm/tree-sitter-postgres.wasm b/src/extraction/wasm/tree-sitter-postgres.wasm new file mode 100644 index 000000000..98c2be25b Binary files /dev/null and b/src/extraction/wasm/tree-sitter-postgres.wasm differ diff --git a/src/index.ts b/src/index.ts index 461ff4797..3d5bb406a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -932,6 +932,17 @@ export class CodeGraph { await this.resolver.resolveDeferredThisMemberRefs(); } + // Whole-graph FK edges can have both endpoints in unchanged CREATE + // migrations while their owning ALTER migration is the only file that + // changed. Scoped sync resolution therefore refreshes this owned edge + // set explicitly; node-cascade deletion alone cannot remove it. + // Scoped resolution does not run the whole-graph exact FK projection; + // batched resolution does, but this call is still cheap there because + // the persisted state fingerprint makes it a no-write fast path. Run + // it even on a no-change sync so a process killed after the store phase + // is healed deterministically on the next sync. + await this.resolver.refreshPostgresForeignKeys(); + // Refresh planner stats + checkpoint the WAL after bulk writes. // Off-thread — see indexAll's call site. if (filesChanged || result.filesRemoved > 0 || orphanCount > 0) { @@ -1149,7 +1160,12 @@ export class CodeGraph { resolveReferences(onProgress?: (current: number, total: number) => void): ResolutionResult { // Get all unresolved references from the database const unresolvedRefs = this.queries.getUnresolvedReferences(); - return this.resolver.resolveAndPersist(unresolvedRefs, onProgress); + const result = this.resolver.resolveAndPersist(unresolvedRefs, onProgress); + // Keep the synchronous API's result immediately traversable, just like the + // batched/full-index path. PostgreSQL FK table edges depend on both resolved + // endpoint refs and therefore cannot be emitted during extraction. + this.resolver.refreshPostgresForeignKeysSync(); + return result; } /** @@ -1276,6 +1292,11 @@ export class CodeGraph { return this.queries.getNodesByName(name); } + /** Exact qualified-name lookup using the dedicated database index. */ + getNodesByQualifiedName(qualifiedName: string): Node[] { + return this.queries.getNodesByQualifiedNameExact(qualifiedName); + } + /** Nodes whose name starts with `prefix` (index range scan, capped). */ getNodesByNamePrefix(prefix: string, limit = 20): Node[] { return this.queries.getNodesByNamePrefix(prefix, limit); diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index b31c64fc7..f623617cf 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -21,7 +21,16 @@ import { type WorktreeIndexMismatch, } from '../sync/worktree'; import type { PendingFile } from '../sync'; -import type { Node, Edge, SearchResult, Subgraph, NodeKind } from '../types'; +import { + LANGUAGES, + NODE_KINDS, + type Node, + type Edge, + type SearchResult, + type Subgraph, + type NodeKind, + type Language, +} from '../types'; import { isTestFile, normalizeNameToken } from '../search/query-utils'; import { existsSync, @@ -31,6 +40,10 @@ import { clamp, validatePathWithinRoot, validateProjectPath, isConfigLeafNode, C import { isGeneratedFile } from '../extraction/generated-detection'; import { scanDynamicDispatch } from './dynamic-boundaries'; import { getUpdateNotice } from '../upgrade/update-check'; +import { + parsePostgresQualifiedName, + serializePostgresQualifiedName, +} from '../postgres/identifiers'; /** * An expected, recoverable "codegraph can't serve this" condition — most @@ -80,6 +93,31 @@ const MAX_PATH_LENGTH = 4_096; */ const RUST_PATH_PREFIXES = new Set(['crate', 'super', 'self']); +/** + * Canonicalize a schema-qualified PostgreSQL lookup using PostgreSQL's + * identifier rules: unquoted segments fold to lower case while quoted + * segments keep their spelling and may contain dots. + */ +function canonicalPostgresLookup(symbol: string): string | null { + const parts = parsePostgresQualifiedName(symbol); + return parts && parts.length > 1 ? serializePostgresQualifiedName(parts) : null; +} + +/** + * Prefer the literal qualified-name index hit used by every language. When it + * misses, also probe PostgreSQL's canonical spelling, but only accept a + * PostgreSQL node so a dotted TypeScript/Python query cannot be redirected to + * an unrelated case-folded symbol. + */ +function getDirectQualifiedSymbolMatches(cg: CodeGraph, symbol: string): Node[] { + const direct = cg.getNodesByQualifiedName(symbol); + if (direct.length > 0) return direct; + + const postgresName = canonicalPostgresLookup(symbol); + if (!postgresName || postgresName === symbol) return []; + return cg.getNodesByQualifiedName(postgresName).filter((node) => node.language === 'postgres'); +} + /** * Node kinds that contain other symbols. For these, `codegraph_node` with * `includeCode=true` returns a structural outline (member names + signatures @@ -547,7 +585,12 @@ export const tools: ToolDefinition[] = [ kind: { type: 'string', description: 'Filter by node kind', - enum: ['function', 'method', 'class', 'interface', 'type', 'variable', 'route', 'component'], + enum: [...NODE_KINDS, 'type'], + }, + language: { + type: 'string', + description: 'Filter by source language (for example postgres or typescript)', + enum: [...LANGUAGES], }, limit: { type: 'number', @@ -1502,10 +1545,15 @@ export class ToolHandler { const kind = rawKind === 'type' ? 'type_alias' : rawKind; const rawLimit = Number(args.limit) || 10; const limit = clamp(rawLimit, 1, 100); + const rawLanguage = typeof args.language === 'string' ? args.language : undefined; + const language = rawLanguage && LANGUAGES.includes(rawLanguage as Language) + ? rawLanguage as Language + : undefined; const results = cg.searchNodes(query, { limit, kinds: kind ? [kind as NodeKind] : undefined, + languages: language ? [language] : undefined, }); if (results.length === 0) { @@ -4028,11 +4076,16 @@ export class ToolHandler { return synth ? `${base} [${synth.compact}]` : base; }; const collect = (edges: Array<{ node: Node; edge: Edge }>): Array<{ node: Node; edge: Edge }> => { - const seen = new Set([node.id]); + const seen = new Set(); const out: Array<{ node: Node; edge: Edge }> = []; for (const e of edges) { - if (seen.has(e.node.id)) continue; - seen.add(e.node.id); + if (e.node.id === node.id) continue; + const relation = typeof e.edge.metadata?.postgresRelation === 'string' + ? e.edge.metadata.postgresRelation + : e.edge.kind; + const key = `${e.node.id}\u0000${relation}`; + if (seen.has(key)) continue; + seen.add(key); out.push(e); } return out; @@ -4041,12 +4094,85 @@ export class ToolHandler { const callers = collect(cg.getCallers(node.id)); if (callees.length === 0 && callers.length === 0) return ''; const lines: string[] = ['', '**Trail — codegraph_node any of these to follow it (no Read needed)**']; - if (callees.length > 0) { - lines.push(`**Calls →** ${callees.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callees.length > TRAIL_CAP ? `, +${callees.length - TRAIL_CAP} more` : ''}`); - } - if (callers.length > 0) { - lines.push(`**Called by ←** ${callers.slice(0, TRAIL_CAP).map(fmt).join(', ')}${callers.length > TRAIL_CAP ? `, +${callers.length - TRAIL_CAP} more` : ''}`); - } + const push = ( + label: string, + entries: Array<{ node: Node; edge: Edge }>, + formatter = fmt + ): void => { + if (entries.length === 0) return; + lines.push( + `**${label}** ${entries.slice(0, TRAIL_CAP).map(formatter).join(', ')}` + + (entries.length > TRAIL_CAP ? `, +${entries.length - TRAIL_CAP} more` : '') + ); + }; + const isCallLike = ({ edge }: { edge: Edge }) => + edge.kind === 'calls' || edge.kind === 'instantiates'; + + // Database relations need domain labels, not the generic call vocabulary: + // a table is referenced by a trigger/FK/view; none of those "call" it. + if (node.language === 'postgres' && node.kind === 'struct') { + const isForeignKeyEdge = ({ edge }: { edge: Edge }) => + edge.metadata?.postgresRelation === 'foreign-key'; + const isTableRelationEdge = ({ edge }: { edge: Edge }) => { + const relation = edge.metadata?.postgresRelation; + return typeof relation === 'string' && + relation !== 'foreign-key' && relation !== 'schema-containment'; + }; + const isTrigger = ({ node: related }: { node: Node }) => + related.decorators?.includes('postgres:trigger') === true; + const isConstraint = ({ node: related }: { node: Node }) => + related.decorators?.includes('postgres:foreign-key') === true; + const fkLabel = ({ node: related, edge }: { node: Node; edge: Edge }) => { + const raw = edge.metadata?.constraints; + const constraints = Array.isArray(raw) ? raw as Array> : []; + const names = constraints + .map((constraint) => constraint.constraintName) + .filter((name): name is string => typeof name === 'string'); + const qualified = related.qualifiedName || related.name; + const suffix = names.length > 0 ? ` [${names.join(', ')}]` : ''; + return `${qualified} (${related.filePath}:${related.startLine})${suffix}`; + }; + const triggerLabel = ({ node: trigger }: { node: Node; edge: Edge }) => { + const routine = cg.getCallees(trigger.id).find(({ edge }) => edge.kind === 'calls')?.node; + const hop = routine ? ` → ${routine.qualifiedName || routine.name}` : ''; + return `${trigger.name}${hop} (${trigger.filePath}:${trigger.startLine})`; + }; + const relationLabel = ({ node: related, edge }: { node: Node; edge: Edge }) => + `${related.qualifiedName || related.name} [${String(edge.metadata?.postgresRelation)}] ` + + `(${related.filePath}:${related.startLine})`; + + const fkOutgoing = callees.filter(isForeignKeyEdge); + const fkIncoming = callers.filter(isForeignKeyEdge); + const relationsOutgoing = callees.filter(isTableRelationEdge); + const relationsIncoming = callers.filter(isTableRelationEdge); + const triggers = callers.filter(isTrigger); + const constraints = callers.filter(isConstraint); + const usedOutgoing = new Set([ + ...fkOutgoing.map(({ node: related }) => related.id), + ...relationsOutgoing.map(({ node: related }) => related.id), + ]); + const usedIncoming = new Set([ + ...fkIncoming.map(({ node: related }) => related.id), + ...relationsIncoming.map(({ node: related }) => related.id), + ...triggers.map(({ node: related }) => related.id), + ...constraints.map(({ node: related }) => related.id), + ]); + + push('Foreign keys →', fkOutgoing, fkLabel); + push('Referenced by foreign keys ←', fkIncoming, fkLabel); + push('Table relations →', relationsOutgoing, relationLabel); + push('Related tables ←', relationsIncoming, relationLabel); + push('Triggers ←', triggers, triggerLabel); + push('Foreign-key constraints ←', constraints); + push('References →', callees.filter((entry) => !usedOutgoing.has(entry.node.id))); + push('Referenced by ←', callers.filter((entry) => !usedIncoming.has(entry.node.id))); + return lines.join('\n'); + } + + push('Calls →', callees.filter(isCallLike)); + push('References →', callees.filter((entry) => !isCallLike(entry))); + push('Called by ←', callers.filter(isCallLike)); + push('Referenced by ←', callers.filter((entry) => !isCallLike(entry))); return lines.join('\n'); } @@ -4404,6 +4530,13 @@ export class ToolHandler { if (node.name === symbol) return true; // File basename match (e.g., "product-card" matches "product-card.liquid") if (node.kind === 'file' && node.name.replace(/\.[^.]+$/, '') === symbol) return true; + // PostgreSQL intentionally preserves SQL's dotted qualified names instead + // of CodeGraph's usual `::` hierarchy separator. + if (node.qualifiedName === symbol) return true; + if (node.language === 'postgres') { + const postgresName = canonicalPostgresLookup(symbol); + if (postgresName) return node.qualifiedName === postgresName; + } // Qualified-name lookups: split on any supported separator. `\w` keeps // identifier chars (incl. `_`) intact; everything else is treated as @@ -4463,6 +4596,17 @@ export class ToolHandler { return fuzzy[0] ? [fuzzy[0].node] : []; } + + // Literal qualified names (notably PostgreSQL schema.object) have a + // dedicated index. Probe it before FTS so a large set of nested symbols + // cannot cap out the exact object. + const direct = getDirectQualifiedSymbolMatches(cg, symbol); + if (direct.length > 0) { + return [...direct].sort((a, b) => + (isGeneratedFile(a.filePath) ? 1 : 0) - (isGeneratedFile(b.filePath) ? 1 : 0) + ); + } + // Qualified lookup (`Session.request`, `stage_apply::run`): FTS + matchesSymbol. const limit = 50; let results = cg.searchNodes(symbol, { limit }); @@ -4516,6 +4660,15 @@ export class ToolHandler { return { nodes, note: '' }; } } + if (/[.\/]|::/.test(symbol)) { + const direct = getDirectQualifiedSymbolMatches(cg, symbol); + if (direct.length > 0) { + const nodes = [...direct].sort((a, b) => + (isGeneratedFile(a.filePath) ? 1 : 0) - (isGeneratedFile(b.filePath) ? 1 : 0) + ); + return { nodes, note: '' }; + } + } let results = cg.searchNodes(symbol, { limit: 50 }); // Mirror the fallback in `findSymbol` for qualified queries — FTS @@ -4574,8 +4727,16 @@ export class ToolHandler { for (const result of results) { const { node } = result; const location = node.startLine ? `:${node.startLine}` : ''; + const postgresKind = node.language === 'postgres' + ? node.decorators?.find((decorator) => + decorator.startsWith('postgres:') && + !decorator.startsWith('postgres:foreign-key-data:') && + !decorator.startsWith('postgres:table-relation-data:') + )?.slice('postgres:'.length) + : undefined; + const displayName = node.language === 'postgres' ? node.qualifiedName : node.name; // Compact format: one line per result with key info - lines.push(`**${node.name}** (${node.kind})`); + lines.push(`**${displayName}** (${postgresKind ?? node.kind})`); lines.push(`${node.filePath}${location}`); if (node.signature) lines.push(`\`${node.signature}\``); lines.push(''); @@ -4667,8 +4828,16 @@ export class ToolHandler { private formatNodeDetails(node: Node, code: string | null, outline?: string | null): string { const location = node.startLine ? `:${node.startLine}` : ''; + const postgresKind = node.language === 'postgres' + ? node.decorators?.find((decorator) => + decorator.startsWith('postgres:') && + !decorator.startsWith('postgres:foreign-key-data:') && + !decorator.startsWith('postgres:table-relation-data:') + )?.slice('postgres:'.length) + : undefined; + const displayName = node.language === 'postgres' ? node.qualifiedName : node.name; const lines: string[] = [ - `**${node.name}** (${node.kind})`, + `**${displayName}** (${postgresKind ?? node.kind})`, '', `**Location:** ${node.filePath}${location}`, ]; diff --git a/src/postgres/constraint-mutation.ts b/src/postgres/constraint-mutation.ts new file mode 100644 index 000000000..492130c89 --- /dev/null +++ b/src/postgres/constraint-mutation.ts @@ -0,0 +1,67 @@ +/** Shared encoding for PostgreSQL constraint-removal migration facts. */ + +export const POSTGRES_DROP_CONSTRAINT_DECORATOR = 'postgres:drop-constraint'; +export const POSTGRES_DROP_CONSTRAINT_DATA_PREFIX = 'postgres:drop-constraint-data:'; +export const POSTGRES_RENAME_CONSTRAINT_DECORATOR = 'postgres:rename-constraint'; +export const POSTGRES_RENAME_CONSTRAINT_DATA_PREFIX = 'postgres:rename-constraint-data:'; + +export interface PostgresDropConstraintDescriptor { + table: string; + constraintName: string; +} + +export interface PostgresRenameConstraintDescriptor { + table: string; + sourceConstraint: string; + targetConstraint: string; +} + +export function encodePostgresDropConstraintDescriptor( + descriptor: PostgresDropConstraintDescriptor +): string { + return `${POSTGRES_DROP_CONSTRAINT_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresDropConstraintDescriptor( + decorators: readonly string[] | undefined +): PostgresDropConstraintDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_DROP_CONSTRAINT_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse(encoded.slice(POSTGRES_DROP_CONSTRAINT_DATA_PREFIX.length)) as + Partial; + if (typeof value.table !== 'string' || typeof value.constraintName !== 'string') return null; + return value as PostgresDropConstraintDescriptor; + } catch { + return null; + } +} + +export function encodePostgresRenameConstraintDescriptor( + descriptor: PostgresRenameConstraintDescriptor +): string { + return `${POSTGRES_RENAME_CONSTRAINT_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresRenameConstraintDescriptor( + decorators: readonly string[] | undefined +): PostgresRenameConstraintDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_RENAME_CONSTRAINT_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse(encoded.slice(POSTGRES_RENAME_CONSTRAINT_DATA_PREFIX.length)) as + Partial; + if ( + typeof value.table !== 'string' || + typeof value.sourceConstraint !== 'string' || + typeof value.targetConstraint !== 'string' + ) return null; + return value as PostgresRenameConstraintDescriptor; + } catch { + return null; + } +} diff --git a/src/postgres/foreign-key.ts b/src/postgres/foreign-key.ts new file mode 100644 index 000000000..6340f18e4 --- /dev/null +++ b/src/postgres/foreign-key.ts @@ -0,0 +1,49 @@ +/** Shared encoding for PostgreSQL foreign-key facts extracted from SQL. */ + +export const POSTGRES_FOREIGN_KEY_DECORATOR = 'postgres:foreign-key'; +export const POSTGRES_FOREIGN_KEY_DATA_PREFIX = 'postgres:foreign-key-data:'; + +export interface PostgresForeignKeyDescriptor { + sourceTable: string; + targetTable: string; + constraintName?: string; + sourceColumns: string[]; + targetColumns: string[]; + match?: 'full' | 'partial' | 'simple'; + onDelete?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'; + onUpdate?: 'no action' | 'restrict' | 'cascade' | 'set null' | 'set default'; + deferrable?: boolean; + initially?: 'deferred' | 'immediate'; + notValid?: boolean; +} + +export function encodePostgresForeignKeyDescriptor( + descriptor: PostgresForeignKeyDescriptor +): string { + return `${POSTGRES_FOREIGN_KEY_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresForeignKeyDescriptor( + decorators: readonly string[] | undefined +): PostgresForeignKeyDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_FOREIGN_KEY_DATA_PREFIX) + ); + if (!encoded) return null; + + try { + const value = JSON.parse(encoded.slice(POSTGRES_FOREIGN_KEY_DATA_PREFIX.length)) as + Partial; + if ( + typeof value.sourceTable !== 'string' || + typeof value.targetTable !== 'string' || + !Array.isArray(value.sourceColumns) || + !Array.isArray(value.targetColumns) + ) { + return null; + } + return value as PostgresForeignKeyDescriptor; + } catch { + return null; + } +} diff --git a/src/postgres/identifiers.ts b/src/postgres/identifiers.ts new file mode 100644 index 000000000..074cd069b --- /dev/null +++ b/src/postgres/identifiers.ts @@ -0,0 +1,79 @@ +/** Canonical PostgreSQL identifier serialization shared by extraction/resolution. */ + +const SIMPLE_IDENTIFIER = /^[a-z_][a-z0-9_$]*$/; + +/** + * Serialize one already-decoded PostgreSQL identifier without losing segment + * boundaries. PostgreSQL folds unquoted names to lower case, so lower-case + * simple identifiers can stay bare; every other value is quoted and escaped. + */ +export function serializePostgresIdentifier(identifier: string): string { + return SIMPLE_IDENTIFIER.test(identifier) + ? identifier + : `"${identifier.replace(/"/g, '""')}"`; +} + +export function serializePostgresQualifiedName(parts: readonly string[]): string { + return parts.map(serializePostgresIdentifier).join('.'); +} + +/** + * Parse canonical or user-entered PostgreSQL dotted names. Quoted dots remain + * inside their identifier segment and doubled quotes are decoded. + */ +export function parsePostgresQualifiedName(value: string): string[] | null { + const input = value.trim(); + if (!input) return null; + const parts: string[] = []; + let index = 0; + + while (index < input.length) { + while (/\s/.test(input[index] ?? '')) index++; + if (index >= input.length) return null; + + let part = ''; + if (input[index] === '"') { + index++; + let closed = false; + while (index < input.length) { + const char = input[index++]!; + if (char !== '"') { + part += char; + } else if (input[index] === '"') { + part += '"'; + index++; + } else { + closed = true; + break; + } + } + if (!closed || !part) return null; + } else { + const start = index; + while (index < input.length && input[index] !== '.') index++; + part = input.slice(start, index).trim().toLowerCase(); + if (!part || /\s/.test(part)) return null; + } + + while (/\s/.test(input[index] ?? '')) index++; + parts.push(part); + if (index >= input.length) break; + if (input[index] !== '.') return null; + index++; + } + + return parts.length > 0 ? parts : null; +} + +export function appendPostgresIdentifier(qualifiedName: string, identifier: string): string { + return `${qualifiedName}.${serializePostgresIdentifier(identifier)}`; +} + +export function qualifyPostgresName(schema: string, name: string): string | null { + const parts = parsePostgresQualifiedName(name); + return parts ? serializePostgresQualifiedName([schema, ...parts]) : null; +} + +export function isPostgresQualifiedName(name: string): boolean { + return (parsePostgresQualifiedName(name)?.length ?? 0) > 1; +} diff --git a/src/postgres/reference-intent.ts b/src/postgres/reference-intent.ts new file mode 100644 index 000000000..eef01744e --- /dev/null +++ b/src/postgres/reference-intent.ts @@ -0,0 +1,29 @@ +/** + * PostgreSQL-only unresolved-reference intents. + * + * These values are deliberately not graph edge kinds. They carry the object + * class that PostgreSQL resolution must enforce, then materialize as ordinary + * `references` edges after an exact, schema-aware match. Keeping the intent on + * the unresolved row prevents a type or sequence name from falling through to + * the relation matcher (or to a generic fuzzy/framework strategy). + */ +export const POSTGRES_TYPE_REFERENCE_KIND = 'postgres_type' as const; +export const POSTGRES_SEQUENCE_REFERENCE_KIND = 'postgres_sequence' as const; + +export const POSTGRES_OBJECT_REFERENCE_KINDS = [ + POSTGRES_TYPE_REFERENCE_KIND, + POSTGRES_SEQUENCE_REFERENCE_KIND, +] as const; + +export type PostgresObjectReferenceKind = + (typeof POSTGRES_OBJECT_REFERENCE_KINDS)[number]; + +const POSTGRES_OBJECT_REFERENCE_KIND_SET = new Set( + POSTGRES_OBJECT_REFERENCE_KINDS +); + +export function isPostgresObjectReferenceKind( + value: string +): value is PostgresObjectReferenceKind { + return POSTGRES_OBJECT_REFERENCE_KIND_SET.has(value); +} diff --git a/src/postgres/relation-lifecycle.ts b/src/postgres/relation-lifecycle.ts new file mode 100644 index 000000000..e4d00ee66 --- /dev/null +++ b/src/postgres/relation-lifecycle.ts @@ -0,0 +1,43 @@ +/** Ordered PostgreSQL relation lifecycle facts used by migration synthesis. */ + +export const POSTGRES_DROP_RELATION_DECORATOR = 'postgres:drop-relation'; +export const POSTGRES_DROP_RELATION_DATA_PREFIX = 'postgres:drop-relation-data:'; + +export type PostgresDroppedRelationKind = + | 'table' + | 'foreign-table' + | 'view' + | 'materialized-view'; + +export interface PostgresDropRelationDescriptor { + relationName: string; + relationKind: PostgresDroppedRelationKind; +} + +export function encodePostgresDropRelationDescriptor( + descriptor: PostgresDropRelationDescriptor +): string { + return `${POSTGRES_DROP_RELATION_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresDropRelationDescriptor( + decorators: readonly string[] | undefined +): PostgresDropRelationDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_DROP_RELATION_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse(encoded.slice(POSTGRES_DROP_RELATION_DATA_PREFIX.length)) as + Partial; + if ( + typeof value.relationName !== 'string' || + !['table', 'foreign-table', 'view', 'materialized-view'].includes( + value.relationKind ?? '' + ) + ) return null; + return value as PostgresDropRelationDescriptor; + } catch { + return null; + } +} diff --git a/src/postgres/routine-body.ts b/src/postgres/routine-body.ts new file mode 100644 index 000000000..a882495ae --- /dev/null +++ b/src/postgres/routine-body.ts @@ -0,0 +1,674 @@ +import type { Node as SyntaxNode, Parser } from 'web-tree-sitter'; +import { parsePostgresQualifiedName, serializePostgresQualifiedName } from './identifiers'; +import { + analyzePostgresSearchPath, + parsePostgresSearchPathSetting, + postgresSearchPathAtOffset, + type PostgresSearchPath, +} from './search-path'; + +export type PostgresRoutineBodyLanguage = 'sql' | 'plpgsql'; +export type PostgresRoutineBodyReferenceKind = 'references' | 'calls' | 'sequence'; + +export interface PostgresRoutineBodyReference { + kind: PostgresRoutineBodyReferenceKind; + /** Decoded PostgreSQL identifier segments. */ + parts: string[]; + /** Canonical, quote-preserving qualified name used by resolution. */ + qualifiedName: string; + simpleName: string; + /** + * Ordered, canonical qualified names supplied by a routine-local + * `SET search_path`. Multiple entries are alternatives in PostgreSQL lookup + * order, not independent dependencies. An empty array represents an + * explicitly empty path and must not fall back to the file/session path. + */ + searchPathCandidates?: string[]; + /** Absolute UTF-16 source offset in the containing SQL file. */ + startIndex: number; + /** One-based source line in the containing SQL file. */ + line: number; + /** Zero-based source column in the containing SQL file. */ + column: number; +} + +export type PostgresRoutineBodyStatus = + | 'analyzed' + | 'missing-body' + | 'unsupported-language' + | 'parser-unavailable' + | 'parse-error'; + +export interface PostgresRoutineBodyDiscovery { + status: PostgresRoutineBodyStatus; + language: PostgresRoutineBodyLanguage | null; + bodyStartIndex: number | null; + facts: PostgresRoutineBodyReference[]; + /** Injected fragments that needed conservative PostgreSQL error recovery. */ + recoveredFragments: number; + /** Dynamic EXECUTE regions deliberately excluded from static dependencies. */ + skippedDynamicFragments: number; +} + +export interface PostgresRoutineBodyParsers { + postgres: Parser; + plpgsql: Parser | null; +} + +interface SqlName { + parts: string[]; + qualified: string; + simple: string; +} + +interface StaticBody { + text: string; + startIndex: number; +} + +interface LocalLocation { + startIndex: number; +} + +type LocationMapper = (node: SyntaxNode) => LocalLocation | null; + +const NAME_SEGMENT_TYPES = new Set(['ColId', 'ColLabel', 'type_function_name']); + +const SQL_STATEMENT_TYPES = new Set([ + 'SelectStmt', + 'InsertStmt', + 'UpdateStmt', + 'DeleteStmt', + 'MergeStmt', + 'CallStmt', +]); + +const QUERY_SCOPE_TYPES = new Set([ + 'select_no_parens', + 'InsertStmt', + 'UpdateStmt', + 'DeleteStmt', + 'MergeStmt', +]); + +const QUERY_BOUNDARY_TYPES = new Set([ + ...QUERY_SCOPE_TYPES, + 'SelectStmt', + 'select_with_parens', +]); + +const STATIC_SEQUENCE_LITERAL_WRAPPERS = new Set([ + 'func_arg_expr', + 'a_expr', + 'a_expr_prec', + 'c_expr', + 'AexprConst', + 'Sconst', + 'func_expr', +]); + +function children(node: SyntaxNode): SyntaxNode[] { + const result: SyntaxNode[] = []; + for (let index = 0; index < node.namedChildCount; index++) { + const child = node.namedChild(index); + if (child) result.push(child); + } + return result; +} + +function directChild(node: SyntaxNode, ...types: string[]): SyntaxNode | null { + return children(node).find((child) => types.includes(child.type)) ?? null; +} + +function descendants(node: SyntaxNode, type: string): SyntaxNode[] { + const result: SyntaxNode[] = []; + const visit = (current: SyntaxNode): void => { + for (const child of children(current)) { + if (child.type === type) result.push(child); + visit(child); + } + }; + visit(node); + return result; +} + +function firstDescendant(node: SyntaxNode, type: string): SyntaxNode | null { + for (const child of children(node)) { + if (child.type === type) return child; + const nested = firstDescendant(child, type); + if (nested) return nested; + } + return null; +} + +function hasDirectChild(node: SyntaxNode, type: string): boolean { + return directChild(node, type) !== null; +} + +/** + * Return the first routine argument only when it reduces to a regular string + * literal through syntax-only parentheses and casts. A descendant search is + * unsafe here: dynamic expressions such as `format('%s_seq', tenant)` and + * `'prefix_' || tenant` also contain string literals but do not identify one + * statically knowable sequence. + */ +export function postgresStaticSequenceLiteral( + application: SyntaxNode +): SyntaxNode | null { + const argumentList = directChild(application, 'func_arg_list'); + const firstArgument = argumentList + ? firstDescendant(argumentList, 'func_arg_expr') + : null; + if (!firstArgument) return null; + + const unwrap = (node: SyntaxNode): SyntaxNode | null => { + if (node.type === 'string_literal') return node; + + const nested = children(node); + if ( + node.type === 'func_expr_common_subexpr' && + hasDirectChild(node, 'kw_cast') && + hasDirectChild(node, 'kw_as') && + hasDirectChild(node, 'Typename') + ) { + const expressions = nested.filter((child) => child.type === 'a_expr'); + return expressions.length === 1 ? unwrap(expressions[0]!) : null; + } + + if (node.type === 'a_expr' || node.type === 'a_expr_prec') { + const valueChildren = nested.filter((child) => child.type !== 'Typename'); + const isCast = nested.length === 2 && nested[1]?.type === 'Typename'; + if (isCast && valueChildren.length === 1) return unwrap(valueChildren[0]!); + } + + return STATIC_SEQUENCE_LITERAL_WRAPPERS.has(node.type) && nested.length === 1 + ? unwrap(nested[0]!) + : null; + }; + + return unwrap(firstArgument); +} + +function canonicalSegment(node: SyntaxNode): string { + const raw = node.text.trim(); + if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) { + return raw.slice(1, -1).replace(/""/g, '"'); + } + return raw.toLowerCase(); +} + +function readSqlName(node: SyntaxNode | null): SqlName | null { + if (!node) return null; + const parts: string[] = []; + + const visit = (current: SyntaxNode): void => { + if (NAME_SEGMENT_TYPES.has(current.type)) { + const part = canonicalSegment(current); + if (part) parts.push(part); + return; + } + if (current.type === 'identifier' || current.type === 'quoted_identifier') { + const part = canonicalSegment(current); + if (part) parts.push(part); + return; + } + for (const child of children(current)) visit(child); + }; + + visit(node); + if (parts.length === 0) return null; + return { + parts, + qualified: serializePostgresQualifiedName(parts), + simple: parts[parts.length - 1]!, + }; +} + +function decodeLanguageOption(option: SyntaxNode): string | null { + const keyword = directChild(option, 'kw_language'); + if (!keyword) return null; + const valueNode = children(option).find((child) => child.startIndex >= keyword.endIndex); + if (!valueNode) return null; + const value = valueNode.text.trim(); + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1).replace(/''/g, "'").toLowerCase(); + } + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/""/g, '"'); + } + return value.toLowerCase(); +} + +function routineLanguage(statement: SyntaxNode): string | null { + const optionType = statement.type === 'DoStmt' ? 'dostmt_opt_item' : 'createfunc_opt_item'; + for (const option of descendants(statement, optionType)) { + const language = decodeLanguageOption(option); + if (language) return language; + } + return statement.type === 'DoStmt' ? 'plpgsql' : null; +} + +/** + * Read CREATE FUNCTION/PROCEDURE's routine-local SET search_path without + * feeding it into the file-level session state machine. PostgreSQL's `FROM + * CURRENT` captures the setting at CREATE time, so that one form deliberately + * reads (but never mutates) the ambient path at the statement offset. + */ +function routineSearchPath( + statement: SyntaxNode, + source: string +): PostgresSearchPath | null { + let configured: PostgresSearchPath | null = null; + let ambient: PostgresSearchPath | null = null; + + for (const clause of descendants(statement, 'FunctionSetResetClause')) { + const rest = directChild(clause, 'set_rest_more'); + const variable = rest ? firstDescendant(rest, 'var_name') : null; + const name = readSqlName(variable); + if (!rest || name?.parts.length !== 1 || name.simple !== 'search_path') continue; + + if (hasDirectChild(rest, 'kw_from') && hasDirectChild(rest, 'kw_current')) { + ambient ??= postgresSearchPathAtOffset( + analyzePostgresSearchPath(source, { copyPayloadsMasked: true }), + statement.startIndex + ); + configured = { + schemas: [...ambient.schemas], + explicit: ambient.explicit, + }; + continue; + } + + const genericSet = firstDescendant(rest, 'generic_set'); + if (!genericSet) continue; + if (hasDirectChild(genericSet, 'kw_default')) { + configured = parsePostgresSearchPathSetting('DEFAULT'); + continue; + } + const values = descendants(genericSet, 'var_value') + .sort((left, right) => left.startIndex - right.startIndex) + .map((value) => source.slice(value.startIndex, value.endIndex)); + if (values.length === 0) continue; + configured = parsePostgresSearchPathSetting(values.join(',')); + } + + return configured; +} + +function applyRoutineSearchPath( + facts: PostgresRoutineBodyReference[], + searchPath: PostgresSearchPath | null +): PostgresRoutineBodyReference[] { + if (!searchPath) return facts; + + return facts.map((fact) => { + // An explicitly schema-qualified body reference always wins over + // search_path, exactly as it does at runtime. + if (fact.parts.length !== 1) return fact; + const candidates = searchPath.schemas.map((schema) => + serializePostgresQualifiedName([schema, ...fact.parts]) + ); + + // A single statically named schema is exact, so expose the qualified fact + // directly. `$user` is role-dependent and remains an ordered candidate + // barrier for the resolver rather than pretending it is a literal schema. + if (candidates.length === 1 && searchPath.schemas[0] !== '$user') { + const parts = [searchPath.schemas[0]!, ...fact.parts]; + return { + ...fact, + parts, + qualifiedName: serializePostgresQualifiedName(parts), + }; + } + + return { ...fact, searchPathCandidates: candidates }; + }); +} + +function staticDollarBody(statement: SyntaxNode, source: string): StaticBody | null { + let bodyLeaves: SyntaxNode[] = []; + if (statement.type === 'CreateFunctionStmt') { + for (const option of descendants(statement, 'createfunc_opt_item')) { + if (!hasDirectChild(option, 'kw_as')) continue; + const funcAs = directChild(option, 'func_as'); + if (funcAs) bodyLeaves.push(...descendants(funcAs, 'dollar_quoted_string')); + } + } else if (statement.type === 'DoStmt') { + for (const option of descendants(statement, 'dostmt_opt_item')) { + if (!hasDirectChild(option, 'kw_language')) { + bodyLeaves.push(...descendants(option, 'dollar_quoted_string')); + } + } + } else { + return null; + } + + if (bodyLeaves.length !== 1) return null; + const leaf = bodyLeaves[0]!; + const raw = source.slice(leaf.startIndex, leaf.endIndex); + const opening = /^\$(?:[A-Za-z_][A-Za-z_0-9]*)?\$/.exec(raw)?.[0]; + if (!opening || raw.length < opening.length * 2 || !raw.endsWith(opening)) return null; + return { + text: raw.slice(opening.length, -opening.length), + startIndex: leaf.startIndex + opening.length, + }; +} + +function nearestSqlStatement(node: SyntaxNode): SyntaxNode | null { + let current: SyntaxNode | null = node.parent; + while (current) { + if (SQL_STATEMENT_TYPES.has(current.type)) return current; + current = current.parent; + } + return null; +} + +function hasErrorAncestorBefore(node: SyntaxNode, boundary: SyntaxNode): boolean { + let current: SyntaxNode | null = node.parent; + while (current && current !== boundary) { + if (current.type === 'ERROR') return true; + current = current.parent; + } + return false; +} + +function ownWithClause(query: SyntaxNode): SyntaxNode | null { + const visit = (node: SyntaxNode): SyntaxNode | null => { + for (const child of children(node)) { + if (child.type === 'with_clause') return child; + if (QUERY_BOUNDARY_TYPES.has(child.type)) continue; + const found = visit(child); + if (found) return found; + } + return null; + }; + return visit(query); +} + +function cteNamesInClause(withClause: SyntaxNode): Set { + const names = new Set(); + const visit = (node: SyntaxNode): void => { + for (const child of children(node)) { + if (child.type === 'common_table_expr') { + const name = readSqlName(directChild(child, 'name')); + if (name) names.add(name.qualified); + continue; + } + visit(child); + } + }; + visit(withClause); + return names; +} + +function cteNamesVisibleFrom(node: SyntaxNode): Set { + const names = new Set(); + let ancestor: SyntaxNode | null = node; + while (ancestor) { + if (QUERY_SCOPE_TYPES.has(ancestor.type)) { + const withClause = ownWithClause(ancestor); + if (withClause) { + for (const name of cteNamesInClause(withClause)) names.add(name); + } + } + ancestor = ancestor.parent; + } + return names; +} + +function lineColumnAt(source: string, startIndex: number): { line: number; column: number } { + let line = 1; + let lineStart = 0; + for (let index = 0; index < startIndex; index++) { + if (source.charCodeAt(index) === 10) { + line++; + lineStart = index + 1; + } + } + return { line, column: startIndex - lineStart }; +} + +function collectPostgresFacts( + root: SyntaxNode, + source: string, + mapLocation: LocationMapper, + recoverFromErrors: boolean +): PostgresRoutineBodyReference[] { + const facts: PostgresRoutineBodyReference[] = []; + + const emitStaticSequence = (application: SyntaxNode): void => { + const functionName = readSqlName(directChild(application, 'func_name')); + if (!functionName || !['nextval', 'currval', 'setval'].includes(functionName.simple)) return; + const literal = postgresStaticSequenceLiteral(application); + if (!literal) return; + const raw = literal.text; + if (raw.length < 2 || !raw.startsWith("'") || !raw.endsWith("'")) return; + const decoded = raw.slice(1, -1).replace(/''/g, "'"); + const parts = parsePostgresQualifiedName(decoded); + const location = mapLocation(literal); + if (!parts || parts.length === 0 || !location) return; + const startIndex = location.startIndex + 1; + const position = lineColumnAt(source, startIndex); + facts.push({ + kind: 'sequence', + parts, + qualifiedName: serializePostgresQualifiedName(parts), + simpleName: parts[parts.length - 1]!, + startIndex, + ...position, + }); + }; + + const emit = ( + node: SyntaxNode, + nameNode: SyntaxNode | null, + kind: PostgresRoutineBodyReferenceKind, + suppressCte: boolean + ): void => { + if (!nameNode) return; + const statement = nearestSqlStatement(node); + if (!statement) return; + if (recoverFromErrors && hasErrorAncestorBefore(node, statement)) return; + const name = readSqlName(nameNode); + if (!name) return; + if ( + suppressCte && + name.parts.length === 1 && + cteNamesVisibleFrom(node).has(name.qualified) + ) return; + const location = mapLocation(nameNode); + if (!location || location.startIndex < 0 || location.startIndex > source.length) return; + const position = lineColumnAt(source, location.startIndex); + facts.push({ + kind, + parts: name.parts, + qualifiedName: name.qualified, + simpleName: name.simple, + startIndex: location.startIndex, + ...position, + }); + }; + + const visit = (node: SyntaxNode): void => { + if (node.type === 'relation_expr') { + emit(node, firstDescendant(node, 'qualified_name'), 'references', true); + } else if (node.type === 'insert_target') { + emit(node, firstDescendant(node, 'qualified_name'), 'references', false); + } else if (node.type === 'func_application') { + emit(node, directChild(node, 'func_name'), 'calls', false); + emitStaticSequence(node); + } + for (const child of children(node)) visit(child); + }; + + visit(root); + return facts; +} + +function isDynamicExpression(expression: SyntaxNode): boolean { + let ancestor: SyntaxNode | null = expression.parent; + while (ancestor && ancestor.type !== 'source_file') { + if (ancestor.type === 'stmt_dynexecute' || ancestor.type === 'for_dynamic') return true; + if ( + (ancestor.type === 'stmt_return' || ancestor.type === 'stmt_open') && + hasDirectChild(ancestor, 'kw_execute') + ) return true; + ancestor = ancestor.parent; + } + return false; +} + +function isDirectSqlExpression(expression: SyntaxNode): boolean { + const parent = expression.parent; + if (!parent) return false; + if (parent.type === 'stmt_execsql' || parent.type === 'for_query') return true; + if (parent.type === 'decl_statement' && hasDirectChild(parent, 'kw_cursor')) return true; + if (parent.type === 'stmt_open' && hasDirectChild(parent, 'kw_for')) return true; + return parent.type === 'stmt_return' && hasDirectChild(parent, 'kw_query'); +} + +function deduplicateFacts( + facts: PostgresRoutineBodyReference[] +): PostgresRoutineBodyReference[] { + const result: PostgresRoutineBodyReference[] = []; + const seen = new Set(); + for (const fact of [...facts].sort((left, right) => left.startIndex - right.startIndex)) { + const key = `${fact.kind}\u0000${fact.qualifiedName}`; + if (seen.has(key)) continue; + seen.add(key); + result.push(fact); + } + return result; +} + +function emptyDiscovery( + status: PostgresRoutineBodyStatus, + language: PostgresRoutineBodyLanguage | null, + bodyStartIndex: number | null = null +): PostgresRoutineBodyDiscovery { + return { + status, + language, + bodyStartIndex, + facts: [], + recoveredFragments: 0, + skippedDynamicFragments: 0, + }; +} + +/** + * Discover static table/routine dependencies inside a PostgreSQL routine or + * anonymous DO body without executing source text. + * + * PL/pgSQL is parsed structurally first so record assignments and dynamic SQL + * cannot bleed into PostgreSQL statement recovery. Each explicit + * `sql_expression` injection region is then parsed independently. Error-free + * expression regions are required; full SQL regions may conservatively retain + * name nodes outside ERROR subtrees so PL/pgSQL's SELECT/RETURNING INTO syntax + * still yields its real table dependencies. + */ +export function discoverPostgresRoutineBodyReferences( + statement: SyntaxNode, + source: string, + parsers: PostgresRoutineBodyParsers +): PostgresRoutineBodyDiscovery { + const rawLanguage = routineLanguage(statement); + const language = rawLanguage === 'sql' || rawLanguage === 'plpgsql' + ? rawLanguage + : null; + if (!language || (statement.type === 'DoStmt' && language !== 'plpgsql')) { + return emptyDiscovery('unsupported-language', language); + } + + const body = staticDollarBody(statement, source); + if (!body) return emptyDiscovery('missing-body', language); + const searchPath = routineSearchPath(statement, source); + + if (language === 'sql') { + const tree = parsers.postgres.parse(body.text); + if (!tree) return emptyDiscovery('parse-error', language, body.startIndex); + try { + if (tree.rootNode.hasError) { + return emptyDiscovery('parse-error', language, body.startIndex); + } + const facts = applyRoutineSearchPath( + collectPostgresFacts( + tree.rootNode, + source, + (node) => ({ startIndex: body.startIndex + node.startIndex }), + false + ), + searchPath + ); + return { + status: 'analyzed', + language, + bodyStartIndex: body.startIndex, + facts: deduplicateFacts(facts), + recoveredFragments: 0, + skippedDynamicFragments: 0, + }; + } finally { + tree.delete(); + } + } + + if (!parsers.plpgsql) { + return emptyDiscovery('parser-unavailable', language, body.startIndex); + } + const plpgsqlTree = parsers.plpgsql.parse(body.text); + if (!plpgsqlTree) return emptyDiscovery('parse-error', language, body.startIndex); + try { + if (plpgsqlTree.rootNode.hasError) { + return emptyDiscovery('parse-error', language, body.startIndex); + } + + const facts: PostgresRoutineBodyReference[] = []; + let recoveredFragments = 0; + let skippedDynamicFragments = 0; + for (const expression of descendants(plpgsqlTree.rootNode, 'sql_expression')) { + if (isDynamicExpression(expression)) { + skippedDynamicFragments++; + continue; + } + + const direct = isDirectSqlExpression(expression); + const prefix = direct ? '' : 'SELECT '; + const suffix = direct ? '' : ';'; + const fragmentTree = parsers.postgres.parse(`${prefix}${expression.text}${suffix}`); + if (!fragmentTree) continue; + try { + const hasErrors = fragmentTree.rootNode.hasError; + // Expression wrappers should be valid PostgreSQL expressions. Reject + // their error recovery wholesale; only full SQL fragments need the + // narrow SELECT/RETURNING INTO recovery used by PL/pgSQL. + if (hasErrors && !direct) continue; + if (hasErrors) recoveredFragments++; + const fragmentStart = body.startIndex + expression.startIndex; + facts.push(...collectPostgresFacts( + fragmentTree.rootNode, + source, + (node) => { + const relative = node.startIndex - prefix.length; + if (relative < 0 || relative >= expression.text.length) return null; + return { startIndex: fragmentStart + relative }; + }, + hasErrors + )); + } finally { + fragmentTree.delete(); + } + } + + return { + status: 'analyzed', + language, + bodyStartIndex: body.startIndex, + facts: deduplicateFacts(applyRoutineSearchPath(facts, searchPath)), + recoveredFragments, + skippedDynamicFragments, + }; + } finally { + plpgsqlTree.delete(); + } +} diff --git a/src/postgres/search-path.ts b/src/postgres/search-path.ts new file mode 100644 index 000000000..7d6f8579c --- /dev/null +++ b/src/postgres/search-path.ts @@ -0,0 +1,509 @@ +/** PostgreSQL search_path parsing shared by extraction and resolution. */ + +export interface PostgresSearchPath { + schemas: string[]; + explicit: boolean; +} + +export interface PostgresSearchPathChange extends PostgresSearchPath { + offset: number; +} + +export interface PostgresSearchPathState { + changes: PostgresSearchPathChange[]; + lineStarts: number[]; +} + +export interface PostgresStatement { + text: string; + endOffset: number; +} + +export interface PostgresStatementOptions { + /** COPY payloads and their `\.` terminators were already blanked in-place. */ + copyPayloadsMasked?: boolean; +} + +interface PostgresSavepoint { + name: string; + session: PostgresSearchPath; + local: PostgresSearchPath | null; +} + +const DEFAULT_PATH: PostgresSearchPath = { + // PostgreSQL's literal default is "$user", public. Source indexing does not + // know the runtime role; public is the useful deterministic fallback used by + // migration tooling, while an explicit $user entry remains a shadow barrier. + schemas: ['public'], + explicit: false, +}; + +function dollarTagAt(source: string, offset: number): string | null { + if (source[offset] !== '$') return null; + let end = offset + 1; + if (source[end] === '$') return '$$'; + if (!/[A-Za-z_]/.test(source[end] ?? '')) return null; + end++; + while (/[A-Za-z0-9_]/.test(source[end] ?? '')) end++; + return source[end] === '$' ? source.slice(offset, end + 1) : null; +} + +function clonePath(path: PostgresSearchPath): PostgresSearchPath { + return { schemas: [...path.schemas], explicit: path.explicit }; +} + +function cloneOptionalPath(path: PostgresSearchPath | null): PostgresSearchPath | null { + return path ? clonePath(path) : null; +} + +function decodeSavepointName(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/""/g, '"'); + } + return value.toLowerCase(); +} + +function findSavepointIndex(savepoints: PostgresSavepoint[], name: string): number { + for (let index = savepoints.length - 1; index >= 0; index--) { + if (savepoints[index]!.name === name) return index; + } + return -1; +} + +function decodeSingleQuoted(raw: string): string | null { + const value = raw.trim(); + const prefixLength = /^[eE]'/.test(value) ? 1 : 0; + if (value[prefixLength] !== "'" || value[value.length - 1] !== "'") return null; + let decoded = ''; + for (let i = prefixLength + 1; i < value.length - 1; i++) { + const char = value[i]!; + if (char === "'" && value[i + 1] === "'") { + decoded += "'"; + i++; + } else if (prefixLength === 1 && char === '\\' && i + 1 < value.length - 1) { + decoded += value[++i]!; + } else { + decoded += char; + } + } + return decoded; +} + +function decodeDollarQuoted(raw: string): string | null { + const value = raw.trim(); + const opener = dollarTagAt(value, 0); + if (!opener || !value.endsWith(opener) || value.length < opener.length * 2) return null; + return value.slice(opener.length, -opener.length); +} + +function splitIdentifierList(raw: string, recognizeSingleQuotes: boolean): string[] { + const parts: string[] = []; + let current = ''; + let single = false; + let double = false; + let dollarTag: string | null = null; + for (let i = 0; i < raw.length; i++) { + const char = raw[i]!; + if (dollarTag) { + current += char; + if (raw.startsWith(dollarTag, i)) { + current += raw.slice(i + 1, i + dollarTag.length); + i += dollarTag.length - 1; + dollarTag = null; + } + continue; + } + if (single) { + current += char; + if (char === "'" && raw[i + 1] === "'") { + current += raw[++i]!; + } else if (char === "'") { + single = false; + } + continue; + } + if (double) { + current += char; + if (char === '"' && raw[i + 1] === '"') { + current += raw[++i]!; + } else if (char === '"') { + double = false; + } + continue; + } + if (recognizeSingleQuotes && char === "'") { + single = true; + current += char; + } else if (char === '"') { + double = true; + current += char; + } else if (recognizeSingleQuotes && char === '$') { + const tag = dollarTagAt(raw, i); + if (tag) { + dollarTag = tag; + current += tag; + i += tag.length - 1; + } else { + current += char; + } + } else if (char === ',') { + parts.push(current); + current = ''; + } else { + current += char; + } + } + parts.push(current); + return parts; +} + +function decodeIdentifier(part: string, setSyntax: boolean): string | null { + const value = part.trim(); + if (!value) return null; + if (setSyntax) { + const single = decodeSingleQuoted(value); + if (single !== null) return single; + const dollar = decodeDollarQuoted(value); + if (dollar !== null) return dollar; + } + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/""/g, '"'); + } + return value.toLowerCase(); +} + +function parseSetSearchPath(raw: string): PostgresSearchPath { + const trimmed = raw.trim(); + if (/^default$/i.test(trimmed)) return clonePath(DEFAULT_PATH); + const schemas = splitIdentifierList(trimmed, true) + .map((part) => decodeIdentifier(part, true)) + .filter((schema): schema is string => schema !== null && schema.length > 0); + return { schemas, explicit: true }; +} + +/** + * Parse the value portion of PostgreSQL's `SET search_path TO/=` syntax. + * + * CREATE FUNCTION/PROCEDURE carries the same grammar inside a routine-local + * configuration clause. Exporting this narrow parser keeps quoted identifiers, + * string-literal schema names, empty paths, and DEFAULT behavior identical to + * top-level SET handling without treating the routine option as session state. + */ +export function parsePostgresSearchPathSetting(raw: string): PostgresSearchPath { + return parseSetSearchPath(raw); +} + +function parseGucSearchPath(rawLiteral: string): PostgresSearchPath | null { + const value = decodeSingleQuoted(rawLiteral) ?? decodeDollarQuoted(rawLiteral); + if (value === null) return null; + const schemas = splitIdentifierList(value, false) + .map((part) => decodeIdentifier(part, false)) + .filter((schema): schema is string => schema !== null && schema.length > 0); + return { schemas, explicit: true }; +} + +function parseSetSchema(rawLiteral: string): PostgresSearchPath | null { + const schema = decodeSingleQuoted(rawLiteral) ?? decodeDollarQuoted(rawLiteral); + if (schema === null) return null; + // SET SCHEMA accepts exactly one string literal. In particular, a comma in + // that literal belongs to the schema name; it is not a search_path separator. + return { schemas: schema.length > 0 ? [schema] : [], explicit: true }; +} + +/** + * Stream top-level statements with bounded buffering. Comments are replaced by + * spaces, quoted bodies suppress semicolon splitting, and pg_dump COPY payloads + * are skipped through their standalone `\.` terminator. + */ +export function* postgresTopLevelStatements( + source: string, + options: PostgresStatementOptions = {} +): IterableIterator { + const buffer: string[] = []; + const MAX_STATEMENT_CHARS = 32_768; + let blockDepth = 0; + let lineComment = false; + let single = false; + let escapeString = false; + let double = false; + let dollarTag: string | null = null; + let copyPayload = false; + let statementTail = ''; + + const append = (text: string): void => { + statementTail = (statementTail + text).slice(-512); + if (buffer.length >= MAX_STATEMENT_CHARS) return; + const remaining = MAX_STATEMENT_CHARS - buffer.length; + buffer.push(...text.slice(0, remaining)); + }; + const resetLexicalState = (): void => { + buffer.length = 0; + blockDepth = 0; + lineComment = false; + single = false; + escapeString = false; + double = false; + dollarTag = null; + statementTail = ''; + }; + + for (let i = 0; i < source.length; i++) { + if (copyPayload) { + const newline = source.indexOf('\n', i); + const lineEnd = newline < 0 ? source.length : newline; + const line = source.slice(i, lineEnd).replace(/\r$/, ''); + if (line === '\\.') copyPayload = false; + i = lineEnd; + resetLexicalState(); + continue; + } + + const char = source[i]!; + const next = source[i + 1]; + if (lineComment) { + append(char === '\n' ? '\n' : ' '); + if (char === '\n') lineComment = false; + continue; + } + if (blockDepth > 0) { + append(char === '\n' || char === '\r' ? char : ' '); + if (char === '/' && next === '*') { + append(' '); + blockDepth++; + i++; + } else if (char === '*' && next === '/') { + append(' '); + blockDepth--; + i++; + } + continue; + } + if (dollarTag) { + if (source.startsWith(dollarTag, i)) { + append(dollarTag); + i += dollarTag.length - 1; + dollarTag = null; + } else { + append(char); + } + continue; + } + if (single) { + append(char); + if (char === "'" && next === "'") { + append(next); + i++; + } else if (char === "'") { + single = false; + escapeString = false; + } else if (escapeString && char === '\\' && i + 1 < source.length) { + append(next ?? ''); + i++; + } + continue; + } + if (double) { + append(char); + if (char === '"' && next === '"') { + append(next); + i++; + } else if (char === '"') { + double = false; + } + continue; + } + + if (char === '-' && next === '-') { + append(' '); + lineComment = true; + i++; + } else if (char === '/' && next === '*') { + append(' '); + blockDepth = 1; + i++; + } else if (char === "'") { + append(char); + single = true; + const prefix = source[i - 1]; + const beforePrefix = source[i - 2]; + escapeString = (prefix === 'E' || prefix === 'e') && + (beforePrefix === undefined || !/[A-Za-z0-9_$]/.test(beforePrefix)); + } else if (char === '"') { + append(char); + double = true; + } else if (char === '$' && + (i === 0 || !/[A-Za-z0-9_$]/.test(source[i - 1]!))) { + const tag = dollarTagAt(source, i); + if (tag) { + append(tag); + dollarTag = tag; + i += tag.length - 1; + } else { + append(char); + } + } else { + append(char); + if (char === ';') { + const text = buffer.join(''); + copyPayload = options.copyPayloadsMasked !== true && + /^\s*COPY\b/i.test(text) && + /\bFROM\s+STDIN\s*;\s*$/i.test(statementTail); + yield { text, endOffset: i + 1 }; + resetLexicalState(); + } + } + } +} + +function samePath(a: PostgresSearchPath, b: PostgresSearchPath): boolean { + return a.explicit === b.explicit && + a.schemas.length === b.schemas.length && + a.schemas.every((schema, index) => schema === b.schemas[index]); +} + +export function analyzePostgresSearchPath( + source: string, + options: PostgresStatementOptions = {} +): PostgresSearchPathState { + const changes: PostgresSearchPathChange[] = []; + const lineStarts = [0]; + for (let i = 0; i < source.length; i++) { + if (source[i] === '\n') lineStarts.push(i + 1); + } + + let session = clonePath(DEFAULT_PATH); + let local: PostgresSearchPath | null = null; + let transactionSnapshot: PostgresSearchPath | null = null; + let savepoints: PostgresSavepoint[] = []; + const effective = (): PostgresSearchPath => local ?? session; + const record = (offset: number, before: PostgresSearchPath): void => { + const after = effective(); + if (!samePath(before, after)) changes.push({ offset, ...clonePath(after) }); + }; + + for (const statement of postgresTopLevelStatements(source, options)) { + const text = statement.text; + const before = clonePath(effective()); + const savepoint = /^\s*SAVEPOINT\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + const rollbackTo = /^\s*ROLLBACK(?:\s+(?:WORK|TRANSACTION))?\s+TO(?:\s+SAVEPOINT)?\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + const release = /^\s*RELEASE(?:\s+SAVEPOINT)?\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + const commit = /^\s*(?:COMMIT|END)(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*;\s*$/i + .test(text); + const rollback = /^\s*ROLLBACK(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*;\s*$/i + .test(text); + const chains = /\bAND\s+CHAIN\s*;\s*$/i.test(text); + if (/^\s*(?:BEGIN|START\s+TRANSACTION)\b[\s\S]*;\s*$/i.test(text)) { + if (!transactionSnapshot) { + transactionSnapshot = clonePath(session); + savepoints = []; + } + } else if (commit) { + local = null; + savepoints = []; + transactionSnapshot = chains ? clonePath(session) : null; + } else if (rollbackTo) { + const name = decodeSavepointName(rollbackTo[1] ?? ''); + const index = findSavepointIndex(savepoints, name); + if (index >= 0) { + const snapshot = savepoints[index]!; + session = clonePath(snapshot.session); + local = cloneOptionalPath(snapshot.local); + // PostgreSQL keeps the target savepoint active but destroys newer ones. + savepoints.length = index + 1; + } + } else if (rollback) { + if (transactionSnapshot) session = transactionSnapshot; + local = null; + savepoints = []; + transactionSnapshot = chains ? clonePath(session) : null; + } else if (savepoint) { + if (transactionSnapshot) { + savepoints.push({ + name: decodeSavepointName(savepoint[1] ?? ''), + session: clonePath(session), + local: cloneOptionalPath(local), + }); + } + } else if (release) { + const name = decodeSavepointName(release[1] ?? ''); + const index = findSavepointIndex(savepoints, name); + if (index >= 0) savepoints.length = index; + } else { + const set = /^\s*SET\s+(?:(SESSION|LOCAL)\s+)?search_path\s*(?:TO|=)\s*([\s\S]*?)\s*;\s*$/i + .exec(text); + const setSchema = /^\s*SET\s+(?:(SESSION|LOCAL)\s+)?SCHEMA\s+((?:[eE])?'(?:''|\\.|[^'])*'|\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$)\s*;\s*$/i + .exec(text); + const reset = /^\s*RESET\s+search_path\s*;\s*$/i.test(text); + const setConfig = /^\s*SELECT\s+(?:pg_catalog\.)?set_config\s*\(\s*'search_path'\s*,\s*((?:[eE])?'(?:''|\\.|[^'])*'|\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$[\s\S]*\$(?:[A-Za-z_][A-Za-z0-9_]*)?\$)\s*,\s*(true|false)\s*\)\s*;\s*$/i + .exec(text); + + if (set) { + const nextPath = parseSetSearchPath(set[2] ?? ''); + if (/^LOCAL$/i.test(set[1] ?? '')) { + if (transactionSnapshot) local = nextPath; + } else { + session = nextPath; + local = null; + } + } else if (setSchema) { + const nextPath = parseSetSchema(setSchema[2] ?? ''); + if (nextPath) { + if (/^LOCAL$/i.test(setSchema[1] ?? '')) { + if (transactionSnapshot) local = nextPath; + } else { + session = nextPath; + local = null; + } + } + } else if (reset) { + session = clonePath(DEFAULT_PATH); + local = null; + } else if (setConfig) { + const nextPath = parseGucSearchPath(setConfig[1] ?? ''); + if (nextPath) { + if (/^true$/i.test(setConfig[2] ?? '')) { + if (transactionSnapshot) local = nextPath; + } else { + session = nextPath; + local = null; + } + } + } + } + record(statement.endOffset, before); + } + return { changes, lineStarts }; +} + +export function postgresSearchPathAtOffset( + state: PostgresSearchPathState, + offset: number +): PostgresSearchPath { + let low = 0; + let high = state.changes.length - 1; + let found = -1; + while (low <= high) { + const middle = (low + high) >>> 1; + if (state.changes[middle]!.offset <= offset) { + found = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + return found >= 0 ? state.changes[found]! : DEFAULT_PATH; +} + +export function postgresOffsetAtPosition( + state: PostgresSearchPathState, + line: number, + column: number +): number { + const lineStart = state.lineStarts[Math.max(0, line - 1)] ?? + state.lineStarts[state.lineStarts.length - 1] ?? 0; + return lineStart + Math.max(0, column); +} diff --git a/src/postgres/table-relation.ts b/src/postgres/table-relation.ts new file mode 100644 index 000000000..6a3f29e2b --- /dev/null +++ b/src/postgres/table-relation.ts @@ -0,0 +1,50 @@ +/** Shared encoding for PostgreSQL structural table-relation facts. */ + +export const POSTGRES_TABLE_RELATION_DECORATOR = 'postgres:table-relation'; +export const POSTGRES_TABLE_RELATION_DATA_PREFIX = 'postgres:table-relation-data:'; + +export type PostgresTableRelationKind = + | 'rename' + | 'partition-of' + | 'inherits' + | 'like' + | 'attach-partition' + | 'detach-partition' + | 'inherit' + | 'no-inherit' + | 'constraint-trigger'; + +export interface PostgresTableRelationDescriptor { + relation: PostgresTableRelationKind; + sourceTable: string; + targetTable: string; + mode?: 'concurrently' | 'finalize'; + triggerName?: string; +} + +export function encodePostgresTableRelationDescriptor( + descriptor: PostgresTableRelationDescriptor +): string { + return `${POSTGRES_TABLE_RELATION_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresTableRelationDescriptor( + decorators: readonly string[] | undefined +): PostgresTableRelationDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_TABLE_RELATION_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse(encoded.slice(POSTGRES_TABLE_RELATION_DATA_PREFIX.length)) as + Partial; + if ( + typeof value.relation !== 'string' || + typeof value.sourceTable !== 'string' || + typeof value.targetTable !== 'string' + ) return null; + return value as PostgresTableRelationDescriptor; + } catch { + return null; + } +} diff --git a/src/postgres/temporary-relations.ts b/src/postgres/temporary-relations.ts new file mode 100644 index 000000000..4fbe0c2ba --- /dev/null +++ b/src/postgres/temporary-relations.ts @@ -0,0 +1,376 @@ +import type { Node } from '../types'; +import { parsePostgresQualifiedName } from './identifiers'; +import { + postgresOffsetAtPosition, + postgresTopLevelStatements, + type PostgresSearchPathState, +} from './search-path'; + +interface VisibilityChange { + offset: number; + visible: boolean; +} + +export interface PostgresTemporaryRelationVisibilityState { + changesByNodeId: Map; +} + +interface TemporaryDeclaration { + node: Node; + endOffset: number; + onCommitDrop: boolean; +} + +interface TemporarySavepoint { + name: string; + visible: Map; +} + +function dollarTagAt(source: string, offset: number): string | null { + if (source[offset] !== '$') return null; + let end = offset + 1; + if (source[end] === '$') return '$$'; + if (!/[A-Za-z_]/.test(source[end] ?? '')) return null; + end++; + while (/[A-Za-z0-9_]/.test(source[end] ?? '')) end++; + return source[end] === '$' ? source.slice(offset, end + 1) : null; +} + +/** Match a real ON COMMIT DROP clause, not the same words in a literal. */ +function hasOnCommitDrop(source: string): boolean { + let searchable = ''; + let single = false; + let double = false; + let dollarTag: string | null = null; + let lineComment = false; + let blockDepth = 0; + + for (let index = 0; index < source.length; index++) { + const char = source[index]!; + const next = source[index + 1]; + if (lineComment) { + searchable += char === '\n' ? '\n' : ' '; + if (char === '\n') lineComment = false; + continue; + } + if (blockDepth > 0) { + searchable += char === '\n' ? '\n' : ' '; + if (char === '/' && next === '*') { + searchable += ' '; + blockDepth++; + index++; + } else if (char === '*' && next === '/') { + searchable += ' '; + blockDepth--; + index++; + } + continue; + } + if (dollarTag) { + searchable += ' '; + if (source.startsWith(dollarTag, index)) { + searchable += ' '.repeat(dollarTag.length - 1); + index += dollarTag.length - 1; + dollarTag = null; + } + continue; + } + if (single) { + searchable += ' '; + if (char === "'" && next === "'") { + searchable += ' '; + index++; + } else if (char === "'") { + single = false; + } else if (char === '\\' && index + 1 < source.length) { + searchable += ' '; + index++; + } + continue; + } + if (double) { + searchable += ' '; + if (char === '"' && next === '"') { + searchable += ' '; + index++; + } else if (char === '"') { + double = false; + } + continue; + } + if (char === '-' && next === '-') { + searchable += ' '; + lineComment = true; + index++; + } else if (char === '/' && next === '*') { + searchable += ' '; + blockDepth = 1; + index++; + } else if (char === "'") { + searchable += ' '; + single = true; + } else if (char === '"') { + searchable += ' '; + double = true; + } else if (char === '$') { + const tag = dollarTagAt(source, index); + if (tag) { + searchable += ' '.repeat(tag.length); + dollarTag = tag; + index += tag.length - 1; + } else { + searchable += char; + } + } else { + searchable += char; + } + } + + return /\bON\s+COMMIT\s+DROP\b/i.test(searchable); +} + +function splitDropTargets(raw: string): string[] { + const result: string[] = []; + let current = ''; + let quoted = false; + for (let index = 0; index < raw.length; index++) { + const char = raw[index]!; + if (char === '"') { + current += char; + if (quoted && raw[index + 1] === '"') { + current += raw[++index]!; + } else { + quoted = !quoted; + } + } else if (char === ',' && !quoted) { + result.push(current.trim()); + current = ''; + } else { + current += char; + } + } + if (current.trim()) result.push(current.trim()); + return result; +} + +function temporaryDropKind(node: Node): string | null { + if (node.decorators?.includes('postgres:table')) return 'table'; + if (node.decorators?.includes('postgres:view')) return 'view'; + if (node.decorators?.includes('postgres:sequence')) return 'sequence'; + return null; +} + +function dropTargets(statement: string): { kind: string; names: string[][] } | null { + const match = /^\s*DROP\s+(TABLE|VIEW|SEQUENCE)\s+(?:IF\s+EXISTS\s+)?([\s\S]*?)\s*;\s*$/i + .exec(statement); + if (!match) return null; + const body = (match[2] ?? '').replace(/\s+(?:CASCADE|RESTRICT)\s*$/i, ''); + const names = splitDropTargets(body) + .map((target) => parsePostgresQualifiedName(target)) + .filter((parts): parts is string[] => parts !== null); + return { kind: (match[1] ?? '').toLowerCase(), names }; +} + +function dropMatches(node: Node, drop: { kind: string; names: string[][] }): boolean { + if (temporaryDropKind(node) !== drop.kind) return false; + const candidate = parsePostgresQualifiedName(node.qualifiedName); + if (!candidate || candidate.length < 2) return false; + const simple = candidate[candidate.length - 1]!; + return drop.names.some((target) => { + if (target.length === 1) return target[0] === simple; + if (target.length !== candidate.length) return false; + const targetSchema = target[0]; + const candidateSchema = candidate[0]; + const sameTemporarySchema = targetSchema === 'pg_temp' && + (candidateSchema === 'pg_temp' || /^pg_temp_[0-9]+$/.test(candidateSchema ?? '')); + return (sameTemporarySchema || targetSchema === candidateSchema) && + target.slice(1).every((part, index) => part === candidate[index + 1]); + }); +} + +function isBegin(statement: string): boolean { + return /^\s*(?:BEGIN|START\s+TRANSACTION)\b[\s\S]*;\s*$/i.test(statement); +} + +function isCommit(statement: string): boolean { + return /^\s*(?:COMMIT|END)(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*;\s*$/i + .test(statement); +} + +function isRollback(statement: string): boolean { + return /^\s*ROLLBACK(?:\s+(?:WORK|TRANSACTION))?(?:\s+AND\s+(?:NO\s+)?CHAIN)?\s*;\s*$/i + .test(statement); +} + +function chains(statement: string): boolean { + return /\bAND\s+CHAIN\s*;\s*$/i.test(statement); +} + +function decodeSavepointName(raw: string): string { + const value = raw.trim(); + if (value.startsWith('"') && value.endsWith('"')) { + return value.slice(1, -1).replace(/""/g, '"'); + } + return value.toLowerCase(); +} + +function findSavepointIndex(savepoints: TemporarySavepoint[], name: string): number { + for (let index = savepoints.length - 1; index >= 0; index--) { + if (savepoints[index]!.name === name) return index; + } + return -1; +} + +/** + * Build statement-ordered visibility timelines for temporary relations in one + * SQL file. Effects become visible at statement completion. Transactional + * CREATE/DROP is restored by ROLLBACK, while ON COMMIT DROP is applied only by + * an actual or implicit commit. + */ +export function analyzePostgresTemporaryRelationVisibility( + source: string, + nodes: readonly Node[], + positions: PostgresSearchPathState, + copyPayloadsMasked = false +): PostgresTemporaryRelationVisibilityState { + const declarations: TemporaryDeclaration[] = nodes + .filter((node) => node.language === 'postgres' && + node.decorators?.includes('postgres:temporary') === true) + .map((node) => { + const startOffset = postgresOffsetAtPosition( + positions, + node.startLine, + node.startColumn + ); + const endOffset = postgresOffsetAtPosition(positions, node.endLine, node.endColumn); + return { + node, + endOffset, + onCommitDrop: hasOnCommitDrop(source.slice(startOffset, endOffset)), + }; + }) + .sort((left, right) => left.endOffset - right.endOffset || + left.node.id.localeCompare(right.node.id)); + + const changesByNodeId = new Map(); + const visible = new Map(); + let transactionSnapshot: Map | null = null; + let savepoints: TemporarySavepoint[] = []; + let declarationIndex = 0; + + const record = (declaration: TemporaryDeclaration, value: boolean, offset: number): void => { + const previous = visible.get(declaration.node.id) ?? false; + if (previous === value) return; + visible.set(declaration.node.id, value); + let changes = changesByNodeId.get(declaration.node.id); + if (!changes) { + changes = []; + changesByNodeId.set(declaration.node.id, changes); + } + changes.push({ offset, visible: value }); + }; + + const snapshot = (): Map => new Map(visible); + const restore = (state: Map, offset: number): void => { + for (const declaration of declarations) { + record(declaration, state.get(declaration.node.id) ?? false, offset); + } + }; + + const declareThrough = (endOffset: number): void => { + while (declarationIndex < declarations.length && + declarations[declarationIndex]!.endOffset <= endOffset) { + const declaration = declarations[declarationIndex++]!; + if (transactionSnapshot) { + record(declaration, true, endOffset); + } else if (!declaration.onCommitDrop) { + record(declaration, true, endOffset); + } + } + }; + + for (const statement of postgresTopLevelStatements(source, { copyPayloadsMasked })) { + const text = statement.text; + const savepoint = /^\s*SAVEPOINT\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + const rollbackTo = /^\s*ROLLBACK(?:\s+(?:WORK|TRANSACTION))?\s+TO(?:\s+SAVEPOINT)?\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + const release = /^\s*RELEASE(?:\s+SAVEPOINT)?\s+("(?:""|[^"])+"|[A-Za-z_][A-Za-z0-9_$]*)\s*;\s*$/i + .exec(text); + if (isBegin(text)) { + if (!transactionSnapshot) { + transactionSnapshot = snapshot(); + savepoints = []; + } + } else if (isCommit(text)) { + for (const declaration of declarations) { + if (declaration.onCommitDrop && visible.get(declaration.node.id) === true) { + record(declaration, false, statement.endOffset); + } + } + savepoints = []; + transactionSnapshot = chains(text) ? snapshot() : null; + } else if (rollbackTo) { + const name = decodeSavepointName(rollbackTo[1] ?? ''); + const index = findSavepointIndex(savepoints, name); + if (index >= 0) { + restore(savepoints[index]!.visible, statement.endOffset); + // The target remains active; later savepoints are destroyed. + savepoints.length = index + 1; + } + } else if (isRollback(text)) { + if (transactionSnapshot) restore(transactionSnapshot, statement.endOffset); + savepoints = []; + transactionSnapshot = chains(text) ? snapshot() : null; + } else if (savepoint) { + if (transactionSnapshot) { + savepoints.push({ + name: decodeSavepointName(savepoint[1] ?? ''), + visible: snapshot(), + }); + } + } else if (release) { + const name = decodeSavepointName(release[1] ?? ''); + const index = findSavepointIndex(savepoints, name); + if (index >= 0) savepoints.length = index; + } else { + const drop = dropTargets(text); + if (drop) { + for (const declaration of declarations) { + if (visible.get(declaration.node.id) === true && dropMatches(declaration.node, drop)) { + record(declaration, false, statement.endOffset); + } + } + } + } + declareThrough(statement.endOffset); + } + + // A final statement does not require a semicolon. Its declarations become + // visible at EOF under the same implicit-transaction rule. The node range is + // also a fallback when a minimal ResolutionContext cannot provide source. + declareThrough(Math.max(source.length, declarations.at(-1)?.endOffset ?? 0)); + return { changesByNodeId }; +} + +export function postgresTemporaryRelationVisibleAt( + state: PostgresTemporaryRelationVisibilityState, + nodeId: string, + offset: number +): boolean { + const changes = state.changesByNodeId.get(nodeId); + if (!changes) return false; + let low = 0; + let high = changes.length - 1; + let found = -1; + while (low <= high) { + const middle = (low + high) >>> 1; + if (changes[middle]!.offset <= offset) { + found = middle; + low = middle + 1; + } else { + high = middle - 1; + } + } + return found >= 0 && changes[found]!.visible; +} diff --git a/src/postgres/type-lifecycle.ts b/src/postgres/type-lifecycle.ts new file mode 100644 index 000000000..142344f3a --- /dev/null +++ b/src/postgres/type-lifecycle.ts @@ -0,0 +1,81 @@ +/** Ordered PostgreSQL type/enum rename facts used by graph synthesis. */ + +export const POSTGRES_TYPE_RENAME_DECORATOR = 'postgres:type-rename'; +export const POSTGRES_TYPE_RENAME_DATA_PREFIX = 'postgres:type-rename-data:'; +export const POSTGRES_ENUM_VALUE_MUTATION_DECORATOR = 'postgres:enum-value-mutation'; +export const POSTGRES_ENUM_VALUE_MUTATION_DATA_PREFIX = + 'postgres:enum-value-mutation-data:'; + +export interface PostgresTypeRenameDescriptor { + sourceType: string; + targetType: string; +} + +export type PostgresEnumValueMutationDescriptor = + | { + mutation: 'add'; + enumType: string; + targetValue: string; + } + | { + mutation: 'rename'; + enumType: string; + sourceValue: string; + targetValue: string; + }; + +export function encodePostgresTypeRenameDescriptor( + descriptor: PostgresTypeRenameDescriptor +): string { + return `${POSTGRES_TYPE_RENAME_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresTypeRenameDescriptor( + decorators: readonly string[] | undefined +): PostgresTypeRenameDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_TYPE_RENAME_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse(encoded.slice(POSTGRES_TYPE_RENAME_DATA_PREFIX.length)) as + Partial; + if (typeof value.sourceType !== 'string' || typeof value.targetType !== 'string') { + return null; + } + return value as PostgresTypeRenameDescriptor; + } catch { + return null; + } +} + +export function encodePostgresEnumValueMutationDescriptor( + descriptor: PostgresEnumValueMutationDescriptor +): string { + return `${POSTGRES_ENUM_VALUE_MUTATION_DATA_PREFIX}${JSON.stringify(descriptor)}`; +} + +export function decodePostgresEnumValueMutationDescriptor( + decorators: readonly string[] | undefined +): PostgresEnumValueMutationDescriptor | null { + const encoded = decorators?.find((decorator) => + decorator.startsWith(POSTGRES_ENUM_VALUE_MUTATION_DATA_PREFIX) + ); + if (!encoded) return null; + try { + const value = JSON.parse( + encoded.slice(POSTGRES_ENUM_VALUE_MUTATION_DATA_PREFIX.length) + ) as Partial; + if ( + (value.mutation !== 'add' && value.mutation !== 'rename') || + typeof value.enumType !== 'string' || + typeof value.targetValue !== 'string' || + (value.mutation === 'rename' && typeof value.sourceValue !== 'string') + ) { + return null; + } + return value as PostgresEnumValueMutationDescriptor; + } catch { + return null; + } +} diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index b8bc1045a..a11b2cf8d 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -3590,7 +3590,7 @@ export const SYNTH_PASSES: SynthPassDef[] = [ { name: 'nixOptionEdges', gate: (has) => has('nix'), run: (q, _c, y) => nixOptionPathEdges(q, y) }, ]; -/** Fixed non-registry steps: goMethodContains, goImplements, dedupe-merge, insertMergedEdges. */ +/** Fixed non-registry steps: Go relationships, merge, and insert. */ const FIXED_SYNTH_STEPS = 4; export const SYNTH_PROGRESS_STEPS = SYNTH_PASSES.length + FIXED_SYNTH_STEPS; export async function synthesizeCallbackEdges( diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 598e5e479..e1bc8c849 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,6 +21,14 @@ import { resolveViaImport, resolveJvmImport, extractImportMappings, extractReExp import { ResolverPool, minRefsForPool } from './resolver-pool'; import { detectFrameworks } from './frameworks'; import { synthesizeCallbackEdges } from './callback-synthesizer'; +import { + refreshPostgresForeignKeyEdges, + refreshPostgresForeignKeyEdgesSync, +} from './postgres-foreign-key-synthesizer'; +import { + refreshPostgresStructureEdges, + refreshPostgresStructureEdgesSync, +} from './postgres-structure-synthesizer'; import { createYielder, type MaybeYield } from './cooperative-yield'; import { loadProjectAliases, type AliasMap } from './path-aliases'; import { loadGoModule, type GoModule } from './go-module'; @@ -28,6 +36,7 @@ import { loadWorkspacePackages, type WorkspacePackages } from './workspace-packa import { logDebug } from '../errors'; import type { ReExport } from './types'; import { LRUCache } from './lru-cache'; +import { isPostgresObjectReferenceKind } from '../postgres/reference-intent'; /** Node kinds that can declare supertypes (extends/implements). */ const SUPERTYPE_BEARING_KINDS = new Set([ @@ -892,6 +901,11 @@ export class ReferenceResolver { : ref.referenceName; const tPre = this.profileStages ? process.hrtime.bigint() : 0n; const preFilterPass = + // Quoted PostgreSQL identifiers intentionally retain their quotes in the + // reference text while node.name stores the decoded segment. Let the + // schema-aware matcher inspect object intents directly rather than + // dropping e.g. `"Status.Type"` on this generic string pre-filter. + isPostgresObjectReferenceKind(ref.referenceKind) || isNixPathImportRef(ref) || this.hasAnyPossibleMatch(existenceName) || this.matchesAnyImport(ref) || @@ -901,6 +915,14 @@ export class ReferenceResolver { return null; } + // PostgreSQL object intents are internal-only and object-class strict. + // Resolve them directly through the PostgreSQL matcher so a framework or + // import strategy can never bind a type/sequence use to a same-named node + // of another language or object class. + if (isPostgresObjectReferenceKind(ref.referenceKind)) { + return this.gateLanguage(matchReference(ref, this.context), ref); + } + // Function-as-value refs (#756) get a dedicated, strictly-gated path: // import-based resolution first (an imported callback resolves through its // import, the most precise cross-file signal), then matchFunctionRef @@ -1051,13 +1073,14 @@ export class ReferenceResolver { */ createEdges(resolved: ResolvedRef[]): Edge[] { return resolved.map((ref) => { - // `function_ref` (#756) is internal-only: it persists as a `references` - // edge (the registration site depends on the callback), distinguishable - // by metadata.resolvedBy === 'function-ref'. callers/impact already - // traverse `references`, so registration sites surface with no - // graph-layer changes. + // Internal intents persist as `references` edges. The original intent is + // retained in metadata.refKind so removal/re-index resurrection uses the + // same strict matcher rather than degrading to a relation reference. let kind: Edge['kind'] = - ref.original.referenceKind === 'function_ref' ? 'references' : ref.original.referenceKind; + ref.original.referenceKind === 'function_ref' || + isPostgresObjectReferenceKind(ref.original.referenceKind) + ? 'references' + : ref.original.referenceKind; // Promote "extends" to "implements" when a class/struct targets an interface if (kind === 'extends') { @@ -1936,6 +1959,20 @@ export class ReferenceResolver { // synthesis is additive and optional; ignore failures } if (process.env.CODEGRAPH_SYNTH_TIMINGS) console.error(`[phase-timing] callback-synthesis: ${Date.now() - tSynth}ms`); + + // PostgreSQL FK table edges are an exact, owned projection of resolved + // constraint endpoints, not a best-effort heuristic. Keep this outside the + // callback-synthesis catch so failure propagates and a caller never marks + // an index complete with stale/missing FK relationships. A state + // fingerprint makes the common unchanged path a single join and no writes. + const tPostgresFk = Date.now(); + aggregateStats.byMethod['postgres-foreign-key-synthesis'] = + await refreshPostgresForeignKeyEdges(this.queries, createYielder()); + aggregateStats.byMethod['postgres-structure-synthesis'] = + await refreshPostgresStructureEdges(this.queries, createYielder()); + if (process.env.CODEGRAPH_SYNTH_TIMINGS) { + console.error(`[phase-timing] postgres-foreign-key-synthesis: ${Date.now() - tPostgresFk}ms`); + } } finally { if (pool) await pool.destroy().catch(() => undefined); } @@ -2432,6 +2469,19 @@ export class ReferenceResolver { if (tgt && ref.language && crossesKnownFamily(tgt, ref.language)) return null; return result; } + + /** Refresh exact PostgreSQL relationship projections after resolution. */ + async refreshPostgresForeignKeys(): Promise { + const yielder = createYielder(); + return await refreshPostgresForeignKeyEdges(this.queries, yielder) + + await refreshPostgresStructureEdges(this.queries, yielder); + } + + /** Synchronous refresh used by CodeGraph.resolveReferences(). */ + refreshPostgresForeignKeysSync(): number { + return refreshPostgresForeignKeyEdgesSync(this.queries) + + refreshPostgresStructureEdgesSync(this.queries); + } } /** diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 2a1fe0d82..ef08e131e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -5,6 +5,29 @@ */ import { Language, Node } from '../types'; +import { preParsePostgresSource } from '../extraction/languages/postgres'; +import { + analyzePostgresSearchPath, + postgresOffsetAtPosition, + postgresSearchPathAtOffset as sharedPostgresSearchPathAtOffset, + type PostgresSearchPathState as SharedPostgresSearchPathState, +} from '../postgres/search-path'; +import { + isPostgresQualifiedName, + parsePostgresQualifiedName, + qualifyPostgresName, +} from '../postgres/identifiers'; +import { + analyzePostgresTemporaryRelationVisibility, + postgresTemporaryRelationVisibleAt, + type PostgresTemporaryRelationVisibilityState, +} from '../postgres/temporary-relations'; +import { + POSTGRES_SEQUENCE_REFERENCE_KIND, + POSTGRES_TYPE_REFERENCE_KIND, +} from '../postgres/reference-intent'; +import { POSTGRES_DROP_RELATION_DECORATOR } from '../postgres/relation-lifecycle'; +import { latestPostgresMigrationNodeBefore } from './postgres-rename-timeline'; import { UnresolvedRef, ResolvedRef, ResolutionContext } from './types'; /** @@ -340,6 +363,245 @@ export function matchFunctionRef( return null; } +/** + * Resolve PostgreSQL object references without the generic matcher's fuzzy or + * path-proximity fallbacks. + * + * SQL repositories routinely contain the same relation name in several + * schemas and several versions of the same object across migration files. In + * that setting, choosing the closest file (or the first suffix match) invents + * a dependency. PostgreSQL extraction therefore has a deliberately strict + * contract: + * + * - a qualified reference matches only an exact canonical qualifiedName; + * - an unqualified reference matches only the exact simple name; + * - one same-file candidate wins, otherwise the project-wide candidate must + * be unique; + * - callable references target decorated routines only; relation references + * target relation-like (`struct`) nodes only; + * - internal type/sequence intents target only their decorated PostgreSQL + * object class and later materialize as ordinary `references` edges. + * + * Unqualified references honor top-level SET/RESET search_path state (with + * PostgreSQL's default public fallback); routine-local SET search_path is + * carried on that routine's references as an ordered override. Duplicate + * historical definitions are still left unresolved until an ordered-migration + * model can distinguish them. + */ +const POSTGRES_SEARCH_PATH_CACHE = new WeakMap< + ResolutionContext, + Map +>(); +const POSTGRES_TEMPORARY_VISIBILITY_CACHE = new WeakMap< + ResolutionContext, + Map +>(); + +function postgresSearchPathState( + context: ResolutionContext, + filePath: string +): SharedPostgresSearchPathState { + let byFile = POSTGRES_SEARCH_PATH_CACHE.get(context); + if (!byFile) { + byFile = new Map(); + POSTGRES_SEARCH_PATH_CACHE.set(context, byFile); + } + const cached = byFile.get(filePath); + if (cached) return cached; + + // Resolution must analyze the exact offset-preserving source seen by the + // PostgreSQL parser. Raw psql commands and COPY FROM STDIN payloads are not + // SQL session statements and must not change search_path here after the + // extractor deliberately blanked them. + const state = analyzePostgresSearchPath( + preParsePostgresSource(context.readFile(filePath) ?? ''), + { copyPayloadsMasked: true } + ); + byFile.set(filePath, state); + return state; +} + +function postgresTemporaryVisibilityState( + context: ResolutionContext, + filePath: string, + positions: SharedPostgresSearchPathState +): PostgresTemporaryRelationVisibilityState { + let byFile = POSTGRES_TEMPORARY_VISIBILITY_CACHE.get(context); + if (!byFile) { + byFile = new Map(); + POSTGRES_TEMPORARY_VISIBILITY_CACHE.set(context, byFile); + } + const cached = byFile.get(filePath); + if (cached) return cached; + + const state = analyzePostgresTemporaryRelationVisibility( + preParsePostgresSource(context.readFile(filePath) ?? ''), + context.getNodesInFile(filePath), + positions, + true + ); + byFile.set(filePath, state); + return state; +} + +export function matchPostgresReference( + ref: UnresolvedRef, + context: ResolutionContext +): ResolvedRef | null { + if (ref.language !== 'postgres') return null; + + const isCallable = ref.referenceKind === 'calls' || ref.referenceKind === 'function_ref'; + const positions = postgresSearchPathState(context, ref.filePath); + const refOffset = postgresOffsetAtPosition(positions, ref.line, ref.column); + let temporaryVisibility: PostgresTemporaryRelationVisibilityState | null = null; + const eligible = (node: Node): boolean => { + if (node.language !== 'postgres') return false; + // `pg_temp` is a per-session alias. A temp object extracted from another + // migration file cannot safely satisfy this file's reference. + if (node.decorators?.includes('postgres:temporary') === true) { + if (node.filePath !== ref.filePath) return false; + temporaryVisibility ??= postgresTemporaryVisibilityState( + context, + ref.filePath, + positions + ); + if (!postgresTemporaryRelationVisibleAt(temporaryVisibility, node.id, refOffset)) { + return false; + } + } + if (isCallable) { + return node.kind === 'function' && ( + node.decorators?.includes('postgres:function') === true || + node.decorators?.includes('postgres:procedure') === true + ); + } + if (ref.referenceKind === POSTGRES_TYPE_REFERENCE_KIND) { + return ( + node.kind === 'enum' && node.decorators?.includes('postgres:enum') === true + ) || ( + node.kind === 'type_alias' && ( + node.decorators?.includes('postgres:type') === true || + node.decorators?.includes('postgres:domain') === true + ) + ); + } + if (ref.referenceKind === POSTGRES_SEQUENCE_REFERENCE_KIND) { + return node.kind === 'variable' && + node.decorators?.includes('postgres:sequence') === true; + } + return node.kind === 'struct'; + }; + + const parsedReference = parsePostgresQualifiedName(ref.referenceName); + const referenceSimple = parsedReference?.[parsedReference.length - 1] ?? ref.referenceName; + const isQualified = ref.referenceName.includes('::') || + isPostgresQualifiedName(ref.referenceName); + const pick = (candidates: Node[]): Node | undefined => { + // DROP is itself a lifecycle boundary. With several historical definitions + // of the same relation, its target is the latest declaration before the + // statement in this migration stream, never a declaration later in the + // same file. Apply chronology before the normal same-file preference. + const source = context.getNodeById?.(ref.fromNodeId); + if (source?.decorators?.includes(POSTGRES_DROP_RELATION_DECORATOR) === true) { + return latestPostgresMigrationNodeBefore(candidates, { + filePath: ref.filePath, + line: ref.line, + column: ref.column, + }) ?? undefined; + } + + const sameFile = candidates.filter((node) => node.filePath === ref.filePath); + if (sameFile.length === 1) return sameFile[0]; + if (sameFile.length === 0 && candidates.length === 1) return candidates[0]; + return undefined; + }; + + let target: Node | undefined; + if (isQualified) { + const candidates = context.getNodesByQualifiedName(ref.referenceName).filter(eligible); + target = pick(candidates); + } else { + let foundInSearchPath = false; + if (!isCallable) { + const temporaryName = qualifyPostgresName('pg_temp', ref.referenceName); + const temporaryCandidates = temporaryName + ? context.getNodesByQualifiedName(temporaryName).filter(eligible) + : []; + if (temporaryCandidates.length > 0) { + foundInSearchPath = true; + target = pick(temporaryCandidates); + } + } + + if (ref.candidates !== undefined) { + // A routine-local SET search_path is carried as ordered canonical + // qualified candidates. It must override (not mutate) the ambient file + // path, and the first schema containing a name shadows every later one. + // An empty list is an explicitly empty path. `$user` is a role-dependent + // barrier: once reached, choosing a later schema would invent certainty. + for (const qualified of ref.candidates) { + if (foundInSearchPath) break; + if (parsePostgresQualifiedName(qualified)?.[0] === '$user') { + foundInSearchPath = true; + break; + } + const schemaCandidates = context + .getNodesByQualifiedName(qualified) + .filter(eligible); + if (schemaCandidates.length === 0) continue; + foundInSearchPath = true; + target = pick(schemaCandidates); + break; + } + } else { + const searchPath = sharedPostgresSearchPathAtOffset(positions, refOffset); + for (const schema of searchPath.schemas) { + if (foundInSearchPath) break; + // The runtime role behind $user is unknowable from source. It shadows + // later schemas when present, so choosing a later candidate would invent + // certainty; leave the reference unresolved instead. + if (schema === '$user') { + foundInSearchPath = true; + break; + } + const qualified = qualifyPostgresName(schema, ref.referenceName); + const schemaCandidates = [ + ...(qualified ? context.getNodesByQualifiedName(qualified) : []), + ...context.getNodesByQualifiedName(`${schema}::${ref.referenceName}`), + ].filter(eligible); + if (schemaCandidates.length === 0) continue; + // The first schema containing the name shadows every later schema. If + // that schema has duplicate historical definitions, preserve ambiguity. + foundInSearchPath = true; + target = pick(schemaCandidates); + break; + } + } + + if (!target && !foundInSearchPath && ref.candidates === undefined) { + const candidates = context.getNodesByName(referenceSimple).filter(eligible); + // Never jump to a qualified schema outside the active path. A uniquely + // unqualified declaration is retained for extractor backward + // compatibility (CREATE TABLE users before schema-aware declarations). + const fallback = candidates.filter((node) => node.qualifiedName === ref.referenceName); + target = pick(fallback); + } + } + if (!target) return null; + + return { + original: ref, + targetNodeId: target.id, + confidence: isQualified ? 0.95 : 0.9, + resolvedBy: + ref.referenceKind === 'function_ref' + ? 'function-ref' + : isQualified + ? 'qualified-name' + : 'exact-match', + }; +} + /** * A function nested inside another FUNCTION is only callable from within its * container — Python, JS/TS, and every closure language scope it lexically. @@ -1171,6 +1433,7 @@ function getInferScanStates(context: ResolutionContext): Map RegExp[]): RegExp[] { @@ -2218,6 +2481,13 @@ export function matchReference( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + // PostgreSQL migration trees routinely contain duplicate object names across + // schemas and historical revisions. They must never reach the generic + // qualified-name suffix, fuzzy, or path-proximity fallbacks. + if (ref.language === 'postgres') { + return matchPostgresReference(ref, context); + } + // Function-as-value refs (#756) resolve ONLY through the dedicated matcher — // never the fuzzy/qualified fallthrough below (a wrong callback edge is // worse than none). diff --git a/src/resolution/postgres-enum-lifecycle.ts b/src/resolution/postgres-enum-lifecycle.ts new file mode 100644 index 000000000..717fb25a4 --- /dev/null +++ b/src/resolution/postgres-enum-lifecycle.ts @@ -0,0 +1,239 @@ +import type { QueryBuilder } from '../db/queries'; +import { + decodePostgresEnumValueMutationDescriptor, + decodePostgresTypeRenameDescriptor, + type PostgresEnumValueMutationDescriptor, + type PostgresTypeRenameDescriptor, +} from '../postgres/type-lifecycle'; +import type { Edge, Node } from '../types'; +import { + comparePostgresMigrationPosition, + type PostgresMigrationPosition, +} from './postgres-rename-timeline'; + +const POSTGRES_STRUCTURE_SYNTHESIZER = 'postgres-structure'; + +interface EnumState { + parent: Node; + members: Map; +} + +type EnumLifecycleEvent = + | { + kind: 'enum-declaration'; + node: Node; + position: PostgresMigrationPosition; + } + | { + kind: 'type-declaration'; + node: Node; + position: PostgresMigrationPosition; + } + | { + kind: 'type-rename'; + node: Node; + descriptor: PostgresTypeRenameDescriptor; + position: PostgresMigrationPosition; + } + | { + kind: 'value-mutation'; + node: Node; + descriptor: PostgresEnumValueMutationDescriptor; + position: PostgresMigrationPosition; + }; + +function migrationStream(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const separator = normalized.lastIndexOf('/'); + return separator < 0 ? '' : normalized.slice(0, separator); +} + +function position(node: Node): PostgresMigrationPosition { + return { + filePath: node.filePath, + line: node.startLine, + column: node.startColumn, + }; +} + +function eventRank(event: EnumLifecycleEvent): number { + if (event.kind === 'enum-declaration' || event.kind === 'type-declaration') return 0; + if (event.kind === 'value-mutation') return 1; + return 2; +} + +function nativeEnumMembers(queries: QueryBuilder, parent: Node): Map { + const members = new Map(); + for (const edge of queries.getOutgoingEdges(parent.id, ['contains'])) { + if (edge.metadata?.synthesizedBy === POSTGRES_STRUCTURE_SYNTHESIZER) continue; + const member = queries.getNodeById(edge.target); + if ( + member?.language !== 'postgres' || + member.kind !== 'enum_member' || + member.decorators?.includes('postgres:enum-value') !== true + ) continue; + members.set(member.name, member); + } + return members; +} + +function isEnumDeclaration(node: Node): boolean { + return node.language === 'postgres' && node.kind === 'enum' && + node.decorators?.includes('postgres:enum') === true; +} + +function isPlainTypeDeclaration(node: Node): boolean { + return node.language === 'postgres' && node.kind === 'type_alias' && + node.decorators?.includes('postgres:type') === true && + decodePostgresTypeRenameDescriptor(node.decorators) === null; +} + +/** + * Carry effective enum identity and membership across ordered ALTER TYPE + * renames without classifying every renamed PostgreSQL type as an enum during + * extraction. A rename becomes enum-specific only when its latest live source + * in the same migration stream is an enum state. + */ +export function postgresEnumLifecycleEdges(queries: QueryBuilder): Edge[] { + const byStream = new Map(); + const append = (node: Node, event: EnumLifecycleEvent): void => { + const stream = migrationStream(node.filePath); + const events = byStream.get(stream); + if (events) events.push(event); + else byStream.set(stream, [event]); + }; + + for (const node of queries.iterateNodesByLanguage('postgres')) { + const typeRename = decodePostgresTypeRenameDescriptor(node.decorators); + if (typeRename) { + append(node, { kind: 'type-rename', node, descriptor: typeRename, position: position(node) }); + continue; + } + const valueMutation = decodePostgresEnumValueMutationDescriptor(node.decorators); + if (valueMutation) { + append(node, { + kind: 'value-mutation', + node, + descriptor: valueMutation, + position: position(node), + }); + continue; + } + if (isEnumDeclaration(node)) { + append(node, { kind: 'enum-declaration', node, position: position(node) }); + } else if (isPlainTypeDeclaration(node)) { + append(node, { kind: 'type-declaration', node, position: position(node) }); + } + } + + const edges = new Map(); + const addEdge = (edge: Edge): void => { + edges.set(`${edge.source}\u0000${edge.target}\u0000${edge.kind}`, edge); + }; + const snapshotAlias = (state: EnumState): void => { + if (!decodePostgresTypeRenameDescriptor(state.parent.decorators)) return; + for (const [label, member] of state.members) { + addEdge({ + source: state.parent.id, + target: member.id, + kind: 'contains', + line: member.startLine, + column: member.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'enum-effective-containment', + effectiveLabel: label, + }, + }); + } + }; + + for (const events of byStream.values()) { + events.sort((left, right) => + comparePostgresMigrationPosition(left.position, right.position) || + eventRank(left) - eventRank(right) || + (left.node.id < right.node.id ? -1 : left.node.id > right.node.id ? 1 : 0) + ); + const states = new Map(); + + for (const event of events) { + if (event.kind === 'enum-declaration') { + states.set(event.node.qualifiedName, { + parent: event.node, + members: nativeEnumMembers(queries, event.node), + }); + continue; + } + if (event.kind === 'type-declaration') { + // A later non-enum declaration with the same name invalidates an older + // enum candidate; do not infer enum identity from stale history. + states.delete(event.node.qualifiedName); + continue; + } + if (event.kind === 'value-mutation') { + const state = states.get(event.descriptor.enumType); + if (!state) continue; + if (event.descriptor.mutation === 'add') { + if (!state.members.has(event.descriptor.targetValue)) { + state.members.set(event.descriptor.targetValue, event.node); + } + } else { + const sourceMember = state.members.get(event.descriptor.sourceValue); + if (sourceMember) { + // Native CREATE TYPE containment is extracted source history and is + // deliberately not deleted by this cross-file synthesizer. Record + // the state transition explicitly so callers can distinguish the + // old, still-queryable label from the effective replacement. + addEdge({ + source: sourceMember.id, + target: event.node.id, + kind: 'references', + line: event.node.startLine, + column: event.node.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'enum-value-rename', + enumType: event.descriptor.enumType, + sourceValue: event.descriptor.sourceValue, + targetValue: event.descriptor.targetValue, + }, + }); + } + state.members.delete(event.descriptor.sourceValue); + state.members.set(event.descriptor.targetValue, event.node); + } + continue; + } + + const source = states.get(event.descriptor.sourceType); + if (!source) continue; + snapshotAlias(source); + states.delete(event.descriptor.sourceType); + const target: EnumState = { + parent: event.node, + members: new Map(source.members), + }; + states.set(event.descriptor.targetType, target); + addEdge({ + source: source.parent.id, + target: event.node.id, + kind: 'references', + line: event.node.startLine, + column: event.node.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'enum-rename', + sourceType: event.descriptor.sourceType, + targetType: event.descriptor.targetType, + }, + }); + } + + for (const state of states.values()) snapshotAlias(state); + } + + return [...edges.values()]; +} diff --git a/src/resolution/postgres-foreign-key-synthesizer.ts b/src/resolution/postgres-foreign-key-synthesizer.ts new file mode 100644 index 000000000..a0c01c82a --- /dev/null +++ b/src/resolution/postgres-foreign-key-synthesizer.ts @@ -0,0 +1,432 @@ +import { createHash } from 'node:crypto'; +import type { Edge, Node } from '../types'; +import type { QueryBuilder } from '../db/queries'; +import { + POSTGRES_FOREIGN_KEY_DECORATOR, + decodePostgresForeignKeyDescriptor, + type PostgresForeignKeyDescriptor, +} from '../postgres/foreign-key'; +import { + POSTGRES_DROP_CONSTRAINT_DECORATOR, + POSTGRES_RENAME_CONSTRAINT_DECORATOR, + decodePostgresDropConstraintDescriptor, + decodePostgresRenameConstraintDescriptor, +} from '../postgres/constraint-mutation'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + decodePostgresTableRelationDescriptor, +} from '../postgres/table-relation'; +import { POSTGRES_DROP_RELATION_DECORATOR } from '../postgres/relation-lifecycle'; +import type { MaybeYield } from './cooperative-yield'; +import { PostgresRelationLifecycle } from './postgres-relation-lifecycle'; +import { + PostgresTableRenameTimeline, + comparePostgresMigrationPosition, + isPostgresMigrationPositionLater, + isPostgresTable, + type PostgresMigrationPosition, + type PostgresResolvedTableEndpoint, +} from './postgres-rename-timeline'; + +export const POSTGRES_FOREIGN_KEY_SYNTHESIZER = 'postgres-foreign-key'; +export const POSTGRES_FOREIGN_KEY_FINGERPRINT_METADATA = + 'postgres_foreign_key_state_fingerprint'; + +interface ConstraintFact extends PostgresForeignKeyDescriptor { + filePath: string; + line: number; + column: number; + originalConstraintName?: string; +} + +interface ForeignKeyGroup { + source: Node; + target: Node; + constraints: ConstraintFact[]; + line: number; +} + +interface DropConstraintEvent extends PostgresMigrationPosition { + nodeId: string; + table: string; + constraintName: string; +} + +interface RenameConstraintEvent extends PostgresMigrationPosition { + nodeId: string; + table: string; + sourceConstraint: string; + targetConstraint: string; +} + +function collectDropConstraintEvents(queries: QueryBuilder): DropConstraintEvent[] { + const events: DropConstraintEvent[] = []; + for (const fact of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_DROP_CONSTRAINT_DECORATOR + )) { + const descriptor = decodePostgresDropConstraintDescriptor(fact.decorators); + if (!descriptor) continue; + events.push({ + ...descriptor, + nodeId: fact.id, + filePath: fact.filePath, + line: fact.startLine, + column: fact.startColumn, + }); + } + return events.sort((left, right) => + comparePostgresMigrationPosition(left, right) || + (left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0) + ); +} + +function collectRenameConstraintEvents(queries: QueryBuilder): RenameConstraintEvent[] { + const events: RenameConstraintEvent[] = []; + for (const fact of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_RENAME_CONSTRAINT_DECORATOR + )) { + const descriptor = decodePostgresRenameConstraintDescriptor(fact.decorators); + if (!descriptor) continue; + events.push({ + ...descriptor, + nodeId: fact.id, + filePath: fact.filePath, + line: fact.startLine, + column: fact.startColumn, + }); + } + return events.sort((left, right) => + comparePostgresMigrationPosition(left, right) || + (left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0) + ); +} + +function canonicalConstraintName( + constraintName: string, + table: string, + position: PostgresMigrationPosition, + renames: PostgresTableRenameTimeline, + constraintRenames: readonly RenameConstraintEvent[], + through?: PostgresMigrationPosition +): string { + const finalTable = renames.canonicalName(table, position); + let current = constraintName; + for (const event of constraintRenames) { + if (!isPostgresMigrationPositionLater(event, position)) continue; + if (through && isPostgresMigrationPositionLater(event, through)) continue; + if (renames.canonicalName(event.table, event) !== finalTable) continue; + if (event.sourceConstraint === current) current = event.targetConstraint; + } + return current; +} + +function isSuppressedByLaterDrop( + descriptor: PostgresForeignKeyDescriptor, + position: PostgresMigrationPosition, + drops: readonly DropConstraintEvent[], + renames: PostgresTableRenameTimeline, + constraintRenames: readonly RenameConstraintEvent[] +): boolean { + if (!descriptor.constraintName) return false; + const finalSourceTable = renames.canonicalName(descriptor.sourceTable, position); + return drops.some((drop) => + isPostgresMigrationPositionLater(drop, position) && + renames.canonicalName(drop.table, drop) === finalSourceTable && + drop.constraintName === canonicalConstraintName( + descriptor.constraintName!, + descriptor.sourceTable, + position, + renames, + constraintRenames, + drop + ) + ); +} + +/** + * Convert resolved constraint endpoints into the relationship users expect to + * traverse: source table -> referenced table. Several constraints between the + * same pair are aggregated on one edge rather than multiplying graph paths. + */ +function collectForeignKey( + grouped: Map, + queries: QueryBuilder, + foreignKey: Node, + renames: PostgresTableRenameTimeline, + lifecycle: PostgresRelationLifecycle, + drops: readonly DropConstraintEvent[], + constraintRenames: readonly RenameConstraintEvent[] +): void { + if (!foreignKey.decorators?.includes(POSTGRES_FOREIGN_KEY_DECORATOR)) return; + const descriptor = decodePostgresForeignKeyDescriptor(foreignKey.decorators); + if (!descriptor) return; + + const position: PostgresMigrationPosition = { + filePath: foreignKey.filePath, + line: foreignKey.startLine, + column: foreignKey.startColumn, + }; + if ( + lifecycle.isDroppedAfter(descriptor.sourceTable, position) || + lifecycle.isDroppedAfter(descriptor.targetTable, position) + ) return; + if (isSuppressedByLaterDrop( + descriptor, + position, + drops, + renames, + constraintRenames + )) return; + + const endpoints = queries + .getOutgoingEdges(foreignKey.id, ['references']) + .map((edge) => ({ + node: queries.getNodeById(edge.target), + refName: typeof edge.metadata?.refName === 'string' ? edge.metadata.refName : undefined, + })) + .filter((endpoint): endpoint is PostgresResolvedTableEndpoint => + isPostgresTable(endpoint.node) + ); + const source = lifecycle.canonicalTableEndpoint(endpoints, descriptor.sourceTable, position); + const target = lifecycle.canonicalTableEndpoint(endpoints, descriptor.targetTable, position); + if (!source || !target) return; + + const key = `${source.id}\u0000${target.id}`; + const finalConstraintName = descriptor.constraintName + ? canonicalConstraintName( + descriptor.constraintName, + descriptor.sourceTable, + position, + renames, + constraintRenames + ) + : undefined; + const fact: ConstraintFact = { + ...descriptor, + ...(finalConstraintName ? { constraintName: finalConstraintName } : {}), + ...(descriptor.constraintName && finalConstraintName !== descriptor.constraintName + ? { originalConstraintName: descriptor.constraintName } + : {}), + filePath: foreignKey.filePath, + line: foreignKey.startLine, + column: foreignKey.startColumn, + }; + const existing = grouped.get(key); + if (existing) { + existing.constraints.push(fact); + existing.line = Math.min(existing.line, foreignKey.startLine); + } else { + grouped.set(key, { + source, + target, + constraints: [fact], + line: foreignKey.startLine, + }); + } +} + +function materializeForeignKeyEdges(grouped: Map): Edge[] { + return [...grouped.values()].map(({ source, target, constraints, line }) => ({ + source: source.id, + target: target.id, + kind: 'references', + line, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_FOREIGN_KEY_SYNTHESIZER, + postgresRelation: 'foreign-key', + constraints: constraints.sort((a, b) => + comparePostgresMigrationPosition(a, b) + ), + }, + })); +} + +function postgresForeignKeyStateFingerprint( + queries: QueryBuilder +): { fingerprint: string; factCount: number } { + const hash = createHash('sha256'); + let factCount = 0; + let previousNodeId: string | null = null; + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_FOREIGN_KEY_DECORATOR + )) { + if (row.nodeId !== previousNodeId) { + factCount++; + previousNodeId = row.nodeId; + } + hash.update(JSON.stringify([ + row.nodeId, + row.decorators, + row.targetId, + row.edgeMetadata, + row.targetQualifiedName, + row.targetKind, + row.targetLanguage, + row.targetDecorators, + ])); + hash.update('\n'); + } + // Rename facts and their exact alias candidates affect the projected FK + // endpoints even though neither is itself an FK fact. + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_TABLE_RELATION_DECORATOR + )) { + let descriptor = null; + try { + descriptor = decodePostgresTableRelationDescriptor(JSON.parse(row.decorators)); + } catch { + // Malformed decorators are ignored by materialization too. + } + if (descriptor?.relation !== 'rename') continue; + hash.update(JSON.stringify(['rename', row])); + hash.update('\n'); + } + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_DROP_CONSTRAINT_DECORATOR + )) { + hash.update(JSON.stringify(['drop-constraint', row])); + hash.update('\n'); + } + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_RENAME_CONSTRAINT_DECORATOR + )) { + hash.update(JSON.stringify(['rename-constraint', row])); + hash.update('\n'); + } + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_DROP_RELATION_DECORATOR + )) { + hash.update(JSON.stringify(['drop-relation', row])); + hash.update('\n'); + } + for (const node of queries.iterateNodesByLanguage('postgres')) { + if (!isPostgresTable(node)) continue; + hash.update(JSON.stringify([ + 'table', node.id, node.qualifiedName, node.filePath, node.startLine, + node.startColumn, node.decorators, + ])); + hash.update('\n'); + } + return { fingerprint: `v3:${hash.digest('hex')}`, factCount }; +} + +function postgresForeignKeyOutputFingerprint( + queries: QueryBuilder +): { fingerprint: string; edgeCount: number } { + const hash = createHash('sha256'); + let edgeCount = 0; + for (const edge of queries.iterateEdgesBySynthesizer(POSTGRES_FOREIGN_KEY_SYNTHESIZER)) { + edgeCount++; + hash.update(JSON.stringify([ + edge.source, + edge.target, + edge.kind, + edge.metadata, + edge.line, + edge.col, + edge.provenance, + ])); + hash.update('\n'); + } + return { fingerprint: `v1:${hash.digest('hex')}`, edgeCount }; +} + +function persistedFingerprint(input: string, output: string): string { + return `input=${input};output=${output}`; +} + +function persistCurrentFingerprint( + queries: QueryBuilder, + input: { fingerprint: string; factCount: number } +): void { + const output = postgresForeignKeyOutputFingerprint(queries); + if (input.factCount === 0 && output.edgeCount === 0) { + queries.deleteMetadata(POSTGRES_FOREIGN_KEY_FINGERPRINT_METADATA); + } else { + queries.setMetadata( + POSTGRES_FOREIGN_KEY_FINGERPRINT_METADATA, + persistedFingerprint(input.fingerprint, output.fingerprint) + ); + } +} + +export function postgresForeignKeyEdgesSync(queries: QueryBuilder): Edge[] { + const grouped = new Map(); + const renames = new PostgresTableRenameTimeline(queries); + const lifecycle = new PostgresRelationLifecycle(queries, renames); + const drops = collectDropConstraintEvents(queries); + const constraintRenames = collectRenameConstraintEvents(queries); + for (const foreignKey of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_FOREIGN_KEY_DECORATOR + )) { + collectForeignKey( + grouped, queries, foreignKey, renames, lifecycle, drops, constraintRenames + ); + } + return materializeForeignKeyEdges(grouped); +} + +export async function postgresForeignKeyEdges( + queries: QueryBuilder, + onYield: MaybeYield +): Promise { + const grouped = new Map(); + const renames = new PostgresTableRenameTimeline(queries); + const lifecycle = new PostgresRelationLifecycle(queries, renames); + const drops = collectDropConstraintEvents(queries); + const constraintRenames = collectRenameConstraintEvents(queries); + let scanned = 0; + for (const foreignKey of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_FOREIGN_KEY_DECORATOR + )) { + if ((++scanned & 127) === 0) await onYield(); + collectForeignKey( + grouped, queries, foreignKey, renames, lifecycle, drops, constraintRenames + ); + } + + return materializeForeignKeyEdges(grouped); +} + +/** Atomically refresh the owned edge set after facts are resolved. */ +export async function refreshPostgresForeignKeyEdges( + queries: QueryBuilder, + onYield: MaybeYield +): Promise { + const state = postgresForeignKeyStateFingerprint(queries); + const output = postgresForeignKeyOutputFingerprint(queries); + const previous = queries.getMetadata(POSTGRES_FOREIGN_KEY_FINGERPRINT_METADATA); + if (state.factCount === 0 && output.edgeCount === 0 && previous === null) return 0; + if (previous === persistedFingerprint(state.fingerprint, output.fingerprint)) return 0; + + // Compute first so a parser/query failure leaves the last complete edge set. + const edges = await postgresForeignKeyEdges(queries, onYield); + queries.replaceEdgesBySynthesizer(POSTGRES_FOREIGN_KEY_SYNTHESIZER, edges); + persistCurrentFingerprint(queries, state); + await onYield(); + return edges.length; +} + +/** Synchronous counterpart for the public synchronous resolution API. */ +export function refreshPostgresForeignKeyEdgesSync(queries: QueryBuilder): number { + const state = postgresForeignKeyStateFingerprint(queries); + const output = postgresForeignKeyOutputFingerprint(queries); + const previous = queries.getMetadata(POSTGRES_FOREIGN_KEY_FINGERPRINT_METADATA); + if (state.factCount === 0 && output.edgeCount === 0 && previous === null) return 0; + if (previous === persistedFingerprint(state.fingerprint, output.fingerprint)) return 0; + + const edges = postgresForeignKeyEdgesSync(queries); + queries.replaceEdgesBySynthesizer(POSTGRES_FOREIGN_KEY_SYNTHESIZER, edges); + persistCurrentFingerprint(queries, state); + return edges.length; +} diff --git a/src/resolution/postgres-relation-lifecycle.ts b/src/resolution/postgres-relation-lifecycle.ts new file mode 100644 index 000000000..7239b800f --- /dev/null +++ b/src/resolution/postgres-relation-lifecycle.ts @@ -0,0 +1,119 @@ +import type { QueryBuilder } from '../db/queries'; +import { + POSTGRES_DROP_RELATION_DECORATOR, + decodePostgresDropRelationDescriptor, + type PostgresDroppedRelationKind, +} from '../postgres/relation-lifecycle'; +import { + PostgresTableRenameTimeline, + comparePostgresMigrationPosition, + isPostgresMigrationPositionLater, + type PostgresMigrationPosition, + type PostgresResolvedTableEndpoint, +} from './postgres-rename-timeline'; +import type { Node } from '../types'; + +export interface PostgresDropRelationEvent extends PostgresMigrationPosition { + nodeId: string; + relationName: string; + relationKind: PostgresDroppedRelationKind; +} + +/** Ordered DROP facts for deciding whether an older migration relation is live. */ +export class PostgresRelationLifecycle { + readonly drops: readonly PostgresDropRelationEvent[]; + + constructor( + queries: QueryBuilder, + private readonly renames: PostgresTableRenameTimeline + ) { + const drops: PostgresDropRelationEvent[] = []; + for (const fact of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_DROP_RELATION_DECORATOR + )) { + const descriptor = decodePostgresDropRelationDescriptor(fact.decorators); + if (!descriptor) continue; + drops.push({ + ...descriptor, + nodeId: fact.id, + filePath: fact.filePath, + line: fact.startLine, + column: fact.startColumn, + }); + } + drops.sort((left, right) => + comparePostgresMigrationPosition(left, right) || + (left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0) + ); + this.drops = drops; + } + + /** True when a later DROP invalidates a relation fact at this position. */ + isDroppedAfter( + relationName: string, + position: PostgresMigrationPosition, + acceptedKinds: readonly PostgresDroppedRelationKind[] = ['table', 'foreign-table'] + ): boolean { + const canonicalRelation = this.renames.canonicalName(relationName, position); + return this.drops.some((drop) => + acceptedKinds.includes(drop.relationKind) && + isPostgresMigrationPositionLater(drop, position) && + this.renames.canonicalName(drop.relationName, drop) === canonicalRelation + ); + } + + /** + * Resolve a table endpoint at a migration position. When a name has been + * dropped and recreated, the generic resolver sees duplicate qualified names + * and leaves the fact unresolved; the DROP boundary makes the newest + * declaration after it unambiguous. + */ + canonicalTableEndpoint( + endpoints: readonly PostgresResolvedTableEndpoint[], + relationName: string, + position: PostgresMigrationPosition + ): Node | null { + const canonicalRelation = this.renames.canonicalName(relationName, position); + const resolved = this.renames.canonicalEndpoint(endpoints, relationName, position); + if (canonicalRelation !== relationName) return resolved; + + let latestDrop: PostgresDropRelationEvent | null = null; + for (const drop of this.drops) { + if (drop.relationKind !== 'table' && drop.relationKind !== 'foreign-table') continue; + if (!isPostgresMigrationPositionLater(position, drop)) continue; + if (this.renames.canonicalName(drop.relationName, drop) !== canonicalRelation) continue; + if (!latestDrop || comparePostgresMigrationPosition(drop, latestDrop) > 0) { + latestDrop = drop; + } + } + if (!latestDrop) return resolved; + return this.renames.latestExactTableBefore(canonicalRelation, position, latestDrop); + } + + /** + * Resolve the source side of a relation rename at that statement's position. + * A DROP followed by a same-named CREATE starts a new relation lifetime, so + * an otherwise ambiguous old-name reference belongs to the newest exact + * declaration after the DROP. + */ + renameSourceEndpoint( + endpoints: readonly PostgresResolvedTableEndpoint[], + relationName: string, + position: PostgresMigrationPosition + ): Node | null { + const resolved = this.renames.immediateRelationEndpoint(endpoints, relationName); + let latestDrop: PostgresDropRelationEvent | null = null; + for (const drop of this.drops) { + if (!isPostgresMigrationPositionLater(position, drop)) continue; + // Compare the name as it existed at the DROP. Projecting it through this + // rename would turn the source into the target and hide the boundary. + if (drop.relationName !== relationName) continue; + if (!latestDrop || comparePostgresMigrationPosition(drop, latestDrop) > 0) { + latestDrop = drop; + } + } + if (!latestDrop) return resolved; + return this.renames.latestExactRelationBefore(relationName, position, latestDrop); + } +} diff --git a/src/resolution/postgres-rename-timeline.ts b/src/resolution/postgres-rename-timeline.ts new file mode 100644 index 000000000..fbf6dd3fe --- /dev/null +++ b/src/resolution/postgres-rename-timeline.ts @@ -0,0 +1,269 @@ +import type { QueryBuilder } from '../db/queries'; +import type { Node } from '../types'; +import { isPostgresQualifiedName, parsePostgresQualifiedName } from '../postgres/identifiers'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + decodePostgresTableRelationDescriptor, +} from '../postgres/table-relation'; + +export interface PostgresMigrationPosition { + filePath: string; + line: number; + column: number; +} + +export interface PostgresResolvedTableEndpoint { + node: Node; + refName: string | undefined; +} + +export interface PostgresTableRenameEvent extends PostgresMigrationPosition { + nodeId: string; + sourceTable: string; + targetTable: string; +} + +/** + * CodeGraph has no database-migration executor, so file ordering is the only + * deterministic chronology available to the synthesizers. PostgreSQL facts + * are ordered by path, then their source position within that file. + */ +export function comparePostgresMigrationPosition( + left: PostgresMigrationPosition, + right: PostgresMigrationPosition +): number { + const pathOrder = left.filePath < right.filePath ? -1 : left.filePath > right.filePath ? 1 : 0; + return pathOrder || + left.line - right.line || + left.column - right.column; +} + +function postgresMigrationStream(filePath: string): string { + const normalized = filePath.replace(/\\/g, '/'); + const separator = normalized.lastIndexOf('/'); + return separator < 0 ? '' : normalized.slice(0, separator); +} + +export function isPostgresMigrationPositionLater( + candidate: PostgresMigrationPosition, + reference: PostgresMigrationPosition +): boolean { + // Separate directories are separate deployment streams unless a future + // project configuration explicitly says otherwise. Inferring chronology + // across e.g. migrations/, fixtures/, snapshots/, and tests/ would silently + // rewrite or suppress otherwise valid graph facts. + if (postgresMigrationStream(candidate.filePath) !== postgresMigrationStream(reference.filePath)) { + return false; + } + return comparePostgresMigrationPosition(candidate, reference) > 0; +} + +export function isPostgresTable(node: Node | null): node is Node { + return node?.language === 'postgres' && node.kind === 'struct' && ( + node.decorators?.includes('postgres:table') === true || + node.decorators?.includes('postgres:foreign-table') === true + ); +} + +export function isPostgresRelation(node: Node | null): node is Node { + return node?.language === 'postgres' && node.kind === 'struct' && ( + node.decorators?.includes('postgres:table') === true || + node.decorators?.includes('postgres:foreign-table') === true || + node.decorators?.includes('postgres:view') === true || + node.decorators?.includes('postgres:materialized-view') === true + ); +} + +function endpointFromFact( + endpoints: readonly PostgresResolvedTableEndpoint[], + name: string +): Node | null { + // refName preserves the role when both endpoint tables have the same simple + // name in different schemas. Fall back to node identity for older edges that + // predate refName metadata. + let matches = endpoints + .filter((endpoint) => endpoint.refName === name) + .map((endpoint) => endpoint.node); + if (matches.length === 0) { + const qualified = isPostgresQualifiedName(name); + const parsed = parsePostgresQualifiedName(name); + const simple = parsed?.[parsed.length - 1] ?? name; + matches = endpoints + .map((endpoint) => endpoint.node) + .filter((node) => qualified ? node.qualifiedName === name : node.name === simple); + } + const unique = new Map(matches.map((node) => [node.id, node])); + return unique.size === 1 ? unique.values().next().value ?? null : null; +} + +function nodePosition(node: Node): PostgresMigrationPosition { + return { + filePath: node.filePath, + line: node.startLine, + column: node.startColumn, + }; +} + +/** + * Pick the latest declaration before a migration fact, optionally bounded by a + * lifecycle event such as DROP. Candidates in other migration directories are + * deliberately ignored because their relative deployment order is unknown. + */ +export function latestPostgresMigrationNodeBefore( + candidates: readonly Node[], + position: PostgresMigrationPosition, + after?: PostgresMigrationPosition +): Node | null { + const preceding = candidates + .filter((candidate) => { + const candidatePosition = nodePosition(candidate); + return isPostgresMigrationPositionLater(position, candidatePosition) && + (!after || isPostgresMigrationPositionLater(candidatePosition, after)); + }) + .sort((left, right) => comparePostgresMigrationPosition( + nodePosition(right), + nodePosition(left) + )); + const latest = preceding[0]; + if (!latest) return null; + const latestPosition = nodePosition(latest); + return preceding.filter((candidate) => + comparePostgresMigrationPosition(nodePosition(candidate), latestPosition) === 0 + ).length === 1 + ? latest + : null; +} + +/** + * Ordered table rename facts plus safe exact-name lookup for their aliases. + * A lookup is usable only when exactly one PostgreSQL table owns the complete + * qualified name; the synthesizers never guess between migration versions. + */ +export class PostgresTableRenameTimeline { + readonly events: readonly PostgresTableRenameEvent[]; + private readonly exactTableCache = new Map(); + private readonly exactRelationCache = new Map(); + + constructor(private readonly queries: QueryBuilder) { + const events: PostgresTableRenameEvent[] = []; + for (const fact of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_TABLE_RELATION_DECORATOR + )) { + const descriptor = decodePostgresTableRelationDescriptor(fact.decorators); + if (descriptor?.relation !== 'rename') continue; + events.push({ + nodeId: fact.id, + sourceTable: descriptor.sourceTable, + targetTable: descriptor.targetTable, + filePath: fact.filePath, + line: fact.startLine, + column: fact.startColumn, + }); + } + events.sort((left, right) => + comparePostgresMigrationPosition(left, right) || + (left.nodeId < right.nodeId ? -1 : left.nodeId > right.nodeId ? 1 : 0) + ); + this.events = events; + } + + /** Follow only rename statements later than the fact being projected. */ + canonicalName(name: string, factPosition: PostgresMigrationPosition): string { + let current = name; + for (const event of this.events) { + if (!isPostgresMigrationPositionLater(event, factPosition)) continue; + if (event.sourceTable === current) current = event.targetTable; + } + return current; + } + + /** Resolve a rename's immediate old/new endpoints; do not collapse its edge. */ + immediateEndpoint( + endpoints: readonly PostgresResolvedTableEndpoint[], + name: string + ): Node | null { + return endpointFromFact(endpoints, name) ?? this.uniqueExactTable(name); + } + + /** Resolve a table/view rename without allowing relation-like nodes into FK matching. */ + immediateRelationEndpoint( + endpoints: readonly PostgresResolvedTableEndpoint[], + name: string + ): Node | null { + return endpointFromFact(endpoints, name) ?? this.uniqueExactRelation(name); + } + + /** + * Resolve a normal fact endpoint, then project it through subsequent rename + * statements. The exact global lookup is needed because a rename fact often + * resolves only its old reference; the new alias node is nevertheless in the + * graph. Ambiguous/missing aliases suppress the projection rather than + * retaining a stale edge to a name the migration has replaced. + */ + canonicalEndpoint( + endpoints: readonly PostgresResolvedTableEndpoint[], + name: string, + factPosition: PostgresMigrationPosition + ): Node | null { + const original = endpointFromFact(endpoints, name); + const canonicalName = this.canonicalName(name, factPosition); + if (canonicalName === name) return original; + return endpointFromFact(endpoints, canonicalName) ?? + this.uniqueExactTable(canonicalName); + } + + /** Resolve the newest exact table declaration inside a lifecycle window. */ + latestExactTableBefore( + qualifiedName: string, + position: PostgresMigrationPosition, + after?: PostgresMigrationPosition + ): Node | null { + return latestPostgresMigrationNodeBefore( + this.queries + .getNodesByQualifiedNameExact(qualifiedName) + .filter((node) => isPostgresTable(node)), + position, + after + ); + } + + /** Resolve the newest exact table/view declaration inside a lifecycle window. */ + latestExactRelationBefore( + qualifiedName: string, + position: PostgresMigrationPosition, + after?: PostgresMigrationPosition + ): Node | null { + return latestPostgresMigrationNodeBefore( + this.queries + .getNodesByQualifiedNameExact(qualifiedName) + .filter((node) => isPostgresRelation(node)), + position, + after + ); + } + + private uniqueExactTable(qualifiedName: string): Node | null { + if (this.exactTableCache.has(qualifiedName)) { + return this.exactTableCache.get(qualifiedName) ?? null; + } + const candidates = this.queries + .getNodesByQualifiedNameExact(qualifiedName) + .filter((node) => isPostgresTable(node)); + const table = candidates.length === 1 ? candidates[0]! : null; + this.exactTableCache.set(qualifiedName, table); + return table; + } + + private uniqueExactRelation(qualifiedName: string): Node | null { + if (this.exactRelationCache.has(qualifiedName)) { + return this.exactRelationCache.get(qualifiedName) ?? null; + } + const candidates = this.queries + .getNodesByQualifiedNameExact(qualifiedName) + .filter((node) => isPostgresRelation(node)); + const relation = candidates.length === 1 ? candidates[0]! : null; + this.exactRelationCache.set(qualifiedName, relation); + return relation; + } +} diff --git a/src/resolution/postgres-structure-synthesizer.ts b/src/resolution/postgres-structure-synthesizer.ts new file mode 100644 index 000000000..5a739b60d --- /dev/null +++ b/src/resolution/postgres-structure-synthesizer.ts @@ -0,0 +1,383 @@ +import { createHash } from 'node:crypto'; +import type { Edge, Node } from '../types'; +import type { QueryBuilder } from '../db/queries'; +import { + parsePostgresQualifiedName, + serializePostgresQualifiedName, +} from '../postgres/identifiers'; +import { + POSTGRES_TABLE_RELATION_DECORATOR, + decodePostgresTableRelationDescriptor, + type PostgresTableRelationDescriptor, +} from '../postgres/table-relation'; +import { POSTGRES_DROP_RELATION_DECORATOR } from '../postgres/relation-lifecycle'; +import type { MaybeYield } from './cooperative-yield'; +import { postgresEnumLifecycleEdges } from './postgres-enum-lifecycle'; +import { PostgresRelationLifecycle } from './postgres-relation-lifecycle'; +import { + PostgresTableRenameTimeline, + comparePostgresMigrationPosition, + isPostgresMigrationPositionLater, + isPostgresRelation, + isPostgresTable, + type PostgresMigrationPosition, + type PostgresResolvedTableEndpoint, +} from './postgres-rename-timeline'; + +export const POSTGRES_STRUCTURE_SYNTHESIZER = 'postgres-structure'; +const POSTGRES_STRUCTURE_FINGERPRINT_METADATA = 'postgres_structure_state_fingerprint'; + +interface RelationFact extends PostgresTableRelationDescriptor { + filePath: string; + line: number; + column: number; +} + +const SCHEMA_OBJECT_DECORATORS = new Set([ + 'postgres:table', + 'postgres:foreign-table', + 'postgres:view', + 'postgres:materialized-view', + 'postgres:function', + 'postgres:procedure', + 'postgres:type', + 'postgres:enum', + 'postgres:domain', + 'postgres:sequence', + 'postgres:index', +]); + +function relationEdges( + queries: QueryBuilder, + renames: PostgresTableRenameTimeline, + lifecycle: PostgresRelationLifecycle +): Edge[] { + const edges: Edge[] = []; + for (const factNode of queries.iterateNodesByLanguageWithDecorator( + 'postgres', + POSTGRES_TABLE_RELATION_DECORATOR + )) { + const descriptor = decodePostgresTableRelationDescriptor(factNode.decorators); + if (!descriptor) continue; + const endpoints = queries + .getOutgoingEdges(factNode.id, ['references']) + .map((edge) => ({ + node: queries.getNodeById(edge.target), + refName: typeof edge.metadata?.refName === 'string' ? edge.metadata.refName : undefined, + })) + .filter((endpoint): endpoint is PostgresResolvedTableEndpoint => + descriptor.relation === 'rename' + ? isPostgresRelation(endpoint.node) + : isPostgresTable(endpoint.node) + ); + const position: PostgresMigrationPosition = { + filePath: factNode.filePath, + line: factNode.startLine, + column: factNode.startColumn, + }; + if ( + descriptor.relation !== 'rename' && ( + lifecycle.isDroppedAfter(descriptor.sourceTable, position) || + lifecycle.isDroppedAfter(descriptor.targetTable, position) + ) + ) continue; + // A rename edge records the immediate transition. All other structural + // facts are projected through rename statements that occur after them. + const source = descriptor.relation === 'rename' + ? lifecycle.renameSourceEndpoint(endpoints, descriptor.sourceTable, position) + : lifecycle.canonicalTableEndpoint(endpoints, descriptor.sourceTable, position); + const target = descriptor.relation === 'rename' + ? renames.immediateRelationEndpoint(endpoints, descriptor.targetTable) + : lifecycle.canonicalTableEndpoint(endpoints, descriptor.targetTable, position); + if (!source || !target) continue; + const fact: RelationFact = { + ...descriptor, + filePath: factNode.filePath, + line: factNode.startLine, + column: factNode.startColumn, + }; + edges.push({ + source: source.id, + target: target.id, + kind: 'references', + line: factNode.startLine, + column: factNode.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: descriptor.relation, + facts: [fact], + }, + }); + } + return edges; +} + +function isSchemaObject(node: Node): boolean { + return node.decorators?.some((decorator) => SCHEMA_OBJECT_DECORATORS.has(decorator)) === true && + node.decorators?.includes('postgres:temporary') !== true; +} + +function isEnum(node: Node): boolean { + return node.language === 'postgres' && node.kind === 'enum' && + node.decorators?.includes('postgres:enum') === true; +} + +function isEnumMember(node: Node): boolean { + return node.language === 'postgres' && node.kind === 'enum_member' && + node.decorators?.includes('postgres:enum-value') === true; +} + +function nativeContainmentTargets( + queries: QueryBuilder, + cache: Map>, + parent: Node +): Set { + let targets = cache.get(parent.id); + if (!targets) { + targets = new Set( + queries.getOutgoingEdges(parent.id, ['contains']) + .filter((edge) => edge.metadata?.synthesizedBy !== POSTGRES_STRUCTURE_SYNTHESIZER) + .map((edge) => edge.target) + ); + cache.set(parent.id, targets); + } + return targets; +} + +function schemaContainmentEdges(queries: QueryBuilder): Edge[] { + const schemas = new Map(); + const nativeContainment = new Map>(); + for (const node of queries.iterateNodesByLanguage('postgres')) { + if (node.kind !== 'namespace' || node.decorators?.includes('postgres:schema') !== true) continue; + const existing = schemas.get(node.qualifiedName); + if (existing) existing.push(node); + else schemas.set(node.qualifiedName, [node]); + } + + const edges: Edge[] = []; + for (const object of queries.iterateNodesByLanguage('postgres')) { + if (!isSchemaObject(object)) continue; + const parts = parsePostgresQualifiedName(object.qualifiedName); + if (!parts || parts.length < 2 || parts[0] === 'pg_temp') continue; + const schemaName = serializePostgresQualifiedName([parts[0]!]); + const candidates = schemas.get(schemaName) ?? []; + const sameFile = candidates.filter((schema) => schema.filePath === object.filePath); + const schema = sameFile.length === 1 + ? sameFile[0] + : sameFile.length === 0 && candidates.length === 1 + ? candidates[0] + : undefined; + if (!schema) continue; + const nativeTargets = nativeContainmentTargets(queries, nativeContainment, schema); + if (nativeTargets.has(object.id)) continue; + edges.push({ + source: schema.id, + target: object.id, + kind: 'contains', + line: object.startLine, + column: object.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'schema-containment', + }, + }); + } + return edges; +} + +function enumContainmentEdges(queries: QueryBuilder): Edge[] { + const enums = new Map(); + const nativeContainment = new Map>(); + for (const node of queries.iterateNodesByLanguage('postgres')) { + if (!isEnum(node)) continue; + const existing = enums.get(node.qualifiedName); + if (existing) existing.push(node); + else enums.set(node.qualifiedName, [node]); + } + + const edges: Edge[] = []; + for (const member of queries.iterateNodesByLanguage('postgres')) { + if (!isEnumMember(member)) continue; + const parts = parsePostgresQualifiedName(member.qualifiedName); + if (!parts || parts.length < 2) continue; + const parentName = serializePostgresQualifiedName(parts.slice(0, -1)); + const candidates = enums.get(parentName) ?? []; + const memberPosition: PostgresMigrationPosition = { + filePath: member.filePath, + line: member.startLine, + column: member.startColumn, + }; + // ALTER TYPE values belong to the most recent preceding declaration in + // the same migration stream. This disambiguates a later DROP/re-CREATE of + // the same enum without attaching an older value to that future version. + const preceding = candidates + .filter((candidate) => isPostgresMigrationPositionLater(memberPosition, { + filePath: candidate.filePath, + line: candidate.startLine, + column: candidate.startColumn, + })) + .sort((left, right) => comparePostgresMigrationPosition({ + filePath: right.filePath, + line: right.startLine, + column: right.startColumn, + }, { + filePath: left.filePath, + line: left.startLine, + column: left.startColumn, + })); + const latest = preceding[0]; + const latestPosition = latest && { + filePath: latest.filePath, + line: latest.startLine, + column: latest.startColumn, + }; + const latestCandidates = latestPosition + ? preceding.filter((candidate) => comparePostgresMigrationPosition({ + filePath: candidate.filePath, + line: candidate.startLine, + column: candidate.startColumn, + }, latestPosition) === 0) + : []; + const sameFile = candidates.filter((candidate) => candidate.filePath === member.filePath); + const parent = latestCandidates.length === 1 + ? latestCandidates[0] + : preceding.length === 0 && sameFile.length === 1 + ? sameFile[0] + : preceding.length === 0 && sameFile.length === 0 && candidates.length === 1 + ? candidates[0] + : undefined; + if (!parent) continue; + if (nativeContainmentTargets(queries, nativeContainment, parent).has(member.id)) continue; + edges.push({ + source: parent.id, + target: member.id, + kind: 'contains', + line: member.startLine, + column: member.startColumn, + provenance: 'tree-sitter', + metadata: { + synthesizedBy: POSTGRES_STRUCTURE_SYNTHESIZER, + postgresRelation: 'enum-containment', + }, + }); + } + return edges; +} + +function materializePostgresStructureEdges(queries: QueryBuilder): Edge[] { + const renames = new PostgresTableRenameTimeline(queries); + const lifecycle = new PostgresRelationLifecycle(queries, renames); + return [ + ...relationEdges(queries, renames, lifecycle), + ...schemaContainmentEdges(queries), + ...enumContainmentEdges(queries), + ...postgresEnumLifecycleEdges(queries), + ]; +} + +function structureInputFingerprint( + queries: QueryBuilder +): { fingerprint: string; inputCount: number } { + const hash = createHash('sha256'); + let inputCount = 0; + let previousFact: string | null = null; + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_TABLE_RELATION_DECORATOR + )) { + if (row.nodeId !== previousFact) { + inputCount++; + previousFact = row.nodeId; + } + hash.update(JSON.stringify(['fact', row])); + hash.update('\n'); + } + for (const row of queries.iterateDecoratorReferenceState( + 'postgres', + POSTGRES_DROP_RELATION_DECORATOR + )) { + inputCount++; + hash.update(JSON.stringify(['drop-relation', row])); + hash.update('\n'); + } + for (const node of queries.iterateNodesByLanguage('postgres')) { + if (node.kind !== 'namespace' && !isSchemaObject(node) && !isEnumMember(node)) continue; + inputCount++; + hash.update(JSON.stringify([ + 'node', node.id, node.kind, node.qualifiedName, node.filePath, node.decorators, + ])); + hash.update('\n'); + if (node.kind === 'namespace' || isEnum(node)) { + for (const edge of queries.getOutgoingEdges(node.id, ['contains'])) { + if (edge.metadata?.synthesizedBy === POSTGRES_STRUCTURE_SYNTHESIZER) continue; + hash.update(JSON.stringify([ + 'native-containment', edge.source, edge.target, edge.line, edge.column, + ])); + hash.update('\n'); + } + } + } + return { fingerprint: `v3:${hash.digest('hex')}`, inputCount }; +} + +function structureOutputFingerprint( + queries: QueryBuilder +): { fingerprint: string; edgeCount: number } { + const hash = createHash('sha256'); + let edgeCount = 0; + for (const edge of queries.iterateEdgesBySynthesizer(POSTGRES_STRUCTURE_SYNTHESIZER)) { + edgeCount++; + hash.update(JSON.stringify(edge)); + hash.update('\n'); + } + return { fingerprint: `v1:${hash.digest('hex')}`, edgeCount }; +} + +function persistedFingerprint(input: string, output: string): string { + return `input=${input};output=${output}`; +} + +function persistFingerprint( + queries: QueryBuilder, + input: { fingerprint: string; inputCount: number } +): void { + const output = structureOutputFingerprint(queries); + if (input.inputCount === 0 && output.edgeCount === 0) { + queries.deleteMetadata(POSTGRES_STRUCTURE_FINGERPRINT_METADATA); + } else { + queries.setMetadata( + POSTGRES_STRUCTURE_FINGERPRINT_METADATA, + persistedFingerprint(input.fingerprint, output.fingerprint) + ); + } +} + +export async function refreshPostgresStructureEdges( + queries: QueryBuilder, + onYield: MaybeYield +): Promise { + const input = structureInputFingerprint(queries); + const output = structureOutputFingerprint(queries); + const previous = queries.getMetadata(POSTGRES_STRUCTURE_FINGERPRINT_METADATA); + if (input.inputCount === 0 && output.edgeCount === 0 && previous === null) return 0; + if (previous === persistedFingerprint(input.fingerprint, output.fingerprint)) return 0; + const edges = materializePostgresStructureEdges(queries); + await onYield(); + queries.replaceEdgesBySynthesizer(POSTGRES_STRUCTURE_SYNTHESIZER, edges); + persistFingerprint(queries, input); + return edges.length; +} + +export function refreshPostgresStructureEdgesSync(queries: QueryBuilder): number { + const input = structureInputFingerprint(queries); + const output = structureOutputFingerprint(queries); + const previous = queries.getMetadata(POSTGRES_STRUCTURE_FINGERPRINT_METADATA); + if (input.inputCount === 0 && output.edgeCount === 0 && previous === null) return 0; + if (previous === persistedFingerprint(input.fingerprint, output.fingerprint)) return 0; + const edges = materializePostgresStructureEdges(queries); + queries.replaceEdgesBySynthesizer(POSTGRES_STRUCTURE_SYNTHESIZER, edges); + persistFingerprint(queries, input); + return edges.length; +} diff --git a/src/types.ts b/src/types.ts index 5b0e407c5..872055494 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,7 @@ +import type { PostgresObjectReferenceKind } from './postgres/reference-intent'; + +export type { PostgresObjectReferenceKind } from './postgres/reference-intent'; + /** * CodeGraph Type Definitions * @@ -116,6 +120,7 @@ export const LANGUAGES = [ 'vbnet', 'erlang', 'terraform', + 'postgres', 'unknown', ] as const; @@ -319,12 +324,13 @@ export interface ExtractionError { } /** - * Kinds an unresolved reference can carry. `function_ref` is internal-only — - * a function name used as a VALUE (callback registration, #756). It never - * becomes an edge kind: resolution maps it to a `references` edge targeting - * function/method nodes only (see `matchFunctionRef`). + * Kinds an unresolved reference can carry. Internal-only intents never become + * edge kinds: resolution maps them to `references` after applying stricter + * target eligibility than an ordinary reference. `function_ref` targets only + * function/method nodes; PostgreSQL object intents retain exact object-class, + * quoted-name, and search_path semantics. */ -export type ReferenceKind = EdgeKind | 'function_ref'; +export type ReferenceKind = EdgeKind | 'function_ref' | PostgresObjectReferenceKind; /** * A reference that couldn't be resolved during extraction