{
- sidebarItems.map((item: SidebarItemProps) => (
+ sidebarItems.map((item: SidebarItemProps , index : number) => (
))
@@ -99,8 +100,10 @@ const Sidebar: React.FC = ({ }) => {
{
- bottomSidebarItems.map((item: SidebarItemProps) => (
+ bottomSidebarItems.map((item: SidebarItemProps, index : number) => (
))
diff --git a/src/i18/languages/en.ts b/src/i18/languages/en.ts
index 2c51298..3f9d384 100644
--- a/src/i18/languages/en.ts
+++ b/src/i18/languages/en.ts
@@ -66,6 +66,14 @@ export const en = {
},
table: {
double_click: "Double click to edit"
+ } ,
+ control_buttons : {
+ redo : "Redo" ,
+ undo : "Undo" ,
+ zoom_in : "Zoom In" ,
+ zoom_out : "Zoom Out" ,
+ adjust_positions : "Adjust Positions" ,
+ show_all : "Show all"
}
}
diff --git a/src/lib/schemas/app-schema.ts b/src/lib/schemas/app-schema.ts
index 65d405b..b8499a5 100644
--- a/src/lib/schemas/app-schema.ts
+++ b/src/lib/schemas/app-schema.ts
@@ -1,21 +1,24 @@
-import { DrizzleAppSchema, wrapPowerSyncWithDrizzle } from '@powersync/drizzle-driver';
+import { DrizzleAppSchema } from '@powersync/drizzle-driver';
import { data_types, dataTypeRelations } from './data-type-schema';
import { tables, tablesRelations } from './table-schema';
import { fields, fieldsRelations } from './field-schema';
import { relationshipRelations, relationships } from './relationship-schema';
+import { databaseRelations, databases } from './database-schema';
export const drizzleSchema = {
data_types ,
tables ,
fields ,
- relationships ,
+ relationships ,
+ databases ,
// relationships
tablesRelations ,
fieldsRelations ,
relationshipRelations ,
- dataTypeRelations
+ dataTypeRelations ,
+ databaseRelations
};
// Infer the PowerSync schema from your Drizzle schema
diff --git a/src/lib/schemas/database-schema.ts b/src/lib/schemas/database-schema.ts
index e69de29..2d4f0f8 100644
--- a/src/lib/schemas/database-schema.ts
+++ b/src/lib/schemas/database-schema.ts
@@ -0,0 +1,26 @@
+import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
+import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
+import { tables, TableType } from './table-schema';
+import { relationships, RelationshipType } from './relationship-schema';
+
+
+export const databases = sqliteTable('databases', {
+ id: text('id')
+ .primaryKey()
+ .notNull()
+ .unique(),
+ name: text('name').notNull(),
+ createdAt: text('createdAt'),
+});
+
+export const databaseRelations = relations(databases, ({ many }) => ({
+ tables: many(tables),
+ relationships: many(relationships),
+}));
+
+
+export interface DatabaseType extends InferSelectModel
{
+ tables : TableType[] ,
+ relationships : RelationshipType[]
+ };
+export interface DatabaseInsertType extends InferInsertModel { };
\ No newline at end of file
diff --git a/src/lib/schemas/relationship-schema.ts b/src/lib/schemas/relationship-schema.ts
index a955f24..bb3e467 100644
--- a/src/lib/schemas/relationship-schema.ts
+++ b/src/lib/schemas/relationship-schema.ts
@@ -5,6 +5,7 @@ import {
import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { tables, TableType } from './table-schema';
import { fields, FieldType } from './field-schema';
+import { databases } from './database-schema';
export const relationships = sqliteTable('relationships', {
id: text('id').primaryKey().notNull(),
@@ -38,6 +39,10 @@ export const relationships = sqliteTable('relationships', {
.notNull()
.default('one_to_many'),
+
+
+ databaseId: text("databaseId").notNull().references(() => databases.id, { onDelete: "cascade" }),
+
sourceAliasName: text('sourceAliasName'),
targetAliasName: text('targetAliasName'),
createdAt: text('createdAt'),
@@ -61,15 +66,19 @@ export const relationshipRelations = relations(relationships, ({ one }) => ({
fields: [relationships.targetFieldId],
references: [fields.id],
}),
+ database: one(databases, {
+ fields: [relationships.databaseId],
+ references: [databases.id],
+ })
}));
export interface RelationshipType extends InferSelectModel {
- sourceTable : TableType ,
- targetTable : TableType ,
- sourceField : FieldType ,
- targetField : FieldType ,
- };
+ sourceTable: TableType,
+ targetTable: TableType,
+ sourceField: FieldType,
+ targetField: FieldType,
+};
export interface RelationshipInsertType extends InferInsertModel { };
diff --git a/src/lib/schemas/table-schema.ts b/src/lib/schemas/table-schema.ts
index 5407587..a3d1934 100644
--- a/src/lib/schemas/table-schema.ts
+++ b/src/lib/schemas/table-schema.ts
@@ -2,6 +2,7 @@ import { InferInsertModel, InferSelectModel, relations } from 'drizzle-orm';
import { sqliteTable, text, real, integer } from 'drizzle-orm/sqlite-core';
import { fields, FieldType } from './field-schema';
import { relationships } from './relationship-schema';
+import { databases } from './database-schema';
export const tables = sqliteTable('tables', {
id: text('id')
@@ -9,7 +10,7 @@ export const tables = sqliteTable('tables', {
.notNull()
.unique(),
- databaseId: text('databaseId'),
+ databaseId: text("databaseId").notNull().references(() => databases.id, { onDelete: "cascade" }),
name: text('name').notNull(),
posX: real('posX').notNull().default(0),
@@ -20,23 +21,29 @@ export const tables = sqliteTable('tables', {
note: text('note'),
sequence: integer('sequence').default(0),
createdAt: text('createdAt'),
-
});
-export const tablesRelations = relations(tables, ({ many }) => ({
+export const tablesRelations = relations(tables, ({ many, one }) => ({
fields: many(fields),
sourceRelations: many(relationships),
targetRelations: many(relationships),
+ database: one(databases, {
+ fields: [tables.databaseId],
+ references: [databases.id],
+ })
}));
export interface TableType extends InferSelectModel {
- fields: FieldType[]
+ fields: FieldType[];
+
};
export interface TableInsertType extends InferInsertModel {
- fields?: FieldType[]
+ fields?: FieldType[];
+
+
};
\ No newline at end of file
diff --git a/src/pages/database/database-control-buttons.tsx b/src/pages/database/database-control-buttons.tsx
new file mode 100644
index 0000000..7e1a5c3
--- /dev/null
+++ b/src/pages/database/database-control-buttons.tsx
@@ -0,0 +1,206 @@
+import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/tooltip/tooltip";
+import { useDatabaseHistory } from "@/providers/database-history/database-history-provider";
+import { Button, cn, Divider, Navbar, NavbarContent } from "@heroui/react";
+import { useOnViewportChange, useReactFlow } from "@xyflow/react";
+
+import { LayoutGrid, Redo, Scan, Undo, ZoomIn, ZoomOut } from "lucide-react";
+import { useCallback, useState } from "react";
+import { useTranslation } from "react-i18next";
+
+
+
+interface DbControlButtons {
+ adjustPositions: () => void,
+}
+
+const ZOOM_DURATION = 100
+
+const DatabaseControlButtons: React.FC = ({ adjustPositions }) => {
+ const { zoomIn, zoomOut, fitView } = useReactFlow();
+ const [zoom, setZoom] = useState();
+ const { t } = useTranslation();
+ const { undo, redo, canRedo, canUndo } = useDatabaseHistory();
+
+ useOnViewportChange({
+ onChange: ({ zoom }) => {
+ setZoom(`${Math.round(zoom * 100)}%`);
+ },
+ });
+
+ const onZoomIn = useCallback(() => {
+ zoomIn({ duration: ZOOM_DURATION });
+ }, [])
+
+ const onZoomOut = useCallback(() => {
+ zoomOut({ duration: ZOOM_DURATION });
+ }, [])
+
+ const onFitView = useCallback(() => {
+ fitView({
+ duration: 500,
+ padding: 0.1,
+ maxZoom: 0.9,
+ })
+ }, [])
+
+ const resetZoom = useCallback(() => {
+ fitView({
+ duration: 500,
+ minZoom: 1,
+ maxZoom: 1,
+ })
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+ {t("control_buttons.undo")}
+
+ Cntl + Z
+
+
+
+
+
+
+
+
+
+
+
+ {t("control_buttons.adjust_positions")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("control_buttons.zoom_out")}
+
+
+
+
+
+
+
+
+
+
+
+
+ {t("control_buttons.zoom_in")}
+
+
+
+
+
+
+
+
+
+
+ {t("control_buttons.show_all")}
+
+
+ Cntl + A
+
+
+
+
+
+
+
+
+
+
+ {t("control_buttons.redo")}
+
+
+ Ctnl + Shift + Z
+
+
+
+
+ )
+}
+
+
+export default DatabaseControlButtons;
\ No newline at end of file
diff --git a/src/pages/database/database-page.tsx b/src/pages/database/database-page.tsx
index f89bbe2..3f5baf8 100644
--- a/src/pages/database/database-page.tsx
+++ b/src/pages/database/database-page.tsx
@@ -20,18 +20,19 @@ import { Button } from "@heroui/react";
import { LayoutGrid } from "lucide-react";
import { adjustTablesPositions } from "@/utils/tables";
import { useTheme } from "next-themes";
+import DatabaseControlButtons from "./database-control-buttons";
const DatabasePage: React.FC = () => {
- const { tables, relationships, updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabase();
+ const { database, updateTablePositions, deleteMultiTables, deleteMultiRelationships, createRelationship } = useDatabase();
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const { setIsConnectionInProgress } = useDiagram();
const [selectedNodeIds, setSelectedNodeIds] = useState([]);
-
+ const { tables, relationships } = database ;
const { fitView } = useReactFlow();
const nodeTypes = useMemo(() => ({ table: Table }), []);
@@ -119,19 +120,18 @@ const DatabasePage: React.FC = () => {
}, []);
- const adjustPositions = useCallback(async () => {
+ useTableToNode(tables);
+ useRelationshipToEdge(relationships);
+
+ const adjustPositions = useCallback(async () => {
updateTablePositions(await adjustTablesPositions(nodes, relationships));
setTimeout(() => {
fitView({
duration: 500
})
}, 500)
-
- }, [relationships, nodes])
-
- useTableToNode(tables);
- useRelationshipToEdge(relationships);
+ }, [relationships, nodes]);
return (
@@ -157,32 +157,21 @@ const DatabasePage: React.FC = () => {
onConnectStart={onConnectStart}
onConnectEnd={onConnectEnd}
>
-
-
-
-
-
-
-
-
-
-
- Adjust Positions
-
-
+
-
-
+
+
{
- !fieldTypesMatches &&
+ !fieldTypesMatches &&
{t("db_controller.relationship_error")}
diff --git a/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx b/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx
index 4f842b6..aad07f1 100644
--- a/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx
+++ b/src/pages/database/db-controller/relationship-controller/relationship-accordion-item/relationship-accordion-body.tsx
@@ -4,7 +4,7 @@ import { Cardinality, RelationshipInsertType, RelationshipType } from "@/lib/sch
import { useDatabase } from "@/providers/database-provider/database-provider";
import { Button, Select, SelectItem, SharedSelection } from "@heroui/react";
import { ChevronsLeftRightEllipsis, FileMinus2, FileOutput, SquareArrowLeft, SquareArrowRight, Trash2 } from "lucide-react";
-import { Key, useState } from "react";
+import { Key, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -33,11 +33,16 @@ const RelationshipAccordionBody: React.FC
= ({ r
} as RelationshipInsertType);
setCardinality(keys as any);
- }
+ }
+
const removeRelationship = () => {
deleteRelationship(relationship.id);
}
+ useEffect(() => {
+ setCardinality(new Set([relationship.cardinality])) ;
+ } , [relationship.cardinality])
+
if (!relationship.sourceTable || !relationship.targetTable)
return;
return (
diff --git a/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx b/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx
index 25f5a20..7398bee 100644
--- a/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx
+++ b/src/pages/database/db-controller/relationship-controller/relationship-controller.tsx
@@ -24,7 +24,9 @@ const RelationshipController: React.FC = ({ }) => {
const [relationship, setRelationship] = useState(undefined);
const [isValid, setIsValid] = useState(false);
const { isOpen, onOpen, onOpenChange } = useDisclosure();
- const { createRelationship, relationships } = useDatabase();
+ const { createRelationship, database } = useDatabase();
+ const { relationships } = database ;
+
const { t } = useTranslation();
const [selectedRelationship, setSelectedRelationship] = useState(new Set([]));
const { focusedRelationshipId } = useDiagram();
@@ -35,8 +37,7 @@ const RelationshipController: React.FC = ({ }) => {
createRelationship({
id: newRelationshipId,
- ...relationship,
- createdAt: new Date().toISOString()
+ ...relationship,
} as RelationshipInsertType);
setSelectedRelationship(new Set([newRelationshipId]) as any);
@@ -118,13 +119,12 @@ const RelationshipController: React.FC = ({ }) => {
+
}
>
diff --git a/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx b/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx
index ab0c481..a6f8254 100644
--- a/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx
+++ b/src/pages/database/db-controller/tables-controller/table-accordion-item/field/field-item.tsx
@@ -1,12 +1,12 @@
import { useSortable } from "@dnd-kit/sortable";
-import { Button, Input, Popover, PopoverContent, PopoverTrigger, Switch, Textarea } from "@heroui/react";
-import { EllipsisVertical, GripVertical, KeyRound, Trash2 } from "lucide-react";
+import { Button, Input, Popover, PopoverContent, PopoverTrigger, Switch, Textarea } from "@heroui/react";
+import { EllipsisVertical, GripVertical, KeyRound, Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { CSS } from "@dnd-kit/utilities";
import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema";
-import { Key , useEffect, useState } from "react";
+import { Key, useEffect, useState } from "react";
import { useDatabase } from "@/providers/database-provider/database-provider";
import Autocomplete from "@/components/auto-complete/auto-complete";
import ToggleButton from "@/components/toggle/toggle";
@@ -24,17 +24,21 @@ const FieldItem: React.FC = ({ field }) => {
const { deleteField, editField, data_types } = useDatabase();
const [note, setNote] = useState(field.note as string | undefined);
+ const [selectedType, setSelectedType] = useState(field.typeId as string | undefined);
const { t } = useTranslation();
- const { attributes, listeners, setNodeRef, transform } = useSortable({ id: field.id });
+ const { attributes, listeners, setNodeRef, transform } = useSortable({ id: field.id });
const style = {
transform: CSS.Transform.toString(transform),
};
- useEffect(() => {
- setFieldName(field.name) ;
- } , [field.name])
+ useEffect(() => {
+ setFieldName(field.name);
+ }, [field.name]);
+ useEffect(() => {
+ setSelectedType(field.typeId as string | undefined);
+ }, [field.typeId])
const removeField = () => {
setPopOverOpen(false);
@@ -65,6 +69,7 @@ const FieldItem: React.FC = ({ field }) => {
id: field.id,
typeId: key
} as FieldType);
+ setSelectedType(key as string | undefined);
}
const toggleNullable = (nullable: boolean) => {
@@ -99,7 +104,8 @@ const FieldItem: React.FC = ({ field }) => {
diff --git a/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx b/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx
index ffd3497..be30ec0 100644
--- a/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx
+++ b/src/pages/database/db-controller/tables-controller/table-accordion-item/table-accordion-body.tsx
@@ -2,7 +2,7 @@
import { Accordion, AccordionItem, Button, cn, Textarea } from "@heroui/react";
import { ChevronLeft, FileKey, FileType, Key, MessageSquareQuote, Plus } from "lucide-react";
-import { MouseEventHandler, useCallback, useState } from "react";
+import { MouseEventHandler, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ColorPicker from "@/components/color-picker/color-picker";
import FieldList from "./field/field-list";
@@ -48,6 +48,10 @@ const TableAccordionBody: React.FC = ({ table }) => {
} as TableType)
}
+
+ useEffect(() => {
+ setNote(table.note ? table.note : "");
+ }, [table.note])
return (
= ({ table, isOp
const [editMode, setEditMode] = useState(false);
const { focusOnTable } = useDiagram();
- useEffect(() => {
+ useEffect(() => {
setTableName(table.name);
}, [table.name])
- const saveTableName = async () => {
- await editTable({ id: table.id, name: tableName });
+ const saveTableName = useCallback(async() => {
+
+ await editTable({ id: table.id, name: tableName } as TableInsertType);
setEditMode(false);
- }
+ } , [tableName])
const onDeleteTable = async () => {
deleteTable(table.id)
@@ -74,7 +75,7 @@ const TableAccordionHeader: React.FC = ({ table, isOp
className="w-full text-editable truncate px-2 py-1 text-sm font-semibold text-black dark:text-white"
onDoubleClick={() => setEditMode(true)}
>
- {table.name}
+ {tableName}
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 44db845..1158906 100644
--- a/src/pages/database/db-controller/tables-controller/tables-controller.tsx
+++ b/src/pages/database/db-controller/tables-controller/tables-controller.tsx
@@ -6,10 +6,9 @@ import { useCallback, useEffect, useState } from "react";
import TableAccordionHeader from "./table-accordion-item/table-accordion-header";
import TableAccordionBody from "./table-accordion-item/table-accordion-body";
import { useDatabase } from "@/providers/database-provider/database-provider";
-import { TableType } from "@/lib/schemas/table-schema";
-import { v4 } from "uuid";
+import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
+import { v4 } from "uuid";
import { useDiagram } from "@/providers/diagram-provider/diagram-provider";
-import { useTheme } from "next-themes";
interface Props { }
@@ -18,7 +17,9 @@ interface Props { }
const TablesController: React.FC = ({ }) => {
- const { tables, createTable } = useDatabase();
+ const { database, createTable } = useDatabase();
+
+ const { tables } = database ;
const { t } = useTranslation();
const [selectedTable, setSelectedTable] = useState(new Set([]));
const { focusedTableId } = useDiagram();
@@ -29,23 +30,22 @@ const TablesController: React.FC = ({ }) => {
await createTable({
id: newTableId,
- name: `table_${tables.length + 1}`,
- createdAt: new Date().toISOString()
- });
+ name: `table_${tables.length + 1}`,
+ } as TableInsertType);
setSelectedTable(new Set([newTableId]) as any);
}, [tables]);
- useEffect(() => {
+ useEffect(() => {
if (focusedTableId) {
setSelectedTable(new Set([focusedTableId]) as any);
}
- } , [focusedTableId]) ;
-
- const selectedTableId = selectedTable.values().next().value;
+ }, [focusedTableId]);
+
+ const selectedTableId = selectedTable.values().next().value;
+
-
return (
@@ -105,7 +105,7 @@ const TablesController: React.FC
= ({ }) => {
> = (props) => {
className={cn([
`!stroke-2 ${selected ? '!stroke-primary' : 'stroke-slate-300 dark:stroke-default-500'}`,
+
])}
onClick={(e) => {
diff --git a/src/pages/database/table/table.tsx b/src/pages/database/table/table.tsx
index b2df096..7cf5b25 100644
--- a/src/pages/database/table/table.tsx
+++ b/src/pages/database/table/table.tsx
@@ -13,7 +13,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/tooltip/to
import FieldComponent from "./field";
import { FieldType } from "@/lib/schemas/field-schema";
-import { TableType } from "@/lib/schemas/table-schema";
+import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
import { useDatabase } from "@/providers/database-provider/database-provider";
import { useTranslation } from "react-i18next";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
@@ -31,20 +31,20 @@ const Table: React.FC> = ({ selected, data: { table } }) =
const { editTable } = useDatabase();
const { focusOnTable } = useDiagram();
- const { t } = useTranslation();
+ const { t } = useTranslation();
- useEffect(() => {
+ useEffect(() => {
setTableName(table.name);
}, [table.name])
const saveTableName = useCallback(async () => {
- await editTable({ id: table.id, name: tableName });
+ await editTable({ id: table.id, name: tableName } as TableInsertType);
setEditMode(false);
- }, []);
+ }, [tableName]);
const focus = useCallback(() => {
focusOnTable(table.id, false);
- }, [])
+ }, [table])
const edges = useStore((store) => store.edges) as Edge[];
@@ -117,7 +117,7 @@ const Table: React.FC> = ({ selected, data: { table } }) =
className=" w-full text-editable truncate px-2 py-0.5 text-sm font-bold dark:text-white dark:group-hover:bg-default-900"
onDoubleClick={() => setEditMode(true)}
>
- {table.name}
+ {tableName}
@@ -150,22 +150,4 @@ const Table: React.FC> = ({ selected, data: { table } }) =
};
export default React.memo(Table)
-//export default React.memo(Table ) ;
-
-
-/*
- focused = { false}
- tableNodeId={id}
- field={field}
- highlighted={selectedRelEdges.some(
- (edge) =>
- edge.data?.relationship
- .sourceFieldId === field.id ||
- edge.data?.relationship
- .targetFieldId === field.id
- )}
- visible={visibleFields.includes(field)}
- isConnectable={!table.isView}
-
-
-*/
\ No newline at end of file
+
\ No newline at end of file
diff --git a/src/providers/database-history/database-history-context.tsx b/src/providers/database-history/database-history-context.tsx
new file mode 100644
index 0000000..9f1c211
--- /dev/null
+++ b/src/providers/database-history/database-history-context.tsx
@@ -0,0 +1,22 @@
+import { DatabaseType } from "@/lib/schemas/database-schema";
+import { RelationshipType } from "@/lib/schemas/relationship-schema";
+import { TableType } from "@/lib/schemas/table-schema";
+import { createContext, Dispatch, SetStateAction } from "react";
+
+
+
+export interface DatabaseHistoryContextType {
+
+ undo: () => void;
+ redo: () => void;
+
+ canUndo : boolean ;
+ canRedo : boolean ;
+
+ present : DatabaseType ;
+
+}
+
+
+
+export default createContext({} as DatabaseHistoryContextType);
\ No newline at end of file
diff --git a/src/providers/database-history/database-history-provider.tsx b/src/providers/database-history/database-history-provider.tsx
new file mode 100644
index 0000000..0e8f134
--- /dev/null
+++ b/src/providers/database-history/database-history-provider.tsx
@@ -0,0 +1,79 @@
+import { useCallback, useContext, useEffect, useRef } from "react";
+import DatabaseHistoryContext from "./database-history-context";
+import useUndo from 'use-undo';
+import { useDatabase } from "../database-provider/database-provider";
+import hash from 'object-hash';
+import { DBDiffOperation, mapDiffToDBDiffOperation, normalizeDatabase } from "@/utils/database";
+import { compare } from 'fast-json-patch';
+import { DatabaseType } from "@/lib/schemas/database-schema";
+
+interface Props { children: React.ReactNode };
+
+const DatabaseHistoryProvider: React.FC = ({ children }) => {
+
+ const udpateDbFlag = useRef(false);
+ const { database, executeDbDiffOps } = useDatabase();
+ const [datatbaseState, { set, undo: undoChanges, redo: redoChanges, canUndo, canRedo }] = useUndo(database);
+
+ useEffect(() => {
+ udpateDbFlag.current = false;
+ const presentHash: string = hash(datatbaseState.present, { algorithm: 'sha1' });
+ const databaseHash: string = hash(database, { algorithm: 'sha1' });
+ if (presentHash != databaseHash)
+ set(database);
+
+
+ }, [database]);
+
+
+ const undo = useCallback(() => {
+ udpateDbFlag.current = true;
+ undoChanges();
+ }, [undoChanges, udpateDbFlag]);
+
+ const redo = useCallback(() => {
+ udpateDbFlag.current = true;
+ redoChanges();
+ }, [redoChanges, udpateDbFlag]);
+
+ useEffect(() => {
+ if (!udpateDbFlag.current) {
+ udpateDbFlag.current = true;
+ return;
+ }
+
+ const normalizedDatabase = normalizeDatabase(database);
+ const normalizedPresent = normalizeDatabase(datatbaseState.present);
+ const differences = compare(normalizedDatabase, normalizedPresent);
+
+ if (differences && differences.length > 0) {
+
+ const operations: DBDiffOperation[] = mapDiffToDBDiffOperation(differences);
+ executeDbDiffOps(operations)
+ }
+
+ }, [datatbaseState.present]);
+
+
+
+ return (
+
+ {children}
+
+ )
+
+}
+
+
+export const useDatabaseHistory = () => useContext(DatabaseHistoryContext);
+
+
+export default DatabaseHistoryProvider;
\ No newline at end of file
diff --git a/src/providers/database-provider/database-context.ts b/src/providers/database-provider/database-context.ts
index ed904dd..607cb07 100644
--- a/src/providers/database-provider/database-context.ts
+++ b/src/providers/database-provider/database-context.ts
@@ -1,8 +1,10 @@
import { DataType } from "@/lib/schemas/data-type-schema";
+import { DatabaseType } from "@/lib/schemas/database-schema";
import { FieldInsertType, FieldType } from "@/lib/schemas/field-schema";
import { RelationshipInsertType, RelationshipType } from "@/lib/schemas/relationship-schema";
import { TableInsertType, TableType } from "@/lib/schemas/table-schema";
+import { DBDiffOperation } from "@/utils/database";
import { QueryResult } from "@powersync/web";
import { createContext } from "react";
@@ -10,25 +12,28 @@ import { createContext } from "react";
export interface DatabaseContextType {
- tables: TableType[],
+
data_types: DataType[],
- relationships: RelationshipType[],
+ database : DatabaseType ,
+ isLoading : boolean ,
// table operations
- createTable: (table: TableInsertType) => Promise,
+ createTable: (table: TableInsertType) => Promise,
editTable: (table: TableInsertType) => Promise,
- deleteTable: (id: string) => Promise,
+ deleteTable: (id: string) => Promise,
updateTablePositions: (tables: TableInsertType[]) => Promise ,
deleteMultiTables: (ids: string[]) => Promise
// field operations
createField: (field: FieldInsertType) => Promise,
editField: (field: FieldInsertType) => Promise,
- deleteField: (id: string) => Promise,
+ deleteField: (id: string) => Promise,
orderTableFields: (fields: FieldType[]) => Promise,
// relationship operations
createRelationship: (relationship: RelationshipInsertType) => Promise,
editRelationship: (relationship: RelationshipInsertType) => Promise,
deleteRelationship: (id: string) => Promise,
- deleteMultiRelationships: (ids: string[]) => Promise
+ deleteMultiRelationships: (ids: string[]) => Promise ,
+
+ executeDbDiffOps : ( operations : DBDiffOperation[]) => void ,
}
diff --git a/src/providers/database-provider/database-provider.tsx b/src/providers/database-provider/database-provider.tsx
index f1f6197..fc50df7 100644
--- a/src/providers/database-provider/database-provider.tsx
+++ b/src/providers/database-provider/database-provider.tsx
@@ -1,62 +1,97 @@
import DatabaseContext from "./database-context";
-import { useCallback, useContext } from "react";
+import { useCallback, useContext, useEffect, useState } from "react";
import { db, powerSyncDb } from "../sync-provider/sync-provider";
-import { TableInsertType, tables, TableType } from "@/lib/schemas/table-schema";
+import { TableInsertType, tables } from "@/lib/schemas/table-schema";
import { useQuery } from "@powersync/react";
import { toCompilableQuery } from "@powersync/drizzle-driver";
-import { asc, desc, eq, inArray, sql } from "drizzle-orm";
+import { asc, desc, eq, inArray, or } from "drizzle-orm";
import { QueryResult } from "@powersync/web";
import { FieldInsertType, fields, FieldType } from "@/lib/schemas/field-schema";
-import { RelationshipInsertType, relationships, RelationshipType } from "@/lib/schemas/relationship-schema";
+import { RelationshipInsertType, relationships } from "@/lib/schemas/relationship-schema";
+import DatabaseHistoryProvider from "../database-history/database-history-provider";
+import { DBDiffOperation } from "@/utils/database";
+import { DatabaseType } from "@/lib/schemas/database-schema";
+import { getTimestamp } from "@/utils/utils";
interface Props { children: React.ReactNode }
-
const DatabaseProvider: React.FC = ({ children }) => {
+ const [currentDatabaseId, setCurrentDatabaseId] = useState(undefined);
- const { data: tablesList } = useQuery(toCompilableQuery(
- db.query.tables.findMany({
- with: {
- fields: {
- orderBy: asc(fields.sequence),
- with: {
- type: true
- }
- }
- },
- orderBy: desc(tables.createdAt)
- })
+ const { data: databases, isLoading: loadingDatabases } = useQuery(toCompilableQuery(
+ db.query.databases.findMany()
));
-
- const { data: relationshipsList } = useQuery(toCompilableQuery(
- db.query.relationships.findMany({
- with: {
- sourceTable: true,
- targetTable: true,
- sourceField: true,
- targetField: true,
- },
- orderBy: desc(tables.createdAt)
- })
- ));
- const { data: data_types } = useQuery(toCompilableQuery(
+ const { data: data_types, isLoading: loadingDataTypes } = useQuery(toCompilableQuery(
db.query.data_types.findMany()
));
+ let { data: database, isLoading: loadingCurrentDatabase } = useQuery(
+ toCompilableQuery(
+ db.query.databases.findFirst({
+ where: (databases, { eq }) => eq(databases.id, currentDatabaseId as string),
+ with: {
+ tables: {
+ orderBy: desc(tables.createdAt),
+ with: {
+ fields: {
+ orderBy: asc(fields.sequence),
+ with: {
+ type: true
+ }
+ }
+ }
+ },
+ relationships: {
+ with: {
+ sourceTable: true,
+ targetTable: true,
+ sourceField: true,
+ targetField: true,
+ },
+ orderBy: desc(relationships.createdAt)
+ }
+ }
+ })
+ )
+ );
- const createTable = useCallback(async (table: TableInsertType): Promise => {
- return await db.insert(tables).values(table);
- }, [db]);
+ if (database.length == 1)
+ database = database[0] as any;
+
+ useEffect(() => {
+ if (databases.length > 0 && !currentDatabaseId) {
+ setCurrentDatabaseId(databases[0].id);
+ }
+ }, [currentDatabaseId, databases])
+
+ const isLoading: boolean = loadingDataTypes || loadingDatabases || loadingCurrentDatabase;
+
+ const createTable = useCallback(async (table: TableInsertType): Promise => {
+
+ if (currentDatabaseId) {
+ await db.transaction(async (tx) => {
+ await tx.insert(tables).values({
+ ...table,
+ databaseId: currentDatabaseId as string,
+ createdAt: table.createdAt ? table.createdAt : getTimestamp()
+ });
+ if (table.fields) {
+ await tx.insert(fields).values(table.fields);
+ }
+ })
+ } else
+ throw Error("No Database selected");
+ }, [db, currentDatabaseId]);
const editTable = useCallback(async (table: TableInsertType): Promise => {
return await db.update(tables).set(table).where(eq(tables.id, table.id));
}, [db]);
- const deleteTable = useCallback(async (id: string): Promise => {
- return await db.delete(tables).where(eq(tables.id, id));
+ const deleteTable = useCallback(async (id: string): Promise => {
+ await db.delete(tables).where(eq(tables.id, id));
}, [db]);
const deleteMultiTables = useCallback(async (ids: string[]): Promise => {
@@ -71,8 +106,11 @@ const DatabaseProvider: React.FC = ({ children }) => {
return await db.update(fields).set(field).where(eq(fields.id, field.id));
}, [db]);
- const deleteField = useCallback(async (id: string): Promise => {
- return await db.delete(fields).where(eq(fields.id, id));
+ const deleteField = useCallback(async (id: string): Promise => {
+ return await db.transaction(async (tx) => {
+ await tx.delete(relationships).where(or(eq(relationships.sourceFieldId, id), eq(relationships.targetFieldId, id)));
+ await tx.delete(fields).where(eq(fields.id, id));
+ })
}, [db]);
const orderTableFields = useCallback(async (fieldsList: FieldType[]): Promise => {
@@ -92,8 +130,15 @@ const DatabaseProvider: React.FC = ({ children }) => {
}, [powerSyncDb]);
const createRelationship = useCallback(async (relationship: RelationshipInsertType): Promise => {
- return await db.insert(relationships).values(relationship);
- }, [db]);
+ if (currentDatabaseId) {
+ return await db.insert(relationships).values({
+ ...relationship,
+ databaseId: currentDatabaseId,
+ createdAt: relationship.createdAt ? relationship.createdAt : getTimestamp()
+ });
+ }
+ throw Error("No database selected");
+ }, [db, currentDatabaseId]);
const editRelationship = useCallback(async (relationship: RelationshipInsertType): Promise => {
return await db.update(relationships).set(relationship).where(eq(relationships.id, relationship.id));
@@ -129,6 +174,49 @@ const DatabaseProvider: React.FC = ({ children }) => {
);`;
return await powerSyncDb.execute(sql);
}, [powerSyncDb]);
+
+ const executeDbDiffOps = useCallback(async (operations: DBDiffOperation[]) => {
+
+ await db.transaction(async (tx) => {
+
+ for (const operation of operations) {
+
+ if (operation.type == "CREATE_TABLE") {
+ await tx.insert(tables).values(operation.table);
+
+ if (operation.table.fields && Object.values(operation.table.fields).length > 0)
+ await tx.insert(fields).values(Object.values(operation.table.fields));
+ }
+ else if (operation.type === "UPDATE_TABLE") {
+
+ await tx.update(tables).set(operation.changes).where(eq(tables.id, operation.tableId));
+
+ } else if (operation.type === "DELETE_TABLE") {
+ await tx.delete(tables).where(eq(tables.id, operation.tableId));
+
+ } else if (operation.type === "CREATE_FIELD") {
+ await tx.insert(fields).values(operation.field);
+
+ } else if (operation.type === "DELETE_FIELD") {
+ await tx.delete(relationships).where(or(eq(relationships.sourceFieldId, operation.fieldId), eq(relationships.targetFieldId, operation.fieldId)));
+ await tx.delete(fields).where(eq(fields.id, operation.fieldId));
+
+ } else if (operation.type === "UPDATE_FIELD") {
+ await tx.update(fields).set(operation.changes).where(eq(fields.id, operation.fieldId));
+
+ } else if (operation.type === "CREATE_RELATIONSHIP") {
+ await tx.insert(relationships).values(operation.relationship);
+
+ } else if (operation.type === "DELETE_RELATIONSHIP") {
+ await tx.delete(relationships).where(eq(relationships.id, operation.relationshipId));
+
+ } else if (operation.type === "UPDATE_RELATIONSHIP") {
+ await tx.update(relationships).set(operation.changes).where(eq(relationships.id, operation.relationshipId));
+ }
+ }
+ })
+
+ }, [db, currentDatabaseId])
return (
= ({ children }) => {
deleteRelationship,
deleteMultiRelationships,
- tables: tablesList as TableType[],
- relationships: relationshipsList as RelationshipType[],
- data_types
+
+ data_types,
+ database: database as unknown as DatabaseType,
+ isLoading,
+ executeDbDiffOps
}}>
- {children}
+ {
+ !isLoading && database &&
+
+ {children}
+
+ }
)
}
diff --git a/src/providers/diagram-provider/diagram-provider.tsx b/src/providers/diagram-provider/diagram-provider.tsx
index 4c5c67f..11d7a1f 100644
--- a/src/providers/diagram-provider/diagram-provider.tsx
+++ b/src/providers/diagram-provider/diagram-provider.tsx
@@ -1,5 +1,5 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
-
+
import { FitViewOptions, useReactFlow } from "@xyflow/react";
import { useNavigate } from "react-router-dom";
import { RelationshipType } from "@/lib/schemas/relationship-schema";
@@ -15,7 +15,7 @@ const DiagramProvider: React.FC = ({ children }) => {
const navigate = useNavigate();
const [focusedTableId, setFocusedTableId] = useState(undefined)
const [focusedRelationshipId, setFocusedRelationshipId] = useState(undefined)
- const [isConnectionInProgress , setIsConnectionInProgress] = useState( false) ;
+ const [isConnectionInProgress, setIsConnectionInProgress] = useState(false);
const focusOnTable = useCallback((id: string, transition: boolean = false) => {
navigate("/database/tables");
@@ -44,7 +44,7 @@ const DiagramProvider: React.FC = ({ children }) => {
const focusOnRelationship = useCallback((id: string, transition: boolean = false) => {
- navigate("/database/relationships");
+ navigate("/database/relationships");
setFocusedRelationshipId(id);
setEdges((edges) =>
@@ -76,11 +76,11 @@ const DiagramProvider: React.FC = ({ children }) => {
const contextValue = useMemo(() => ({
focusedTableId,
focusedRelationshipId,
- isConnectionInProgress ,
+ isConnectionInProgress,
focusOnTable,
- focusOnRelationship ,
- setIsConnectionInProgress
- }), [focusedTableId, focusedRelationshipId, focusOnTable, focusOnRelationship , isConnectionInProgress , setIsConnectionInProgress ]);
+ focusOnRelationship,
+ setIsConnectionInProgress
+ }), [focusedTableId, focusedRelationshipId, focusOnTable, focusOnRelationship, isConnectionInProgress, setIsConnectionInProgress]);
return (
= wrapPowerSyncWithDrizzle(powerSyncDb, {
diff --git a/src/utils/database.ts b/src/utils/database.ts
new file mode 100644
index 0000000..21ef625
--- /dev/null
+++ b/src/utils/database.ts
@@ -0,0 +1,174 @@
+import { DatabaseType } from "@/lib/schemas/database-schema";
+import { FieldType } from "@/lib/schemas/field-schema";
+import { RelationshipType } from "@/lib/schemas/relationship-schema";
+import { TableType } from "@/lib/schemas/table-schema";
+import { excludeFields } from "./utils";
+
+
+
+export type DBDiffOperation =
+ | { type: 'CREATE_TABLE'; table: TableType }
+ | { type: 'DELETE_TABLE'; tableId: string }
+ | { type: 'UPDATE_TABLE'; tableId: string; changes: Partial }
+ | { type: 'CREATE_FIELD'; tableId: string; field: FieldType }
+ | { type: 'DELETE_FIELD'; tableId: string; fieldId: string }
+ | { type: 'UPDATE_FIELD'; tableId: string; fieldId: string; changes: Partial }
+ | { type: 'CREATE_RELATIONSHIP'; relationship: RelationshipType }
+ | { type: 'DELETE_RELATIONSHIP'; relationshipId: string }
+ | { type: 'UPDATE_RELATIONSHIP'; relationshipId: string; changes: Partial };
+
+export function mapDiffToDBDiffOperation(patch: any[]): DBDiffOperation[] {
+
+ const operations: DBDiffOperation[] = [];
+
+ const tableChanges: Record> = {};
+ const fieldChanges: Record>> = {};
+ const fieldCreates: Record = {};
+ const fieldDeletes: Record = {};
+
+ const relationshipChanges: Record> = {};
+ const relationshipCreates: RelationshipType[] = [];
+ const relationshipDeletes: string[] = [];
+
+ for (const op of patch) {
+ const parts = op.path.split('/').filter(Boolean);
+
+ if (parts[0] == "tables") {
+ const tableId = parts[1];
+
+
+ if (parts.length === 2) {
+ if (op.op === 'add') {
+ operations.push({ type: 'CREATE_TABLE', table: op.value });
+ } else if (op.op === 'remove') {
+ operations.push({ type: 'DELETE_TABLE', tableId });
+ }
+ }
+
+
+ else if (parts[2] === 'fields') {
+ const fieldId = parts[3];
+
+ if (op.op === 'add') {
+ fieldCreates[tableId] ??= [];
+ fieldCreates[tableId].push(op.value);
+ } else if (op.op === 'remove') {
+ fieldDeletes[tableId] ??= [];
+ fieldDeletes[tableId].push(fieldId);
+ } else if (op.op === 'replace') {
+ const attr = parts.slice(4).join('/');
+ fieldChanges[tableId] ??= {};
+ fieldChanges[tableId][fieldId] ??= {};
+ fieldChanges[tableId][fieldId][attr as keyof FieldType] = op.value;
+ }
+ }
+
+
+ else if (op.op === 'replace') {
+ const attr = parts[2];
+ tableChanges[tableId] ??= {};
+ tableChanges[tableId][attr as keyof TableType] = op.value;
+ }
+ }
+
+ else if (parts[0] === 'relationships') {
+
+ const relationshipId = parts[1];
+
+ if (parts.length === 2) {
+ if (op.op === 'add') {
+ relationshipCreates.push(op.value);
+ } else if (op.op === 'remove') {
+ relationshipDeletes.push(relationshipId);
+ }
+ } else if (op.op === 'replace') {
+ const attr = parts.slice(2).join('/');
+ relationshipChanges[relationshipId] ??= {};
+ relationshipChanges[relationshipId][attr as keyof RelationshipType] = op.value;
+ }
+ }
+ }
+
+
+ for (const [tableId, changes] of Object.entries(tableChanges)) {
+ operations.push({
+ type: 'UPDATE_TABLE',
+ tableId,
+ changes
+ });
+ }
+
+ for (const [tableId, fields] of Object.entries(fieldChanges)) {
+ for (const [fieldId, changes] of Object.entries(fields)) {
+ operations.push({
+ type: 'UPDATE_FIELD',
+ tableId,
+ fieldId,
+ changes
+ });
+ }
+ }
+
+ for (const [tableId, fields] of Object.entries(fieldCreates)) {
+ for (const field of fields) {
+ operations.push({
+ type: 'CREATE_FIELD',
+ tableId,
+ field
+ });
+ }
+ }
+
+ for (const [tableId, fieldIds] of Object.entries(fieldDeletes)) {
+ for (const fieldId of fieldIds) {
+ operations.push({
+ type: 'DELETE_FIELD',
+ tableId,
+ fieldId
+ });
+ }
+ }
+
+ for (const relationship of relationshipCreates) {
+ operations.push({ type: 'CREATE_RELATIONSHIP', relationship });
+ }
+
+ for (const relationshipId of relationshipDeletes) {
+ operations.push({ type: 'DELETE_RELATIONSHIP', relationshipId });
+ }
+
+ for (const [relationshipId, changes] of Object.entries(relationshipChanges)) {
+ operations.push({ type: 'UPDATE_RELATIONSHIP', relationshipId, changes });
+ }
+ return operations;
+}
+
+export function normalizeDatabase(db: DatabaseType): any {
+
+ return {
+ ...db,
+ tables: Object.fromEntries(
+ db.tables.map(table => [
+ table.id,
+ {
+ ...table,
+ fields: Object.fromEntries(
+ table.fields.map(field => [field.id, field])
+ )
+ }
+ ])
+ ),
+ relationships: Object.fromEntries(
+ db.relationships.map((rel : RelationshipType) => [
+ rel.id,
+ excludeFields(rel , {
+ root : ["sourceField" , "targetField" , "sourceTable" , "targetTable" ]
+ })
+
+ ])
+ )
+ };
+}
+
+
+
\ No newline at end of file
diff --git a/src/utils/utils.ts b/src/utils/utils.ts
index bf7945c..98b7bd5 100644
--- a/src/utils/utils.ts
+++ b/src/utils/utils.ts
@@ -8,6 +8,53 @@ const areArraysEqual = (a: string[], b: string[]): boolean => {
};
+function getTimestamp() {
+ const date = new Date();
+
+ // Get ISO string and split date and time
+ const [datePart, timePart] = date.toISOString().split('T');
+
+ // Extract milliseconds
+ const [time, msZ] = timePart.split('.');
+ const milliseconds = msZ.slice(0, -1); // remove 'Z'
+
+ // Add 3 random digits to simulate microseconds (since JS only gives ms)
+ const microseconds = milliseconds.padEnd(3, '0') + Math.floor(Math.random() * 1000).toString().padStart(3, '0');
+
+ return `${datePart} ${time}.${microseconds}Z`;
+}
+
+
+type NestedObject = { [key: string]: any };
+
+function excludeFields(
+ obj: NestedObject,
+ exclusions: { [key: string]: string[] }
+): NestedObject {
+ const newObj: NestedObject = {};
+ for (const [key, value] of Object.entries(obj)) {
+ if (exclusions["root"]?.includes(key)) {
+ // Skip top-level fields listed in 'root'
+ continue;
+ }
+ if (typeof value === "object" && !Array.isArray(value) && value !== null) {
+ // Recursively exclude fields in nested objects
+ newObj[key] = exclusions[key]
+ ? Object.fromEntries(
+ Object.entries(value).filter(([k]) => !exclusions[key].includes(k))
+ )
+ : excludeFields(value, exclusions);
+ } else {
+ newObj[key] = value;
+ }
+ }
+ return newObj;
+}
+
+
+
export {
- areArraysEqual
+ areArraysEqual ,
+ getTimestamp ,
+ excludeFields
}
\ No newline at end of file