Compare commits

...

3 Commits

Author SHA1 Message Date
Guy Ben-Aharon
619cdc564c chore(main): release 1.17.0 2025-10-16 21:08:33 +03:00
Jonathan Fishner
459698b5d0 fix: add support for parsing default values in DBML (#948) 2025-10-16 21:07:55 +03:00
Guy Ben-Aharon
7ad0e7712d fix: manipulate schema directly from the canvas (#947) 2025-10-16 17:37:20 +03:00
8 changed files with 341 additions and 78 deletions

View File

@@ -1,5 +1,25 @@
# Changelog
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-16)
### 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 rels export dbml ([#937](https://github.com/chartdb/chartdb/issues/937)) ([c3c646b](https://github.com/chartdb/chartdb/commit/c3c646bf7cbb1328f4b2eb85c9a7e929f0fcd3b9))
* 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))
* manipulate schema directly from the canvas ([#947](https://github.com/chartdb/chartdb/issues/947)) ([7ad0e77](https://github.com/chartdb/chartdb/commit/7ad0e7712de975a23b2a337dc0a4a7fb4b122bd1))
* 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))
## [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

@@ -13,6 +13,55 @@ import { exportSQLite } from './export-per-type/sqlite';
import { exportMySQL } from './export-per-type/mysql';
import { escapeSQLComment } from './export-per-type/common';
// Function to format default values with proper quoting
const formatDefaultValue = (value: string): string => {
const trimmed = value.trim();
// SQL keywords and function-like keywords that don't need quotes
const keywords = [
'TRUE',
'FALSE',
'NULL',
'CURRENT_TIMESTAMP',
'CURRENT_DATE',
'CURRENT_TIME',
'NOW',
'GETDATE',
'NEWID',
'UUID',
];
if (keywords.includes(trimmed.toUpperCase())) {
return trimmed;
}
// Function calls (contain parentheses) don't need quotes
if (trimmed.includes('(') && trimmed.includes(')')) {
return trimmed;
}
// Numbers don't need quotes
if (/^-?\d+(\.\d+)?$/.test(trimmed)) {
return trimmed;
}
// Already quoted strings - keep as is
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed;
}
// Check if it's a simple identifier (alphanumeric, no spaces) that might be a currency or enum
// These typically don't have spaces and are short (< 10 chars)
if (/^[A-Z][A-Z0-9_]*$/i.test(trimmed) && trimmed.length <= 10) {
return trimmed; // Treat as unquoted identifier (e.g., EUR, USD)
}
// Everything else needs to be quoted and escaped
return `'${trimmed.replace(/'/g, "''")}'`;
};
// Function to simplify verbose data type names
const simplifyDataType = (typeName: string): string => {
const typeMap: Record<string, string> = {};
@@ -391,7 +440,9 @@ export const exportBaseSQL = ({
}
}
sqlScript += ` DEFAULT ${fieldDefault}`;
// Format default value with proper quoting
const formattedDefault = formatDefaultValue(fieldDefault);
sqlScript += ` DEFAULT ${formattedDefault}`;
}
}

View File

@@ -295,4 +295,51 @@ describe('DBML Import cases', () => {
it('should handle case 2 - tables with relationships', async () => {
await testDBMLImportCase('2');
});
it('should handle table with default values', async () => {
const dbmlContent = `Table "public"."products" {
"id" bigint [pk, not null]
"name" varchar(255) [not null]
"price" decimal(10,2) [not null, default: 0]
"is_active" boolean [not null, default: true]
"status" varchar(50) [not null, default: "deprecated"]
"description" varchar(100) [default: \`complex "value" with quotes\`]
"created_at" timestamp [not null, default: "now()"]
Indexes {
(name) [name: "idx_products_name"]
}
}`;
const result = await importDBMLToDiagram(dbmlContent, {
databaseType: DatabaseType.POSTGRESQL,
});
expect(result.tables).toHaveLength(1);
const table = result.tables![0];
expect(table.name).toBe('products');
expect(table.fields).toHaveLength(7);
// Check numeric default (0)
const priceField = table.fields.find((f) => f.name === 'price');
expect(priceField?.default).toBe('0');
// Check boolean default (true)
const isActiveField = table.fields.find((f) => f.name === 'is_active');
expect(isActiveField?.default).toBe('true');
// Check string default with all quotes removed
const statusField = table.fields.find((f) => f.name === 'status');
expect(statusField?.default).toBe('deprecated');
// Check backtick string - all quotes removed
const descField = table.fields.find((f) => f.name === 'description');
expect(descField?.default).toBe('complex value with quotes');
// Check function default with all quotes removed
const createdAtField = table.fields.find(
(f) => f.name === 'created_at'
);
expect(createdAtField?.default).toBe('now()');
});
});

View File

@@ -89,6 +89,7 @@ interface DBMLField {
precision?: number | null;
scale?: number | null;
note?: string | { value: string } | null;
default?: string | null;
}
interface DBMLIndexColumn {
@@ -334,6 +335,20 @@ export const importDBMLToDiagram = async (
schema: schemaName,
note: table.note,
fields: table.fields.map((field): DBMLField => {
// Extract default value and remove all quotes
let defaultValue: string | undefined;
if (
field.dbdefault !== undefined &&
field.dbdefault !== null
) {
const rawDefault = String(
field.dbdefault.value
);
// Remove ALL quotes (single, double, backticks) to clean the value
// The SQL export layer will handle adding proper quotes when needed
defaultValue = rawDefault.replace(/['"`]/g, '');
}
return {
name: field.name,
type: field.type,
@@ -342,6 +357,7 @@ export const importDBMLToDiagram = async (
not_null: field.not_null,
increment: field.increment,
note: field.note,
default: defaultValue,
...getFieldExtraAttributes(field, allEnums),
} satisfies DBMLField;
}),
@@ -488,6 +504,7 @@ export const importDBMLToDiagram = async (
precision: field.precision,
scale: field.scale,
...(fieldComment ? { comments: fieldComment } : {}),
...(field.default ? { default: field.default } : {}),
};
});

View File

@@ -14,14 +14,14 @@ import { Table, Workflow, Group, View } from 'lucide-react';
import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter';
import { useLocalConfig } from '@/hooks/use-local-config';
import { useCanvas } from '@/hooks/use-canvas';
import type { DBTable } from '@/lib/domain';
import { defaultSchemas } from '@/lib/data/default-schemas';
export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
children,
}) => {
const { createTable, readonly, createArea } = useChartDB();
const { createTable, readonly, createArea, databaseType } = useChartDB();
const { schemasDisplayed } = useDiagramFilter();
const { openCreateRelationshipDialog, openTableSchemaDialog } = useDialog();
const { openCreateRelationshipDialog } = useDialog();
const { screenToFlowPosition } = useReactFlow();
const { t } = useTranslation();
const { showDBViews } = useLocalConfig();
@@ -36,30 +36,23 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
y: event.clientY,
});
let newTable: DBTable | null = null;
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: async ({ schema }) => {
newTable = await createTable({
x: position.x,
y: position.y,
schema: schema.name,
});
},
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
newTable = await createTable({
const newTable = await createTable({
x: position.x,
y: position.y,
schema,
});
}
if (newTable) {
setEditTableModeTable({ tableId: newTable.id });
@@ -68,9 +61,9 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);
@@ -81,32 +74,24 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
y: event.clientY,
});
let newView: DBTable | null = null;
// Auto-select schema with priority: default schema > first displayed schema > undefined
let schema: string | undefined = undefined;
if (schemasDisplayed.length > 0) {
const defaultSchemaName = defaultSchemas[databaseType];
const defaultSchemaInList = schemasDisplayed.find(
(s) => s.name === defaultSchemaName
);
schema = defaultSchemaInList
? defaultSchemaInList.name
: schemasDisplayed[0]?.name;
}
if (schemasDisplayed.length > 1) {
openTableSchemaDialog({
onConfirm: async ({ schema }) => {
newView = await createTable({
x: position.x,
y: position.y,
schema: schema.name,
isView: true,
});
},
schemas: schemasDisplayed,
});
} else {
const schema =
schemasDisplayed?.length === 1
? schemasDisplayed[0]?.name
: undefined;
newView = await createTable({
const newView = await createTable({
x: position.x,
y: position.y,
schema,
isView: true,
});
}
if (newView) {
setEditTableModeTable({ tableId: newView.id });
@@ -115,9 +100,9 @@ export const CanvasContextMenu: React.FC<React.PropsWithChildren> = ({
[
createTable,
screenToFlowPosition,
openTableSchemaDialog,
schemasDisplayed,
setEditTableModeTable,
databaseType,
]
);

View File

@@ -1,7 +1,13 @@
import { Input } from '@/components/input/input';
import type { DBTable } from '@/lib/domain';
import { FileType2, X } from 'lucide-react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { FileType2, X, SquarePlus } from 'lucide-react';
import React, {
useEffect,
useState,
useRef,
useCallback,
useMemo,
} from 'react';
import { TableEditModeField } from './table-edit-mode-field';
import { cn } from '@/lib/utils';
import { ScrollArea } from '@/components/scroll-area/scroll-area';
@@ -11,6 +17,14 @@ import { Separator } from '@/components/separator/separator';
import { useChartDB } from '@/hooks/use-chartdb';
import { useUpdateTable } from '@/hooks/use-update-table';
import { useTranslation } from 'react-i18next';
import { SelectBox } from '@/components/select-box/select-box';
import type { SelectBoxOption } from '@/components/select-box/select-box';
import {
databasesWithSchemas,
schemaNameToSchemaId,
} from '@/lib/domain/db-schema';
import type { DBSchema } from '@/lib/domain/db-schema';
import { defaultSchemas } from '@/lib/data/default-schemas';
export interface TableEditModeProps {
table: DBTable;
@@ -25,7 +39,8 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
const scrollAreaRef = useRef<HTMLDivElement>(null);
const fieldRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const [isVisible, setIsVisible] = useState(false);
const { createField, updateTable } = useChartDB();
const { createField, updateTable, schemas, databaseType } =
useChartDB();
const { t } = useTranslation();
const { tableName, handleTableNameChange } = useUpdateTable(table);
const [focusFieldId, setFocusFieldId] = useState<string | undefined>(
@@ -33,6 +48,39 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
);
const inputRef = useRef<HTMLInputElement>(null);
// Schema-related state
const [isCreatingNewSchema, setIsCreatingNewSchema] = useState(false);
const [newSchemaName, setNewSchemaName] = useState('');
const [selectedSchemaId, setSelectedSchemaId] = useState<string>(() =>
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
// Sync selectedSchemaId when table.schema changes
useEffect(() => {
setSelectedSchemaId(
table.schema ? schemaNameToSchemaId(table.schema) : ''
);
}, [table.schema]);
const supportsSchemas = useMemo(
() => databasesWithSchemas.includes(databaseType),
[databaseType]
);
const defaultSchemaName = useMemo(
() => defaultSchemas?.[databaseType],
[databaseType]
);
const schemaOptions: SelectBoxOption[] = useMemo(
() =>
schemas.map((schema) => ({
value: schema.id,
label: schema.name,
})),
[schemas]
);
useEffect(() => {
setFocusFieldId(focusFieldIdProp);
if (!focusFieldIdProp) {
@@ -115,6 +163,43 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
[updateTable, table.id]
);
const handleSchemaChange = useCallback(
(schemaId: string) => {
const schema = schemas.find((s) => s.id === schemaId);
if (schema) {
updateTable(table.id, { schema: schema.name });
setSelectedSchemaId(schemaId);
}
},
[schemas, updateTable, table.id]
);
const handleCreateNewSchema = useCallback(() => {
if (newSchemaName.trim()) {
const trimmedName = newSchemaName.trim();
const newSchema: DBSchema = {
id: schemaNameToSchemaId(trimmedName),
name: trimmedName,
tableCount: 0,
};
updateTable(table.id, { schema: newSchema.name });
setSelectedSchemaId(newSchema.id);
setIsCreatingNewSchema(false);
setNewSchemaName('');
}
}, [newSchemaName, updateTable, table.id]);
const handleToggleSchemaMode = useCallback(() => {
if (isCreatingNewSchema && newSchemaName.trim()) {
// If we're leaving create mode with a value, create the schema
handleCreateNewSchema();
} else {
// Otherwise just toggle modes
setIsCreatingNewSchema(!isCreatingNewSchema);
setNewSchemaName('');
}
}, [isCreatingNewSchema, newSchemaName, handleCreateNewSchema]);
return (
<div
ref={containerRef}
@@ -134,18 +219,60 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
onClick={(e) => e.stopPropagation()}
>
<div
className="h-2 rounded-t-[6px]"
className="h-2 cursor-move rounded-t-[6px]"
style={{ backgroundColor: color }}
></div>
<div className="group flex h-9 items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="group flex h-9 cursor-move items-center justify-between gap-2 bg-slate-200 px-2 dark:bg-slate-900">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ColorPicker
color={color}
onChange={handleColorChange}
disabled={table.isView}
popoverOnMouseDown={(e) => e.stopPropagation()}
popoverOnClick={(e) => e.stopPropagation()}
{supportsSchemas && !isCreatingNewSchema && (
<SelectBox
options={schemaOptions}
value={selectedSchemaId}
onChange={(value) =>
handleSchemaChange(value as string)
}
placeholder={
defaultSchemaName || 'Select schema'
}
className="h-6 min-h-6 w-20 shrink-0 rounded-sm border-slate-600 bg-background py-0 pl-2 pr-0.5 text-sm"
popoverClassName="w-[200px]"
commandOnMouseDown={(e) => e.stopPropagation()}
commandOnClick={(e) => e.stopPropagation()}
footerButtons={
<Button
variant="ghost"
size="sm"
className="w-full justify-center rounded-none text-xs"
onClick={(e) => {
e.stopPropagation();
handleToggleSchemaMode();
}}
>
<SquarePlus className="!size-3.5" />
Create new schema
</Button>
}
/>
)}
{supportsSchemas && isCreatingNewSchema && (
<Input
value={newSchemaName}
onChange={(e) =>
setNewSchemaName(e.target.value)
}
placeholder={`Enter schema name${defaultSchemaName ? ` (e.g. ${defaultSchemaName})` : ''}`}
className="h-6 w-28 shrink-0 rounded-sm border-slate-600 bg-background text-sm"
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleCreateNewSchema();
} else if (e.key === 'Escape') {
handleToggleSchemaMode();
}
}}
onBlur={handleToggleSchemaMode}
autoFocus
/>
)}
<Input
ref={inputRef}
className="h-6 flex-1 rounded-sm border-slate-600 bg-background text-sm"
@@ -179,7 +306,22 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
</ScrollArea>
<Separator />
<div className="flex items-center justify-between p-2">
<div className="flex cursor-move items-center justify-between p-2">
<div className="flex items-center gap-2">
{!table.isView ? (
<>
<ColorPicker
color={color}
onChange={handleColorChange}
popoverOnMouseDown={(e) =>
e.stopPropagation()
}
popoverOnClick={(e) => e.stopPropagation()}
/>
</>
) : (
<div />
)}
<Button
variant="outline"
className="h-8 p-2 text-xs"
@@ -188,6 +330,7 @@ export const TableEditMode: React.FC<TableEditModeProps> = React.memo(
<FileType2 className="mr-1 h-4" />
{t('side_panel.tables_section.table.add_field')}
</Button>
</div>
<span className="text-xs font-medium text-muted-foreground">
{table.fields.length}{' '}
{t('side_panel.tables_section.table.fields')}