feat: add table selection for large database imports (#776)

* feat: add table selection UI for large database imports (>50 tables)

* some changes

* some changes

* some changes

* fix

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
This commit is contained in:
Jonathan Fishner
2025-07-23 11:35:27 +03:00
committed by GitHub
parent b7dbe54c83
commit 0d9f57a9c9
18 changed files with 1348 additions and 120 deletions

73
package-lock.json generated
View File

@@ -28,7 +28,7 @@
"@radix-ui/react-scroll-area": "1.2.0", "@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",
@@ -2255,6 +2255,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-direction": { "node_modules/@radix-ui/react-direction": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
@@ -2968,7 +2986,7 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot": { "node_modules/@radix-ui/react-separator/node_modules/@radix-ui/react-slot": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
@@ -2986,6 +3004,39 @@
} }
} }
}, },
"node_modules/@radix-ui/react-slot": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz",
"integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-slot/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz",
"integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.2.tgz",
@@ -3267,6 +3318,24 @@
} }
} }
}, },
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": { "node_modules/@radix-ui/react-tooltip/node_modules/@radix-ui/react-visually-hidden": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.1.2.tgz",

View File

@@ -36,7 +36,7 @@
"@radix-ui/react-scroll-area": "1.2.0", "@radix-ui/react-scroll-area": "1.2.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.2", "@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tabs": "^1.1.0",
"@radix-ui/react-toast": "^1.2.1", "@radix-ui/react-toast": "^1.2.1",
"@radix-ui/react-toggle": "^1.1.0", "@radix-ui/react-toggle": "^1.1.0",

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type { ButtonProps } from '../button/button';
import { buttonVariants } from '../button/button-variants';
import {
ChevronLeftIcon,
ChevronRightIcon,
DotsHorizontalIcon,
} from '@radix-ui/react-icons';
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
<nav
role="navigation"
aria-label="pagination"
className={cn('mx-auto flex w-full justify-center', className)}
{...props}
/>
);
Pagination.displayName = 'Pagination';
const PaginationContent = React.forwardRef<
HTMLUListElement,
React.ComponentProps<'ul'>
>(({ className, ...props }, ref) => (
<ul
ref={ref}
className={cn('flex flex-row items-center gap-1', className)}
{...props}
/>
));
PaginationContent.displayName = 'PaginationContent';
const PaginationItem = React.forwardRef<
HTMLLIElement,
React.ComponentProps<'li'>
>(({ className, ...props }, ref) => (
<li ref={ref} className={cn('', className)} {...props} />
));
PaginationItem.displayName = 'PaginationItem';
type PaginationLinkProps = {
isActive?: boolean;
} & Pick<ButtonProps, 'size'> &
React.ComponentProps<'a'>;
const PaginationLink = ({
className,
isActive,
size = 'icon',
...props
}: PaginationLinkProps) => (
<a
aria-current={isActive ? 'page' : undefined}
className={cn(
buttonVariants({
variant: isActive ? 'outline' : 'ghost',
size,
}),
className
)}
{...props}
/>
);
PaginationLink.displayName = 'PaginationLink';
const PaginationPrevious = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn('gap-1 pl-2.5', className)}
{...props}
>
<ChevronLeftIcon className="size-4" />
<span>Previous</span>
</PaginationLink>
);
PaginationPrevious.displayName = 'PaginationPrevious';
const PaginationNext = ({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) => (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn('gap-1 pr-2.5', className)}
{...props}
>
<span>Next</span>
<ChevronRightIcon className="size-4" />
</PaginationLink>
);
PaginationNext.displayName = 'PaginationNext';
const PaginationEllipsis = ({
className,
...props
}: React.ComponentProps<'span'>) => (
<span
aria-hidden
className={cn('flex h-9 w-9 items-center justify-center', className)}
{...props}
>
<DotsHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
);
PaginationEllipsis.displayName = 'PaginationEllipsis';
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
};

View File

@@ -43,6 +43,15 @@ import {
} from '@/lib/data/sql-import/sql-validator'; } from '@/lib/data/sql-import/sql-validator';
import { SQLValidationStatus } from './sql-validation-status'; import { SQLValidationStatus } from './sql-validation-status';
const calculateContentSizeMB = (content: string): number => {
return content.length / (1024 * 1024); // Convert to MB
};
const calculateIsLargeFile = (content: string): boolean => {
const contentSizeMB = calculateContentSizeMB(content);
return contentSizeMB > 2; // Consider large if over 2MB
};
const errorScriptOutputMessage = const errorScriptOutputMessage =
'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.'; 'Invalid JSON. Please correct it or contact us at support@chartdb.io for help.';
@@ -246,6 +255,16 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const formatEditor = useCallback(() => { const formatEditor = useCallback(() => {
if (editorRef.current) { if (editorRef.current) {
const model = editorRef.current.getModel();
if (model) {
const content = model.getValue();
// Skip formatting for large files (> 2MB)
if (calculateIsLargeFile(content)) {
return;
}
}
setTimeout(() => { setTimeout(() => {
editorRef.current editorRef.current
?.getAction('editor.action.formatDocument') ?.getAction('editor.action.formatDocument')
@@ -315,14 +334,17 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
const content = model.getValue(); const content = model.getValue();
// Skip formatting for large files (> 2MB) to prevent browser freezing
const isLargeFile = calculateIsLargeFile(content);
// First, detect content type to determine if we should switch modes // First, detect content type to determine if we should switch modes
const detectedType = detectContentType(content); const detectedType = detectContentType(content);
if (detectedType && detectedType !== importMethod) { if (detectedType && detectedType !== importMethod) {
// Switch to the detected mode immediately // Switch to the detected mode immediately
setImportMethod(detectedType); setImportMethod(detectedType);
// Only format if it's JSON (query mode) // Only format if it's JSON (query mode) AND file is not too large
if (detectedType === 'query') { if (detectedType === 'query' && !isLargeFile) {
// For JSON mode, format after a short delay // For JSON mode, format after a short delay
setTimeout(() => { setTimeout(() => {
editor editor
@@ -333,15 +355,15 @@ export const ImportDatabase: React.FC<ImportDatabaseProps> = ({
// For DDL mode, do NOT format as it can break the SQL // For DDL mode, do NOT format as it can break the SQL
} else { } else {
// Content type didn't change, apply formatting based on current mode // Content type didn't change, apply formatting based on current mode
if (importMethod === 'query') { if (importMethod === 'query' && !isLargeFile) {
// Only format JSON content // Only format JSON content if not too large
setTimeout(() => { setTimeout(() => {
editor editor
.getAction('editor.action.formatDocument') .getAction('editor.action.formatDocument')
?.run(); ?.run();
}, 100); }, 100);
} }
// For DDL mode, do NOT format // For DDL mode or large files, do NOT format
} }
}); });

View File

@@ -0,0 +1,2 @@
export const MAX_TABLES_IN_DIAGRAM = 500;
export const MAX_TABLES_WITHOUT_SHOWING_FILTER = 50;

View File

@@ -0,0 +1,665 @@
import React, { useState, useMemo, useEffect, useCallback } from 'react';
import { Button } from '@/components/button/button';
import { Input } from '@/components/input/input';
import { Search, AlertCircle, Check, X, View, Table } from 'lucide-react';
import { Checkbox } from '@/components/checkbox/checkbox';
import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata';
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
import { cn } from '@/lib/utils';
import {
DialogDescription,
DialogFooter,
DialogHeader,
DialogInternalContent,
DialogTitle,
} from '@/components/dialog/dialog';
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
import { generateTableKey } from '@/lib/domain';
import { Spinner } from '@/components/spinner/spinner';
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationPrevious,
PaginationNext,
} from '@/components/pagination/pagination';
import { MAX_TABLES_IN_DIAGRAM } from './constants';
import { useBreakpoint } from '@/hooks/use-breakpoint';
import { useTranslation } from 'react-i18next';
export interface SelectTablesProps {
databaseMetadata?: DatabaseMetadata;
onImport: ({
selectedTables,
databaseMetadata,
}: {
selectedTables?: SelectedTable[];
databaseMetadata?: DatabaseMetadata;
}) => Promise<void>;
onBack: () => void;
isLoading?: boolean;
}
const TABLES_PER_PAGE = 10;
interface TableInfo {
key: string;
schema?: string;
tableName: string;
fullName: string;
type: 'table' | 'view';
}
export const SelectTables: React.FC<SelectTablesProps> = ({
databaseMetadata,
onImport,
onBack,
isLoading = false,
}) => {
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [showTables, setShowTables] = useState(true);
const [showViews, setShowViews] = useState(false);
const { t } = useTranslation();
// Prepare all tables and views with their metadata
const allTables = useMemo(() => {
const tables: TableInfo[] = [];
// Add regular tables
databaseMetadata?.tables.forEach((table) => {
const schema = schemaNameToDomainSchemaName(table.schema);
const tableName = table.table;
const key = `table:${generateTableKey({ tableName, schemaName: schema })}`;
tables.push({
key,
schema,
tableName,
fullName: schema ? `${schema}.${tableName}` : tableName,
type: 'table',
});
});
// Add views
databaseMetadata?.views?.forEach((view) => {
const schema = schemaNameToDomainSchemaName(view.schema);
const viewName = view.view_name;
if (!viewName) {
return;
}
const key = `view:${generateTableKey({
tableName: viewName,
schemaName: schema,
})}`;
tables.push({
key,
schema,
tableName: viewName,
fullName:
schema === 'default' ? viewName : `${schema}.${viewName}`,
type: 'view',
});
});
return tables.sort((a, b) => a.fullName.localeCompare(b.fullName));
}, [databaseMetadata?.tables, databaseMetadata?.views]);
// Count tables and views separately
const tableCount = useMemo(
() => allTables.filter((t) => t.type === 'table').length,
[allTables]
);
const viewCount = useMemo(
() => allTables.filter((t) => t.type === 'view').length,
[allTables]
);
// Initialize selectedTables with all tables (not views) if less than 100 tables
const [selectedTables, setSelectedTables] = useState<Set<string>>(() => {
const tables = allTables.filter((t) => t.type === 'table');
if (tables.length < MAX_TABLES_IN_DIAGRAM) {
return new Set(tables.map((t) => t.key));
}
return new Set();
});
// Filter tables based on search term and type filters
const filteredTables = useMemo(() => {
let filtered = allTables;
// Filter by type
filtered = filtered.filter((table) => {
if (table.type === 'table' && !showTables) return false;
if (table.type === 'view' && !showViews) return false;
return true;
});
// Filter by search term
if (searchTerm.trim()) {
const searchLower = searchTerm.toLowerCase();
filtered = filtered.filter(
(table) =>
table.tableName.toLowerCase().includes(searchLower) ||
table.schema?.toLowerCase().includes(searchLower) ||
table.fullName.toLowerCase().includes(searchLower)
);
}
return filtered;
}, [allTables, searchTerm, showTables, showViews]);
// Calculate pagination
const totalPages = useMemo(
() => Math.max(1, Math.ceil(filteredTables.length / TABLES_PER_PAGE)),
[filteredTables.length]
);
const paginatedTables = useMemo(() => {
const startIndex = (currentPage - 1) * TABLES_PER_PAGE;
const endIndex = startIndex + TABLES_PER_PAGE;
return filteredTables.slice(startIndex, endIndex);
}, [filteredTables, currentPage]);
// Get currently visible selected tables
const visibleSelectedTables = useMemo(() => {
return paginatedTables.filter((table) => selectedTables.has(table.key));
}, [paginatedTables, selectedTables]);
const canAddMore = useMemo(
() => selectedTables.size < MAX_TABLES_IN_DIAGRAM,
[selectedTables.size]
);
const hasSearchResults = useMemo(
() => filteredTables.length > 0,
[filteredTables.length]
);
const allVisibleSelected = useMemo(
() =>
visibleSelectedTables.length === paginatedTables.length &&
paginatedTables.length > 0,
[visibleSelectedTables.length, paginatedTables.length]
);
const canSelectAllFiltered = useMemo(
() =>
filteredTables.length > 0 &&
filteredTables.some((table) => !selectedTables.has(table.key)) &&
canAddMore,
[filteredTables, selectedTables, canAddMore]
);
// Reset to first page when search changes
useEffect(() => {
setCurrentPage(1);
}, [searchTerm]);
const handleTableToggle = useCallback(
(tableKey: string) => {
const newSelected = new Set(selectedTables);
if (newSelected.has(tableKey)) {
newSelected.delete(tableKey);
} else if (selectedTables.size < MAX_TABLES_IN_DIAGRAM) {
newSelected.add(tableKey);
}
setSelectedTables(newSelected);
},
[selectedTables]
);
const handleTogglePageSelection = useCallback(() => {
const newSelected = new Set(selectedTables);
if (allVisibleSelected) {
// Deselect all on current page
for (const table of paginatedTables) {
newSelected.delete(table.key);
}
} else {
// Select all on current page
for (const table of paginatedTables) {
if (newSelected.size >= MAX_TABLES_IN_DIAGRAM) break;
newSelected.add(table.key);
}
}
setSelectedTables(newSelected);
}, [allVisibleSelected, paginatedTables, selectedTables]);
const handleSelectAllFiltered = useCallback(() => {
const newSelected = new Set(selectedTables);
for (const table of filteredTables) {
if (newSelected.size >= MAX_TABLES_IN_DIAGRAM) break;
newSelected.add(table.key);
}
setSelectedTables(newSelected);
}, [filteredTables, selectedTables]);
const handleNextPage = useCallback(() => {
if (currentPage < totalPages) {
setCurrentPage(currentPage + 1);
}
}, [currentPage, totalPages]);
const handlePrevPage = useCallback(() => {
if (currentPage > 1) {
setCurrentPage(currentPage - 1);
}
}, [currentPage]);
const handleClearSelection = useCallback(() => {
setSelectedTables(new Set());
}, []);
const handleConfirm = useCallback(() => {
const selectedTableObjects: SelectedTable[] = Array.from(selectedTables)
.map((key): SelectedTable | null => {
const table = allTables.find((t) => t.key === key);
if (!table) return null;
return {
schema: table.schema,
table: table.tableName,
type: table.type,
} satisfies SelectedTable;
})
.filter((t): t is SelectedTable => t !== null);
onImport({ selectedTables: selectedTableObjects, databaseMetadata });
}, [selectedTables, allTables, onImport, databaseMetadata]);
const { isMd: isDesktop } = useBreakpoint('md');
const renderPagination = useCallback(
() => (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={handlePrevPage}
className={cn(
'cursor-pointer',
currentPage === 1 &&
'pointer-events-none opacity-50'
)}
/>
</PaginationItem>
<PaginationItem>
<span className="px-3 text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={handleNextPage}
className={cn(
'cursor-pointer',
(currentPage >= totalPages ||
filteredTables.length === 0) &&
'pointer-events-none opacity-50'
)}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
),
[
currentPage,
totalPages,
handlePrevPage,
handleNextPage,
filteredTables.length,
]
);
if (isLoading) {
return (
<div className="flex h-[400px] items-center justify-center">
<div className="text-center">
<Spinner className="mb-4" />
<p className="text-sm text-muted-foreground">
Parsing database metadata...
</p>
</div>
</div>
);
}
return (
<>
<DialogHeader>
<DialogTitle>Select Tables to Import</DialogTitle>
<DialogDescription>
{tableCount} {tableCount === 1 ? 'table' : 'tables'}
{viewCount > 0 && (
<>
{' and '}
{viewCount} {viewCount === 1 ? 'view' : 'views'}
</>
)}
{' found. '}
{allTables.length > MAX_TABLES_IN_DIAGRAM
? `Select up to ${MAX_TABLES_IN_DIAGRAM} to import.`
: 'Choose which ones to import.'}
</DialogDescription>
</DialogHeader>
<DialogInternalContent>
<div className="flex h-full flex-col space-y-4">
{/* Warning/Info Banner */}
{allTables.length > MAX_TABLES_IN_DIAGRAM ? (
<div
className={cn(
'flex items-center gap-2 rounded-lg p-3 text-sm',
'bg-amber-50 text-amber-800 dark:bg-amber-950 dark:text-amber-200'
)}
>
<AlertCircle className="size-4 shrink-0" />
<span>
Due to performance limitations, you can import a
maximum of {MAX_TABLES_IN_DIAGRAM} tables.
</span>
</div>
) : null}
{/* Search Input */}
<div className="relative">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search tables..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="px-9"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="size-4" />
</button>
)}
</div>
{/* Selection Status and Actions - Responsive layout */}
<div className="flex flex-col items-center gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4">
{/* Left side: selection count -> checkboxes -> results found */}
<div className="flex flex-col items-center gap-3 text-sm sm:flex-row sm:items-center sm:gap-4">
<div className="flex flex-col items-center gap-1 sm:flex-row sm:items-center sm:gap-4">
<span className="text-center font-medium">
{selectedTables.size} /{' '}
{Math.min(
MAX_TABLES_IN_DIAGRAM,
allTables.length
)}{' '}
items selected
</span>
</div>
<div className="flex items-center gap-3 sm:border-x sm:px-4">
<div className="flex items-center gap-2">
<Checkbox
checked={showTables}
onCheckedChange={(checked) => {
// Prevent unchecking if it's the only one checked
if (!checked && !showViews) return;
setShowTables(!!checked);
}}
/>
<Table
className="size-4"
strokeWidth={1.5}
/>
<span>tables</span>
</div>
<div className="flex items-center gap-2">
<Checkbox
checked={showViews}
onCheckedChange={(checked) => {
// Prevent unchecking if it's the only one checked
if (!checked && !showTables) return;
setShowViews(!!checked);
}}
/>
<View
className="size-4"
strokeWidth={1.5}
/>
<span>views</span>
</div>
</div>
<span className="hidden text-muted-foreground sm:inline">
{filteredTables.length}{' '}
{filteredTables.length === 1
? 'result'
: 'results'}{' '}
found
</span>
</div>
{/* Right side: action buttons */}
<div className="flex flex-wrap items-center justify-center gap-2">
{hasSearchResults && (
<>
{/* Show page selection button when not searching and no selection */}
{!searchTerm &&
selectedTables.size === 0 && (
<Button
variant="outline"
size="sm"
onClick={
handleTogglePageSelection
}
disabled={
paginatedTables.length === 0
}
>
{allVisibleSelected
? 'Deselect'
: 'Select'}{' '}
page
</Button>
)}
{/* Show Select all button when there are unselected tables */}
{canSelectAllFiltered &&
selectedTables.size === 0 && (
<Button
variant="outline"
size="sm"
onClick={
handleSelectAllFiltered
}
disabled={!canSelectAllFiltered}
title={(() => {
const unselectedCount =
filteredTables.filter(
(table) =>
!selectedTables.has(
table.key
)
).length;
const remainingCapacity =
MAX_TABLES_IN_DIAGRAM -
selectedTables.size;
if (
unselectedCount >
remainingCapacity
) {
return `Can only select ${remainingCapacity} more tables (${MAX_TABLES_IN_DIAGRAM} max limit)`;
}
return undefined;
})()}
>
{(() => {
const unselectedCount =
filteredTables.filter(
(table) =>
!selectedTables.has(
table.key
)
).length;
const remainingCapacity =
MAX_TABLES_IN_DIAGRAM -
selectedTables.size;
if (
unselectedCount >
remainingCapacity
) {
return `Select ${remainingCapacity} of ${unselectedCount}`;
}
return `Select all ${unselectedCount}`;
})()}
</Button>
)}
</>
)}
{selectedTables.size > 0 && (
<>
{/* Show page selection/deselection button when user has selections */}
{paginatedTables.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleTogglePageSelection}
>
{allVisibleSelected
? 'Deselect'
: 'Select'}{' '}
page
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={handleClearSelection}
>
Clear selection
</Button>
</>
)}
</div>
</div>
</div>
{/* Table List */}
<div className="flex min-h-[428px] flex-1 flex-col">
{hasSearchResults ? (
<>
<div className="flex-1 py-4">
<div className="space-y-1">
{paginatedTables.map((table) => {
const isSelected = selectedTables.has(
table.key
);
const isDisabled =
!isSelected &&
selectedTables.size >=
MAX_TABLES_IN_DIAGRAM;
return (
<div
key={table.key}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm transition-colors',
{
'cursor-not-allowed':
isDisabled,
'bg-muted hover:bg-muted/80':
isSelected,
'hover:bg-accent':
!isSelected &&
!isDisabled,
}
)}
>
<Checkbox
checked={isSelected}
disabled={isDisabled}
onCheckedChange={() =>
handleTableToggle(
table.key
)
}
/>
{table.type === 'view' ? (
<View
className="size-4"
strokeWidth={1.5}
/>
) : (
<Table
className="size-4"
strokeWidth={1.5}
/>
)}
<span className="flex-1">
{table.schema ? (
<span className="text-muted-foreground">
{table.schema}.
</span>
) : null}
<span className="font-medium">
{table.tableName}
</span>
{table.type === 'view' && (
<span className="ml-2 text-xs text-muted-foreground">
(view)
</span>
)}
</span>
{isSelected && (
<Check className="size-4 text-pink-600" />
)}
</div>
);
})}
</div>
</div>
</>
) : (
<div className="flex h-full items-center justify-center py-4">
<p className="text-sm text-muted-foreground">
{searchTerm
? 'No tables found matching your search.'
: 'Start typing to search for tables...'}
</p>
</div>
)}
</div>
{isDesktop ? renderPagination() : null}
</DialogInternalContent>
<DialogFooter
// className={cn(
// 'gap-2',
// isDesktop
// ? 'flex items-center justify-between'
// : 'flex flex-col'
// )}
className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 md:justify-between md:gap-0"
>
{/* Desktop layout */}
<Button type="button" variant="secondary" onClick={onBack}>
{t('new_diagram_dialog.back')}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedTables.size === 0}
className="bg-pink-500 text-white hover:bg-pink-600"
>
Import {selectedTables.size} Tables
</Button>
{!isDesktop ? renderPagination() : null}
</DialogFooter>
</>
);
};

View File

@@ -1,4 +1,5 @@
export enum CreateDiagramDialogStep { export enum CreateDiagramDialogStep {
SELECT_DATABASE = 'SELECT_DATABASE', SELECT_DATABASE = 'SELECT_DATABASE',
IMPORT_DATABASE = 'IMPORT_DATABASE', IMPORT_DATABASE = 'IMPORT_DATABASE',
SELECT_TABLES = 'SELECT_TABLES',
} }

View File

@@ -15,9 +15,13 @@ import type { DatabaseEdition } from '@/lib/domain/database-edition';
import { SelectDatabase } from './select-database/select-database'; import { SelectDatabase } from './select-database/select-database';
import { CreateDiagramDialogStep } from './create-diagram-dialog-step'; import { CreateDiagramDialogStep } from './create-diagram-dialog-step';
import { ImportDatabase } from '../common/import-database/import-database'; import { ImportDatabase } from '../common/import-database/import-database';
import { SelectTables } from '../common/select-tables/select-tables';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { BaseDialogProps } from '../common/base-dialog-props'; import type { BaseDialogProps } from '../common/base-dialog-props';
import { sqlImportToDiagram } from '@/lib/data/sql-import'; import { sqlImportToDiagram } from '@/lib/data/sql-import';
import type { SelectedTable } from '@/lib/data/import-metadata/filter-metadata';
import { filterMetadataByTables } from '@/lib/data/import-metadata/filter-metadata';
import { MAX_TABLES_WITHOUT_SHOWING_FILTER } from '../common/select-tables/constants';
export interface CreateDiagramDialogProps extends BaseDialogProps {} export interface CreateDiagramDialogProps extends BaseDialogProps {}
@@ -42,6 +46,8 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
const { listDiagrams, addDiagram } = useStorage(); const { listDiagrams, addDiagram } = useStorage();
const [diagramNumber, setDiagramNumber] = useState<number>(1); const [diagramNumber, setDiagramNumber] = useState<number>(1);
const navigate = useNavigate(); const navigate = useNavigate();
const [parsedMetadata, setParsedMetadata] = useState<DatabaseMetadata>();
const [isParsingMetadata, setIsParsingMetadata] = useState(false);
useEffect(() => { useEffect(() => {
setDatabaseEdition(undefined); setDatabaseEdition(undefined);
@@ -62,49 +68,72 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
setDatabaseEdition(undefined); setDatabaseEdition(undefined);
setScriptResult(''); setScriptResult('');
setImportMethod('query'); setImportMethod('query');
setParsedMetadata(undefined);
}, [dialog.open]); }, [dialog.open]);
const hasExistingDiagram = (diagramId ?? '').trim().length !== 0; const hasExistingDiagram = (diagramId ?? '').trim().length !== 0;
const importNewDiagram = useCallback(async () => { const importNewDiagram = useCallback(
let diagram: Diagram | undefined; async ({
selectedTables,
databaseMetadata,
}: {
selectedTables?: SelectedTable[];
databaseMetadata?: DatabaseMetadata;
} = {}) => {
let diagram: Diagram | undefined;
if (importMethod === 'ddl') { if (importMethod === 'ddl') {
diagram = await sqlImportToDiagram({ diagram = await sqlImportToDiagram({
sqlContent: scriptResult, sqlContent: scriptResult,
sourceDatabaseType: databaseType, sourceDatabaseType: databaseType,
targetDatabaseType: databaseType, targetDatabaseType: databaseType,
});
} else {
let metadata: DatabaseMetadata | undefined = databaseMetadata;
if (!metadata) {
metadata = loadDatabaseMetadata(scriptResult);
}
if (selectedTables && selectedTables.length > 0) {
metadata = filterMetadataByTables({
metadata,
selectedTables,
});
}
diagram = await loadFromDatabaseMetadata({
databaseType,
databaseMetadata: metadata,
diagramNumber,
databaseEdition:
databaseEdition?.trim().length === 0
? undefined
: databaseEdition,
});
}
await addDiagram({ diagram });
await updateConfig({
config: { defaultDiagramId: diagram.id },
}); });
} else {
const databaseMetadata: DatabaseMetadata =
loadDatabaseMetadata(scriptResult);
diagram = await loadFromDatabaseMetadata({ closeCreateDiagramDialog();
databaseType, navigate(`/diagrams/${diagram.id}`);
databaseMetadata, },
diagramNumber, [
databaseEdition: importMethod,
databaseEdition?.trim().length === 0 databaseType,
? undefined addDiagram,
: databaseEdition, databaseEdition,
}); closeCreateDiagramDialog,
} navigate,
updateConfig,
await addDiagram({ diagram }); scriptResult,
await updateConfig({ config: { defaultDiagramId: diagram.id } }); diagramNumber,
closeCreateDiagramDialog(); ]
navigate(`/diagrams/${diagram.id}`); );
}, [
importMethod,
databaseType,
addDiagram,
databaseEdition,
closeCreateDiagramDialog,
navigate,
updateConfig,
scriptResult,
diagramNumber,
]);
const createEmptyDiagram = useCallback(async () => { const createEmptyDiagram = useCallback(async () => {
const diagram: Diagram = { const diagram: Diagram = {
@@ -138,10 +167,56 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
openImportDBMLDialog, openImportDBMLDialog,
]); ]);
const importNewDiagramOrFilterTables = useCallback(async () => {
try {
setIsParsingMetadata(true);
if (importMethod === 'ddl') {
await importNewDiagram();
} else {
// Parse metadata asynchronously to avoid blocking the UI
const metadata = await new Promise<DatabaseMetadata>(
(resolve, reject) => {
setTimeout(() => {
try {
const result =
loadDatabaseMetadata(scriptResult);
resolve(result);
} catch (err) {
reject(err);
}
}, 0);
}
);
const totalTablesAndViews =
metadata.tables.length + (metadata.views?.length || 0);
setParsedMetadata(metadata);
// Check if it's a large database that needs table selection
if (totalTablesAndViews > MAX_TABLES_WITHOUT_SHOWING_FILTER) {
setStep(CreateDiagramDialogStep.SELECT_TABLES);
} else {
await importNewDiagram({
databaseMetadata: metadata,
});
}
}
} finally {
setIsParsingMetadata(false);
}
}, [importMethod, scriptResult, importNewDiagram]);
return ( return (
<Dialog <Dialog
{...dialog} {...dialog}
onOpenChange={(open) => { onOpenChange={(open) => {
// Don't allow closing while parsing metadata
if (isParsingMetadata) {
return;
}
if (!hasExistingDiagram) { if (!hasExistingDiagram) {
return; return;
} }
@@ -154,6 +229,8 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
<DialogContent <DialogContent
className="flex max-h-dvh w-full flex-col md:max-w-[900px]" className="flex max-h-dvh w-full flex-col md:max-w-[900px]"
showClose={hasExistingDiagram} showClose={hasExistingDiagram}
onInteractOutside={(e) => e.preventDefault()}
onEscapeKeyDown={(e) => e.preventDefault()}
> >
{step === CreateDiagramDialogStep.SELECT_DATABASE ? ( {step === CreateDiagramDialogStep.SELECT_DATABASE ? (
<SelectDatabase <SelectDatabase
@@ -165,9 +242,9 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
setStep(CreateDiagramDialogStep.IMPORT_DATABASE) setStep(CreateDiagramDialogStep.IMPORT_DATABASE)
} }
/> />
) : ( ) : step === CreateDiagramDialogStep.IMPORT_DATABASE ? (
<ImportDatabase <ImportDatabase
onImport={importNewDiagram} onImport={importNewDiagramOrFilterTables}
onCreateEmptyDiagram={createEmptyDiagram} onCreateEmptyDiagram={createEmptyDiagram}
databaseEdition={databaseEdition} databaseEdition={databaseEdition}
databaseType={databaseType} databaseType={databaseType}
@@ -180,8 +257,18 @@ export const CreateDiagramDialog: React.FC<CreateDiagramDialogProps> = ({
title={t('new_diagram_dialog.import_database.title')} title={t('new_diagram_dialog.import_database.title')}
importMethod={importMethod} importMethod={importMethod}
setImportMethod={setImportMethod} setImportMethod={setImportMethod}
keepDialogAfterImport={true}
/> />
)} ) : step === CreateDiagramDialogStep.SELECT_TABLES ? (
<SelectTables
isLoading={isParsingMetadata || !parsedMetadata}
databaseMetadata={parsedMetadata}
onImport={importNewDiagram}
onBack={() =>
setStep(CreateDiagramDialogStep.IMPORT_DATABASE)
}
/>
) : null}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );

View File

@@ -21,7 +21,7 @@ import { useTranslation } from 'react-i18next';
export interface TableSchemaDialogProps extends BaseDialogProps { export interface TableSchemaDialogProps extends BaseDialogProps {
table?: DBTable; table?: DBTable;
schemas: DBSchema[]; schemas: DBSchema[];
onConfirm: (schema: string) => void; onConfirm: ({ schema }: { schema: DBSchema }) => void;
} }
export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
@@ -31,7 +31,7 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
onConfirm, onConfirm,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [selectedSchema, setSelectedSchema] = React.useState<string>( const [selectedSchemaId, setSelectedSchemaId] = React.useState<string>(
table?.schema table?.schema
? schemaNameToSchemaId(table.schema) ? schemaNameToSchemaId(table.schema)
: (schemas?.[0]?.id ?? '') : (schemas?.[0]?.id ?? '')
@@ -39,7 +39,7 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
useEffect(() => { useEffect(() => {
if (!dialog.open) return; if (!dialog.open) return;
setSelectedSchema( setSelectedSchemaId(
table?.schema table?.schema
? schemaNameToSchemaId(table.schema) ? schemaNameToSchemaId(table.schema)
: (schemas?.[0]?.id ?? '') : (schemas?.[0]?.id ?? '')
@@ -48,8 +48,11 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
const { closeTableSchemaDialog } = useDialog(); const { closeTableSchemaDialog } = useDialog();
const handleConfirm = useCallback(() => { const handleConfirm = useCallback(() => {
onConfirm(selectedSchema); const schema = schemas.find((s) => s.id === selectedSchemaId);
}, [onConfirm, selectedSchema]); if (!schema) return;
onConfirm({ schema });
}, [onConfirm, selectedSchemaId, schemas]);
const schemaOptions: SelectBoxOption[] = useMemo( const schemaOptions: SelectBoxOption[] = useMemo(
() => () =>
@@ -89,9 +92,9 @@ export const TableSchemaDialog: React.FC<TableSchemaDialogProps> = ({
<SelectBox <SelectBox
options={schemaOptions} options={schemaOptions}
multiple={false} multiple={false}
value={selectedSchema} value={selectedSchemaId}
onChange={(value) => onChange={(value) =>
setSelectedSchema(value as string) setSelectedSchemaId(value as string)
} }
/> />
</div> </div>

View File

@@ -0,0 +1,126 @@
import type { DatabaseMetadata } from './metadata-types/database-metadata';
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
export interface SelectedTable {
schema?: string | null;
table: string;
type: 'table' | 'view';
}
export function filterMetadataByTables({
metadata,
selectedTables: inputSelectedTables,
}: {
metadata: DatabaseMetadata;
selectedTables: SelectedTable[];
}): DatabaseMetadata {
const selectedTables = inputSelectedTables.map((st) => {
// Normalize schema names to ensure consistent filtering
const schema = schemaNameToDomainSchemaName(st.schema) ?? '';
return {
...st,
schema,
};
});
// Create sets for faster lookup
const selectedTableSet = new Set(
selectedTables
.filter((st) => st.type === 'table')
.map((st) => `${st.schema}.${st.table}`)
);
const selectedViewSet = new Set(
selectedTables
.filter((st) => st.type === 'view')
.map((st) => `${st.schema}.${st.table}`)
);
// Filter tables
const filteredTables = metadata.tables.filter((table) => {
const schema = schemaNameToDomainSchemaName(table.schema) ?? '';
const tableId = `${schema}.${table.table}`;
return selectedTableSet.has(tableId);
});
// Filter views - include views that were explicitly selected
const filteredViews =
metadata.views?.filter((view) => {
const schema = schemaNameToDomainSchemaName(view.schema) ?? '';
const viewName = view.view_name ?? '';
const viewId = `${schema}.${viewName}`;
return selectedViewSet.has(viewId);
}) || [];
// Filter columns - include columns from both tables and views
const filteredColumns = metadata.columns.filter((col) => {
const fromTable = filteredTables.some(
(tb) => tb.schema === col.schema && tb.table === col.table
);
// For views, the column.table field might contain the view name
const fromView = filteredViews.some(
(view) => view.schema === col.schema && view.view_name === col.table
);
return fromTable || fromView;
});
// Filter primary keys
const filteredPrimaryKeys = metadata.pk_info.filter((pk) =>
filteredTables.some(
(tb) => tb.schema === pk.schema && tb.table === pk.table
)
);
// Filter indexes
const filteredIndexes = metadata.indexes.filter((idx) =>
filteredTables.some(
(tb) => tb.schema === idx.schema && tb.table === idx.table
)
);
// Filter foreign keys - include if either source or target table is selected
// This ensures all relationships related to selected tables are preserved
const filteredForeignKeys = metadata.fk_info.filter((fk) => {
// Handle reference_schema and reference_table fields from the JSON
const targetSchema = fk.reference_schema;
const targetTable = (fk.reference_table || '').replace(/^"+|"+$/g, ''); // Remove extra quotes
const sourceIncluded = filteredTables.some(
(tb) => tb.schema === fk.schema && tb.table === fk.table
);
const targetIncluded = filteredTables.some(
(tb) => tb.schema === targetSchema && tb.table === targetTable
);
return sourceIncluded || targetIncluded;
});
const schemasWithTables = new Set(filteredTables.map((tb) => tb.schema));
const schemasWithViews = new Set(filteredViews.map((view) => view.schema));
// Filter custom types if they exist
const filteredCustomTypes =
metadata.custom_types?.filter((customType) => {
// Also check if the type is used by any of the selected tables' columns
const typeUsedInColumns = filteredColumns.some(
(col) =>
col.type === customType.type ||
col.type.includes(customType.type) // Handle array types like "custom_type[]"
);
return (
schemasWithTables.has(customType.schema) ||
schemasWithViews.has(customType.schema) ||
typeUsedInColumns
);
}) || [];
return {
...metadata,
tables: filteredTables,
columns: filteredColumns,
pk_info: filteredPrimaryKeys,
indexes: filteredIndexes,
fk_info: filteredForeignKeys,
views: filteredViews,
custom_types: filteredCustomTypes,
};
}

View File

@@ -1,4 +1,3 @@
import { schemaNameToDomainSchemaName } from '@/lib/domain/db-schema';
import type { TableInfo } from './table-info'; import type { TableInfo } from './table-info';
import { z } from 'zod'; import { z } from 'zod';
@@ -33,20 +32,12 @@ export type AggregatedIndexInfo = Omit<IndexInfo, 'column'> & {
}; };
export const createAggregatedIndexes = ({ export const createAggregatedIndexes = ({
tableInfo, tableIndexes,
tableSchema,
indexes,
}: { }: {
tableInfo: TableInfo; tableInfo: TableInfo;
indexes: IndexInfo[]; tableIndexes: IndexInfo[];
tableSchema?: string; tableSchema?: string;
}): AggregatedIndexInfo[] => { }): AggregatedIndexInfo[] => {
const tableIndexes = indexes.filter((idx) => {
const indexSchema = schemaNameToDomainSchemaName(idx.schema);
return idx.table === tableInfo.table && indexSchema === tableSchema;
});
return Object.values( return Object.values(
tableIndexes.reduce( tableIndexes.reduce(
(acc, idx) => { (acc, idx) => {

View File

@@ -60,6 +60,10 @@ export const createDependenciesFromMetadata = async ({
tables: DBTable[]; tables: DBTable[];
databaseType: DatabaseType; databaseType: DatabaseType;
}): Promise<DBDependency[]> => { }): Promise<DBDependency[]> => {
if (!views || views.length === 0) {
return [];
}
const { Parser } = await import('node-sql-parser'); const { Parser } = await import('node-sql-parser');
const parser = new Parser(); const parser = new Parser();

View File

@@ -4,7 +4,6 @@ import type { ColumnInfo } from '../data/import-metadata/metadata-types/column-i
import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info'; import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info';
import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info'; import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info';
import type { TableInfo } from '../data/import-metadata/metadata-types/table-info'; import type { TableInfo } from '../data/import-metadata/metadata-types/table-info';
import { schemaNameToDomainSchemaName } from './db-schema';
import { generateId } from '../utils'; import { generateId } from '../utils';
export interface DBField { export interface DBField {
@@ -42,42 +41,30 @@ export const dbFieldSchema: z.ZodType<DBField> = z.object({
}); });
export const createFieldsFromMetadata = ({ export const createFieldsFromMetadata = ({
columns, tableColumns,
tableSchema, tablePrimaryKeys,
tableInfo,
primaryKeys,
aggregatedIndexes, aggregatedIndexes,
}: { }: {
columns: ColumnInfo[]; tableColumns: ColumnInfo[];
tableSchema?: string; tableSchema?: string;
tableInfo: TableInfo; tableInfo: TableInfo;
primaryKeys: PrimaryKeyInfo[]; tablePrimaryKeys: PrimaryKeyInfo[];
aggregatedIndexes: AggregatedIndexInfo[]; aggregatedIndexes: AggregatedIndexInfo[];
}) => { }) => {
const uniqueColumns = columns const uniqueColumns = tableColumns.reduce((acc, col) => {
.filter( if (!acc.has(col.name)) {
(col) => acc.set(col.name, col);
schemaNameToDomainSchemaName(col.schema) === tableSchema && }
col.table === tableInfo.table return acc;
) }, new Map<string, ColumnInfo>());
.reduce((acc, col) => {
if (!acc.has(col.name)) {
acc.set(col.name, col);
}
return acc;
}, new Map<string, ColumnInfo>());
const sortedColumns = Array.from(uniqueColumns.values()).sort( const sortedColumns = Array.from(uniqueColumns.values()).sort(
(a, b) => a.ordinal_position - b.ordinal_position (a, b) => a.ordinal_position - b.ordinal_position
); );
const tablePrimaryKeys = primaryKeys const tablePrimaryKeysColumns = tablePrimaryKeys.map((pk) =>
.filter( pk.column.trim()
(pk) => );
pk.table === tableInfo.table &&
schemaNameToDomainSchemaName(pk.schema) === tableSchema
)
.map((pk) => pk.column.trim());
return sortedColumns.map( return sortedColumns.map(
(col: ColumnInfo): DBField => ({ (col: ColumnInfo): DBField => ({
@@ -87,7 +74,7 @@ export const createFieldsFromMetadata = ({
id: col.type.split(' ').join('_').toLowerCase(), id: col.type.split(' ').join('_').toLowerCase(),
name: col.type.toLowerCase(), name: col.type.toLowerCase(),
}, },
primaryKey: tablePrimaryKeys.includes(col.name), primaryKey: tablePrimaryKeysColumns.includes(col.name),
unique: Object.values(aggregatedIndexes).some( unique: Object.values(aggregatedIndexes).some(
(idx) => (idx) =>
idx.unique && idx.unique &&

View File

@@ -69,6 +69,14 @@ export const dbTableSchema: z.ZodType<DBTable> = z.object({
parentAreaId: z.string().or(z.null()).optional(), parentAreaId: z.string().or(z.null()).optional(),
}); });
export const generateTableKey = ({
schemaName,
tableName,
}: {
schemaName: string | null | undefined;
tableName: string;
}) => `${schemaNameToDomainSchemaName(schemaName) ?? ''}.${tableName}`;
export const shouldShowTableSchemaBySchemaFilter = ({ export const shouldShowTableSchemaBySchemaFilter = ({
filteredSchemas, filteredSchemas,
tableSchema, tableSchema,
@@ -122,20 +130,93 @@ export const createTablesFromMetadata = ({
views: views, views: views,
} = databaseMetadata; } = databaseMetadata;
return tableInfos.map((tableInfo: TableInfo) => { // Pre-compute view names for faster lookup if there are views
const viewNamesSet = new Set<string>();
const materializedViewNamesSet = new Set<string>();
if (views && views.length > 0) {
views.forEach((view) => {
const key = generateTableKey({
schemaName: view.schema,
tableName: view.view_name,
});
viewNamesSet.add(key);
if (
view.view_definition &&
decodeViewDefinition(databaseType, view.view_definition)
.toLowerCase()
.includes('materialized')
) {
materializedViewNamesSet.add(key);
}
});
}
// Pre-compute lookup maps for better performance
const columnsByTable = new Map<string, (typeof columns)[0][]>();
const indexesByTable = new Map<string, (typeof indexes)[0][]>();
const primaryKeysByTable = new Map<string, (typeof primaryKeys)[0][]>();
// Group columns by table
columns.forEach((col) => {
const key = generateTableKey({
schemaName: col.schema,
tableName: col.table,
});
if (!columnsByTable.has(key)) {
columnsByTable.set(key, []);
}
columnsByTable.get(key)!.push(col);
});
// Group indexes by table
indexes.forEach((idx) => {
const key = generateTableKey({
schemaName: idx.schema,
tableName: idx.table,
});
if (!indexesByTable.has(key)) {
indexesByTable.set(key, []);
}
indexesByTable.get(key)!.push(idx);
});
// Group primary keys by table
primaryKeys.forEach((pk) => {
const key = generateTableKey({
schemaName: pk.schema,
tableName: pk.table,
});
if (!primaryKeysByTable.has(key)) {
primaryKeysByTable.set(key, []);
}
primaryKeysByTable.get(key)!.push(pk);
});
const result = tableInfos.map((tableInfo: TableInfo) => {
const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema); const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema);
const tableKey = generateTableKey({
schemaName: tableInfo.schema,
tableName: tableInfo.table,
});
// Use pre-computed lookups instead of filtering entire arrays
const tableIndexes = indexesByTable.get(tableKey) || [];
const tablePrimaryKeys = primaryKeysByTable.get(tableKey) || [];
const tableColumns = columnsByTable.get(tableKey) || [];
// Aggregate indexes with multiple columns // Aggregate indexes with multiple columns
const aggregatedIndexes = createAggregatedIndexes({ const aggregatedIndexes = createAggregatedIndexes({
tableInfo, tableInfo,
tableSchema, tableSchema,
indexes, tableIndexes,
}); });
const fields = createFieldsFromMetadata({ const fields = createFieldsFromMetadata({
aggregatedIndexes, aggregatedIndexes,
columns, tableColumns,
primaryKeys, tablePrimaryKeys,
tableInfo, tableInfo,
tableSchema, tableSchema,
}); });
@@ -145,21 +226,13 @@ export const createTablesFromMetadata = ({
fields, fields,
}); });
// Determine if the current table is a view by checking against viewInfo // Determine if the current table is a view by checking against pre-computed sets
const isView = views.some( const viewKey = generateTableKey({
(view) => schemaName: tableSchema,
schemaNameToDomainSchemaName(view.schema) === tableSchema && tableName: tableInfo.table,
view.view_name === tableInfo.table });
); const isView = viewNamesSet.has(viewKey);
const isMaterializedView = materializedViewNamesSet.has(viewKey);
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 // Initial random positions; these will be adjusted later
return { return {
@@ -181,6 +254,72 @@ export const createTablesFromMetadata = ({
comments: tableInfo.comment ? tableInfo.comment : undefined, comments: tableInfo.comment ? tableInfo.comment : undefined,
}; };
}); });
return result;
};
// Simple grid-based positioning for large databases
const adjustTablePositionsSimple = (
tables: DBTable[],
mode: 'all' | 'perSchema' = 'all'
): DBTable[] => {
const TABLES_PER_ROW = 20;
const TABLE_WIDTH = 250;
const TABLE_HEIGHT = 350;
const GAP_X = 50;
const GAP_Y = 50;
const START_X = 100;
const START_Y = 100;
if (mode === 'perSchema') {
// Group tables by schema for better organization
const tablesBySchema = new Map<string, DBTable[]>();
tables.forEach((table) => {
const schema = table.schema || 'default';
if (!tablesBySchema.has(schema)) {
tablesBySchema.set(schema, []);
}
tablesBySchema.get(schema)!.push(table);
});
const result: DBTable[] = [];
let currentSchemaOffset = 0;
// Position each schema's tables in its own section
tablesBySchema.forEach((schemaTables) => {
schemaTables.forEach((table, index) => {
const row = Math.floor(index / TABLES_PER_ROW);
const col = index % TABLES_PER_ROW;
result.push({
...table,
x: START_X + col * (TABLE_WIDTH + GAP_X),
y:
START_Y +
currentSchemaOffset +
row * (TABLE_HEIGHT + GAP_Y),
});
});
// Add extra spacing between schemas
const schemaRows = Math.ceil(schemaTables.length / TABLES_PER_ROW);
currentSchemaOffset += schemaRows * (TABLE_HEIGHT + GAP_Y) + 200;
});
return result;
}
// Simple mode - just arrange all tables in a grid
return tables.map((table, index) => {
const row = Math.floor(index / TABLES_PER_ROW);
const col = index % TABLES_PER_ROW;
return {
...table,
x: START_X + col * (TABLE_WIDTH + GAP_X),
y: START_Y + row * (TABLE_HEIGHT + GAP_Y),
};
});
}; };
export const adjustTablePositions = ({ export const adjustTablePositions = ({
@@ -192,6 +331,13 @@ export const adjustTablePositions = ({
relationships: DBRelationship[]; relationships: DBRelationship[];
mode?: 'all' | 'perSchema'; mode?: 'all' | 'perSchema';
}): DBTable[] => { }): DBTable[] => {
// For large databases, use simple grid layout for better performance
if (inputTables.length > 200) {
const result = adjustTablePositionsSimple(inputTables, mode);
return result;
}
// For smaller databases, use the existing complex algorithm
const tables = deepCopy(inputTables); const tables = deepCopy(inputTables);
const relationships = deepCopy(inputRelationships); const relationships = deepCopy(inputRelationships);

View File

@@ -108,7 +108,7 @@ export const loadFromDatabaseMetadata = async ({
return a.isView ? 1 : -1; return a.isView ? 1 : -1;
}); });
return { const diagram = {
id: generateDiagramId(), id: generateDiagramId(),
name: databaseMetadata.database_name name: databaseMetadata.database_name
? `${databaseMetadata.database_name}-db` ? `${databaseMetadata.database_name}-db`
@@ -124,4 +124,6 @@ export const loadFromDatabaseMetadata = async ({
createdAt: new Date(), createdAt: new Date(),
updatedAt: new Date(), updatedAt: new Date(),
}; };
return diagram;
}; };

View File

@@ -32,11 +32,11 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
if ((filteredSchemas?.length ?? 0) > 1) { if ((filteredSchemas?.length ?? 0) > 1) {
openTableSchemaDialog({ openTableSchemaDialog({
onConfirm: (schema) => onConfirm: ({ schema }) =>
createTable({ createTable({
x: position.x, x: position.x,
y: position.y, y: position.y,
schema, schema: schema.name,
}), }),
schemas: schemas.filter((schema) => schemas: schemas.filter((schema) =>
filteredSchemas?.includes(schema.id) filteredSchemas?.includes(schema.id)

View File

@@ -37,6 +37,7 @@ import {
TooltipTrigger, TooltipTrigger,
} from '@/components/tooltip/tooltip'; } from '@/components/tooltip/tooltip';
import { cloneTable } from '@/lib/clone'; import { cloneTable } from '@/lib/clone';
import type { DBSchema } from '@/lib/domain';
export interface TableListItemHeaderProps { export interface TableListItemHeaderProps {
table: DBTable; table: DBTable;
@@ -126,8 +127,8 @@ export const TableListItemHeader: React.FC<TableListItemHeaderProps> = ({
}, [table.id, removeTable]); }, [table.id, removeTable]);
const updateTableSchema = useCallback( const updateTableSchema = useCallback(
(schema: string) => { ({ schema }: { schema: DBSchema }) => {
updateTable(table.id, { schema }); updateTable(table.id, { schema: schema.name });
}, },
[table.id, updateTable] [table.id, updateTable]
); );

View File

@@ -20,6 +20,7 @@ import { useDialog } from '@/hooks/use-dialog';
import { TableDBML } from './table-dbml/table-dbml'; import { TableDBML } from './table-dbml/table-dbml';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { getOperatingSystem } from '@/lib/utils'; import { getOperatingSystem } from '@/lib/utils';
import type { DBSchema } from '@/lib/domain';
export interface TablesSectionProps {} export interface TablesSectionProps {}
@@ -45,7 +46,7 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
}, [tables, filterText, filteredSchemas]); }, [tables, filterText, filteredSchemas]);
const createTableWithLocation = useCallback( const createTableWithLocation = useCallback(
async (schema?: string) => { async ({ schema }: { schema?: DBSchema }) => {
const padding = 80; const padding = 80;
const centerX = const centerX =
-viewport.x / viewport.zoom + padding / viewport.zoom; -viewport.x / viewport.zoom + padding / viewport.zoom;
@@ -54,7 +55,7 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
const table = await createTable({ const table = await createTable({
x: centerX, x: centerX,
y: centerY, y: centerY,
schema, schema: schema?.name,
}); });
openTableFromSidebar(table.id); openTableFromSidebar(table.id);
}, },
@@ -80,9 +81,9 @@ export const TablesSection: React.FC<TablesSectionProps> = () => {
} else { } else {
const schema = const schema =
filteredSchemas?.length === 1 filteredSchemas?.length === 1
? schemas.find((s) => s.id === filteredSchemas[0])?.name ? schemas.find((s) => s.id === filteredSchemas[0])
: undefined; : undefined;
createTableWithLocation(schema); createTableWithLocation({ schema });
} }
}, [ }, [
createTableWithLocation, createTableWithLocation,