Compare commits

...

6 Commits

10 changed files with 472 additions and 88 deletions

View File

@@ -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 &&

View File

@@ -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`;
}
});

View File

@@ -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:.*\]\}/);
});
});

View File

@@ -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;
}
}

View File

@@ -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}

View File

@@ -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);

View File

@@ -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'

View File

@@ -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,

View File

@@ -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}

View File

@@ -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)