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");
+});