mirror of
https://github.com/chartdb/chartdb.git
synced 2025-11-02 13:03:17 +00:00
Compare commits
6 Commits
jf/fix-fie
...
fix/relati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfcd30269f | ||
|
|
a5f8e56b3c | ||
|
|
8ffde62c1a | ||
|
|
39247b77a2 | ||
|
|
984b2aeee2 | ||
|
|
eed104be5b |
@@ -44,6 +44,7 @@ export interface CodeSnippetProps {
|
||||
editorProps?: React.ComponentProps<EditorType>;
|
||||
actions?: CodeSnippetAction[];
|
||||
actionsTooltipSide?: 'top' | 'right' | 'bottom' | 'left';
|
||||
allowCopy?: boolean;
|
||||
}
|
||||
|
||||
export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
|
||||
@@ -58,6 +59,7 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
|
||||
editorProps,
|
||||
actions,
|
||||
actionsTooltipSide,
|
||||
allowCopy = true,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const monaco = useMonaco();
|
||||
@@ -131,33 +133,37 @@ export const CodeSnippet: React.FC<CodeSnippetProps> = React.memo(
|
||||
<Suspense fallback={<Spinner />}>
|
||||
{isComplete ? (
|
||||
<div className="absolute right-1 top-1 z-10 flex flex-col gap-1">
|
||||
<Tooltip
|
||||
onOpenChange={setTooltipOpen}
|
||||
open={isCopied || tooltipOpen}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
className="h-fit p-1.5"
|
||||
variant="outline"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CopyCheck size={16} />
|
||||
) : (
|
||||
<Copy size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={actionsTooltipSide}>
|
||||
{t(
|
||||
isCopied
|
||||
? 'copied'
|
||||
: 'copy_to_clipboard'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{allowCopy ? (
|
||||
<Tooltip
|
||||
onOpenChange={setTooltipOpen}
|
||||
open={isCopied || tooltipOpen}
|
||||
>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
className="h-fit p-1.5"
|
||||
variant="outline"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{isCopied ? (
|
||||
<CopyCheck size={16} />
|
||||
) : (
|
||||
<Copy size={16} />
|
||||
)}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side={actionsTooltipSide}
|
||||
>
|
||||
{t(
|
||||
isCopied
|
||||
? 'copied'
|
||||
: 'copy_to_clipboard'
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{actions &&
|
||||
actions.length > 0 &&
|
||||
|
||||
@@ -362,9 +362,10 @@ export const exportBaseSQL = ({
|
||||
.join(', ');
|
||||
|
||||
if (fieldNames) {
|
||||
const indexName = table.schema
|
||||
? `${table.schema}_${index.name}`
|
||||
: index.name;
|
||||
const indexName =
|
||||
table.schema && !isDBMLFlow
|
||||
? `${table.schema}_${index.name}`
|
||||
: index.name;
|
||||
sqlScript += `CREATE ${index.unique ? 'UNIQUE ' : ''}INDEX ${indexName} ON ${tableName} (${fieldNames});\n`;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -937,26 +937,267 @@ describe('DBML Export - Issue Fixes', () => {
|
||||
// Check that indexes are properly formatted with names
|
||||
// Note: When a table has a schema, index names are prefixed with the schema
|
||||
expect(result.standardDbml).toContain(
|
||||
'email [unique, name: "public_idx_email"]'
|
||||
'email [unique, name: "idx_email"]'
|
||||
);
|
||||
expect(result.standardDbml).toContain(
|
||||
'created_at [name: "public_idx_created_at"]'
|
||||
'created_at [name: "idx_created_at"]'
|
||||
);
|
||||
expect(result.standardDbml).toContain(
|
||||
'(email, created_at) [name: "public_idx_email_created"]'
|
||||
'(email, created_at) [name: "idx_email_created"]'
|
||||
);
|
||||
|
||||
// Verify proper index syntax in the table
|
||||
const indexSection = result.standardDbml.match(/Indexes \{[\s\S]*?\}/);
|
||||
expect(indexSection).toBeTruthy();
|
||||
expect(indexSection![0]).toContain('email [unique, name: "idx_email"]');
|
||||
expect(indexSection![0]).toContain(
|
||||
'email [unique, name: "public_idx_email"]'
|
||||
'created_at [name: "idx_created_at"]'
|
||||
);
|
||||
expect(indexSection![0]).toContain(
|
||||
'created_at [name: "public_idx_created_at"]'
|
||||
);
|
||||
expect(indexSection![0]).toContain(
|
||||
'(email, created_at) [name: "public_idx_email_created"]'
|
||||
'(email, created_at) [name: "idx_email_created"]'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tables with multiple relationships correctly', () => {
|
||||
const diagram: Diagram = {
|
||||
id: 'test-diagram',
|
||||
name: 'Test',
|
||||
databaseType: DatabaseType.POSTGRESQL,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
tables: [
|
||||
{
|
||||
id: 'users',
|
||||
name: 'users',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: 'users_id',
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: 'blue',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'posts',
|
||||
name: 'posts',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: 'posts_id',
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'posts_user_id',
|
||||
name: 'user_id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: 'blue',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'reviews',
|
||||
name: 'reviews',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: 'reviews_id',
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'reviews_user_id',
|
||||
name: 'user_id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: 'blue',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'user_activities',
|
||||
name: 'user_activities',
|
||||
x: 0,
|
||||
y: 0,
|
||||
fields: [
|
||||
{
|
||||
id: 'activities_id',
|
||||
name: 'id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: true,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'activities_entity_id',
|
||||
name: 'entity_id',
|
||||
type: { id: 'integer', name: 'integer' },
|
||||
primaryKey: false,
|
||||
nullable: false,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: null,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'activities_type',
|
||||
name: 'activity_type',
|
||||
type: { id: 'varchar', name: 'varchar' },
|
||||
primaryKey: false,
|
||||
nullable: true,
|
||||
unique: false,
|
||||
collation: null,
|
||||
default: null,
|
||||
characterMaximumLength: '50',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
indexes: [],
|
||||
color: 'blue',
|
||||
isView: false,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
relationships: [
|
||||
{
|
||||
id: 'rel1',
|
||||
name: 'fk_posts_user',
|
||||
sourceTableId: 'posts',
|
||||
sourceFieldId: 'posts_user_id',
|
||||
targetTableId: 'users',
|
||||
targetFieldId: 'users_id',
|
||||
sourceCardinality: 'many',
|
||||
targetCardinality: 'one',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'rel2',
|
||||
name: 'fk_reviews_user',
|
||||
sourceTableId: 'reviews',
|
||||
sourceFieldId: 'reviews_user_id',
|
||||
targetTableId: 'users',
|
||||
targetFieldId: 'users_id',
|
||||
sourceCardinality: 'many',
|
||||
targetCardinality: 'one',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'rel3',
|
||||
name: 'fk_activities_posts',
|
||||
sourceTableId: 'user_activities',
|
||||
sourceFieldId: 'activities_entity_id',
|
||||
targetTableId: 'posts',
|
||||
targetFieldId: 'posts_id',
|
||||
sourceCardinality: 'many',
|
||||
targetCardinality: 'one',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'rel4',
|
||||
name: 'fk_activities_reviews',
|
||||
sourceTableId: 'user_activities',
|
||||
sourceFieldId: 'activities_entity_id',
|
||||
targetTableId: 'reviews',
|
||||
targetFieldId: 'reviews_id',
|
||||
sourceCardinality: 'many',
|
||||
targetCardinality: 'one',
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = generateDBMLFromDiagram(diagram);
|
||||
|
||||
// Debug output removed
|
||||
// console.log('Inline DBML:', result.inlineDbml);
|
||||
|
||||
// Check standard DBML output
|
||||
expect(result.standardDbml).toContain('Table "users" {');
|
||||
expect(result.standardDbml).toContain('Table "posts" {');
|
||||
expect(result.standardDbml).toContain('Table "reviews" {');
|
||||
expect(result.standardDbml).toContain('Table "user_activities" {');
|
||||
|
||||
// Check that the entity_id field in user_activities has multiple relationships in inline DBML
|
||||
// The field should have both references in a single bracket
|
||||
expect(result.inlineDbml).toContain(
|
||||
'"entity_id" int [not null, ref: < "posts"."id", ref: < "reviews"."id"]'
|
||||
);
|
||||
|
||||
// Check that standard DBML has separate Ref entries for each relationship
|
||||
expect(result.standardDbml).toContain(
|
||||
'Ref "fk_0_fk_posts_user":"users"."id" < "posts"."user_id"'
|
||||
);
|
||||
expect(result.standardDbml).toContain(
|
||||
'Ref "fk_1_fk_reviews_user":"users"."id" < "reviews"."user_id"'
|
||||
);
|
||||
expect(result.standardDbml).toContain(
|
||||
'Ref "fk_2_fk_activities_posts":"posts"."id" < "user_activities"."entity_id"'
|
||||
);
|
||||
expect(result.standardDbml).toContain(
|
||||
'Ref "fk_3_fk_activities_reviews":"reviews"."id" < "user_activities"."entity_id"'
|
||||
);
|
||||
|
||||
// No automatic comment is added for fields with multiple relationships
|
||||
|
||||
// Check proper formatting - closing brace should be on a new line
|
||||
expect(result.inlineDbml).toMatch(
|
||||
/Table "user_activities" \{\s*\n\s*"id".*\n\s*"entity_id".*\]\s*\n\s*"activity_type".*\n\s*\}/
|
||||
);
|
||||
|
||||
// Ensure no closing brace appears on the same line as a field with inline refs
|
||||
expect(result.inlineDbml).not.toMatch(/\[.*ref:.*\]\}/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -286,9 +286,14 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
// Create a map for faster table lookup
|
||||
const tableMap = new Map(Object.entries(tables));
|
||||
|
||||
// 1. Add inline refs to table contents
|
||||
// 1. First, collect all refs per field
|
||||
const fieldRefs = new Map<
|
||||
string,
|
||||
{ table: string; refs: string[]; relatedTables: string[] }
|
||||
>();
|
||||
|
||||
refs.forEach((ref) => {
|
||||
let targetTableName, fieldNameToModify, inlineRefSyntax;
|
||||
let targetTableName, fieldNameToModify, inlineRefSyntax, relatedTable;
|
||||
|
||||
if (ref.direction === '<') {
|
||||
targetTableName = ref.targetSchema
|
||||
@@ -299,6 +304,7 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
? `"${ref.sourceSchema}"."${ref.sourceTable}"."${ref.sourceField}"`
|
||||
: `"${ref.sourceTable}"."${ref.sourceField}"`;
|
||||
inlineRefSyntax = `ref: < ${sourceRef}`;
|
||||
relatedTable = ref.sourceTable;
|
||||
} else {
|
||||
targetTableName = ref.sourceSchema
|
||||
? `${ref.sourceSchema}.${ref.sourceTable}`
|
||||
@@ -308,13 +314,32 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
? `"${ref.targetSchema}"."${ref.targetTable}"."${ref.targetField}"`
|
||||
: `"${ref.targetTable}"."${ref.targetField}"`;
|
||||
inlineRefSyntax = `ref: > ${targetRef}`;
|
||||
relatedTable = ref.targetTable;
|
||||
}
|
||||
|
||||
const tableData = tableMap.get(targetTableName);
|
||||
const fieldKey = `${targetTableName}.${fieldNameToModify}`;
|
||||
const existing = fieldRefs.get(fieldKey) || {
|
||||
table: targetTableName,
|
||||
refs: [],
|
||||
relatedTables: [],
|
||||
};
|
||||
existing.refs.push(inlineRefSyntax);
|
||||
existing.relatedTables.push(relatedTable);
|
||||
fieldRefs.set(fieldKey, existing);
|
||||
});
|
||||
|
||||
// 2. Apply all refs to fields
|
||||
fieldRefs.forEach((fieldData, fieldKey) => {
|
||||
// fieldKey might be "schema.table.field" or just "table.field"
|
||||
const lastDotIndex = fieldKey.lastIndexOf('.');
|
||||
const tableName = fieldKey.substring(0, lastDotIndex);
|
||||
const fieldName = fieldKey.substring(lastDotIndex + 1);
|
||||
const tableData = tableMap.get(tableName);
|
||||
|
||||
if (tableData) {
|
||||
// Updated pattern to capture field definition and all existing attributes in brackets
|
||||
const fieldPattern = new RegExp(
|
||||
`^([ \t]*"${fieldNameToModify}"[^\\n]*?)(?:\\s*(\\[[^\\]]*\\]))*\\s*(//.*)?$`,
|
||||
`^([ \t]*"${fieldName}"[^\\n]*?)(?:\\s*(\\[[^\\]]*\\]))*\\s*(//.*)?$`,
|
||||
'gm'
|
||||
);
|
||||
let newContent = tableData.content;
|
||||
@@ -322,11 +347,6 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
newContent = newContent.replace(
|
||||
fieldPattern,
|
||||
(lineMatch, fieldPart, existingBrackets, commentPart) => {
|
||||
// Avoid adding duplicate refs
|
||||
if (lineMatch.includes('ref:')) {
|
||||
return lineMatch;
|
||||
}
|
||||
|
||||
// Collect all attributes from existing brackets
|
||||
const allAttributes: string[] = [];
|
||||
if (existingBrackets) {
|
||||
@@ -344,20 +364,24 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new ref
|
||||
allAttributes.push(inlineRefSyntax);
|
||||
// Add all refs for this field
|
||||
allAttributes.push(...fieldData.refs);
|
||||
|
||||
// Combine all attributes into a single bracket
|
||||
const combinedAttributes = allAttributes.join(', ');
|
||||
|
||||
return `${fieldPart.trim()} [${combinedAttributes}]${commentPart || ''}`;
|
||||
// Preserve original spacing from fieldPart
|
||||
const leadingSpaces = fieldPart.match(/^(\s*)/)?.[1] || '';
|
||||
const fieldDefWithoutSpaces = fieldPart.trim();
|
||||
|
||||
return `${leadingSpaces}${fieldDefWithoutSpaces} [${combinedAttributes}]${commentPart || ''}`;
|
||||
}
|
||||
);
|
||||
|
||||
// Update the table content if modified
|
||||
if (newContent !== tableData.content) {
|
||||
tableData.content = newContent;
|
||||
tableMap.set(targetTableName, tableData);
|
||||
tableMap.set(tableName, tableData);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -373,9 +397,18 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
reconstructedDbml += dbml.substring(lastIndex, tableData.start);
|
||||
// Preserve the original table definition format but with updated content
|
||||
const originalTableDef = tableData.fullMatch;
|
||||
|
||||
// Ensure the content ends with proper whitespace before the closing brace
|
||||
let content = tableData.content;
|
||||
// Check if content ends with a field that has inline refs
|
||||
if (content.match(/\[.*ref:.*\]\s*$/)) {
|
||||
// Ensure there's a newline before the closing brace
|
||||
content = content.trimEnd() + '\n';
|
||||
}
|
||||
|
||||
const updatedTableDef = originalTableDef.replace(
|
||||
/{[^}]*}/,
|
||||
`{${tableData.content}}`
|
||||
`{${content}}`
|
||||
);
|
||||
reconstructedDbml += updatedTableDef;
|
||||
lastIndex = tableData.end;
|
||||
@@ -388,7 +421,10 @@ const convertToInlineRefs = (dbml: string): string => {
|
||||
.filter((line) => !line.trim().startsWith('Ref '));
|
||||
const finalDbml = finalLines.join('\n').trim();
|
||||
|
||||
return finalDbml;
|
||||
// Clean up excessive empty lines - replace multiple consecutive empty lines with just one
|
||||
const cleanedDbml = finalDbml.replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||
|
||||
return cleanedDbml;
|
||||
};
|
||||
|
||||
// Function to check for SQL keywords (add more if needed)
|
||||
@@ -804,9 +840,15 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
standard = restoreTableSchemas(standard, diagram);
|
||||
|
||||
// Prepend Enum DBML to the standard output
|
||||
standard = enumsDBML + '\n' + standard;
|
||||
if (enumsDBML) {
|
||||
standard = enumsDBML + '\n\n' + standard;
|
||||
}
|
||||
|
||||
inline = normalizeCharTypeFormat(convertToInlineRefs(standard));
|
||||
|
||||
// Clean up excessive empty lines in both outputs
|
||||
standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||
inline = inline.replace(/\n\s*\n\s*\n/g, '\n\n');
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
'Error during DBML generation process:',
|
||||
@@ -822,11 +864,11 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
|
||||
|
||||
// If an error occurred, still prepend enums if they exist, or they'll be lost.
|
||||
// The error message will then follow.
|
||||
if (standard.startsWith('// Error generating DBML:')) {
|
||||
standard = enumsDBML + standard;
|
||||
if (standard.startsWith('// Error generating DBML:') && enumsDBML) {
|
||||
standard = enumsDBML + '\n\n' + standard;
|
||||
}
|
||||
if (inline.startsWith('// Error generating DBML:')) {
|
||||
inline = enumsDBML + inline;
|
||||
if (inline.startsWith('// Error generating DBML:') && enumsDBML) {
|
||||
inline = enumsDBML + '\n\n' + inline;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,13 +82,15 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
|
||||
openCreateRelationshipDialog();
|
||||
}, [openCreateRelationshipDialog]);
|
||||
|
||||
if (!isDesktop || readonly) {
|
||||
if (!isDesktop) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuTrigger disabled={readonly}>
|
||||
{children}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={createTableHandler}
|
||||
|
||||
@@ -294,6 +294,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
setEdges,
|
||||
showDependenciesOnCanvas,
|
||||
databaseType,
|
||||
tables, // Add tables to force edge recreation when table properties change
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -997,6 +998,19 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
width: event.data.table.width,
|
||||
};
|
||||
|
||||
// Trigger a dimension change to force React Flow to update the node
|
||||
onNodesChangeHandler([
|
||||
{
|
||||
id: event.data.id,
|
||||
type: 'dimensions',
|
||||
dimensions: {
|
||||
width: event.data.table.width,
|
||||
height: node.measured?.height || 0,
|
||||
},
|
||||
resizing: true, // Set resizing flag to ensure the change is processed
|
||||
} as NodeDimensionChange,
|
||||
]);
|
||||
|
||||
newOverlappingGraph = findTableOverlapping(
|
||||
{
|
||||
node: {
|
||||
@@ -1051,7 +1065,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => {
|
||||
setOverlapGraph(overlappingTablesInDiagram);
|
||||
}
|
||||
},
|
||||
[overlapGraph, setOverlapGraph, getNode, nodes, filteredSchemas]
|
||||
[
|
||||
overlapGraph,
|
||||
setOverlapGraph,
|
||||
getNode,
|
||||
nodes,
|
||||
filteredSchemas,
|
||||
onNodesChangeHandler,
|
||||
]
|
||||
);
|
||||
|
||||
events.useSubscription(eventConsumer);
|
||||
|
||||
@@ -383,7 +383,8 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo(
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'content-center text-right text-xs text-muted-foreground overflow-hidden min-w-[3rem] max-w-[8rem]',
|
||||
'content-center text-right text-xs text-muted-foreground overflow-hidden max-w-[8rem]',
|
||||
field.primaryKey ? 'min-w-0' : 'min-w-[3rem]',
|
||||
!readonly ? 'group-hover:hidden' : '',
|
||||
isDiffFieldRemoved
|
||||
? 'text-red-800 dark:text-red-200'
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Textarea } from '@/components/textarea/textarea';
|
||||
import { useDebounce } from '@/hooks/use-debounce';
|
||||
import equal from 'fast-deep-equal';
|
||||
import type { DatabaseType } from '@/lib/domain';
|
||||
import type { DatabaseType, DBTable } from '@/lib/domain';
|
||||
|
||||
import {
|
||||
Select,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
|
||||
export interface TableFieldPopoverProps {
|
||||
field: DBField;
|
||||
table: DBTable;
|
||||
databaseType: DatabaseType;
|
||||
updateField: (attrs: Partial<DBField>) => void;
|
||||
removeField: () => void;
|
||||
@@ -36,6 +37,7 @@ export interface TableFieldPopoverProps {
|
||||
|
||||
export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
|
||||
field,
|
||||
table,
|
||||
databaseType,
|
||||
updateField,
|
||||
removeField,
|
||||
@@ -44,6 +46,19 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
|
||||
const [localField, setLocalField] = React.useState<DBField>(field);
|
||||
const [isOpen, setIsOpen] = React.useState(false);
|
||||
|
||||
// Check if this field is the only primary key in the table
|
||||
const isOnlyPrimaryKey = React.useMemo(() => {
|
||||
if (!field.primaryKey) return false;
|
||||
|
||||
// Early exit if we find another primary key
|
||||
for (const f of table.fields) {
|
||||
if (f.id !== field.id && f.primaryKey) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}, [table.fields, field.primaryKey, field.id]);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalField(field);
|
||||
}, [field]);
|
||||
@@ -113,7 +128,7 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
|
||||
</Label>
|
||||
<Checkbox
|
||||
checked={localField.unique}
|
||||
disabled={field.primaryKey}
|
||||
disabled={isOnlyPrimaryKey}
|
||||
onCheckedChange={(value) =>
|
||||
setLocalField((current) => ({
|
||||
...current,
|
||||
|
||||
@@ -23,8 +23,10 @@ import type {
|
||||
} from '@/components/select-box/select-box';
|
||||
import { SelectBox } from '@/components/select-box/select-box';
|
||||
import { TableFieldPopover } from './table-field-modal/table-field-modal';
|
||||
import type { DBTable } from '@/lib/domain';
|
||||
|
||||
export interface TableFieldProps {
|
||||
table: DBTable;
|
||||
field: DBField;
|
||||
updateField: (attrs: Partial<DBField>) => void;
|
||||
removeField: () => void;
|
||||
@@ -76,6 +78,7 @@ const generateFieldRegexPatterns = (
|
||||
};
|
||||
|
||||
export const TableField: React.FC<TableFieldProps> = ({
|
||||
table,
|
||||
field,
|
||||
updateField,
|
||||
removeField,
|
||||
@@ -83,6 +86,13 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
const { databaseType, customTypes } = useChartDB();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Only calculate primary key fields, not just count
|
||||
const primaryKeyFields = useMemo(() => {
|
||||
return table.fields.filter((f) => f.primaryKey);
|
||||
}, [table.fields]);
|
||||
|
||||
const primaryKeyCount = primaryKeyFields.length;
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition } =
|
||||
useSortable({ id: field.id });
|
||||
|
||||
@@ -191,6 +201,42 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
transition,
|
||||
};
|
||||
|
||||
const handlePrimaryKeyToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
if (value) {
|
||||
// When setting as primary key
|
||||
const updates: Partial<DBField> = {
|
||||
primaryKey: true,
|
||||
};
|
||||
// Only auto-set unique if this will be the only primary key
|
||||
if (primaryKeyCount === 0) {
|
||||
updates.unique = true;
|
||||
}
|
||||
updateField(updates);
|
||||
} else {
|
||||
// When removing primary key
|
||||
updateField({
|
||||
primaryKey: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
[primaryKeyCount, updateField]
|
||||
);
|
||||
|
||||
const handleNullableToggle = useCallback(
|
||||
(value: boolean) => {
|
||||
updateField({ nullable: value });
|
||||
},
|
||||
[updateField]
|
||||
);
|
||||
|
||||
const handleNameChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
updateField({ name: e.target.value });
|
||||
},
|
||||
[updateField]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
|
||||
@@ -215,11 +261,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
'side_panel.tables_section.table.field_name'
|
||||
)}
|
||||
value={field.name}
|
||||
onChange={(e) =>
|
||||
updateField({
|
||||
name: e.target.value,
|
||||
})
|
||||
}
|
||||
onChange={handleNameChange}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
@@ -265,11 +307,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={field.nullable}
|
||||
onPressedChange={(value) =>
|
||||
updateField({
|
||||
nullable: value,
|
||||
})
|
||||
}
|
||||
onPressedChange={handleNullableToggle}
|
||||
>
|
||||
N
|
||||
</TableFieldToggle>
|
||||
@@ -284,12 +322,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
<span>
|
||||
<TableFieldToggle
|
||||
pressed={field.primaryKey}
|
||||
onPressedChange={(value) =>
|
||||
updateField({
|
||||
unique: value,
|
||||
primaryKey: value,
|
||||
})
|
||||
}
|
||||
onPressedChange={handlePrimaryKeyToggle}
|
||||
>
|
||||
<KeyRound className="h-3.5" />
|
||||
</TableFieldToggle>
|
||||
@@ -301,6 +334,7 @@ export const TableField: React.FC<TableFieldProps> = ({
|
||||
</Tooltip>
|
||||
<TableFieldPopover
|
||||
field={field}
|
||||
table={table}
|
||||
updateField={updateField}
|
||||
removeField={removeField}
|
||||
databaseType={databaseType}
|
||||
|
||||
@@ -56,6 +56,32 @@ export const TableListItemContent: React.FC<TableListItemContentProps> = ({
|
||||
>(['fields']);
|
||||
const sensors = useSensors(useSensor(PointerSensor));
|
||||
|
||||
// Create a memoized version of the field updater that handles primary key logic
|
||||
const handleFieldUpdate = useCallback(
|
||||
(fieldId: string, attrs: Partial<DBField>) => {
|
||||
updateField(table.id, fieldId, attrs);
|
||||
|
||||
// Handle the case when removing a primary key and only one remains
|
||||
if (attrs.primaryKey === false) {
|
||||
const remainingPrimaryKeys = table.fields.filter(
|
||||
(f) => f.id !== fieldId && f.primaryKey
|
||||
);
|
||||
if (remainingPrimaryKeys.length === 1) {
|
||||
// Set the remaining primary key field as unique
|
||||
updateField(
|
||||
table.id,
|
||||
remainingPrimaryKeys[0].id,
|
||||
{
|
||||
unique: true,
|
||||
},
|
||||
{ updateHistory: false }
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
[table.id, table.fields, updateField]
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
@@ -147,14 +173,9 @@ export const TableListItemContent: React.FC<TableListItemContentProps> = ({
|
||||
<TableField
|
||||
key={field.id}
|
||||
field={field}
|
||||
updateField={(
|
||||
attrs: Partial<DBField>
|
||||
) =>
|
||||
updateField(
|
||||
table.id,
|
||||
field.id,
|
||||
attrs
|
||||
)
|
||||
table={table}
|
||||
updateField={(attrs) =>
|
||||
handleFieldUpdate(field.id, attrs)
|
||||
}
|
||||
removeField={() =>
|
||||
removeField(table.id, field.id)
|
||||
|
||||
Reference in New Issue
Block a user