diff --git a/package-lock.json b/package-lock.json index e8fe798a..60032e29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -840,7 +840,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -862,7 +861,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-1.30.1.tgz", "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" }, @@ -899,7 +897,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.57.2.tgz", "integrity": "sha512-BdBGhQBh8IjZ2oIIX6F2/Q3LKm/FDDKi6ccYKcBTeilh6SNdNKveDOLk73BkSJjQLJk6qe4Yh+hHw1UPhCDdrg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.57.2", "@types/shimmer": "^1.2.0", @@ -1439,7 +1436,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.32.0.tgz", "integrity": "sha512-s0OpmpQFSfMrmedAn9Lhg4KWJELHCU6uU9dtIJ28N8UGhf9Y55im5X8fEzwhwDwiSqN+ZPSNrDJF7ivf/AuRPQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -1877,8 +1873,7 @@ "version": "0.31.28", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz", "integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@tsconfig/node10": { "version": "1.0.11", @@ -1945,7 +1940,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.28.tgz", "integrity": "sha512-DHlH/fNL6Mho38jTy7/JT7sn2wnXI+wULR6PV4gy4VHLVvnrV/d3pHAMQHhc4gjdLmK2ZiPoMxzp6B3yRajLSQ==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.19.2" } @@ -2149,7 +2143,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3298,7 +3291,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "@fastify/ajv-compiler": "^3.5.0", "@fastify/error": "^3.4.0", @@ -5276,6 +5268,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.14.1.tgz", "integrity": "sha512-0TdbqfjwIun9Fm/r89oB7RFQ0bLgduAhiIqIXOsyKoiC/L54DbuAAzIEN/9Op0f1Po9X7iCPXGoa/Ah+2aI8Xw==", "license": "MIT", + "peer": true, "dependencies": { "pg-connection-string": "^2.7.0", "pg-pool": "^3.8.0", @@ -5327,7 +5320,8 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz", "integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/pg/node_modules/postgres-array": { "version": "2.0.0", @@ -5624,7 +5618,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -6767,7 +6760,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6971,7 +6963,6 @@ "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7045,7 +7036,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", @@ -7159,7 +7149,6 @@ "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7173,7 +7162,6 @@ "integrity": "sha512-BbcFDqNyBlfSpATmTtXOAOj71RNKDDvjBM/uPfnxxVGrG+FSH2RQIwgeEngTaTkuU/h0ScFvf+tRcKfYXzBybQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "3.0.9", "@vitest/mocker": "3.0.9", diff --git a/src/lib/PostgresMeta.ts b/src/lib/PostgresMeta.ts index eb931624..afb784bd 100644 --- a/src/lib/PostgresMeta.ts +++ b/src/lib/PostgresMeta.ts @@ -2,6 +2,7 @@ import * as Parser from './Parser.js' import PostgresMetaColumnPrivileges from './PostgresMetaColumnPrivileges.js' import PostgresMetaColumns from './PostgresMetaColumns.js' import PostgresMetaConfig from './PostgresMetaConfig.js' +import PostgresMetaDependencyGraph from './PostgresMetaDependencyGraph.js' import PostgresMetaExtensions from './PostgresMetaExtensions.js' import PostgresMetaForeignTables from './PostgresMetaForeignTables.js' import PostgresMetaFunctions from './PostgresMetaFunctions.js' @@ -30,6 +31,7 @@ export default class PostgresMeta { columnPrivileges: PostgresMetaColumnPrivileges columns: PostgresMetaColumns config: PostgresMetaConfig + dependencyGraph: PostgresMetaDependencyGraph extensions: PostgresMetaExtensions foreignTables: PostgresMetaForeignTables functions: PostgresMetaFunctions @@ -58,6 +60,7 @@ export default class PostgresMeta { this.columnPrivileges = new PostgresMetaColumnPrivileges(this.query) this.columns = new PostgresMetaColumns(this.query) this.config = new PostgresMetaConfig(this.query) + this.dependencyGraph = new PostgresMetaDependencyGraph(this.query) this.extensions = new PostgresMetaExtensions(this.query) this.foreignTables = new PostgresMetaForeignTables(this.query) this.functions = new PostgresMetaFunctions(this.query) diff --git a/src/lib/PostgresMetaDependencyGraph.ts b/src/lib/PostgresMetaDependencyGraph.ts new file mode 100644 index 00000000..b673abaf --- /dev/null +++ b/src/lib/PostgresMetaDependencyGraph.ts @@ -0,0 +1,99 @@ +import { filterByList } from './helpers.js' +import { DEFAULT_SYSTEM_SCHEMAS } from './constants.js' +import type { PostgresMetaResult, DependencyGraphNode, DependencyGraphEdge } from './types.js' +import { + DEPENDENCY_GRAPH_NODES_SQL, + DEPENDENCY_GRAPH_EDGES_SQL, +} from './sql/dependency_graph.sql.js' + +/** + * Options for querying the dependency graph + */ +export interface DependencyGraphOptions { + /** Include system schemas (pg_catalog, information_schema, etc.) */ + includeSystemSchemas?: boolean + /** Only include objects from these schemas */ + includedSchemas?: string[] + /** Exclude objects from these schemas */ + excludedSchemas?: string[] + /** Only include these object types (table, view, function, etc.) */ + includedTypes?: string[] +} + +/** + * Result containing nodes (database objects) and edges (dependencies) + */ +export interface DependencyGraphResult { + nodes: DependencyGraphNode[] + edges: DependencyGraphEdge[] +} + +/** + * Queries PostgreSQL system catalogs to build a dependency graph of database objects. + * Supports tables, views, functions, triggers, policies, indexes, sequences, and custom types. + */ +export default class PostgresMetaDependencyGraph { + query: (sql: string) => Promise> + + constructor(query: (sql: string) => Promise>) { + this.query = query + } + + async get({ + includeSystemSchemas = false, + includedSchemas, + excludedSchemas, + includedTypes, + }: DependencyGraphOptions = {}): Promise> { + const schemaFilter = filterByList( + includedSchemas, + excludedSchemas, + !includeSystemSchemas ? DEFAULT_SYSTEM_SCHEMAS : undefined + ) + + // Build type filter if specified + let typeFilter: string | undefined + if (includedTypes && includedTypes.length > 0) { + const typeList = includedTypes.map((t) => `'${t}'`).join(', ') + typeFilter = `IN (${typeList})` + } + + // Fetch nodes + const nodesSql = DEPENDENCY_GRAPH_NODES_SQL({ schemaFilter, typeFilter }) + const nodesResult = (await this.query(nodesSql)) as PostgresMetaResult + if (nodesResult.error) { + return { data: null, error: nodesResult.error } + } + + // Fetch edges + const edgesSql = DEPENDENCY_GRAPH_EDGES_SQL({ schemaFilter }) + const edgesResult = (await this.query(edgesSql)) as PostgresMetaResult< + { id: string; source_id: number; target_id: number; type: string; label: string }[] + > + if (edgesResult.error) { + return { data: null, error: edgesResult.error } + } + + // Create a set of valid node IDs for filtering edges + const nodeIds = new Set(nodesResult.data.map((n) => n.id)) + + // Transform edges to use node IDs and filter out edges with missing nodes + const edges: DependencyGraphEdge[] = edgesResult.data + .filter((e) => nodeIds.has(e.source_id) && nodeIds.has(e.target_id)) + .map((e) => ({ + id: e.id, + source: e.source_id, + target: e.target_id, + type: e.type as DependencyGraphEdge['type'], + label: e.label, + })) + + return { + data: { + nodes: nodesResult.data, + edges, + }, + error: null, + } + } +} diff --git a/src/lib/index.ts b/src/lib/index.ts index 246b1e71..b3d250d0 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -23,4 +23,7 @@ export { PostgresType, PostgresVersion, PostgresView, + DependencyGraphNode, + DependencyGraphEdge, + DependencyGraph, } from './types.js' diff --git a/src/lib/sql/dependency_graph.sql.ts b/src/lib/sql/dependency_graph.sql.ts new file mode 100644 index 00000000..768c759d --- /dev/null +++ b/src/lib/sql/dependency_graph.sql.ts @@ -0,0 +1,314 @@ +import type { SQLQueryPropsWithSchemaFilter } from './common.js' + +/** + * Query to build a complete dependency graph of database objects. + * Returns nodes (objects) and edges (dependencies) for visualization. + */ +export const DEPENDENCY_GRAPH_NODES_SQL = ( + props: SQLQueryPropsWithSchemaFilter & { + typeFilter?: string // e.g., "IN ('table', 'view', 'function')" + } +) => /* SQL */ ` +WITH all_objects AS ( + -- Tables + SELECT + c.oid::bigint AS id, + c.relname AS name, + n.nspname AS schema, + 'table' AS type, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'r' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Partitioned tables + SELECT + c.oid::bigint AS id, + c.relname AS name, + n.nspname AS schema, + 'table' AS type, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'p' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Views + SELECT + c.oid::bigint AS id, + c.relname AS name, + n.nspname AS schema, + 'view' AS type, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'v' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Materialized views + SELECT + c.oid::bigint AS id, + c.relname AS name, + n.nspname AS schema, + 'materialized_view' AS type, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'm' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Functions (excluding internal/system) + SELECT + p.oid::bigint AS id, + p.proname AS name, + n.nspname AS schema, + 'function' AS type, + obj_description(p.oid, 'pg_proc') AS comment + FROM pg_proc p + JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND p.prokind IN ('f', 'p') -- functions and procedures + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Triggers + SELECT + t.oid::bigint AS id, + t.tgname AS name, + n.nspname AS schema, + 'trigger' AS type, + NULL AS comment + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Policies + SELECT + pol.oid::bigint AS id, + pol.polname AS name, + n.nspname AS schema, + 'policy' AS type, + NULL AS comment + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Indexes + SELECT + i.indexrelid::bigint AS id, + ic.relname AS name, + n.nspname AS schema, + 'index' AS type, + obj_description(i.indexrelid, 'pg_class') AS comment + FROM pg_index i + JOIN pg_class ic ON ic.oid = i.indexrelid + JOIN pg_class tc ON tc.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tc.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Sequences + SELECT + c.oid::bigint AS id, + c.relname AS name, + n.nspname AS schema, + 'sequence' AS type, + obj_description(c.oid, 'pg_class') AS comment + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind = 'S' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Custom types (composites, enums, domains) + SELECT + t.oid::bigint AS id, + t.typname AS name, + n.nspname AS schema, + 'type' AS type, + obj_description(t.oid, 'pg_type') AS comment + FROM pg_type t + JOIN pg_namespace n ON n.oid = t.typnamespace + WHERE t.typtype IN ('c', 'e', 'd') -- composite, enum, domain + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} +) +SELECT * FROM all_objects +${props.typeFilter ? `WHERE type ${props.typeFilter}` : ''} +ORDER BY schema, type, name +` + +export const DEPENDENCY_GRAPH_EDGES_SQL = ( + props: SQLQueryPropsWithSchemaFilter +) => /* SQL */ ` +WITH edges AS ( + -- Foreign key relationships + SELECT + con.conrelid::text || '_' || con.confrelid::text || '_fk_' || con.conname AS id, + con.conrelid::bigint AS source_id, + con.confrelid::bigint AS target_id, + 'fk' AS type, + con.conname AS label + FROM pg_constraint con + JOIN pg_class c ON c.oid = con.conrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE con.contype = 'f' + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Trigger -> Table relationships + SELECT + t.oid::text || '_' || t.tgrelid::text || '_trigger' AS id, + t.oid::bigint AS source_id, + t.tgrelid::bigint AS target_id, + 'trigger_table' AS type, + 'ON ' || c.relname AS label + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT t.tgisinternal + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Trigger -> Function relationships + SELECT + t.oid::text || '_' || t.tgfoid::text || '_trigger_func' AS id, + t.oid::bigint AS source_id, + t.tgfoid::bigint AS target_id, + 'trigger_function' AS type, + 'CALLS' AS label + FROM pg_trigger t + JOIN pg_class c ON c.oid = t.tgrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_proc p ON p.oid = t.tgfoid + JOIN pg_namespace pn ON pn.oid = p.pronamespace + WHERE NOT t.tgisinternal + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND pn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Policy -> Table relationships + SELECT + pol.oid::text || '_' || pol.polrelid::text || '_policy' AS id, + pol.oid::bigint AS source_id, + pol.polrelid::bigint AS target_id, + 'policy' AS type, + 'ON ' || c.relname AS label + FROM pg_policy pol + JOIN pg_class c ON c.oid = pol.polrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Index -> Table relationships + SELECT + i.indexrelid::text || '_' || i.indrelid::text || '_index' AS id, + i.indexrelid::bigint AS source_id, + i.indrelid::bigint AS target_id, + 'index' AS type, + 'ON ' || tc.relname AS label + FROM pg_index i + JOIN pg_class ic ON ic.oid = i.indexrelid + JOIN pg_class tc ON tc.oid = i.indrelid + JOIN pg_namespace n ON n.oid = tc.relnamespace + WHERE n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- View dependencies (view -> table/view it depends on) + SELECT DISTINCT + d.objid::text || '_' || d.refobjid::text || '_view_dep' AS id, + d.objid::bigint AS source_id, + d.refobjid::bigint AS target_id, + 'view_dependency' AS type, + 'DEPENDS ON' AS label + FROM pg_depend d + JOIN pg_class c ON c.oid = d.objid + JOIN pg_class rc ON rc.oid = d.refobjid + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_namespace rn ON rn.oid = rc.relnamespace + WHERE d.deptype IN ('n', 'a') + AND c.relkind IN ('v', 'm') -- view or materialized view + AND rc.relkind IN ('r', 'v', 'm', 'p') -- table, view, matview, partitioned + AND c.oid != rc.oid -- exclude self-references + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND rn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Function dependencies on tables (function uses table) + SELECT DISTINCT + d.objid::text || '_' || d.refobjid::text || '_func_table' AS id, + d.objid::bigint AS source_id, + d.refobjid::bigint AS target_id, + 'function_table' AS type, + 'USES' AS label + FROM pg_depend d + JOIN pg_proc p ON p.oid = d.objid + JOIN pg_class rc ON rc.oid = d.refobjid + JOIN pg_namespace n ON n.oid = p.pronamespace + JOIN pg_namespace rn ON rn.oid = rc.relnamespace + WHERE d.deptype IN ('n', 'a') + AND rc.relkind IN ('r', 'v', 'm', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + AND rn.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} + + UNION ALL + + -- Sequence ownership (sequence -> table) + SELECT + seq.oid::text || '_' || d.refobjid::text || '_seq_owned' AS id, + seq.oid::bigint AS source_id, + d.refobjid::bigint AS target_id, + 'sequence_owned' AS type, + 'OWNED BY' AS label + FROM pg_class seq + JOIN pg_depend d ON d.objid = seq.oid + JOIN pg_class tc ON tc.oid = d.refobjid + JOIN pg_namespace n ON n.oid = seq.relnamespace + WHERE seq.relkind = 'S' + AND d.deptype = 'a' + AND tc.relkind IN ('r', 'p') + AND n.nspname NOT IN ('pg_catalog', 'information_schema', 'pg_toast') + ${props.schemaFilter ? `AND n.nspname ${props.schemaFilter}` : ''} +) +SELECT * FROM edges +` diff --git a/src/lib/types.ts b/src/lib/types.ts index 26b3bc78..882b9d48 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -592,3 +592,47 @@ export type PostgresColumnPrivilegesRevoke = Static + +export const dependencyGraphEdgeSchema = Type.Object({ + id: Type.String(), + source: Type.Integer(), + target: Type.Integer(), + type: Type.Union([ + Type.Literal('fk'), + Type.Literal('trigger_table'), + Type.Literal('trigger_function'), + Type.Literal('policy'), + Type.Literal('index'), + Type.Literal('view_dependency'), + Type.Literal('function_table'), + Type.Literal('sequence_owned'), + ]), + label: Type.Union([Type.String(), Type.Null()]), +}) +export type DependencyGraphEdge = Static + +export const dependencyGraphSchema = Type.Object({ + nodes: Type.Array(dependencyGraphNodeSchema), + edges: Type.Array(dependencyGraphEdgeSchema), +}) +export type DependencyGraph = Static diff --git a/src/server/routes/dependency-graph.ts b/src/server/routes/dependency-graph.ts new file mode 100644 index 00000000..bd453429 --- /dev/null +++ b/src/server/routes/dependency-graph.ts @@ -0,0 +1,61 @@ +import { FastifyPluginAsyncTypebox } from '@fastify/type-provider-typebox' +import { Type } from '@sinclair/typebox' +import { PostgresMeta } from '../../lib/index.js' +import { dependencyGraphSchema } from '../../lib/types.js' +import { + createConnectionConfig, + extractRequestForLogging, + translateErrorToResponseCode, +} from '../utils.js' + +const route: FastifyPluginAsyncTypebox = async (fastify) => { + fastify.get( + '/', + { + schema: { + headers: Type.Object({ + pg: Type.String(), + 'x-pg-application-name': Type.Optional(Type.String()), + }), + querystring: Type.Object({ + include_system_schemas: Type.Optional(Type.Boolean()), + included_schemas: Type.Optional(Type.String()), + excluded_schemas: Type.Optional(Type.String()), + included_types: Type.Optional(Type.String()), + }), + response: { + 200: dependencyGraphSchema, + 500: Type.Object({ + error: Type.String(), + }), + }, + }, + }, + async (request, reply) => { + const includeSystemSchemas = request.query.include_system_schemas + const includedSchemas = request.query.included_schemas?.split(',') + const excludedSchemas = request.query.excluded_schemas?.split(',') + const includedTypes = request.query.included_types?.split(',') + + const config = createConnectionConfig(request) + const pgMeta = new PostgresMeta(config) + const { data, error } = await pgMeta.dependencyGraph.get({ + includeSystemSchemas, + includedSchemas, + excludedSchemas, + includedTypes, + }) + await pgMeta.end() + + if (error) { + request.log.error({ error, request: extractRequestForLogging(request) }) + reply.code(translateErrorToResponseCode(error, 500)) + return { error: error.message } + } + + return data + } + ) +} + +export default route diff --git a/src/server/routes/index.ts b/src/server/routes/index.ts index 46ffba0f..01b799d2 100644 --- a/src/server/routes/index.ts +++ b/src/server/routes/index.ts @@ -3,6 +3,7 @@ import { FastifyInstance } from 'fastify' import ColumnPrivilegesRoute from './column-privileges.js' import ColumnRoute from './columns.js' import ConfigRoute from './config.js' +import DependencyGraphRoute from './dependency-graph.js' import ExtensionsRoute from './extensions.js' import ForeignTablesRoute from './foreign-tables.js' import FunctionsRoute from './functions.js' @@ -65,6 +66,7 @@ export default async (fastify: FastifyInstance) => { fastify.register(ColumnPrivilegesRoute, { prefix: '/column-privileges' }) fastify.register(ColumnRoute, { prefix: '/columns' }) fastify.register(ConfigRoute, { prefix: '/config' }) + fastify.register(DependencyGraphRoute, { prefix: '/dependency-graph' }) fastify.register(ExtensionsRoute, { prefix: '/extensions' }) fastify.register(ForeignTablesRoute, { prefix: '/foreign-tables' }) fastify.register(FunctionsRoute, { prefix: '/functions' })