mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 04:53:27 +00:00
422 lines
13 KiB
TypeScript
422 lines
13 KiB
TypeScript
import {
|
|
createIndexesFromMetadata,
|
|
dbIndexSchema,
|
|
type DBIndex,
|
|
} from './db-index';
|
|
import {
|
|
createFieldsFromMetadata,
|
|
dbFieldSchema,
|
|
type DBField,
|
|
} from './db-field';
|
|
import type { TableInfo } from '../data/import-metadata/metadata-types/table-info';
|
|
import { createAggregatedIndexes } from '../data/import-metadata/metadata-types/index-info';
|
|
import { materializedViewColor, viewColor, randomColor } from '@/lib/colors';
|
|
import type { DBRelationship } from './db-relationship';
|
|
import {
|
|
decodeBase64ToUtf16LE,
|
|
decodeBase64ToUtf8,
|
|
deepCopy,
|
|
generateId,
|
|
} from '../utils';
|
|
import {
|
|
schemaNameToDomainSchemaName,
|
|
schemaNameToSchemaId,
|
|
} from './db-schema';
|
|
import { DatabaseType } from './database-type';
|
|
import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata';
|
|
import { z } from 'zod';
|
|
|
|
export const MAX_TABLE_SIZE = 450;
|
|
export const MID_TABLE_SIZE = 337;
|
|
export const MIN_TABLE_SIZE = 224;
|
|
export const TABLE_MINIMIZED_FIELDS = 10;
|
|
|
|
export interface DBTable {
|
|
id: string;
|
|
name: string;
|
|
schema?: string | null;
|
|
x: number;
|
|
y: number;
|
|
fields: DBField[];
|
|
indexes: DBIndex[];
|
|
color: string;
|
|
isView: boolean;
|
|
isMaterializedView?: boolean | null;
|
|
createdAt: number;
|
|
width?: number | null;
|
|
comments?: string | null;
|
|
order?: number | null;
|
|
expanded?: boolean | null;
|
|
parentAreaId?: string | null;
|
|
}
|
|
|
|
export const dbTableSchema: z.ZodType<DBTable> = z.object({
|
|
id: z.string(),
|
|
name: z.string(),
|
|
schema: z.string().or(z.null()).optional(),
|
|
x: z.number(),
|
|
y: z.number(),
|
|
fields: z.array(dbFieldSchema),
|
|
indexes: z.array(dbIndexSchema),
|
|
color: z.string(),
|
|
isView: z.boolean(),
|
|
isMaterializedView: z.boolean().or(z.null()).optional(),
|
|
createdAt: z.number(),
|
|
width: z.number().or(z.null()).optional(),
|
|
comments: z.string().or(z.null()).optional(),
|
|
order: z.number().or(z.null()).optional(),
|
|
expanded: z.boolean().or(z.null()).optional(),
|
|
parentAreaId: z.string().or(z.null()).optional(),
|
|
});
|
|
|
|
export const shouldShowTableSchemaBySchemaFilter = ({
|
|
filteredSchemas,
|
|
tableSchema,
|
|
}: {
|
|
tableSchema?: string | null;
|
|
filteredSchemas?: string[];
|
|
}): boolean =>
|
|
!filteredSchemas ||
|
|
!tableSchema ||
|
|
filteredSchemas.includes(schemaNameToSchemaId(tableSchema));
|
|
|
|
export const shouldShowTablesBySchemaFilter = (
|
|
table: DBTable,
|
|
filteredSchemas?: string[]
|
|
): boolean =>
|
|
shouldShowTableSchemaBySchemaFilter({
|
|
filteredSchemas,
|
|
tableSchema: table?.schema,
|
|
});
|
|
|
|
export const decodeViewDefinition = (
|
|
databaseType: DatabaseType,
|
|
viewDefinition?: string
|
|
): string => {
|
|
if (!viewDefinition) {
|
|
return '';
|
|
}
|
|
|
|
let decodedViewDefinition: string;
|
|
if (databaseType === DatabaseType.SQL_SERVER) {
|
|
decodedViewDefinition = decodeBase64ToUtf16LE(viewDefinition);
|
|
} else {
|
|
decodedViewDefinition = decodeBase64ToUtf8(viewDefinition);
|
|
}
|
|
|
|
return decodedViewDefinition;
|
|
};
|
|
|
|
export const createTablesFromMetadata = ({
|
|
databaseMetadata,
|
|
databaseType,
|
|
}: {
|
|
databaseMetadata: DatabaseMetadata;
|
|
databaseType: DatabaseType;
|
|
}): DBTable[] => {
|
|
const {
|
|
tables: tableInfos,
|
|
pk_info: primaryKeys,
|
|
columns,
|
|
indexes,
|
|
views: views,
|
|
} = databaseMetadata;
|
|
|
|
return tableInfos.map((tableInfo: TableInfo) => {
|
|
const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema);
|
|
|
|
// Aggregate indexes with multiple columns
|
|
const aggregatedIndexes = createAggregatedIndexes({
|
|
tableInfo,
|
|
tableSchema,
|
|
indexes,
|
|
});
|
|
|
|
const fields = createFieldsFromMetadata({
|
|
aggregatedIndexes,
|
|
columns,
|
|
primaryKeys,
|
|
tableInfo,
|
|
tableSchema,
|
|
});
|
|
|
|
const dbIndexes = createIndexesFromMetadata({
|
|
aggregatedIndexes,
|
|
fields,
|
|
});
|
|
|
|
// Determine if the current table is a view by checking against viewInfo
|
|
const isView = views.some(
|
|
(view) =>
|
|
schemaNameToDomainSchemaName(view.schema) === tableSchema &&
|
|
view.view_name === tableInfo.table
|
|
);
|
|
|
|
const isMaterializedView = views.some(
|
|
(view) =>
|
|
schemaNameToDomainSchemaName(view.schema) === tableSchema &&
|
|
view.view_name === tableInfo.table &&
|
|
decodeViewDefinition(databaseType, view.view_definition)
|
|
.toLowerCase()
|
|
.includes('materialized')
|
|
);
|
|
|
|
// Initial random positions; these will be adjusted later
|
|
return {
|
|
id: generateId(),
|
|
name: tableInfo.table,
|
|
schema: tableSchema,
|
|
x: Math.random() * 1000, // Placeholder X
|
|
y: Math.random() * 800, // Placeholder Y
|
|
fields,
|
|
indexes: dbIndexes,
|
|
color: isMaterializedView
|
|
? materializedViewColor
|
|
: isView
|
|
? viewColor
|
|
: randomColor(),
|
|
isView: isView,
|
|
isMaterializedView: isMaterializedView,
|
|
createdAt: Date.now(),
|
|
comments: tableInfo.comment ? tableInfo.comment : undefined,
|
|
};
|
|
});
|
|
};
|
|
|
|
export const adjustTablePositions = ({
|
|
relationships: inputRelationships,
|
|
tables: inputTables,
|
|
mode = 'all',
|
|
}: {
|
|
tables: DBTable[];
|
|
relationships: DBRelationship[];
|
|
mode?: 'all' | 'perSchema';
|
|
}): DBTable[] => {
|
|
const tables = deepCopy(inputTables);
|
|
const relationships = deepCopy(inputRelationships);
|
|
|
|
const adjustPositionsForTables = (tablesToAdjust: DBTable[]) => {
|
|
const defaultTableWidth = 200;
|
|
const defaultTableHeight = 300;
|
|
const gapX = 100;
|
|
const gapY = 100;
|
|
const startX = 100;
|
|
const startY = 100;
|
|
|
|
// Create a map of table connections
|
|
const tableConnections = new Map<string, Set<string>>();
|
|
relationships.forEach((rel) => {
|
|
if (!tableConnections.has(rel.sourceTableId)) {
|
|
tableConnections.set(rel.sourceTableId, new Set());
|
|
}
|
|
if (!tableConnections.has(rel.targetTableId)) {
|
|
tableConnections.set(rel.targetTableId, new Set());
|
|
}
|
|
tableConnections.get(rel.sourceTableId)!.add(rel.targetTableId);
|
|
tableConnections.get(rel.targetTableId)!.add(rel.sourceTableId);
|
|
});
|
|
|
|
// Sort tables by number of connections
|
|
const sortedTables = [...tablesToAdjust].sort(
|
|
(a, b) =>
|
|
(tableConnections.get(b.id)?.size || 0) -
|
|
(tableConnections.get(a.id)?.size || 0)
|
|
);
|
|
|
|
const positionedTables = new Set<string>();
|
|
const tablePositions = new Map<string, { x: number; y: number }>();
|
|
|
|
const getTableWidthAndHeight = (
|
|
tableId: string
|
|
): {
|
|
width: number;
|
|
height: number;
|
|
} => {
|
|
const table = tablesToAdjust.find((t) => t.id === tableId);
|
|
|
|
if (!table)
|
|
return { width: defaultTableWidth, height: defaultTableHeight };
|
|
|
|
return getTableDimensions(table);
|
|
};
|
|
|
|
const isOverlapping = (
|
|
x: number,
|
|
y: number,
|
|
currentTableId: string
|
|
): boolean => {
|
|
for (const [tableId, pos] of tablePositions) {
|
|
if (tableId === currentTableId) continue;
|
|
|
|
const { width, height } = getTableWidthAndHeight(tableId);
|
|
if (
|
|
Math.abs(x - pos.x) < width + gapX &&
|
|
Math.abs(y - pos.y) < height + gapY
|
|
) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const findNonOverlappingPosition = (
|
|
baseX: number,
|
|
baseY: number,
|
|
tableId: string
|
|
): { x: number; y: number } => {
|
|
const { width, height } = getTableWidthAndHeight(tableId);
|
|
const spiralStep = Math.max(width, height) / 2;
|
|
let angle = 0;
|
|
let radius = 0;
|
|
let iterations = 0;
|
|
const maxIterations = 1000; // Prevent infinite loop
|
|
|
|
while (iterations < maxIterations) {
|
|
const x = baseX + radius * Math.cos(angle);
|
|
const y = baseY + radius * Math.sin(angle);
|
|
if (!isOverlapping(x, y, tableId)) {
|
|
return { x, y };
|
|
}
|
|
angle += Math.PI / 4;
|
|
if (angle >= 2 * Math.PI) {
|
|
angle = 0;
|
|
radius += spiralStep;
|
|
}
|
|
iterations++;
|
|
}
|
|
|
|
// If we can't find a non-overlapping position, return a position far from others
|
|
return {
|
|
x: baseX + radius * Math.cos(angle),
|
|
y: baseY + radius * Math.sin(angle),
|
|
};
|
|
};
|
|
|
|
const positionTable = (
|
|
table: DBTable,
|
|
baseX: number,
|
|
baseY: number
|
|
) => {
|
|
if (positionedTables.has(table.id)) return;
|
|
|
|
const { x, y } = findNonOverlappingPosition(baseX, baseY, table.id);
|
|
|
|
table.x = x;
|
|
table.y = y;
|
|
tablePositions.set(table.id, { x: table.x, y: table.y });
|
|
positionedTables.add(table.id);
|
|
|
|
// Position connected tables
|
|
const connectedTables = tableConnections.get(table.id) || new Set();
|
|
let angle = 0;
|
|
const angleStep = (2 * Math.PI) / connectedTables.size;
|
|
|
|
connectedTables.forEach((connectedTableId) => {
|
|
if (!positionedTables.has(connectedTableId)) {
|
|
const connectedTable = tablesToAdjust.find(
|
|
(t) => t.id === connectedTableId
|
|
);
|
|
if (connectedTable) {
|
|
const { width: tableWidth, height: tableHeight } =
|
|
getTableWidthAndHeight(table.id);
|
|
const {
|
|
width: connectedTableWidth,
|
|
height: connectedTableHeight,
|
|
} = getTableWidthAndHeight(connectedTableId);
|
|
const avgWidth = (tableWidth + connectedTableWidth) / 2;
|
|
|
|
const avgHeight =
|
|
(tableHeight + connectedTableHeight) / 2;
|
|
|
|
const newX =
|
|
x + Math.cos(angle) * (avgWidth + gapX * 2);
|
|
const newY =
|
|
y + Math.sin(angle) * (avgHeight + gapY * 2);
|
|
positionTable(connectedTable, newX, newY);
|
|
angle += angleStep;
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
// Position tables
|
|
sortedTables.forEach((table, index) => {
|
|
if (!positionedTables.has(table.id)) {
|
|
const row = Math.floor(index / 6);
|
|
const col = index % 6;
|
|
const { width: tableWidth, height: tableHeight } =
|
|
getTableWidthAndHeight(table.id);
|
|
|
|
const x = startX + col * (tableWidth + gapX * 2);
|
|
const y = startY + row * (tableHeight + gapY * 2);
|
|
positionTable(table, x, y);
|
|
}
|
|
});
|
|
|
|
// Apply positions to tables
|
|
tablesToAdjust.forEach((table) => {
|
|
const position = tablePositions.get(table.id);
|
|
if (position) {
|
|
table.x = position.x;
|
|
table.y = position.y;
|
|
}
|
|
});
|
|
};
|
|
|
|
if (mode === 'perSchema') {
|
|
// Group tables by schema
|
|
const tablesBySchema = tables.reduce(
|
|
(acc, table) => {
|
|
const schema = table.schema || 'default';
|
|
if (!acc[schema]) {
|
|
acc[schema] = [];
|
|
}
|
|
acc[schema].push(table);
|
|
return acc;
|
|
},
|
|
{} as Record<string, DBTable[]>
|
|
);
|
|
|
|
// Adjust positions for each schema group
|
|
Object.values(tablesBySchema).forEach(adjustPositionsForTables);
|
|
} else {
|
|
// Adjust positions for all tables
|
|
adjustPositionsForTables(tables);
|
|
}
|
|
|
|
return tables;
|
|
};
|
|
|
|
export const calcTableHeight = (table?: DBTable): number => {
|
|
if (!table) {
|
|
return 300;
|
|
}
|
|
|
|
const FIELD_HEIGHT = 32; // h-8 per field
|
|
const TABLE_FOOTER_HEIGHT = 32; // h-8 for show more button
|
|
const TABLE_HEADER_HEIGHT = 42;
|
|
// Calculate how many fields are visible
|
|
const fieldCount = table.fields.length;
|
|
let visibleFieldCount = fieldCount;
|
|
|
|
// If not expanded, use minimum of field count and TABLE_MINIMIZED_FIELDS
|
|
if (!table.expanded) {
|
|
visibleFieldCount = Math.min(fieldCount, TABLE_MINIMIZED_FIELDS);
|
|
}
|
|
|
|
// Calculate height based on visible fields
|
|
const fieldsHeight = visibleFieldCount * FIELD_HEIGHT;
|
|
const showMoreButtonHeight =
|
|
fieldCount > TABLE_MINIMIZED_FIELDS ? TABLE_FOOTER_HEIGHT : 0;
|
|
|
|
return TABLE_HEADER_HEIGHT + fieldsHeight + showMoreButtonHeight;
|
|
};
|
|
|
|
export const getTableDimensions = (
|
|
table: DBTable
|
|
): { width: number; height: number } => {
|
|
const height = calcTableHeight(table);
|
|
const width = table.width || MIN_TABLE_SIZE;
|
|
return { width, height };
|
|
};
|