Compare commits

...

8 Commits

Author SHA1 Message Date
Jonathan Fishner
68412f90a7 fix: add postgres array type support for import and export (#958)
* fix: add postgres array type support for import and export

* fix

---------

Co-authored-by: Guy Ben-Aharon <baguy3@gmail.com>
2025-10-27 17:02:44 +02:00
Guy Ben-Aharon
084a1d505c chore(main): release 1.17.0 (#936) 2025-10-27 16:27:46 +02:00
Guy Ben-Aharon
91e713c30a fix: import array fields (#961) 2025-10-27 11:55:11 +02:00
Guy Ben-Aharon
acf6d4b365 fix: show SQL Script option conditionally for databases without DDL support (#960) 2025-10-27 11:24:23 +02:00
Guy Ben-Aharon
9e8979d062 update translate (#957) 2025-10-21 15:44:49 +03:00
Guy Ben-Aharon
9ed27cf30c fix: preserve multi-word types in DBML export/import (#956)
* fix: preserve multi-word types in DBML export/import

* fix
2025-10-21 15:10:02 +03:00
Guy Ben-Aharon
2c4b344efb fix: resolve dbml increment & nullable attributes issue (#954)
* fix: resolve dbml increment attribute

* fix nullable

* fix
2025-10-21 12:31:32 +03:00
Guy Ben-Aharon
ccb29e0a57 fix: resolve canvas filter tree state issues (#953) 2025-10-20 17:12:15 +03:00
50 changed files with 900 additions and 109 deletions

View File

@@ -1,5 +1,33 @@
# Changelog
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-27)
### Features
* create relationships on canvas modal ([#946](https://github.com/chartdb/chartdb/issues/946)) ([34475ad](https://github.com/chartdb/chartdb/commit/34475add32f11323589ef092ccf2a8e9152ff272))
### Bug Fixes
* add auto-increment field detection in smart-query import ([#935](https://github.com/chartdb/chartdb/issues/935)) ([57b3b87](https://github.com/chartdb/chartdb/commit/57b3b8777fd0a445abf0ba6603faab612d469d5c))
* add open table in editor from canvas edit ([#952](https://github.com/chartdb/chartdb/issues/952)) ([7d811de](https://github.com/chartdb/chartdb/commit/7d811de097eb11e51012772fa6bf586fd0b16c62))
* add rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9))
* add support for arrays ([#949](https://github.com/chartdb/chartdb/issues/949)) ([49328d8](https://github.com/chartdb/chartdb/commit/49328d8fbd7786f6c0c04cd5605d43a24cbf10ea))
* add support for parsing default values in DBML ([#948](https://github.com/chartdb/chartdb/issues/948)) ([459698b](https://github.com/chartdb/chartdb/commit/459698b5d0a1ff23a3719c2e55e4ab2e2384c4fe))
* add timestampz and int as datatypes to postgres ([#940](https://github.com/chartdb/chartdb/issues/940)) ([b15bc94](https://github.com/chartdb/chartdb/commit/b15bc945acb96d7cb3832b3b1b607dfcaef9e5ca))
* auto-enter edit mode when creating new tables from canvas ([#943](https://github.com/chartdb/chartdb/issues/943)) ([bcd8aa9](https://github.com/chartdb/chartdb/commit/bcd8aa9378aa563f40a2b6802cc503be4c882356))
* dbml diff fields types preview ([#934](https://github.com/chartdb/chartdb/issues/934)) ([bb03309](https://github.com/chartdb/chartdb/commit/bb033091b1f64b888822be1423a80f16f5314f6b))
* exit table edit on area click ([#945](https://github.com/chartdb/chartdb/issues/945)) ([38fedce](https://github.com/chartdb/chartdb/commit/38fedcec0c10ea2b3f0b7fc92ca1f5ac9e540389))
* import array fields ([#961](https://github.com/chartdb/chartdb/issues/961)) ([91e713c](https://github.com/chartdb/chartdb/commit/91e713c30a44f1ba7a767ca7816079610136fcb8))
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
* preserve multi-word types in DBML export/import ([#956](https://github.com/chartdb/chartdb/issues/956)) ([9ed27cf](https://github.com/chartdb/chartdb/commit/9ed27cf30cca1312713e80e525138f0c27154936))
* prevent text input glitch when editing table field names ([#944](https://github.com/chartdb/chartdb/issues/944)) ([498655e](https://github.com/chartdb/chartdb/commit/498655e7b77e57eaf641ba86263ce1ef60b93e16))
* resolve canvas filter tree state issues ([#953](https://github.com/chartdb/chartdb/issues/953)) ([ccb29e0](https://github.com/chartdb/chartdb/commit/ccb29e0a574dfa4cfdf0ebf242a4c4aaa48cc37b))
* resolve dbml increment & nullable attributes issue ([#954](https://github.com/chartdb/chartdb/issues/954)) ([2c4b344](https://github.com/chartdb/chartdb/commit/2c4b344efb24041e7f607fc6124e109b69aaa457))
* show SQL Script option conditionally for databases without DDL support ([#960](https://github.com/chartdb/chartdb/issues/960)) ([acf6d4b](https://github.com/chartdb/chartdb/commit/acf6d4b3654d8868b8a8ebf717c608d9749b71da))
* use flag for custom types ([#951](https://github.com/chartdb/chartdb/issues/951)) ([62dec48](https://github.com/chartdb/chartdb/commit/62dec4857211b705a8039691da1772263ea986fe))
## [1.16.0](https://github.com/chartdb/chartdb/compare/v1.15.1...v1.16.0) (2025-09-24)

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "chartdb",
"version": "1.16.0",
"version": "1.17.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chartdb",
"version": "1.16.0",
"version": "1.17.0",
"dependencies": {
"@ai-sdk/openai": "^0.0.51",
"@dbml/core": "^3.13.9",

View File

@@ -1,7 +1,7 @@
{
"name": "chartdb",
"private": true,
"version": "1.16.0",
"version": "1.17.0",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -42,6 +42,7 @@ interface TreeViewProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
export function TreeView<
@@ -62,12 +63,14 @@ export function TreeView<
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeViewProps<Type, Context>) {
const { expanded, loading, loadedChildren, hasMoreChildren, toggleNode } =
useTree({
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache,
});
const [selectedIdInternal, setSelectedIdInternal] = React.useState<
string | undefined
@@ -145,6 +148,7 @@ export function TreeView<
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
</div>
@@ -179,6 +183,7 @@ interface TreeNodeProps<
renderHoverComponent?: (node: TreeNode<Type, Context>) => ReactNode;
renderActionsComponent?: (node: TreeNode<Type, Context>) => ReactNode;
loadingNodeIds?: string[];
disableCache?: boolean;
}
function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
@@ -201,11 +206,16 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent,
renderActionsComponent,
loadingNodeIds,
disableCache = false,
}: TreeNodeProps<Type, Context>) {
const [isHovered, setIsHovered] = useState(false);
const isExpanded = expanded[node.id];
const isLoading = loading[node.id];
const children = loadedChildren[node.id] || node.children;
// If cache is disabled, always use fresh node.children
// Otherwise, use cached loadedChildren if available (for async fetched data)
const children = disableCache
? node.children
: node.children || loadedChildren[node.id];
const isSelected = selectedId === node.id;
const IconComponent =
@@ -423,6 +433,7 @@ function TreeNode<Type extends string, Context extends Record<Type, unknown>>({
renderHoverComponent={renderHoverComponent}
renderActionsComponent={renderActionsComponent}
loadingNodeIds={loadingNodeIds}
disableCache={disableCache}
/>
))}
{isLoading ? (

View File

@@ -28,10 +28,12 @@ export function useTree<
fetchChildren,
expanded: expandedProp,
setExpanded: setExpandedProp,
disableCache = false,
}: {
fetchChildren?: FetchChildrenFunction<Type, Context>;
expanded?: ExpandedState;
setExpanded?: Dispatch<SetStateAction<ExpandedState>>;
disableCache?: boolean;
}) {
const [expandedInternal, setExpandedInternal] = useState<ExpandedState>({});
@@ -89,8 +91,8 @@ export function useTree<
// Get any previously fetched children
const previouslyFetchedChildren = loadedChildren[nodeId] || [];
// If we have static children, merge them with any previously fetched children
if (staticChildren?.length) {
// Only cache if caching is enabled
if (!disableCache && staticChildren?.length) {
const mergedChildren = mergeChildren(
staticChildren,
previouslyFetchedChildren
@@ -110,8 +112,8 @@ export function useTree<
// Set expanded state immediately to show static/previously fetched children
setExpanded((prev) => ({ ...prev, [nodeId]: true }));
// If we haven't loaded dynamic children yet
if (!previouslyFetchedChildren.length) {
// If we haven't loaded dynamic children yet and cache is enabled
if (!disableCache && !previouslyFetchedChildren.length) {
setLoading((prev) => ({ ...prev, [nodeId]: true }));
try {
const fetchedChildren = await fetchChildren?.(
@@ -140,7 +142,14 @@ export function useTree<
}
}
},
[expanded, loadedChildren, fetchChildren, mergeChildren, setExpanded]
[
expanded,
loadedChildren,
fetchChildren,
mergeChildren,
setExpanded,
disableCache,
]
);
return {

View File

@@ -117,35 +117,37 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</div>
) : null}
{DatabasesWithoutDDLInstructions.includes(databaseType) ? null : (
<div className="flex flex-col gap-1">
<p className="text-sm leading-6 text-primary">
How would you like to import?
</p>
<ToggleGroup
type="single"
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as ImportMethod;
}
<div className="flex flex-col gap-1">
<p className="text-sm leading-6 text-primary">
How would you like to import?
</p>
<ToggleGroup
type="single"
className="ml-1 flex-wrap justify-start gap-2"
value={importMethod}
onValueChange={(value) => {
let selectedImportMethod: ImportMethod = 'query';
if (value) {
selectedImportMethod = value as ImportMethod;
}
setImportMethod(selectedImportMethod);
}}
setImportMethod(selectedImportMethod);
}}
>
<ToggleGroupItem
value="query"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<ToggleGroupItem
value="query"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="h-3 w-4 rounded-none">
<AvatarImage src={logo} alt="query" />
<AvatarFallback>Query</AvatarFallback>
</Avatar>
Smart Query
</ToggleGroupItem>
<Avatar className="h-3 w-4 rounded-none">
<AvatarImage src={logo} alt="query" />
<AvatarFallback>Query</AvatarFallback>
</Avatar>
Smart Query
</ToggleGroupItem>
{!DatabasesWithoutDDLInstructions.includes(
databaseType
) && (
<ToggleGroupItem
value="ddl"
variant="outline"
@@ -156,19 +158,19 @@ export const InstructionsSection: React.FC<InstructionsSectionProps> = ({
</Avatar>
SQL Script
</ToggleGroupItem>
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
)}
<ToggleGroupItem
value="dbml"
variant="outline"
className="h-6 gap-1 p-0 px-2 shadow-none data-[state=on]:bg-slate-200 dark:data-[state=on]:bg-slate-700"
>
<Avatar className="size-4 rounded-none">
<Code size={16} />
</Avatar>
DBML
</ToggleGroupItem>
</ToggleGroup>
</div>
<div className="flex flex-col gap-2">
<div className="text-sm font-semibold">Instructions:</div>

View File

@@ -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<DBField> = { 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
);

View File

@@ -308,7 +308,7 @@ export const ar: LanguageTranslation = {
cancel: 'إلغاء',
import_from_file: 'استيراد من ملف',
back: 'رجوع',
empty_diagram: 'مخطط فارغ',
empty_diagram: 'قاعدة بيانات فارغة',
continue: 'متابعة',
import: 'استيراد',
},

View File

@@ -310,7 +310,7 @@ export const bn: LanguageTranslation = {
cancel: 'বাতিল করুন',
back: 'ফিরে যান',
import_from_file: 'ফাইল থেকে আমদানি করুন',
empty_diagram: 'ফাঁকা চিত্র',
empty_diagram: 'খালি ডাটাবেস',
continue: 'চালিয়ে যান',
import: 'আমদানি করুন',
},

View File

@@ -313,7 +313,7 @@ export const de: LanguageTranslation = {
back: 'Zurück',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Leeres Diagramm',
empty_diagram: 'Leere Datenbank',
continue: 'Weiter',
import: 'Importieren',
},

View File

@@ -301,7 +301,7 @@ export const en = {
cancel: 'Cancel',
import_from_file: 'Import from File',
back: 'Back',
empty_diagram: 'Empty diagram',
empty_diagram: 'Empty database',
continue: 'Continue',
import: 'Import',
},

View File

@@ -310,7 +310,7 @@ export const es: LanguageTranslation = {
back: 'Atrás',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vacío',
empty_diagram: 'Base de datos vacía',
continue: 'Continuar',
import: 'Importar',
},

View File

@@ -307,7 +307,7 @@ export const fr: LanguageTranslation = {
cancel: 'Annuler',
back: 'Retour',
import_from_file: "Importer à partir d'un fichier",
empty_diagram: 'Diagramme vide',
empty_diagram: 'Base de données vide',
continue: 'Continuer',
import: 'Importer',
},

View File

@@ -310,7 +310,7 @@ export const gu: LanguageTranslation = {
cancel: 'રદ કરો',
back: 'પાછા',
import_from_file: 'ફાઇલમાંથી આયાત કરો',
empty_diagram: 'ખાલી ડાયાગ્રામ',
empty_diagram: 'ખાલી ડેટાબેસ',
continue: 'ચાલુ રાખો',
import: 'આયાત કરો',
},

View File

@@ -312,7 +312,7 @@ export const hi: LanguageTranslation = {
back: 'वापस',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'खाली आरेख',
empty_diagram: 'खाली डेटाबेस',
continue: 'जारी रखें',
import: 'आयात करें',
},

View File

@@ -305,7 +305,7 @@ export const hr: LanguageTranslation = {
cancel: 'Odustani',
import_from_file: 'Uvezi iz datoteke',
back: 'Natrag',
empty_diagram: 'Prazan dijagram',
empty_diagram: 'Prazna baza podataka',
continue: 'Nastavi',
import: 'Uvezi',
},

View File

@@ -309,7 +309,7 @@ export const id_ID: LanguageTranslation = {
cancel: 'Batal',
import_from_file: 'Impor dari file',
back: 'Kembali',
empty_diagram: 'Diagram Kosong',
empty_diagram: 'Database Kosong',
continue: 'Lanjutkan',
import: 'Impor',
},

View File

@@ -314,7 +314,7 @@ export const ja: LanguageTranslation = {
back: '戻る',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: '空のダイアグラム',
empty_diagram: '空のデータベース',
continue: '続行',
import: 'インポート',
},

View File

@@ -309,7 +309,7 @@ export const ko_KR: LanguageTranslation = {
cancel: '취소',
back: '뒤로가기',
import_from_file: '파일에서 가져오기',
empty_diagram: '빈 다이어그램으로 시작',
empty_diagram: '빈 데이터베이스',
continue: '계속',
import: '가져오기',
},

View File

@@ -315,7 +315,7 @@ export const mr: LanguageTranslation = {
// TODO: Add translations
import_from_file: 'Import from File',
back: 'मागे',
empty_diagram: 'रिक्त आरेख',
empty_diagram: 'रिक्त डेटाबेस',
continue: 'सुरू ठेवा',
import: 'आयात करा',
},

View File

@@ -311,7 +311,7 @@ export const ne: LanguageTranslation = {
cancel: 'रद्द गर्नुहोस्',
import_from_file: 'फाइलबाट आयात गर्नुहोस्',
back: 'फर्क',
empty_diagram: 'रिक्त डायाग्राम',
empty_diagram: 'खाली डाटाबेस',
continue: 'जारी राख्नुहोस्',
import: 'आयात गर्नुहोस्',
},

View File

@@ -311,7 +311,7 @@ export const pt_BR: LanguageTranslation = {
back: 'Voltar',
// TODO: Translate
import_from_file: 'Import from File',
empty_diagram: 'Diagrama vazio',
empty_diagram: 'Banco de dados vazio',
continue: 'Continuar',
import: 'Importar',
},

View File

@@ -307,7 +307,7 @@ export const ru: LanguageTranslation = {
cancel: 'Отменить',
back: 'Назад',
import_from_file: 'Импортировать из файла',
empty_diagram: 'Пустая диаграмма',
empty_diagram: 'Пустая база данных',
continue: 'Продолжить',
import: 'Импорт',
},

View File

@@ -312,7 +312,7 @@ export const te: LanguageTranslation = {
// TODO: Translate
import_from_file: 'Import from File',
back: 'తిరుగు',
empty_diagram: 'ఖాళీ చిత్రము',
empty_diagram: 'ఖాళీ డేటాబేస్',
continue: 'కొనసాగించు',
import: 'డిగుమతి',
},

View File

@@ -308,7 +308,7 @@ export const tr: LanguageTranslation = {
import_from_file: 'Import from File',
cancel: 'İptal',
back: 'Geri',
empty_diagram: 'Boş diyagram',
empty_diagram: 'Boş veritabanı',
continue: 'Devam',
import: 'İçe Aktar',
},

View File

@@ -308,7 +308,7 @@ export const uk: LanguageTranslation = {
cancel: 'Скасувати',
back: 'Назад',
import_from_file: 'Імпортувати з файлу',
empty_diagram: 'Порожня діаграма',
empty_diagram: 'Порожня база даних',
continue: 'Продовжити',
import: 'Імпорт',
},

View File

@@ -309,7 +309,7 @@ export const vi: LanguageTranslation = {
cancel: 'Hủy',
import_from_file: 'Nhập từ tệp',
back: 'Trở lại',
empty_diagram: 'Sơ đồ trống',
empty_diagram: 'Cơ sở dữ liệu trống',
continue: 'Tiếp tục',
import: 'Nhập',
},

View File

@@ -306,7 +306,7 @@ export const zh_CN: LanguageTranslation = {
cancel: '取消',
import_from_file: '从文件导入',
back: '上一步',
empty_diagram: '新建空关系图',
empty_diagram: '空数据库',
continue: '下一步',
import: '导入',
},

View File

@@ -305,7 +305,7 @@ export const zh_TW: LanguageTranslation = {
cancel: '取消',
import_from_file: '從檔案匯入',
back: '返回',
empty_diagram: '空白圖表',
empty_diagram: '空資料庫',
continue: '繼續',
import: '匯入',
},

View File

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

View File

@@ -60,6 +60,7 @@ export const createFieldsFromMetadata = ({
...(col.is_identity !== undefined
? { increment: col.is_identity }
: {}),
...(col.is_array !== undefined ? { isArray: col.is_array } : {}),
createdAt: Date.now(),
comments: col.comment ? col.comment : undefined,
})

View File

@@ -64,7 +64,7 @@ export const loadFromDatabaseMetadata = async ({
const diagram: Diagram = {
id: generateDiagramId(),
name: databaseMetadata.database_name
? `${databaseMetadata.database_name}-db`
? `${databaseMetadata.database_name}`
: diagramNumber
? `Diagram ${diagramNumber}`
: 'New Diagram',

View File

@@ -15,7 +15,8 @@ export interface ColumnInfo {
default?: string | null; // Default value for the column, nullable
collation?: string | null;
comment?: string | null;
is_identity?: boolean; // Indicates if the column is auto-increment/identity
is_identity?: boolean | null; // Indicates if the column is auto-increment/identity
is_array?: boolean | null; // Indicates if the column is an array type
}
export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
@@ -36,5 +37,6 @@ export const ColumnInfoSchema: z.ZodType<ColumnInfo> = z.object({
default: z.string().nullable().optional(),
collation: z.string().nullable().optional(),
comment: z.string().nullable().optional(),
is_identity: z.boolean().optional(),
is_identity: z.boolean().nullable().optional(),
is_array: z.boolean().nullable().optional(),
});

View File

@@ -181,7 +181,13 @@ cols AS (
'","table":"', cols.table_name,
'","name":"', cols.column_name,
'","ordinal_position":', cols.ordinal_position,
',"type":"', case when LOWER(replace(cols.data_type, '"', '')) = 'user-defined' then pg_type.typname else LOWER(replace(cols.data_type, '"', '')) end,
',"type":"', CASE WHEN cols.data_type = 'ARRAY' THEN
format_type(pg_type.typelem, NULL)
WHEN LOWER(replace(cols.data_type, '"', '')) = 'user-defined' THEN
format_type(pg_type.oid, NULL)
ELSE
LOWER(replace(cols.data_type, '"', ''))
END,
'","character_maximum_length":"', COALESCE(cols.character_maximum_length::text, 'null'),
'","precision":',
CASE
@@ -194,11 +200,15 @@ cols AS (
',"default":"', ${withExtras ? withDefault : withoutDefault},
'","collation":"', COALESCE(cols.COLLATION_NAME, ''),
'","comment":"', ${withExtras ? withComments : withoutComments},
'","is_identity":', CASE
'","is_identity":', CASE
WHEN cols.is_identity = 'YES' THEN 'true'
WHEN cols.column_default IS NOT NULL AND cols.column_default LIKE 'nextval(%' THEN 'true'
ELSE 'false'
END,
',"is_array":', CASE
WHEN cols.data_type = 'ARRAY' OR pg_type.typelem > 0 THEN 'true'
ELSE 'false'
END,
'}')), ',') AS cols_metadata
FROM information_schema.columns cols
LEFT JOIN pg_catalog.pg_class c
@@ -211,6 +221,8 @@ cols AS (
ON attr.attrelid = c.oid AND attr.attname = cols.column_name
LEFT JOIN pg_catalog.pg_type
ON pg_type.oid = attr.atttypid
LEFT JOIN pg_catalog.pg_type AS elem_type
ON elem_type.oid = pg_type.typelem
WHERE cols.table_schema NOT IN ('information_schema', 'pg_catalog')${
databaseEdition === DatabaseEdition.POSTGRESQL_TIMESCALE
? timescaleColFilter

View File

@@ -286,10 +286,14 @@ export function exportPostgreSQL({
}
}
// Handle array types (check if the type name ends with '[]')
if (typeName.endsWith('[]')) {
typeWithSize =
typeWithSize.replace('[]', '') + '[]';
// Handle array types (check if isArray flag or if type name ends with '[]')
if (field.isArray || typeName.endsWith('[]')) {
// Remove any existing [] notation
const baseTypeWithoutArray = typeWithSize.replace(
/\[\]$/,
''
);
typeWithSize = baseTypeWithoutArray + '[]';
}
const notNull = field.nullable ? '' : ' NOT NULL';

View File

@@ -338,7 +338,13 @@ export const exportBaseSQL = ({
const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow);
sqlScript += ` ${quotedFieldName} ${typeName}`;
// Quote multi-word type names for DBML flow to prevent @dbml/core parser issues
const quotedTypeName =
isDBMLFlow && typeName.includes(' ')
? `"${typeName}"`
: typeName;
sqlScript += ` ${quotedFieldName} ${quotedTypeName}`;
// Add size for character types
if (
@@ -395,9 +401,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 +473,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)

View File

@@ -1,6 +1,6 @@
Table "public"."guy_table" {
"id" integer [pk, not null]
"created_at" timestamp [not null]
"created_at" "timestamp without time zone" [not null]
"column3" text
"arrayfield" text[]
"field_5" "character varying"

View File

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

View File

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

View File

@@ -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":[]}

View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Empty Tables', () => {
it('should filter out tables with no fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table',
schema: 'public',
x: 0,
y: 0,
fields: [], // Empty fields array
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'another_valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'name',
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Verify the DBML doesn't contain the empty table
expect(result.inlineDbml).not.toContain('empty_table');
expect(result.standardDbml).not.toContain('empty_table');
// Verify the valid tables are still present
expect(result.inlineDbml).toContain('valid_table');
expect(result.inlineDbml).toContain('another_valid_table');
});
it('should handle diagram with only empty tables', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'empty_table_1',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'empty_table_2',
schema: 'public',
x: 0,
y: 0,
fields: [],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Should not error and should return empty DBML (or just enums if any)
expect(result.inlineDbml).toBeTruthy();
expect(result.standardDbml).toBeTruthy();
expect(result.error).toBeUndefined();
});
it('should filter out table that becomes empty after removing invalid fields', () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'table_with_only_empty_field_names',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'integer', name: 'integer' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: '', // Empty field name - will be filtered
type: { id: 'varchar', name: 'varchar' },
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'valid_table',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const result = generateDBMLFromDiagram(diagram);
// Table with only empty field names should be filtered out
expect(result.inlineDbml).not.toContain(
'table_with_only_empty_field_names'
);
// Valid table should remain
expect(result.inlineDbml).toContain('valid_table');
});
});

View File

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

View File

@@ -0,0 +1,248 @@
import { describe, it, expect } from 'vitest';
import { generateDBMLFromDiagram } from '../dbml-export';
import { importDBMLToDiagram } from '../../dbml-import/dbml-import';
import { DatabaseType } from '@/lib/domain/database-type';
import type { Diagram } from '@/lib/domain/diagram';
import { generateId, generateDiagramId } from '@/lib/utils';
describe('DBML Export - Timestamp with Time Zone', () => {
it('should preserve "timestamp with time zone" type through export and reimport', async () => {
// Create a diagram with timestamp with time zone field
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'events',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'created_at',
type: {
id: 'timestamp_with_time_zone',
name: 'timestamp with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'updated_at',
type: {
id: 'timestamp_without_time_zone',
name: 'timestamp without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
// Export to DBML
const exportResult = generateDBMLFromDiagram(diagram);
// Verify the DBML contains quoted multi-word types
expect(exportResult.inlineDbml).toContain('"timestamp with time zone"');
expect(exportResult.inlineDbml).toContain(
'"timestamp without time zone"'
);
// Reimport the DBML
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
// Verify the types are preserved
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'events'
);
expect(table).toBeDefined();
const createdAtField = table?.fields.find(
(f) => f.name === 'created_at'
);
const updatedAtField = table?.fields.find(
(f) => f.name === 'updated_at'
);
expect(createdAtField?.type.name).toBe('timestamp with time zone');
expect(updatedAtField?.type.name).toBe('timestamp without time zone');
});
it('should handle time with time zone types', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'schedules',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'start_time',
type: {
id: 'time_with_time_zone',
name: 'time with time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'end_time',
type: {
id: 'time_without_time_zone',
name: 'time without time zone',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"time with time zone"');
expect(exportResult.inlineDbml).toContain('"time without time zone"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'schedules'
);
const startTimeField = table?.fields.find(
(f) => f.name === 'start_time'
);
const endTimeField = table?.fields.find((f) => f.name === 'end_time');
expect(startTimeField?.type.name).toBe('time with time zone');
expect(endTimeField?.type.name).toBe('time without time zone');
});
it('should handle double precision type', async () => {
const diagram: Diagram = {
id: generateDiagramId(),
name: 'Test Diagram',
databaseType: DatabaseType.POSTGRESQL,
tables: [
{
id: generateId(),
name: 'measurements',
schema: 'public',
x: 0,
y: 0,
fields: [
{
id: generateId(),
name: 'id',
type: { id: 'integer', name: 'integer' },
primaryKey: true,
unique: true,
nullable: false,
createdAt: Date.now(),
},
{
id: generateId(),
name: 'value',
type: {
id: 'double_precision',
name: 'double precision',
},
primaryKey: false,
unique: false,
nullable: true,
createdAt: Date.now(),
},
],
indexes: [],
color: '#8eb7ff',
isView: false,
createdAt: Date.now(),
},
],
relationships: [],
createdAt: new Date(),
updatedAt: new Date(),
};
const exportResult = generateDBMLFromDiagram(diagram);
expect(exportResult.inlineDbml).toContain('"double precision"');
const reimportedDiagram = await importDBMLToDiagram(
exportResult.inlineDbml,
{
databaseType: DatabaseType.POSTGRESQL,
}
);
const table = reimportedDiagram.tables?.find(
(t) => t.name === 'measurements'
);
const valueField = table?.fields.find((f) => f.name === 'value');
expect(valueField?.type.name).toBe('double precision');
});
});

View File

@@ -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;
@@ -759,31 +807,37 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
};
}) ?? [];
// Remove duplicate tables (consider both schema and table name)
// Filter out empty tables and duplicates in a single pass for performance
const seenTableIdentifiers = new Set<string>();
const uniqueTables = sanitizedTables.filter((table) => {
const tablesWithFields = sanitizedTables.filter((table) => {
// Skip tables with no fields (empty tables cause DBML export to fail)
if (table.fields.length === 0) {
return false;
}
// Create a unique identifier combining schema and table name
const tableIdentifier = table.schema
? `${table.schema}.${table.name}`
: table.name;
// Skip duplicate tables
if (seenTableIdentifiers.has(tableIdentifier)) {
return false; // Skip duplicate
return false;
}
seenTableIdentifiers.add(tableIdentifier);
return true; // Keep unique table
return true; // Keep unique, non-empty table
});
// Create the base filtered diagram structure
const filteredDiagram: Diagram = {
...diagram,
tables: uniqueTables,
tables: tablesWithFields,
relationships:
diagram.relationships?.filter((rel) => {
const sourceTable = uniqueTables.find(
const sourceTable = tablesWithFields.find(
(t) => t.id === rel.sourceTableId
);
const targetTable = uniqueTables.find(
const targetTable = tablesWithFields.find(
(t) => t.id === rel.targetTableId
);
const sourceFieldExists = sourceTable?.fields.some(
@@ -883,10 +937,13 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult {
);
// Restore schema information that may have been stripped by DBML importer
standard = restoreTableSchemas(standard, uniqueTables);
standard = restoreTableSchemas(standard, tablesWithFields);
// Restore composite primary key names
standard = restoreCompositePKNames(standard, uniqueTables);
standard = restoreCompositePKNames(standard, tablesWithFields);
// Restore increment attribute for auto-incrementing fields
standard = restoreIncrementAttribute(standard, tablesWithFields);
// Prepend Enum DBML to the standard output
if (enumsDBML) {

View File

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

View File

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

View File

@@ -101,13 +101,32 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
areas,
]);
// Initialize expanded state with all schemas expanded
useMemo(() => {
const initialExpanded: Record<string, boolean> = {};
treeData.forEach((node) => {
initialExpanded[node.id] = true;
// Sync expanded state with tree data changes - only expand NEW nodes
useEffect(() => {
setExpanded((prev) => {
const currentNodeIds = new Set(treeData.map((n) => n.id));
let hasChanges = false;
const newExpanded: Record<string, boolean> = { ...prev };
// Add any new nodes with expanded=true (preserve existing state)
treeData.forEach((node) => {
if (!(node.id in prev)) {
newExpanded[node.id] = true;
hasChanges = true;
}
});
// Remove nodes that no longer exist (cleanup)
Object.keys(prev).forEach((id) => {
if (!currentNodeIds.has(id)) {
delete newExpanded[id];
hasChanges = true;
}
});
// Only update state if something actually changed (performance)
return hasChanges ? newExpanded : prev;
});
setExpanded(initialExpanded);
}, [treeData]);
// Filter tree data based on search query
@@ -317,6 +336,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => {
expanded={expanded}
setExpanded={setExpanded}
className="py-2"
disableCache={true}
/>
</ScrollArea>
</div>

View File

@@ -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<TableEditModeFieldProps> = React.memo(
const inputRef = React.useRef<HTMLInputElement>(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<TableEditModeFieldProps> = React.memo(
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={typeRequiresNotNull}
>
N
</TableFieldToggle>

View File

@@ -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<TableFieldPopoverProps> = ({
[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 (
<Popover
open={isOpen}
@@ -166,10 +179,12 @@ export const TableFieldPopover: React.FC<TableFieldPopoverProps> = ({
)}
</Label>
<Checkbox
checked={localField.increment ?? false}
disabled={
!localField.primaryKey || readonly
checked={
forceAutoIncrement
? true
: (localField.increment ?? false)
}
disabled={isIncrementDisabled}
onCheckedChange={(value) =>
setLocalField((current) => ({
...current,

View File

@@ -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<TableFieldProps> = ({
transition,
};
const typeRequiresNotNull = requiresNotNull(field.type.name);
return (
<div
className="flex flex-1 touch-none flex-row justify-between gap-2 p-1"
@@ -130,7 +133,7 @@ export const TableField: React.FC<TableFieldProps> = ({
<TableFieldToggle
pressed={nullable}
onPressedChange={handleNullableToggle}
disabled={readonly}
disabled={readonly || typeRequiresNotNull}
>
N
</TableFieldToggle>