diff --git a/assets/viewer/index.template.html b/assets/viewer/index.template.html index ce69209..9d0155e 100644 --- a/assets/viewer/index.template.html +++ b/assets/viewer/index.template.html @@ -769,7 +769,16 @@

edge.from === node.id && edge.verb === 'has column').length; N.push(this.mkNode(node, box.x, box.y, box.w, box.h, { sub: `${columns} columns · ${degrees.get(node.id) || 0} relations` })); }); - D.edges.filter((edge) => edge.verb === 'references' && visibleIds.has(edge.from) && visibleIds.has(edge.to)).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, true))); + D.edges.filter((edge) => edge.verb === 'references' && edge.details?.associationOnly !== true && visibleIds.has(edge.from) && visibleIds.has(edge.to)).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, true))); return { nodes: N, edges: E, groups: [], cols: C, status: `Showing all ${visible.length} detected tables across every schema. Select a table to isolate its columns, indexes, migrations, readers, writers and foreign-key relationships.`, emptyOk: tables.length === 0 }; } @@ -1917,7 +1936,7 @@

edge.from === node.id && edge.verb === 'has column').length; N.push(this.mkNode(node, box.x, box.y, box.w, box.h, { sub: `${columnCount} columns · ${degrees.get(node.id) || 0} relations` })); }); - D.edges.filter((edge) => edge.verb === 'references' && visibleIds.has(edge.from) && visibleIds.has(edge.to)).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, false))); + D.edges.filter((edge) => edge.verb === 'references' && edge.details?.associationOnly !== true && visibleIds.has(edge.from) && visibleIds.has(edge.to)).forEach((edge) => E.push(this.linkEdge(pos[edge.from], pos[edge.to], edge, false, false, false))); return { nodes: N, edges: E, groups: [], cols: C, status: `${root.label}: showing ${visible.length} relationship-rich tables of ${tables.length}. Search the complete catalog and select a table to inspect every column, key, index, migration, reader and writer.` }; } @@ -1939,7 +1958,7 @@

{ const col = index % 4, row = Math.floor(index / 4), b = { x: 340 + col * 250, y: 80 + row * 68, w: 230, h: 56 }; const cols = Array.isArray(node.details?.columns) ? node.details.columns.join(', ') : node.details?.expression || this.tLabel(node.type); N.push(this.mkNode(node, b.x, b.y, b.w, b.h, { sub: String(cols), onClick: () => this.select(node.id, { mode: 'database', dbDetail: 'indexes' }) })); }); } else if (detail === 'relations') { - heading(340, 34, 1340, 'RELATED TABLES', 'direction, ORM relation and foreign-key fields'); - relations.forEach((item, index) => { const col = index % 4, row = Math.floor(index / 4), b = { x: 340 + col * 250, y: 80 + row * 68, w: 230, h: 56 }; const meta = item.edge.details ? Object.entries(item.edge.details).map(([key, value]) => `${key}: ${Array.isArray(value) ? value.join(', ') : value}`).join(' · ') : ''; N.push(this.mkNode(item.node, b.x, b.y, b.w, b.h, { sub: `${item.direction}${meta ? ' · ' + meta : ''}` })); }); - const accessY = 110 + Math.ceil(relations.length / 4) * 68; + heading(340, 34, 1400, 'FOREIGN KEYS', 'exact source field → target field · click a card to open the related table'); + const names = (value) => Array.isArray(value) ? value.map(String).filter(Boolean) : value ? [String(value)] : []; + const primaryColumns = (tableId) => D.edges.filter((edge) => edge.from === tableId && edge.verb === 'has column').map((edge) => this.node(edge.to)).filter((node) => node?.details?.primaryKey).map((node) => String(node.details?.databaseName || node.label.split('.').at(-1))); + relations.forEach((item, index) => { + const source = this.node(item.edge.from), target = this.node(item.edge.to); + const sourceColumns = names(item.edge.details?.sourceColumns || item.edge.details?.sourceColumn); + const targetColumns = names(item.edge.details?.targetColumns || item.edge.details?.targetColumn); + const resolvedTarget = targetColumns.length ? targetColumns : primaryColumns(item.edge.to); + const sourceFields = sourceColumns.length ? sourceColumns.join(', ') : 'field not detected'; + const targetFields = resolvedTarget.length ? resolvedTarget.join(', ') : 'field not detected'; + const mapping = `${source.label}.${sourceFields} → ${target.label}.${targetFields}`; + const notes = [item.direction, item.edge.details?.constraint ? `constraint ${item.edge.details.constraint}` : '', item.edge.details?.relation ? String(item.edge.details.relation) : '', item.edge.details?.orm ? String(item.edge.details.orm) : '', item.edge.details?.onDelete ? `on delete ${item.edge.details.onDelete}` : ''].filter(Boolean).join(' · '); + const col = index % 3, row = Math.floor(index / 3), b = { x: 340 + col * 360, y: 80 + row * 82, w: 336, h: 68 }; + const relationCard = { ...item.node, id: `fk:${sel}:${index}:${item.node.id}`, type: 'constraint', label: mapping, desc: `Foreign key: ${mapping}. ${notes}` }; + N.push(this.mkNode(relationCard, b.x, b.y, b.w, b.h, { sub: notes, onClick: () => this.reveal(item.node.id) })); + const visual = this.linkEdge(item.edge.from === sel ? tableBox : b, item.edge.from === sel ? b : tableBox, { ...item.edge, verb: `${sourceFields} → ${targetFields}` }, false, false, true); + visual.id = `${visual.id}:${index}`; + visual.onClick = (event) => { event.stopPropagation(); this.reveal(item.node.id); }; + E.push(visual); + }); + const accessY = 116 + Math.ceil(relations.length / 3) * 82; heading(340, accessY, 1340, 'READERS & WRITERS', 'services, repositories and jobs that access this table'); access.forEach((item, index) => { const col = index % 4, row = Math.floor(index / 4), b = { x: 340 + col * 250, y: accessY + 46 + row * 62, w: 230, h: 50 }; N.push(this.mkNode(item.node, b.x, b.y, b.w, b.h, { sub: `${item.verb} ${table.label}` })); }); } else if (detail === 'migrations') { @@ -1993,47 +2030,54 @@

e.from === sel && e.verb === 'has column').length) }); facts.push({ k: 'Indexes', v: String(D.edges.filter((e) => e.to === sel && e.verb === 'indexes').length) }); - facts.push({ k: 'Relationships', v: String(D.edges.filter((e) => e.verb === 'references' && (e.from === sel || e.to === sel)).length) }); + facts.push({ k: 'Relationships', v: String(D.edges.filter((e) => e.verb === 'references' && e.details?.associationOnly !== true && (e.from === sel || e.to === sel) && this.node(e.from)?.type === 'table' && this.node(e.to)?.type === 'table').length) }); facts.push({ k: 'Readers / writers', v: String(D.edges.filter((e) => e.to === sel && ['reads', 'writes'].includes(e.verb)).length) }); facts.push({ k: 'Migrations', v: String(D.edges.filter((e) => e.to === sel && ['creates', 'alters', 'drops'].includes(e.verb)).length) }); } @@ -2529,7 +2573,7 @@

; } interface ClassInfo { @@ -75,7 +77,8 @@ export class NestAdapter implements ArchitectureAdapter { const warnings: string[] = []; const addNode = (node: GraphNode) => nodes.set(node.id, { ...nodes.get(node.id), ...node, metadata: { ...nodes.get(node.id)?.metadata, ...node.metadata } }); const addEdge = (from: string, to: string, type: GraphEdge["type"], metadata?: Record, source: GraphEdge["source"] = "ast", confidence = 1) => { - edges.push({ from, to, type, label: type, source, confidence, metadata }); + const relationshipKey = typeof metadata?.relationshipKey === "string" ? metadata.relationshipKey : ""; + edges.push({ from, to, type, label: relationshipKey ? `${type}#${relationshipKey}` : type, source, confidence, metadata }); }; const tsFiles = context.files.filter((file) => file.extension === ".ts" || file.extension === ".js"); @@ -496,7 +499,23 @@ function parseTypeOrm(info: ClassInfo, classes: ClassRegistry, addNode: NodeAdde if (!targetName) continue; const { id: targetTableId, name: targetTableName } = tableForEntity(targetName, classes, info.file); addNode({ id: targetTableId, type: "table", label: targetTableName, name: targetTableName, framework: "typeorm", source: "heuristic", confidence: 0.85 }); - addEdge(tableId, targetTableId, "references", { relation, property: property.getName(), orm: "typeorm" }, "ast", 1); + const joinColumn = property.getDecorator("JoinColumn"); + const configuredSource = joinColumn ? decoratorOptionString(joinColumn, "name") : ""; + const configuredTarget = joinColumn ? decoratorOptionString(joinColumn, "referencedColumnName") : ""; + const conventionalProperty = `${property.getName()}Id`; + const sourceProperty = info.declaration.getProperty(conventionalProperty); + const sourceColumn = configuredSource || (sourceProperty ? typeOrmColumnName(sourceProperty) : ""); + const targetColumn = configuredTarget || typeOrmPrimaryColumn(targetName, classes, info.file); + const associationOnly = relation === "OneToMany"; + addEdge(tableId, targetTableId, "references", { + relationshipKey: sourceColumn || `${relation}:${property.getName()}`, + relation, + property: property.getName(), + sourceColumns: associationOnly ? [] : sourceColumn ? [sourceColumn] : [], + targetColumns: associationOnly ? [] : targetColumn ? [targetColumn] : [], + ...(associationOnly ? { associationOnly: true } : {}), + orm: "typeorm", + }, "ast", 1); } } } @@ -511,6 +530,14 @@ function parseSequelize(info: ClassInfo, classes: ClassRegistry, addNode: NodeAd addEdge("database:sequelize", tableId, "contains"); addEdge(info.id, tableId, "references", { orm: "sequelize" }); + const foreignKeys = info.declaration.getProperties().flatMap((property) => { + const decorator = property.getDecorator("ForeignKey"); + if (!decorator) return []; + const targetName = decorator.getArguments().flatMap((argument) => referencedTypeNames(argument.getText())).find((name) => name !== info.name); + if (!targetName) return []; + return [{ targetName, property: property.getName(), column: sequelizeColumnName(property) }]; + }); + for (const property of info.declaration.getProperties()) { const columnDecorator = property.getDecorator("Column"); const decorators = property.getDecorators().map((item) => item.getName()); @@ -549,7 +576,28 @@ function parseSequelize(info: ClassInfo, classes: ClassRegistry, addNode: NodeAd if (!target?.declaration.getDecorator("Table")) continue; const { id: targetTableId, name: targetTableName } = tableForSequelizeModel(targetName, classes, info.file); addNode({ id: targetTableId, type: "table", label: targetTableName, name: targetTableName, file: target.file, framework: "sequelize", source: "ast", confidence: 1 }); - addEdge(tableId, targetTableId, "references", { relation: relationDecorator.getName(), property: property.getName(), orm: "sequelize" }); + const relation = relationDecorator.getName(); + const configuredForeignKey = sequelizeAssociationForeignKey(relationDecorator); + const candidates = foreignKeys.filter((item) => item.targetName === targetName); + const matchingForeignKey = candidates.find((item) => configuredForeignKey && [item.property, item.column].includes(configuredForeignKey)) + ?? (candidates.length === 1 ? candidates[0] : undefined); + const sourceColumn = relation === "ForeignKey" + ? sequelizeColumnName(property) + : matchingForeignKey?.column || configuredForeignKey; + const targetColumn = sequelizePrimaryColumn(targetName, classes, info.file); + const associationOnly = ["HasMany", "HasOne", "BelongsToMany"].includes(relation); + const sourceColumns = associationOnly ? [] : sourceColumn ? [sourceColumn] : []; + const targetColumns = associationOnly ? [] : targetColumn ? [targetColumn] : []; + addEdge(tableId, targetTableId, "references", { + relationshipKey: sourceColumn || `${relation}:${property.getName()}`, + relation, + property: property.getName(), + sourceColumns, + targetColumns, + orm: "sequelize", + ...(associationOnly ? { associationOnly: true } : {}), + ...(relation === "BelongsToMany" ? { association: "many_to_many" } : {}), + }); } } @@ -580,7 +628,19 @@ async function parsePrisma(context: AdapterContext, addNode: NodeAdder, addEdge: const [, fieldName, fieldType] = field; const relatedModel = fieldType.replace(/[\[\]?]/g, ""); if (modelNames.has(relatedModel)) { - addEdge(tableId, `table:${relatedModel}`, "references", { relation: fieldType.includes("[]") ? "has_many" : "belongs_to", field: fieldName, orm: "prisma" }, "config", 1); + const relationOptions = line.match(/@relation\s*\(([^)]*)\)/)?.[1] ?? ""; + const sourceColumns = prismaRelationColumns(relationOptions, "fields"); + const targetColumns = prismaRelationColumns(relationOptions, "references"); + const associationOnly = fieldType.includes("[]") || sourceColumns.length === 0; + addEdge(tableId, `table:${relatedModel}`, "references", { + relationshipKey: sourceColumns.join(",") || fieldName, + relation: fieldType.includes("[]") ? "has_many" : "belongs_to", + field: fieldName, + sourceColumns, + targetColumns, + orm: "prisma", + ...(associationOnly ? { associationOnly: true } : {}), + }, "config", 1); continue; } const columnId = `column:${modelName}.${fieldName}`; @@ -612,7 +672,7 @@ async function parsePrisma(context: AdapterContext, addNode: NodeAdder, addEdge: function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, addNode: NodeAdder, addEdge: EdgeAdder): Map { const tables = new Map(); - const pendingReferences: Array<{ from: string; variable: string; property: string }> = []; + const pendingReferences: Array<{ from: string; variable: string; sourceColumn: string; targetProperty: string }> = []; const tableFactories = new Set(["pgTable", "mysqlTable", "sqliteTable", "sqliteTableCreator"]); const columnModifiers = new Set(["notNull", "primaryKey", "default", "defaultNow", "$defaultFn", "$onUpdate", "unique", "references"]); @@ -633,7 +693,7 @@ function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, add const variable = declaration.getName(); const tableId = `table:${tableName}`; - const table: DrizzleTableInfo = { variable, tableName, tableId, file }; + const table: DrizzleTableInfo = { variable, tableName, tableId, file, columns: new Map() }; tables.set(variable, table); addNode({ id: "database:drizzle", type: "database", label: "Drizzle", name: "Drizzle", framework: "drizzle", source: "config", confidence: 1 }); addNode({ id: tableId, type: "table", label: tableName, name: tableName, file, framework: "drizzle", source: "ast", confidence: 1, metadata: { schemaVariable: variable } }); @@ -654,6 +714,7 @@ function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, add return /^[A-Za-z_$][\w$]*$/.test(name) && !columnModifiers.has(name); }); const columnName = expressionValue(builder?.getArguments()[0]) || propertyName; + table.columns.set(propertyName, columnName); const columnType = builder?.getExpression().getText().split(".").at(-1) ?? propertyName; const columnText = columnInitializer.getText(); const columnId = `column:${tableName}.${columnName}`; @@ -675,8 +736,8 @@ function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, add }); addEdge(tableId, columnId, "has_column"); - const reference = columnText.match(/\.references\s*\(\s*\(\s*\)\s*=>\s*([A-Za-z_$][\w$]*)\./); - if (reference) pendingReferences.push({ from: tableId, variable: reference[1], property: propertyName }); + const reference = columnText.match(/\.references\s*\(\s*\(\s*\)\s*=>\s*([A-Za-z_$][\w$]*)\.([A-Za-z_$][\w$]*)/); + if (reference) pendingReferences.push({ from: tableId, variable: reference[1], sourceColumn: columnName, targetProperty: reference[2] }); } const extraConfig = tableCall.getArguments()[2]?.getText() ?? ""; for (const indexMatch of extraConfig.matchAll(/(uniqueIndex|index)\s*\(\s*["'`]([^"'`]+)["'`]\s*\)\.on\s*\(([^)]*)\)/g)) { @@ -689,7 +750,13 @@ function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, add } for (const reference of pendingReferences) { const target = tables.get(reference.variable); - if (target) addEdge(reference.from, target.tableId, "references", { property: reference.property, orm: "drizzle" }); + if (target) addEdge(reference.from, target.tableId, "references", { + relationshipKey: reference.sourceColumn, + sourceColumns: [reference.sourceColumn], + targetColumns: [target.columns.get(reference.targetProperty) || reference.targetProperty], + property: reference.sourceColumn, + orm: "drizzle", + }); } return tables; } @@ -1175,6 +1242,45 @@ function decoratorOptionString(decorator: Decorator, option: string): string { return expressionValue(property.getInitializer()); } +function sequelizeAssociationForeignKey(decorator: Decorator): string { + const argument = decorator.getArguments()[1]; + if (!argument) return ""; + if (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) return expressionValue(argument); + if (!Node.isObjectLiteralExpression(argument)) return ""; + const property = argument.getProperty("foreignKey"); + return property && Node.isPropertyAssignment(property) ? expressionValue(property.getInitializer()) : ""; +} + +function sequelizeColumnName(property: PropertyDeclaration): string { + const column = property.getDecorator("Column"); + return (column && decoratorOptionString(column, "field")) || property.getName(); +} + +function typeOrmColumnName(property: PropertyDeclaration): string { + const column = property.getDecorators().find((item) => ["Column", "PrimaryColumn", "PrimaryGeneratedColumn"].includes(item.getName())); + return (column && decoratorOptionString(column, "name")) || property.getName(); +} + +function typeOrmPrimaryColumn(entityName: string, classes: ClassRegistry, file?: string): string { + const entity = resolveClass(entityName, classes, file); + const primary = entity?.declaration.getProperties().find((property) => Boolean(property.getDecorator("PrimaryColumn") || property.getDecorator("PrimaryGeneratedColumn"))); + return primary ? typeOrmColumnName(primary) : "id"; +} + +function prismaRelationColumns(options: string, key: string): string[] { + const match = options.match(new RegExp(`${key}\\s*:\\s*\\[([^\\]]*)\\]`)); + return (match?.[1] ?? "").split(",").map((item) => item.trim()).filter(Boolean); +} + +function sequelizePrimaryColumn(modelName: string, classes: ClassRegistry, file?: string): string { + const model = resolveClass(modelName, classes, file); + const primary = model?.declaration.getProperties().find((property) => { + const column = property.getDecorator("Column"); + return Boolean(property.getDecorator("PrimaryKey") || (column && decoratorOptionText(column, "primaryKey") === "true")); + }); + return primary ? sequelizeColumnName(primary) : "id"; +} + function joinRoute(...parts: string[]): string { const joined = `/${parts.join("/")}`.replace(/\/+/g, "/"); return joined.length > 1 && joined.endsWith("/") ? joined.slice(0, -1) : joined; diff --git a/src/adapters/project-adapter.ts b/src/adapters/project-adapter.ts index 08594ca..4234eab 100644 --- a/src/adapters/project-adapter.ts +++ b/src/adapters/project-adapter.ts @@ -34,7 +34,8 @@ export class ProjectAdapter implements ArchitectureAdapter { ...nodes.get(node.id), ...node, metadata: { ...nodes.get(node.id)?.metadata, ...node.metadata }, }); const addEdge: AddEdge = (from, to, type, metadata) => { - edges.push({ from, to, type, label: type, source: "config", confidence: 1, metadata }); + const relationshipKey = typeof metadata?.relationshipKey === "string" ? metadata.relationshipKey : ""; + edges.push({ from, to, type, label: relationshipKey ? `${type}#${relationshipKey}` : type, source: "config", confidence: 1, metadata }); }; const contentOf = async (file: ScannedFile) => { if (contents.has(file.path)) return contents.get(file.path)!; @@ -148,7 +149,8 @@ function parseSqlFile(file: ScannedFile, fragments: string[], clickHouseProject: addEdge(`file:${file.path}`, migrationId, "declares"); } for (const statement of statements) { - const clickhouse = clickHouseProject || /MergeTree|ReplacingMergeTree|Replicated\w*MergeTree|PARTITION\s+BY|TTL\s+/i.test(statement); + const clickHouseDialect = /(?:Replicated|Replacing|Summing|Aggregating|Collapsing|VersionedCollapsing)?MergeTree|ENGINE\s*=\s*Kafka|DateTime64|LowCardinality\s*\(|Nullable\s*\(|\bUInt(?:8|16|32|64|128|256)\b|\bTTL\s+/i.test(statement); + const clickhouse = clickHouseDialect || (clickHouseProject && /clickhouse/i.test(file.path)); const databaseId = clickhouse ? "database:clickhouse" : "database:sql"; addNode({ id: databaseId, type: "database", label: clickhouse ? "ClickHouse" : "SQL database", name: clickhouse ? "ClickHouse" : "SQL database", file: file.path, framework: clickhouse ? "clickhouse" : "sql", source: "config", confidence: clickhouse ? 1 : 0.7 }); parseCreateTable(statement, file, databaseId, migration ? migrationId : null, clickhouse, addNode, addEdge); @@ -186,12 +188,18 @@ function parseCreateTable(statement: string, file: ScannedFile, databaseId: stri ...(rest.match(/\bDEFAULT\s+([^,]+)/i)?.[1] ? { default: rest.match(/\bDEFAULT\s+([^,]+)/i)![1].trim() } : {}), } }); addEdge(tableId, columnId, "has_column"); - const reference = rest.match(/\bREFERENCES\s+([^\s(]+)/i); + const reference = rest.match(/\bREFERENCES\s+([^\s(]+)\s*(?:\(([^)]*)\))?/i); if (reference) { const target = splitQualifiedName(sqlName(reference[1])); const targetId = tableNodeId(target.schema, target.table); addSchemaAndTable(databaseId, target.schema, target.table, targetId, file.path, clickhouse, addNode, addEdge); - addEdge(tableId, targetId, "references", { column: name }); + const targetColumns = sqlColumns(reference[2]); + addEdge(tableId, targetId, "references", { + relationshipKey: name, + sourceColumns: [name], + targetColumns, + orm: "sql", + }); } } } @@ -205,12 +213,18 @@ function parseTableConstraint(definition: string, qualified: string, tableId: st const id = `constraint:${qualified}.${name}`; addNode({ id, type: "constraint", label: name, name, file, source: "config", confidence: 1, metadata: { kind, columns, expression: match[4].trim() } }); addEdge(tableId, id, "contains"); - const reference = match[4].match(/REFERENCES\s+([^\s(]+)/i); + const reference = match[4].match(/REFERENCES\s+([^\s(]+)\s*(?:\(([^)]*)\))?/i); if (reference) { const target = splitQualifiedName(sqlName(reference[1])); const targetId = tableNodeId(target.schema, target.table); addSchemaAndTable(databaseId, target.schema, target.table, targetId, file, clickhouse, addNode, addEdge); - addEdge(tableId, targetId, "references", { constraint: name, columns }); + addEdge(tableId, targetId, "references", { + relationshipKey: columns.join(",") || name, + constraint: name, + sourceColumns: columns, + targetColumns: sqlColumns(reference[2]), + orm: "sql", + }); } return true; } @@ -223,7 +237,8 @@ function parseAlterTable(statement: string, file: ScannedFile, databaseId: strin const tableId = tableNodeId(schema, table); addSchemaAndTable(databaseId, schema, table, tableId, file.path, clickhouse, addNode, addEdge); if (migrationId) addEdge(migrationId, tableId, /\bDROP\s+TABLE\b/i.test(statement) ? "drops" : "alters", { statement: compactSql(match[2]) }); - const addColumn = match[2].match(/ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([`"\w.]+)\s+([^\s,]+)/i); + const foreignKey = match[2].match(/(?:ADD\s+)?(?:CONSTRAINT\s+([`"\w.-]+)\s+)?FOREIGN\s+KEY\s*\(([^)]*)\)\s+REFERENCES\s+([^\s(]+)\s*\(([^)]*)\)/i); + const addColumn = foreignKey ? null : match[2].match(/ADD\s+(?:COLUMN\s+)?(?:IF\s+NOT\s+EXISTS\s+)?([`"\w.]+)\s+([^\s,]+)/i); if (addColumn) { const name = sqlName(addColumn[1]); const columnId = `column:${qualified}.${name}`; @@ -231,6 +246,26 @@ function parseAlterTable(statement: string, file: ScannedFile, databaseId: strin addEdge(tableId, columnId, "has_column"); if (migrationId) addEdge(migrationId, columnId, "creates"); } + if (foreignKey) { + const sourceColumns = sqlColumns(foreignKey[2]); + const target = splitQualifiedName(sqlName(foreignKey[3])); + const targetId = tableNodeId(target.schema, target.table); + const targetColumns = sqlColumns(foreignKey[4]); + addSchemaAndTable(databaseId, target.schema, target.table, targetId, file.path, clickhouse, addNode, addEdge); + addEdge(tableId, targetId, "references", { + relationshipKey: sourceColumns.join(",") || sqlName(foreignKey[1] ?? "foreign_key"), + ...(foreignKey[1] ? { constraint: sqlName(foreignKey[1]) } : {}), + sourceColumns, + targetColumns, + orm: "sql", + ...(match[2].match(/ON\s+DELETE\s+(CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)/i)?.[1] ? { onDelete: match[2].match(/ON\s+DELETE\s+(CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)/i)![1].toUpperCase() } : {}), + ...(match[2].match(/ON\s+UPDATE\s+(CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)/i)?.[1] ? { onUpdate: match[2].match(/ON\s+UPDATE\s+(CASCADE|SET\s+NULL|SET\s+DEFAULT|RESTRICT|NO\s+ACTION)/i)![1].toUpperCase() } : {}), + }); + } +} + +function sqlColumns(value?: string): string[] { + return (value ?? "").split(",").map((item) => sqlName(item.trim())).filter(Boolean); } function parseCreateIndex(statement: string, file: ScannedFile, migrationId: string | null, addNode: AddNode, addEdge: AddEdge) { diff --git a/src/viewer/data.ts b/src/viewer/data.ts index 8bd8709..43a69ad 100644 --- a/src/viewer/data.ts +++ b/src/viewer/data.ts @@ -186,7 +186,8 @@ function viewerType(type: GraphNode["type"]): string { } function toViewerEdge(edge: GraphEdge): ViewerEdge { - const customLabel = edge.label && edge.label !== edge.type ? edge.label : ""; + const internalRelationshipLabel = edge.label?.startsWith(`${edge.type}#`); + const customLabel = edge.label && edge.label !== edge.type && !internalRelationshipLabel ? edge.label : ""; const details = safeDetails(edge.metadata ?? {}); return { from: edge.from, @@ -228,6 +229,7 @@ function safeDetails(metadata: Record): Record = {}; for (const [key, value] of Object.entries(metadata)) { diff --git a/tests/fixtures/nest-app/migrations/001_create_profiles.sql b/tests/fixtures/nest-app/migrations/001_create_profiles.sql index 13eda47..177bc20 100644 --- a/tests/fixtures/nest-app/migrations/001_create_profiles.sql +++ b/tests/fixtures/nest-app/migrations/001_create_profiles.sql @@ -8,4 +8,6 @@ CREATE TABLE public.profiles ( CREATE UNIQUE INDEX profiles_user_id_unique ON public.profiles (user_id); ALTER TABLE public.profiles ADD COLUMN avatar_url varchar(500); - +ALTER TABLE public.profiles ADD COLUMN invited_user_id uuid; +ALTER TABLE public.profiles ADD CONSTRAINT profiles_invited_user_fk + FOREIGN KEY (invited_user_id) REFERENCES public.users(id) ON DELETE SET NULL; diff --git a/tests/project.test.mjs b/tests/project.test.mjs index 43f69ea..20a5333 100644 --- a/tests/project.test.mjs +++ b/tests/project.test.mjs @@ -89,10 +89,14 @@ test("covers the complete NestJS MVP architecture surface", async () => { assert.ok(result.graph.edges.some((edge) => edge.from === "table:typeorm_users" && edge.to === "table:typeorm_posts" && edge.type === "references")); assert.ok(result.graph.edges.some((edge) => edge.from === "method:UserEntityRepository.find" && edge.to === "table:typeorm_users" && edge.type === "reads")); assert.ok(result.graph.edges.some((edge) => edge.from === "method:UserEntityRepository.save" && edge.to === "table:typeorm_users" && edge.type === "writes")); - assert.ok(result.graph.edges.some((edge) => edge.from === "table:sequelize_sessions" && edge.to === "table:sequelize_accounts" && edge.type === "references")); + const sequelizeReference = result.graph.edges.find((edge) => edge.from === "table:sequelize_sessions" && edge.to === "table:sequelize_accounts" && edge.type === "references"); + assert.deepEqual(sequelizeReference?.metadata?.sourceColumns, ["account_id"]); + assert.deepEqual(sequelizeReference?.metadata?.targetColumns, ["id"]); assert.ok(result.graph.edges.some((edge) => edge.from === "method:SequelizeAccount.findAll" && edge.to === "table:sequelize_accounts" && edge.type === "reads")); assert.ok(result.graph.edges.some((edge) => edge.from === "method:SequelizeAccount.create" && edge.to === "table:sequelize_accounts" && edge.type === "writes")); - assert.ok(result.graph.edges.some((edge) => edge.from === "table:drizzle_events" && edge.to === "table:drizzle_accounts" && edge.type === "references")); + const drizzleReference = result.graph.edges.find((edge) => edge.from === "table:drizzle_events" && edge.to === "table:drizzle_accounts" && edge.type === "references"); + assert.deepEqual(drizzleReference?.metadata?.sourceColumns, ["account_id"]); + assert.deepEqual(drizzleReference?.metadata?.targetColumns, ["id"]); assert.ok(result.graph.edges.some((edge) => edge.from === "method:DrizzleEventsRepository.listEvents" && edge.to === "table:drizzle_events" && edge.type === "reads")); assert.ok(result.graph.edges.some((edge) => edge.from === "method:DrizzleEventsRepository.addEvent" && edge.to === "table:drizzle_events" && edge.type === "writes")); assert.ok(result.graph.edges.some((edge) => edge.from === "method:DrizzleEventsRepository.updateEvent" && edge.to === "table:drizzle_events" && edge.type === "writes")); @@ -106,7 +110,10 @@ test("covers the complete NestJS MVP architecture surface", async () => { assert.ok(result.graph.edges.some((edge) => edge.from === "queue:email-jobs" && edge.to === "method:EmailProcessor.handleEmail" && edge.type === "delivers_to")); assert.ok(result.graph.edges.some((edge) => edge.from === "migration:migrations/001_create_profiles.sql" && edge.to === "table:profiles" && edge.type === "creates")); assert.ok(result.graph.edges.some((edge) => edge.from === "index:public.profiles.profiles_user_id_unique" && edge.to === "table:profiles" && edge.type === "indexes")); - assert.ok(result.graph.edges.some((edge) => edge.from === "table:profiles" && edge.to === "table:users" && edge.type === "references")); + const profileReferences = result.graph.edges.filter((edge) => edge.from === "table:profiles" && edge.to === "table:users" && edge.type === "references"); + assert.deepEqual(profileReferences.map((edge) => edge.metadata?.sourceColumns?.[0]).sort(), ["invited_user_id", "user_id"]); + assert.ok(profileReferences.every((edge) => edge.metadata?.targetColumns?.[0] === "id")); + assert.equal(query.getNode("column:public.profiles.FOREIGN"), null, "ALTER TABLE foreign keys must not become fake columns"); assert.equal(query.getNode("table:analytics.order_events").metadata.engine, "MergeTree()"); assert.match(query.getNode("table:analytics.order_events").metadata.partitionBy, /toYYYYMM/); assert.match(query.getNode("table:analytics.order_events").metadata.orderBy, /order_id/); diff --git a/tests/viewer-interactions.test.mjs b/tests/viewer-interactions.test.mjs index 35100ab..4ce7c67 100644 --- a/tests/viewer-interactions.test.mjs +++ b/tests/viewer-interactions.test.mjs @@ -172,3 +172,34 @@ test("HTTP flow explicitly passes through its controller and drills into context assert.equal(viewer.state.activeFlow, null, "drilling into an element must leave the previous route flow"); assert.match(viewer.scene().status, /controller operations/); }); + +test("database relations show exact foreign-key fields and keep repeated table links", async () => { + const viewer = await createViewer(); + viewer.state = { ...viewer.state, mode: "database", sel: "table:profiles", dbDetail: "relations" }; + + const scene = viewer.scene(); + const mappings = scene.nodes.filter((node) => node.id.startsWith("fk:table:profiles:")); + assert.ok(mappings.some((node) => node.label === "profiles.user_id → users.id")); + assert.ok(mappings.some((node) => node.label === "profiles.invited_user_id → users.id")); + assert.equal(mappings.filter((node) => node.label.endsWith("→ users.id")).length, 2); + assert.ok(scene.edges.length >= 2, "every visible foreign key must have a directional line"); + assert.ok(scene.edges.every((edge) => edge.d.startsWith("M ") && !edge.d.includes("NaN"))); +}); + +test("ClickHouse overview stays bounded and a selected service opens direct context", async () => { + const viewer = await createViewer(); + viewer.state = { ...viewer.state, mode: "clickhouse", sel: null }; + + const overview = viewer.scene(); + assert.match(overview.status, /ClickHouse overview/); + assert.ok(overview.nodes.length <= 54); + assert.ok(overview.nodes.every((node) => !["column", "index", "constraint"].includes(viewer.node(node.id)?.type))); + + const service = viewer.node("service:UsersService"); + service.label = "AnalyticsClickhouseService"; + viewer.reveal(service.id); + assert.equal(viewer.state.mode, "clickhouse"); + const focused = viewer.scene(); + assert.match(focused.status, /One-hop context/); + assert.ok(focused.nodes.length < 40, "a ClickHouse service must not expand the complete database catalog"); +});