diff --git a/src/hooks/use-update-table-field.ts b/src/hooks/use-update-table-field.ts index 72225c08..f25f92b8 100644 --- a/src/hooks/use-update-table-field.ts +++ b/src/hooks/use-update-table-field.ts @@ -10,6 +10,8 @@ import { dataTypeDataToDataType, sortedDataTypeMap, supportsArrayDataType, + autoIncrementAlwaysOn, + requiresNotNull, } from '@/lib/data/data-types/data-types'; import { generateDBFieldSuffix } from '@/lib/domain/db-field'; import type { DataTypeData } from '@/lib/data/data-types/data-types'; @@ -224,12 +226,17 @@ export const useUpdateTableField = ( } } + const newTypeName = dataType?.name ?? (value as string); + const typeRequiresNotNull = requiresNotNull(newTypeName); + const shouldForceIncrement = autoIncrementAlwaysOn(newTypeName); + updateField(table.id, field.id, { characterMaximumLength, precision, scale, isArray, - increment: undefined, + ...(typeRequiresNotNull ? { nullable: false } : {}), + increment: shouldForceIncrement ? true : undefined, default: undefined, type: dataTypeDataToDataType( dataType ?? { @@ -267,9 +274,16 @@ export const useUpdateTableField = ( const debouncedNullableUpdate = useDebounce( useCallback( (value: boolean) => { - updateField(table.id, field.id, { nullable: value }); + const updates: Partial = { nullable: value }; + + // If setting to nullable, clear increment (auto-increment requires NOT NULL) + if (value && field.increment) { + updates.increment = undefined; + } + + updateField(table.id, field.id, updates); }, - [updateField, table.id, field.id] + [updateField, table.id, field.id, field.increment] ), 100 // 100ms debounce for toggle ); diff --git a/src/lib/data/data-types/data-types.ts b/src/lib/data/data-types/data-types.ts index f44b1910..701da23b 100644 --- a/src/lib/data/data-types/data-types.ts +++ b/src/lib/data/data-types/data-types.ts @@ -167,6 +167,18 @@ export const supportsAutoIncrementDataType = ( ].includes(dataTypeName.toLocaleLowerCase()); }; +export const autoIncrementAlwaysOn = (dataTypeName: string): boolean => { + return ['serial', 'bigserial', 'smallserial'].includes( + dataTypeName.toLowerCase() + ); +}; + +export const requiresNotNull = (dataTypeName: string): boolean => { + return ['serial', 'bigserial', 'smallserial'].includes( + dataTypeName.toLowerCase() + ); +}; + const ARRAY_INCOMPATIBLE_TYPES = [ 'serial', 'bigserial', diff --git a/src/lib/data/sql-export/export-sql-script.ts b/src/lib/data/sql-export/export-sql-script.ts index 39f9ef82..cd019dbd 100644 --- a/src/lib/data/sql-export/export-sql-script.ts +++ b/src/lib/data/sql-export/export-sql-script.ts @@ -395,9 +395,26 @@ export const exportBaseSQL = ({ sqlScript += ` UNIQUE`; } - // Handle AUTO INCREMENT - add as a comment for AI to process + // Handle AUTO INCREMENT if (field.increment) { - sqlScript += ` /* AUTO_INCREMENT */`; + if (isDBMLFlow) { + // For DBML flow, generate proper database-specific syntax + if ( + targetDatabaseType === DatabaseType.MYSQL || + targetDatabaseType === DatabaseType.MARIADB + ) { + sqlScript += ` AUTO_INCREMENT`; + } else if (targetDatabaseType === DatabaseType.SQL_SERVER) { + sqlScript += ` IDENTITY(1,1)`; + } else if (targetDatabaseType === DatabaseType.SQLITE) { + // SQLite AUTOINCREMENT only works with INTEGER PRIMARY KEY + // Will be handled when PRIMARY KEY is added + } + // PostgreSQL/CockroachDB: increment attribute added by restoreIncrementAttribute in DBML export + } else { + // For non-DBML flow, add as a comment for AI to process + sqlScript += ` /* AUTO_INCREMENT */`; + } } // Handle DEFAULT value @@ -450,6 +467,17 @@ export const exportBaseSQL = ({ const pkIndex = table.indexes.find((idx) => idx.isPrimaryKey); if (field.primaryKey && !hasCompositePrimaryKey && !pkIndex?.name) { sqlScript += ' PRIMARY KEY'; + + // For SQLite with DBML flow, add AUTOINCREMENT after PRIMARY KEY + if ( + isDBMLFlow && + field.increment && + targetDatabaseType === DatabaseType.SQLITE && + (typeName.toLowerCase() === 'integer' || + typeName.toLowerCase() === 'int') + ) { + sqlScript += ' AUTOINCREMENT'; + } } // Add a comma after each field except the last one (or before PK constraint) diff --git a/src/lib/dbml/dbml-export/__tests__/cases/4.dbml b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml index 3b2bcd98..084bd53b 100644 --- a/src/lib/dbml/dbml-export/__tests__/cases/4.dbml +++ b/src/lib/dbml/dbml-export/__tests__/cases/4.dbml @@ -1,5 +1,5 @@ Table "public"."orders" { - "order_id" integer [pk, not null] + "order_id" integer [pk, not null, increment] "customer_id" integer [not null] "order_date" date [not null, default: `CURRENT_DATE`] "total_amount" numeric [not null, default: 0] diff --git a/src/lib/dbml/dbml-export/__tests__/cases/6.dbml b/src/lib/dbml/dbml-export/__tests__/cases/6.dbml new file mode 100644 index 00000000..5fd1cb90 --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/6.dbml @@ -0,0 +1,14 @@ +Table "users" { + "id" integer [pk, not null, increment] + "username" varchar(100) [unique, not null] + "email" varchar(255) [not null] +} + +Table "posts" { + "post_id" bigint [pk, not null, increment] + "user_id" integer [not null] + "title" varchar(200) [not null] + "order_num" integer [not null, increment] +} + +Ref "fk_0_fk_posts_users":"users"."id" < "posts"."user_id" diff --git a/src/lib/dbml/dbml-export/__tests__/cases/6.json b/src/lib/dbml/dbml-export/__tests__/cases/6.json new file mode 100644 index 00000000..189f499b --- /dev/null +++ b/src/lib/dbml/dbml-export/__tests__/cases/6.json @@ -0,0 +1 @@ +{"id":"test_auto_increment","name":"Auto Increment Test (mysql)","createdAt":"2025-01-20T00:00:00.000Z","updatedAt":"2025-01-20T00:00:00.000Z","databaseType":"mysql","tables":[{"id":"table1","name":"users","order":1,"fields":[{"id":"field1","name":"id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field2","name":"username","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":true,"default":"","increment":false,"characterMaximumLength":"100","createdAt":1705708800000},{"id":"field3","name":"email","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"255","createdAt":1705708800000}],"indexes":[],"x":100,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000},{"id":"table2","name":"posts","order":2,"fields":[{"id":"field4","name":"post_id","type":{"id":"bigint","name":"bigint"},"nullable":false,"primaryKey":true,"unique":false,"default":"","increment":true,"createdAt":1705708800000},{"id":"field5","name":"user_id","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"createdAt":1705708800000},{"id":"field6","name":"title","type":{"id":"varchar","name":"varchar","fieldAttributes":{"hasCharMaxLength":true}},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":false,"characterMaximumLength":"200","createdAt":1705708800000},{"id":"field7","name":"order_num","type":{"id":"integer","name":"integer"},"nullable":false,"primaryKey":false,"unique":false,"default":"","increment":true,"createdAt":1705708800000}],"indexes":[],"x":300,"y":100,"color":"#8eb7ff","isView":false,"createdAt":1705708800000}],"relationships":[{"id":"rel1","name":"fk_posts_users","sourceTableId":"table2","targetTableId":"table1","sourceFieldId":"field5","targetFieldId":"field1","type":"one_to_many","sourceCardinality":"many","targetCardinality":"one","createdAt":1705708800000}],"dependencies":[],"storageMode":"project","areas":[],"creationMethod":"manual","customTypes":[]} \ No newline at end of file diff --git a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts index f1fd54ca..0d9c3a5b 100644 --- a/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts +++ b/src/lib/dbml/dbml-export/__tests__/export-sql-dbml-cases.test.ts @@ -66,4 +66,12 @@ describe('DBML Export cases', () => { it('should handle case 5 diagram', { timeout: 30000 }, async () => { testCase('5'); }); + + it( + 'should handle case 6 diagram - auto increment', + { timeout: 30000 }, + async () => { + testCase('6'); + } + ); }); diff --git a/src/lib/dbml/dbml-export/dbml-export.ts b/src/lib/dbml/dbml-export/dbml-export.ts index 4185bfa1..5819a718 100644 --- a/src/lib/dbml/dbml-export/dbml-export.ts +++ b/src/lib/dbml/dbml-export/dbml-export.ts @@ -583,6 +583,54 @@ const fixMultilineTableNames = (dbml: string): string => { ); }; +// Restore increment attribute for auto-incrementing fields +const restoreIncrementAttribute = (dbml: string, tables: DBTable[]): string => { + if (!tables || tables.length === 0) return dbml; + + let result = dbml; + + tables.forEach((table) => { + // Find fields with increment=true + const incrementFields = table.fields.filter((f) => f.increment); + + incrementFields.forEach((field) => { + // Build the table identifier pattern + const tableIdentifier = table.schema + ? `"${table.schema.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\."${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"` + : `"${table.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"`; + + // Escape field name for regex + const escapedFieldName = field.name.replace( + /[.*+?^${}()|[\]\\]/g, + '\\$&' + ); + + // Pattern to match the field line with existing attributes in brackets + // Matches: "field_name" type [existing, attributes] + const fieldPattern = new RegExp( + `(Table ${tableIdentifier} \\{[^}]*?^\\s*"${escapedFieldName}"[^\\[\\n]+)(\\[[^\\]]*\\])`, + 'gms' + ); + + result = result.replace( + fieldPattern, + (match, fieldPart, brackets) => { + // Check if increment already exists + if (brackets.includes('increment')) { + return match; + } + + // Add increment to the attributes + const newBrackets = brackets.replace(']', ', increment]'); + return fieldPart + newBrackets; + } + ); + }); + }); + + return result; +}; + // Restore composite primary key names in the DBML const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => { if (!tables || tables.length === 0) return dbml; @@ -888,6 +936,9 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { // Restore composite primary key names standard = restoreCompositePKNames(standard, uniqueTables); + // Restore increment attribute for auto-incrementing fields + standard = restoreIncrementAttribute(standard, uniqueTables); + // Prepend Enum DBML to the standard output if (enumsDBML) { standard = enumsDBML + '\n\n' + standard; diff --git a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts index 170fc02b..28cfd3b4 100644 --- a/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts +++ b/src/lib/dbml/dbml-import/__tests__/dbml-import-cases.test.ts @@ -342,4 +342,85 @@ describe('DBML Import cases', () => { ); expect(createdAtField?.default).toBe('now()'); }); + + it('should handle auto-increment fields correctly', async () => { + const dbmlContent = `Table "public"."table_1" { + "id" integer [pk, not null, increment] + "field_2" bigint [increment] + "field_3" serial [increment] + "field_4" varchar(100) [not null] +}`; + + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + const table = result.tables![0]; + expect(table.name).toBe('table_1'); + expect(table.fields).toHaveLength(4); + + // field with [pk, not null, increment] - should be not null and increment + const idField = table.fields.find((f) => f.name === 'id'); + expect(idField?.increment).toBe(true); + expect(idField?.nullable).toBe(false); + expect(idField?.primaryKey).toBe(true); + + // field with [increment] only - should be not null and increment + // (auto-increment requires NOT NULL even if not explicitly stated) + const field2 = table.fields.find((f) => f.name === 'field_2'); + expect(field2?.increment).toBe(true); + expect(field2?.nullable).toBe(false); // CRITICAL: must be false! + + // SERIAL type with [increment] - should be not null and increment + const field3 = table.fields.find((f) => f.name === 'field_3'); + expect(field3?.increment).toBe(true); + expect(field3?.nullable).toBe(false); + expect(field3?.type?.name).toBe('serial'); + + // Regular field with [not null] - should be not null, no increment + const field4 = table.fields.find((f) => f.name === 'field_4'); + expect(field4?.increment).toBeUndefined(); + expect(field4?.nullable).toBe(false); + }); + + it('should handle SERIAL types without increment attribute', async () => { + const dbmlContent = `Table "public"."test_table" { + "id" serial [pk] + "counter" bigserial + "small_counter" smallserial + "regular" integer +}`; + + const result = await importDBMLToDiagram(dbmlContent, { + databaseType: DatabaseType.POSTGRESQL, + }); + + expect(result.tables).toHaveLength(1); + const table = result.tables![0]; + expect(table.fields).toHaveLength(4); + + // SERIAL type without [increment] - should STILL be not null (type requires it) + const idField = table.fields.find((f) => f.name === 'id'); + expect(idField?.type?.name).toBe('serial'); + expect(idField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + expect(idField?.primaryKey).toBe(true); + + // BIGSERIAL without [increment] - should be not null + const counterField = table.fields.find((f) => f.name === 'counter'); + expect(counterField?.type?.name).toBe('bigserial'); + expect(counterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + + // SMALLSERIAL without [increment] - should be not null + const smallCounterField = table.fields.find( + (f) => f.name === 'small_counter' + ); + expect(smallCounterField?.type?.name).toBe('smallserial'); + expect(smallCounterField?.nullable).toBe(false); // CRITICAL: Type requires NOT NULL + + // Regular INTEGER - should be nullable by default + const regularField = table.fields.find((f) => f.name === 'regular'); + expect(regularField?.type?.name).toBe('integer'); + expect(regularField?.nullable).toBe(true); // No NOT NULL constraint + }); }); diff --git a/src/lib/dbml/dbml-import/dbml-import.ts b/src/lib/dbml/dbml-import/dbml-import.ts index 95c9711f..c82125a9 100644 --- a/src/lib/dbml/dbml-import/dbml-import.ts +++ b/src/lib/dbml/dbml-import/dbml-import.ts @@ -5,7 +5,10 @@ import type { DBTable } from '@/lib/domain/db-table'; import type { Cardinality, DBRelationship } from '@/lib/domain/db-relationship'; import type { DBField } from '@/lib/domain/db-field'; import type { DataTypeData } from '@/lib/data/data-types/data-types'; -import { findDataTypeDataById } from '@/lib/data/data-types/data-types'; +import { + findDataTypeDataById, + requiresNotNull, +} from '@/lib/data/data-types/data-types'; import { defaultTableColor } from '@/lib/colors'; import { DatabaseType } from '@/lib/domain/database-type'; import type Field from '@dbml/core/types/model_structure/field'; @@ -552,7 +555,10 @@ export const importDBMLToDiagram = async ( ...options, enums: extractedData.enums, }), - nullable: !field.not_null, + nullable: + field.increment || requiresNotNull(field.type.type_name) + ? false + : !field.not_null, primaryKey: field.pk || false, unique: field.unique || field.pk || false, // Primary keys are always unique createdAt: Date.now(), diff --git a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx index 69778c22..da46531e 100644 --- a/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx +++ b/src/pages/editor-page/canvas/table-node/table-edit-mode/table-edit-mode-field.tsx @@ -13,6 +13,7 @@ import { useTranslation } from 'react-i18next'; import { SelectBox } from '@/components/select-box/select-box'; import { cn } from '@/lib/utils'; import { TableFieldToggle } from './table-field-toggle'; +import { requiresNotNull } from '@/lib/data/data-types/data-types'; export interface TableEditModeFieldProps { table: DBTable; @@ -41,6 +42,8 @@ export const TableEditModeField: React.FC = React.memo( const inputRef = React.useRef(null); + const typeRequiresNotNull = requiresNotNull(field.type.name); + // Animate the highlight after mount if focused useEffect(() => { if (focused) { @@ -135,6 +138,7 @@ export const TableEditModeField: React.FC = React.memo( N diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx index 124dd445..d60318cc 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field-modal/table-field-modal.tsx @@ -9,6 +9,7 @@ import { findDataTypeDataById, supportsAutoIncrementDataType, supportsArrayDataType, + autoIncrementAlwaysOn, } from '@/lib/data/data-types/data-types'; import { Popover, @@ -111,6 +112,18 @@ export const TableFieldPopover: React.FC = ({ [field.type.name, databaseType] ); + // Check if this is a SERIAL-type that is inherently auto-incrementing + const forceAutoIncrement = useMemo( + () => autoIncrementAlwaysOn(field.type.name) && !localField.nullable, + [field.type.name, localField.nullable] + ); + + // Auto-increment is disabled if the field is nullable (auto-increment requires NOT NULL) + const isIncrementDisabled = useMemo( + () => localField.nullable || readonly || forceAutoIncrement, + [localField.nullable, readonly, forceAutoIncrement] + ); + return ( = ({ )} setLocalField((current) => ({ ...current, diff --git a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx index 585a619a..b3d72d9d 100644 --- a/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx +++ b/src/pages/editor-page/side-panel/tables-section/table-list/table-list-item/table-list-item-content/table-field/table-field.tsx @@ -15,6 +15,7 @@ import { CSS } from '@dnd-kit/utilities'; import { SelectBox } from '@/components/select-box/select-box'; import { TableFieldPopover } from './table-field-modal/table-field-modal'; import type { DatabaseType, DBTable } from '@/lib/domain'; +import { requiresNotNull } from '@/lib/data/data-types/data-types'; export interface TableFieldProps { table: DBTable; @@ -55,6 +56,8 @@ export const TableField: React.FC = ({ transition, }; + const typeRequiresNotNull = requiresNotNull(field.type.name); + return (
= ({ N