diff --git a/src/hooks/user-render-sql.tsx b/src/hooks/user-render-sql.tsx index b946b22..77b5df3 100644 --- a/src/hooks/user-render-sql.tsx +++ b/src/hooks/user-render-sql.tsx @@ -7,8 +7,9 @@ import { useEffect, useState } from "react"; import { Parser } from "node-sql-parser"; import { DatabaseType } from "@/lib/schemas/database-schema"; import { format } from 'sql-formatter'; -import { getDatabaseByDialect } from "@/lib/database"; -import { fixCharsetPlacement } from "@/utils/render/parsers/render-uttils"; +import { DatabaseDialect, getDatabaseByDialect } from "@/lib/database"; +import { CircularDependencyError, fixCharsetPlacement, fixSQLiteColumnOrder } from "@/utils/render/parsers/render-uttils"; +import { areArraysEqual } from "@/utils/utils"; const parser = new Parser(); @@ -16,24 +17,35 @@ const parser = new Parser(); export const useRenderSql = (database: DatabaseType) => { const [sql, setSql] = useState(""); const { data_types } = useDatabaseOperations(); - + const [circularDependency , setCircularDependency] = useState(undefined) useEffect(() => { try { const dbAst: any = DatabaseToAst(database, data_types); - const sql = fixCharsetPlacement(format(parser.sqlify(dbAst, { + let sql: string = parser.sqlify(dbAst, { database: getDatabaseByDialect(database.dialect).name - }), { language: "sql" } )); - + }); + if (database.dialect != DatabaseDialect.SQLITE) + sql = fixCharsetPlacement(format(sql, { language: "sql" })); + else + sql = fixSQLiteColumnOrder(format(sql, { language: "sql" })); + const formattedSqlCode = format(sql, { language: 'sql' }); setSql( (formattedSqlCode) ); + setCircularDependency( undefined ) ; } catch (error) { - console.log(error) + + setCircularDependency((previousError) => { + if ( !previousError || !areArraysEqual( previousError.cycle , (error as CircularDependencyError).cycle) ) + return error as CircularDependencyError ; + return previousError ; + }) + } }, [database]); - return sql; + return {sql , circularDependency}; } \ No newline at end of file diff --git a/src/i18/languages/en.ts b/src/i18/languages/en.ts index af17323..b75cf48 100644 --- a/src/i18/languages/en.ts +++ b/src/i18/languages/en.ts @@ -111,6 +111,13 @@ export const en = { invalid_relationship: { title: "Invalid Relationship", description: "The source key type does not match the referenced key type. Please ensure both keys have the same data type." + } , + circular_dependency : { + title : "Circular Dependency Detected" , + toast_description : "A circular reference between tables was found. Check the diagram on the left and remove one of the relationships to fix it." , + description : "Your schema contains a circular foreign key relationship between tables. To fix it" , + suggestion : "remove one of the relationships listed below that are causing the cycle." , + remove_relationship : "Remove relationship" } }, table: { diff --git a/src/lib/database.ts b/src/lib/database.ts index 60f953c..ebdf7ec 100644 --- a/src/lib/database.ts +++ b/src/lib/database.ts @@ -1,15 +1,10 @@ - - - export interface DatabaseType { name: string, dialect: string; logo: string } - - export enum DatabaseDialect { MYSQL = "mysql", POSTGRES = "postgres", diff --git a/src/pages/database/database-page.tsx b/src/pages/database/database-page.tsx index dc3b997..73a0832 100644 --- a/src/pages/database/database-page.tsx +++ b/src/pages/database/database-page.tsx @@ -98,7 +98,8 @@ const DatabasePage: React.FC = () => { addToast({ title: t("db_controller.invalid_relationship.title"), description: t("db_controller.invalid_relationship.description"), - color: "danger", + color: "danger", + variant : "solid" }); } diff --git a/src/pages/database/db-controller/circular-dependecy-alert.tsx b/src/pages/database/db-controller/circular-dependecy-alert.tsx new file mode 100644 index 0000000..90eaf29 --- /dev/null +++ b/src/pages/database/db-controller/circular-dependecy-alert.tsx @@ -0,0 +1,109 @@ +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/tooltip"; +import { RelationshipType } from "@/lib/schemas/relationship-schema"; +import { useDatabase, useDatabaseOperations } from "@/providers/database-provider/database-provider"; +import { useDiagramOps } from "@/providers/diagram-provider/diagram-provider"; +import { CircularDependencyError } from "@/utils/render/parsers/render-uttils"; +import { addToast, Alert, Button, Listbox, ListboxItem, toast } from "@heroui/react"; +import { AlertTriangle, Trash } from "lucide-react"; +import React, { useCallback, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; + +interface CircularDependencyAlertProps { + error: CircularDependencyError +} +const CircularDependencyAlert: React.FC = ({ error }) => { + + const { database } = useDatabase() || { relationships: [] }; + const { relationships } = database || { relationships: [] } + const { focusOnRelationship } = useDiagramOps(); + const { deleteRelationship } = useDatabaseOperations(); + + const { t } = useTranslation(); + + const circularRelationships: RelationshipType[] = useMemo(() => { + + let cycle: RelationshipType[] = []; + + for (let index = 0; index < error.cycle?.length - 1; index++) { + const sourceTableId: string = error.cycle[index]; + const targetTableId: string = error.cycle[index + 1]; + const relationship: RelationshipType | undefined = relationships.find((relationship: RelationshipType) => relationship.sourceTableId == sourceTableId && relationship.targetTableId == targetTableId) + if (relationship) + cycle.push( + relationship + ) + } + + return cycle; + + }, [error, relationships]) + + + const focus = (key: any) => { + console.log(key) + focusOnRelationship( + key, true, false + ) + } + + + const removeRelationship = useCallback((id: string) => { + deleteRelationship(id) + }, []) + return ( +
+ +

+ {t("db_controller.circular_dependency.title")} +

+

+ {t("db_controller.circular_dependency.description")} , {t("db_controller.circular_dependency.suggestion")} +

+ + + { + circularRelationships.map((relationship: RelationshipType) => ( + +
+ + {relationship.sourceTable.name} -> {relationship.targetTable.name} + + + + + + + + + {t("db_controller.circular_dependency.remove_relationship")} + + +
+
+ )) + } +
+ +
+ ) +}; + + +export default React.memo(CircularDependencyAlert) \ No newline at end of file diff --git a/src/pages/database/db-controller/sql-preview.tsx b/src/pages/database/db-controller/sql-preview.tsx index ec1bf3b..71e7043 100644 --- a/src/pages/database/db-controller/sql-preview.tsx +++ b/src/pages/database/db-controller/sql-preview.tsx @@ -6,13 +6,12 @@ import CodeMirror, { EditorView } from '@uiw/react-codemirror'; import { sql } from '@codemirror/lang-sql'; import { oneDark } from '@codemirror/theme-one-dark'; import { useTheme } from "next-themes"; -import { Parser } from "node-sql-parser"; + import { overrideDarkTheme, overrideLightTheme } from "@/lib/colors"; import { DatabaseType } from "@/lib/schemas/database-schema"; -const parser = new Parser(); -const code = ` - -` +import CircularDependencyAlert from "./circular-dependecy-alert"; +import { addToast } from "@heroui/react"; +import { useTranslation } from "react-i18next"; @@ -20,30 +19,36 @@ const code = ` const SqlPreview: React.FC = ({ }) => { const { database } = useDatabase(); - const sqlCode = useRenderSql(database as DatabaseType); + + const { sql: sqlCode, circularDependency } = useRenderSql(database as DatabaseType); const { resolvedTheme } = useTheme(); + const { t } = useTranslation() ; - useEffect(() => { - const ast = parser.astify(code, { - database: "Postgresql" - }) ; - - console.log(ast) - }, []) + useEffect(() => { + if (circularDependency) + addToast({ + title: t("db_controller.circular_dependency.title"), + description: t("db_controller.circular_dependency.description") , + color: "danger", + variant: "solid" + }); + }, [circularDependency]) - return ( -
- { - - - } -
- ) + if (circularDependency) + return + else + return ( +
+ { + + } +
+ ) } diff --git a/src/pages/database/db-controller/tables-controller/tables-controller.tsx b/src/pages/database/db-controller/tables-controller/tables-controller.tsx index aa092b4..1de3723 100644 --- a/src/pages/database/db-controller/tables-controller/tables-controller.tsx +++ b/src/pages/database/db-controller/tables-controller/tables-controller.tsx @@ -45,7 +45,6 @@ const TablesController: React.FC = ({ }) => { name: `table_${tables.length + 1}`, posX, posY, - fields: [{ id: v4(), name: "id", diff --git a/src/providers/database-provider/database-provider.tsx b/src/providers/database-provider/database-provider.tsx index de0019c..0d65610 100644 --- a/src/providers/database-provider/database-provider.tsx +++ b/src/providers/database-provider/database-provider.tsx @@ -373,10 +373,13 @@ const DatabaseProvider: React.FC = ({ children }) => { const getDefaultPrimaryKeyType = useCallback((dialect?: DatabaseDialect) => { - if (!dialect || dialect != DatabaseDialect.POSTGRES) - return data_types.find((dataType: DataType) => dataType.name == "bigint"); - else if (dialect == DatabaseDialect.POSTGRES) + if (dialect == DatabaseDialect.POSTGRES) return data_types.find((dataType: DataType) => dataType.name == "bigserial"); + else if (dialect == DatabaseDialect.SQLITE) + return data_types.find((dataType: DataType) => dataType.name == "integer"); + + return data_types.find((dataType: DataType) => dataType.name == "bigint"); + }, [data_types]); const databaseOpsValue = useMemo(() => ({ diff --git a/src/providers/diagram-provider/diagram-context.tsx b/src/providers/diagram-provider/diagram-context.tsx index f0aac07..0babebc 100644 --- a/src/providers/diagram-provider/diagram-context.tsx +++ b/src/providers/diagram-provider/diagram-context.tsx @@ -8,7 +8,7 @@ interface DiagramDataContextType { interface DiagramOpsContextType { focusOnTable: (id: string, transition?: boolean) => void, - focusOnRelationship: (id: string, transition?: boolean) => void, + focusOnRelationship: (id: string, transition?: boolean , withNavigate? : boolean) => void, setIsConnectionInProgress: Dispatch, isConnectionInProgress: boolean diff --git a/src/providers/diagram-provider/diagram-provider.tsx b/src/providers/diagram-provider/diagram-provider.tsx index 4e6b365..9d277ac 100644 --- a/src/providers/diagram-provider/diagram-provider.tsx +++ b/src/providers/diagram-provider/diagram-provider.tsx @@ -1,6 +1,6 @@ -import { useCallback, useContext, useMemo, useState } from "react"; +import { useCallback, useContext, useMemo, useState } from "react"; -import { useReactFlow } from "@xyflow/react"; +import { useReactFlow } from "@xyflow/react"; import { useNavigate } from "react-router-dom"; import { RelationshipType } from "@/lib/schemas/relationship-schema"; import { DiagramDataContext, DiagramOpsContext } from "./diagram-context"; @@ -43,8 +43,10 @@ const DiagramProvider: React.FC = ({ children }) => { }, [setFocusedTableId]) - const focusOnRelationship = useCallback((id: string, transition: boolean = false) => { - navigate("/database/relationships"); + const focusOnRelationship = useCallback((id: string, transition: boolean = false, withNavigate: boolean = true) => { + if (withNavigate) + navigate("/database/relationships"); + setFocusedRelationshipId(id); setEdges((edges) => diff --git a/src/utils/render/parsers/database_to_ast.ts b/src/utils/render/parsers/database_to_ast.ts index c5fb519..ceb7db2 100644 --- a/src/utils/render/parsers/database_to_ast.ts +++ b/src/utils/render/parsers/database_to_ast.ts @@ -27,7 +27,16 @@ export const DatabaseToAst = (database: DatabaseType, data_types: DataType[]) => renderableTables.find((table: TableType) => table.id == id) as TableType ); for (const table of sortedTables) { - dbAst.push(TableToAst(table, data_types)); + let postgresEnums: FieldType[]; + if (database.dialect == DatabaseDialect.POSTGRES) { + postgresEnums = table.fields.filter((field: FieldType) => field.type?.name == "enum"); + if (postgresEnums.length > 0) { + for (const postgresEnum of postgresEnums) + dbAst.push(PotgresEnumToAst(postgresEnum)); + } + } + + dbAst.push(TableToAst(table, data_types, database.dialect == DatabaseDialect.POSTGRES)); if (table.indices && table.indices.length > 0) for (const index of table.indices) @@ -39,16 +48,18 @@ export const DatabaseToAst = (database: DatabaseType, data_types: DataType[]) => }, table)); }; } catch (error) { - console.log(error); + throw error ; + } return dbAst; } -export const TableToAst = (table: TableType, data_types: DataType[]) => { +export const TableToAst = (table: TableType, data_types: DataType[], isPostgresDialect: boolean = false) => { const primaryKeys: FieldType[] = table.fields.filter((field: FieldType) => field.isPrimary); const multiPrimaryKeys: boolean = primaryKeys.length > 1; + const primaryKeysConstraints: any[] | undefined = multiPrimaryKeys ? [{ constraint_type: "primary key", resource: "constraint", @@ -59,13 +70,26 @@ export const TableToAst = (table: TableType, data_types: DataType[]) => { }] : []; let foreignRelationships = getForeignRelationships(table); - const foreignKeysConstraints = foreignRelationships.map((relationship: RelationshipType) => relationshipToAst(relationship)); - const constraints: any[] = [...primaryKeysConstraints, ...foreignKeysConstraints]; - const filed_definitions = table.fields.map((field: FieldType) => FieldToAst({ - ...field, - type: data_types.find((dataType: DataType) => dataType.id == field.typeId) as DataType, - }, multiPrimaryKeys)); + const constraints: any[] = [ + ...primaryKeysConstraints, + ...foreignKeysConstraints + ]; + + const filed_definitions = table.fields.map((field: FieldType) => { + const isPostgresEnum: boolean = isPostgresDialect && field.type.name == "enum"; + + return FieldToAst({ + ...field, + type: !(isPostgresEnum) ? + data_types.find((dataType: DataType) => dataType.id == field.typeId) as DataType : + { name: `${field.name.toLowerCase()}_enum` } as DataType, + }, multiPrimaryKeys , !isPostgresEnum) + }) + + + + return { keyword: "table", type: "create", @@ -75,10 +99,11 @@ export const TableToAst = (table: TableType, data_types: DataType[]) => { create_definitions: [...filed_definitions, ...constraints] } } -export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false) => { +export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false, upperCaseType: boolean = true) => { const modifiers: string[] = field?.type.modifiers ? JSON.parse(field?.type.modifiers) : []; let suffix: string[] = []; + if (modifiers.includes(Modifiers.ZEROFILL) && field.zeroFill) { suffix.push(Modifiers.ZEROFILL.toUpperCase()); } @@ -97,8 +122,7 @@ export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false) let valuesExpr: any | null; let default_val: any | null = null; - let dataType : string | undefined = field.type?.name?.toLocaleUpperCase() ; - + let dataType: string | undefined = upperCaseType ? field.type?.name?.toLocaleUpperCase() : field.type?.name as string | undefined ; if (modifiers.includes(Modifiers.PRECISION) && field.precision) { length = field.precision; @@ -111,8 +135,9 @@ export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false) if (modifiers.includes(Modifiers.LENGTH) && field.maxLength) length = field.maxLength; - else if (modifiers.includes(Modifiers.LENGTH) && field.type.dialect == DatabaseDialect.MYSQL && field.type.name?.startsWith('var') && !field.maxLength) - length = MYSQL_MAX_VAR_LENGTH ; + + else if (modifiers.includes(Modifiers.LENGTH) && (field.type.dialect == DatabaseDialect.MYSQL || field.type.dialect == DatabaseDialect.MARIADB) && field.type.name?.startsWith('var') && !field.maxLength) + length = MYSQL_MAX_VAR_LENGTH; if (modifiers.includes(Modifiers.CHARSET) && field.charset) character_set = { @@ -186,9 +211,6 @@ export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false) } } - - - // field.type?.name == "varchar" && (field.type?.dialect == DatabaseDialect.MYSQL || field.type?.dialect == DatabaseDialect.MARIADB) ? 255 : null return { column: { @@ -206,11 +228,11 @@ export const FieldToAst = (field: FieldType, ignorePrimaryKey: boolean = false) auto_increment: (modifiers.includes(Modifiers.AUTO_INCREMENT) && field.autoIncrement && field.type.dialect != DatabaseDialect.POSTGRES) ? "auto_increment" : null, nullable: { - type: field.nullable ? "null" : "not null", + type: field.nullable ? "null" : "not null", value: field.nullable ? "null" : "not null", }, definition: { - dataType , + dataType, length, scale, suffix, @@ -239,6 +261,29 @@ export const IndexToAst = (index: IndexType, table: TableType) => { } } +export const PotgresEnumToAst = (field: FieldType) => { + + const jsonValues = field.values ? JSON.parse(field.values) : []; + + return { + as : "as" , + type: "create", + resource: "enum", + name: { + schema: null, + name: `${field.name.toLowerCase()}_enum` + }, + keyword: "type", + create_definitions: { + parentheses: true, + type: "expr_list", + value: jsonValues.map((value: string) => ({ + type: "single_quote_string", + value + })) + } + } +} diff --git a/src/utils/render/parsers/render-uttils.ts b/src/utils/render/parsers/render-uttils.ts index 4ce6d33..5a74b0c 100644 --- a/src/utils/render/parsers/render-uttils.ts +++ b/src/utils/render/parsers/render-uttils.ts @@ -79,4 +79,76 @@ function processColumn(columnDef: string): string { if (cleanRest) reconstructed += ` ${cleanRest}`; return reconstructed; +} + + +export function fixSQLiteColumnOrder(sql: string): string { + const lines = sql.split('\n'); + let currentColumn = ''; + const result: string[] = []; + + for (const line of lines) { + const trimmed = line.trim(); + + if (isTableStructureLine(trimmed)) { + if (currentColumn) { + result.push(processSQLiteIntegerColumn(currentColumn)); + currentColumn = ''; + } + result.push(line); + continue; + } + + if (trimmed.endsWith(',')) { + currentColumn += ' ' + trimmed.slice(0, -1); + result.push(processSQLiteIntegerColumn(currentColumn) + ','); + currentColumn = ''; + } else { + currentColumn += ' ' + trimmed; + } + } + + if (currentColumn) result.push(processSQLiteIntegerColumn(currentColumn)); + return result.join('\n'); +} + +function processSQLiteIntegerColumn(columnDef: string): string { + // Only process INTEGER columns with PRIMARY KEY and/or AUTOINCREMENT + const integerPkMatch = columnDef.match(/^\s*(\w+)\s+INTEGER\s+(.*)/i); + if (!integerPkMatch) return columnDef; + + const [_, colName, rest] = integerPkMatch; + + // Check if this is a PRIMARY KEY column + const isPrimaryKey = rest.match(/\bPRIMARY\s+KEY\b/i); + const isAutoIncrement = rest.match(/\bAUTOINCREMENT\b/i); + const isNotNull = rest.match(/\bNOT\s+NULL\b/i); + + if (!isPrimaryKey && !isAutoIncrement) { + return columnDef; // Leave non-PK INTEGER columns unchanged + } + + // Clean the remaining attributes + let cleanRest = rest + .replace(/\bPRIMARY\s+KEY\b/gi, '') + .replace(/\bAUTOINCREMENT\b/gi, '') + .replace(/\bNOT\s+NULL\b/gi, '') + .replace(/\s+/g, ' ') + .trim(); + + // Reconstruct with SQLite's required order + let reconstructed = `${colName} INTEGER`; + + if (isPrimaryKey) reconstructed += ' PRIMARY KEY'; + if (isAutoIncrement) reconstructed += ' AUTOINCREMENT'; + if (isNotNull) reconstructed += ' NOT NULL'; + if (cleanRest) reconstructed += ` ${cleanRest}`; + + return reconstructed.trim(); +} + +export interface CircularDependencyError { + cycle : string[] ; + success : boolean ; + message : string ; } \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index c251918..a229096 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -6,7 +6,7 @@ export default { "./index.html", "./src/**/*.{js,ts,jsx,tsx}", "./node_modules/@nextui-org/theme/dist/**/*.{js,ts,jsx,tsx}", - + "./node_modules/@heroui/theme/dist/components/(button|code|dropdown|input|kbd|link|navbar|snippet|toggle|popover|ripple|spinner|menu|divider|form|modal|toast).js", ], darkMode: "class",