Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 84 additions & 40 deletions assets/viewer/index.template.html

Large diffs are not rendered by default.

124 changes: 115 additions & 9 deletions src/adapters/nest-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type Decorator,
type FunctionDeclaration,
type MethodDeclaration,
type PropertyDeclaration,
type SourceFile,
} from "ts-morph";
import type { GraphEdge, GraphNode, GraphNodeType } from "../core/types.js";
Expand Down Expand Up @@ -36,6 +37,7 @@ interface DrizzleTableInfo {
tableName: string;
tableId: string;
file: string;
columns: Map<string, string>;
}

interface ClassInfo {
Expand Down Expand Up @@ -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<string, unknown>, 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");
Expand Down Expand Up @@ -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);
}
}
}
Expand All @@ -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());
Expand Down Expand Up @@ -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" } : {}),
});
}
}

Expand Down Expand Up @@ -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}`;
Expand Down Expand Up @@ -612,7 +672,7 @@ async function parsePrisma(context: AdapterContext, addNode: NodeAdder, addEdge:

function parseDrizzleSchemas(sourceFiles: SourceFile[], projectRoot: string, addNode: NodeAdder, addEdge: EdgeAdder): Map<string, DrizzleTableInfo> {
const tables = new Map<string, DrizzleTableInfo>();
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"]);

Expand All @@ -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 } });
Expand All @@ -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}`;
Expand All @@ -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)) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
Expand Down
49 changes: 42 additions & 7 deletions src/adapters/project-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)!;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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",
});
}
}
}
Expand All @@ -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;
}
Expand All @@ -223,14 +237,35 @@ 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}`;
addNode({ id: columnId, type: "column", label: `${qualified}.${name}`, name, file: file.path, framework: clickhouse ? "clickhouse" : "sql", source: "config", confidence: 0.9, metadata: { type: addColumn[2], addedByMigration: migrationId } });
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) {
Expand Down
Loading