mirror of
https://github.com/chartdb/chartdb.git
synced 2025-10-23 07:11:56 +00:00
Compare commits
3 Commits
d55716dfe1
...
release-pl
Author | SHA1 | Date | |
---|---|---|---|
|
15b8308f03 | ||
|
9ed27cf30c | ||
|
2c4b344efb |
26
CHANGELOG.md
26
CHANGELOG.md
@@ -1,5 +1,31 @@
|
||||
# Changelog
|
||||
|
||||
## [1.17.0](https://github.com/chartdb/chartdb/compare/v1.16.0...v1.17.0) (2025-10-21)
|
||||
|
||||
|
||||
### 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))
|
||||
* 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))
|
||||
* 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
4
package-lock.json
generated
@@ -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",
|
||||
|
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "chartdb",
|
||||
"private": true,
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
@@ -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
|
||||
);
|
||||
|
@@ -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',
|
||||
|
@@ -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)
|
||||
|
@@ -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"
|
||||
|
@@ -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]
|
||||
|
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal file
14
src/lib/dbml/dbml-export/__tests__/cases/6.dbml
Normal 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"
|
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal file
1
src/lib/dbml/dbml-export/__tests__/cases/6.json
Normal 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":[]}
|
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal file
205
src/lib/dbml/dbml-export/__tests__/empty-tables.test.ts
Normal 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');
|
||||
});
|
||||
});
|
@@ -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');
|
||||
}
|
||||
);
|
||||
});
|
||||
|
@@ -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');
|
||||
});
|
||||
});
|
@@ -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) {
|
||||
|
@@ -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
|
||||
});
|
||||
});
|
||||
|
@@ -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(),
|
||||
|
@@ -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>
|
||||
|
@@ -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,
|
||||
|
@@ -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>
|
||||
|
Reference in New Issue
Block a user