mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 07:11:56 +00:00
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:
73
package-lock.json
generated
73
package-lock.json
generated
@@ -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",
|
||||||
|
@@ -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",
|
||||||
|
121
src/components/pagination/pagination.tsx
Normal file
121
src/components/pagination/pagination.tsx
Normal 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,
|
||||||
|
};
|
@@ -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
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
2
src/dialogs/common/select-tables/constants.ts
Normal file
2
src/dialogs/common/select-tables/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const MAX_TABLES_IN_DIAGRAM = 500;
|
||||||
|
export const MAX_TABLES_WITHOUT_SHOWING_FILTER = 50;
|
665
src/dialogs/common/select-tables/select-tables.tsx
Normal file
665
src/dialogs/common/select-tables/select-tables.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
@@ -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',
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
);
|
);
|
||||||
|
@@ -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>
|
||||||
|
126
src/lib/data/import-metadata/filter-metadata.ts
Normal file
126
src/lib/data/import-metadata/filter-metadata.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@@ -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) => {
|
||||||
|
@@ -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();
|
||||||
|
|
||||||
|
@@ -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 &&
|
||||||
|
@@ -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);
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
};
|
};
|
||||||
|
@@ -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)
|
||||||
|
@@ -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]
|
||||||
);
|
);
|
||||||
|
@@ -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,
|
||||||
|
Reference in New Issue
Block a user