From ed15510d2b8e9bfb563f7aea73612edebece16a6 Mon Sep 17 00:00:00 2001 From: sokphaladam Date: Wed, 25 Dec 2024 17:18:27 +0700 Subject: [PATCH] feat: add trigger management functionality and UI components --- src/components/gui/schema-sidebar-list.tsx | 13 ++ src/components/gui/tabs/trigger-tab.tsx | 106 +++------- src/components/gui/trigger-editor/index.tsx | 193 ++++++++++++++++++ .../gui/trigger-editor/trigger-controller.tsx | 62 ++++++ .../trigger-editor/trigger-save-dialog.tsx | 70 +++++++ src/drivers/base-driver.ts | 16 ++ src/drivers/mysql/generate-schema.ts | 10 + src/drivers/mysql/mysql-driver.ts | 71 ++++++- src/drivers/sqlite-base-driver.ts | 20 +- src/drivers/sqlite/sqlite-generate-schema.ts | 10 +- src/messages/open-tab.tsx | 2 +- 11 files changed, 485 insertions(+), 88 deletions(-) create mode 100644 src/components/gui/trigger-editor/index.tsx create mode 100644 src/components/gui/trigger-editor/trigger-controller.tsx create mode 100644 src/components/gui/trigger-editor/trigger-save-dialog.tsx diff --git a/src/components/gui/schema-sidebar-list.tsx b/src/components/gui/schema-sidebar-list.tsx index 7bdad0fe..7bab0438 100644 --- a/src/components/gui/schema-sidebar-list.tsx +++ b/src/components/gui/schema-sidebar-list.tsx @@ -139,6 +139,7 @@ export default function SchemaList({ search }: Readonly) { (item?: DatabaseSchemaItem) => { const selectedName = item?.name; const isTable = item?.type === "table"; + const isTrigger = item?.type === "trigger"; return [ { @@ -170,6 +171,18 @@ export default function SchemaList({ search }: Readonly) { }, } : undefined, + { separator: true }, + { + title: isTrigger ? "Edit Trigger" : "Create New Trigger", + onClick: () => { + openTab({ + type: "trigger", + schemaName: item?.schemaName ?? currentSchemaName, + name: isTrigger ? item.name : 'create', + tableName: item?.tableSchema?.tableName + }); + } + }, databaseDriver.getFlags().supportCreateUpdateTable ? { separator: true } : undefined, diff --git a/src/components/gui/tabs/trigger-tab.tsx b/src/components/gui/tabs/trigger-tab.tsx index 1f7c83ac..0e9cb384 100644 --- a/src/components/gui/tabs/trigger-tab.tsx +++ b/src/components/gui/tabs/trigger-tab.tsx @@ -1,88 +1,28 @@ -import { DatabaseTriggerSchema } from "@/drivers/base-driver"; -import { useEffect, useState } from "react"; -import { Input } from "@/components/ui/input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { useDatabaseDriver } from "@/context/driver-provider"; -import SqlEditor from "../sql-editor"; -import TableCombobox from "../table-combobox/TableCombobox"; -import { noop } from "@/lib/utils"; +import { useSchema } from "@/context/schema-provider"; +import TriggerEditor, { TriggerEditorProps } from "../trigger-editor"; +import { useTabsContext } from "../windows-tab"; +import { LucideTableProperties } from "lucide-react"; -export default function TriggerTab({ - schemaName, - name, -}: { - schemaName: string; - name: string; -}) { - const { databaseDriver } = useDatabaseDriver(); - const [trigger, setTrigger] = useState(); - const [error, setError] = useState(); +export default function TriggerTab(props: TriggerEditorProps) { + const { refresh: refreshSchema } = useSchema(); + const { replaceCurrentTab } = useTabsContext(); - useEffect(() => { - databaseDriver - .trigger(schemaName, name) - .then(setTrigger) - .catch((e: Error) => { - setError(e.message); - }); - }, [databaseDriver, schemaName, name]); - - if (error) { - return
{error}
; + const onSave = (trigger: TriggerEditorProps) => { + refreshSchema(); + replaceCurrentTab({ + component: ( + + ), + key: 'trigger-' + trigger.name || '', + identifier: 'trigger-' + trigger.name || '', + title: trigger.name || '', + icon: LucideTableProperties, + }); } - return ( -
-
-
Trigger Name
- - -
-
- -
-
- -
- -
-
-
-
- -
-
-
- ); + return } diff --git a/src/components/gui/trigger-editor/index.tsx b/src/components/gui/trigger-editor/index.tsx new file mode 100644 index 00000000..2d58774f --- /dev/null +++ b/src/components/gui/trigger-editor/index.tsx @@ -0,0 +1,193 @@ +import { useDatabaseDriver } from "@/context/driver-provider"; +import { DatabaseTriggerSchemaChange, TriggerOperation, TriggerWhen } from "@/drivers/base-driver"; +import { LucideAlertCircle } from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import TableCombobox from "../table-combobox/TableCombobox"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import SqlEditor from "../sql-editor"; +import { TriggerController } from "./trigger-controller"; +import { TriggerSaveDialog } from "./trigger-save-dialog"; + +export interface TriggerEditorProps { + name: string; + tableName?: string; + schemaName: string; +} + +interface Props extends TriggerEditorProps { + onSave: (trigger: TriggerEditorProps) => void; +} + +const initailTrigger: DatabaseTriggerSchemaChange = { + name: { + new: '' + }, + operation: "INSERT", + when: "BEFORE", + tableName: "", + whenExpression: "", + statement: "", + schemaName: '' +} + +function triggerHasChange(defaultTrigger: DatabaseTriggerSchemaChange, trigger: DatabaseTriggerSchemaChange) { + const objects = Object.keys(defaultTrigger) as (keyof DatabaseTriggerSchemaChange)[]; + + for (const key of objects) { + if (defaultTrigger[key as keyof DatabaseTriggerSchemaChange] !== trigger[key as keyof DatabaseTriggerSchemaChange]) { + return true; + } + } + + return false; +} + +export default function TriggerEditor(props: Props) { + const { name, tableName, schemaName } = props; + const { databaseDriver } = useDatabaseDriver(); + const [defaultTrigger, setDefaultTrigger] = useState({ + ...initailTrigger + }) + const [trigger, setTrigger] = useState({ + ...initailTrigger + }); + const [error, setError] = useState(); + const [isExecuting, setIsExecuting] = useState(false); + + const previewScript = useMemo(() => { + return trigger ? databaseDriver.createUpdateTriggerSchema(trigger) : ['']; + }, [trigger, databaseDriver]); + + const triggerChanging = useMemo(() => triggerHasChange(defaultTrigger, trigger), [defaultTrigger, trigger]) + + const getDefaultTrigger = useCallback(() => { + if (name !== 'create') { + databaseDriver + .trigger(schemaName, name) + .then(res => { + const t = { + ...res, + name: { + new: res.name || '', + old: res.name || '' + }, + schemaName + } + setDefaultTrigger(t) + setTrigger(t) + }) + .catch((e: Error) => { + setError(e.message); + }); + } + else { + const t = { + ...initailTrigger, + tableName: tableName ?? "", + schemaName + } + setDefaultTrigger(t); + setTrigger(t) + } + }, [databaseDriver, name, schemaName, tableName]) + + useEffect(() => { + getDefaultTrigger(); + }, [getDefaultTrigger]); + + const onDiscard = () => { + setTrigger({ ...defaultTrigger }) + } + + return ( +
+ { + isExecuting && ( + { + props.onSave(value); + setIsExecuting(false); + }} + onClose={() => setIsExecuting(false)} + previewScript={previewScript} + schemaName={schemaName} + trigger={trigger} + tableName={tableName} + /> + ) + } + setIsExecuting(true)} + onDiscard={onDiscard} + previewScript={previewScript.join('\n')} + disabled={!trigger.name?.new || !schemaName || !triggerChanging} + isExecuting={isExecuting} + /> +
+
+
Trigger Name
+ setTrigger({ ...trigger, name: { ...trigger.name, new: e.target.value } })} /> +
+
+
On Table
+ { + setTrigger({ + ...trigger, + tableName: value + }) + }} + /> +
+
+
+
Event
+
+
+ +
+
+ +
+
+
+ {error && ( +
+ +

{error}

+
+ )} +
+
+
Trigger statement: (eg: "SET NEW.columnA = TRIM(OLD.columnA)")
+ setTrigger({ ...trigger, statement: value })} + /> +
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/gui/trigger-editor/trigger-controller.tsx b/src/components/gui/trigger-editor/trigger-controller.tsx new file mode 100644 index 00000000..2df4483b --- /dev/null +++ b/src/components/gui/trigger-editor/trigger-controller.tsx @@ -0,0 +1,62 @@ +import { Button, buttonVariants } from "@/components/ui/button"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { LucideCode, LucideLoader, LucideSave } from "lucide-react"; +import CodePreview from "../code-preview"; + +interface Props { + onSave: () => void; + onDiscard: () => void; + previewScript: string; + isExecuting?: boolean; + disabled?: boolean; +} + +export function TriggerController(props: Props) { + const { onSave, onDiscard, isExecuting, disabled, previewScript } = props; + return ( +
+ + + +
+ +
+ + + +
+ + SQL Preview +
+
+ +
SQL Preview
+
+ +
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/components/gui/trigger-editor/trigger-save-dialog.tsx b/src/components/gui/trigger-editor/trigger-save-dialog.tsx new file mode 100644 index 00000000..04f005b3 --- /dev/null +++ b/src/components/gui/trigger-editor/trigger-save-dialog.tsx @@ -0,0 +1,70 @@ +import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogFooter, AlertDialogTitle } from "@/components/ui/alert-dialog"; +import { LucideAlertCircle, LucideLoader, LucideSave } from "lucide-react"; +import { useState } from "react"; +import CodePreview from "../code-preview"; +import { Button } from "@/components/ui/button"; +import { useDatabaseDriver } from "@/context/driver-provider"; +import { TriggerEditorProps } from "."; +import { DatabaseTriggerSchemaChange } from "@/drivers/base-driver"; + +interface Props { + onClose: () => void; + previewScript: string[]; + onSave: (trigger: TriggerEditorProps) => void; + trigger: DatabaseTriggerSchemaChange; + schemaName: string; + tableName?: string; +} + +export function TriggerSaveDialog(props: Props) { + const { databaseDriver } = useDatabaseDriver(); + const [isExecuting, setIsExecuting] = useState(false); + const [errorMessage, setErrorMessage] = useState(""); + + const onSave = () => { + setIsExecuting(true); + const isCreated = !props.trigger.name.old + databaseDriver + databaseDriver.transaction( + isCreated ? props.previewScript : [`DROP TRIGGER IF EXISTS \`${props.schemaName}\`.\`${props.trigger.name.old}\``, ...props.previewScript], + ).then(() => { + props.onSave({ + tableName: props.tableName, + schemaName: props.schemaName, + name: props.trigger.name.new ?? "" + }) + }).catch((err) => setErrorMessage((err as Error).message)) + .finally(() => { + setIsExecuting(false); + }); + } + + return ( + + + Preview + + {errorMessage && ( +
+ +

{errorMessage}

+
+ )} + +

Are you sure you want to run this change?

+ + + Cancel + + +
+
+ ) +} \ No newline at end of file diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index e629bca4..8ba51bad 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -279,6 +279,19 @@ export interface DatabaseSchemaChange { collate?: string; } +export interface DatabaseTriggerSchemaChange { + name: { + old?: string; + new?: string; + }; + operation: TriggerOperation; + when: TriggerWhen; + tableName: string; + whenExpression: string; + statement: string; + schemaName?: string; +} + export abstract class BaseDriver { // Flags abstract getFlags(): DriverFlags; @@ -338,4 +351,7 @@ export abstract class BaseDriver { abstract createUpdateTableSchema(change: DatabaseTableSchemaChange): string[]; abstract createUpdateDatabaseSchema(change: DatabaseSchemaChange): string[]; + abstract createUpdateTriggerSchema( + change: DatabaseTriggerSchemaChange + ): string[]; } diff --git a/src/drivers/mysql/generate-schema.ts b/src/drivers/mysql/generate-schema.ts index 14becbca..d45cd12c 100644 --- a/src/drivers/mysql/generate-schema.ts +++ b/src/drivers/mysql/generate-schema.ts @@ -4,6 +4,7 @@ import { DatabaseTableColumn, DatabaseTableColumnConstraint, DatabaseTableSchemaChange, + DatabaseTriggerSchemaChange, } from "../base-driver"; import { omit, isEqual } from "lodash"; @@ -128,6 +129,15 @@ function generateConstraintScript( } } +export function generateMysqlTriggerSchema( + driver: BaseDriver, + change: DatabaseTriggerSchemaChange +): string[] { + return [ + `CREATE TRIGGER ${driver.escapeId(change.schemaName || "")}.${driver.escapeId(change.name.new ?? "")} \n${change.when} ${change.operation} ON ${driver.escapeId(change.tableName)} \nFOR EACH ROW \nBEGIN \n\t${change.statement} \nEND`, + ]; +} + export function generateMysqlDatabaseSchema( driver: BaseDriver, change: DatabaseSchemaChange diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index e40050a8..4a2a4213 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -10,12 +10,16 @@ import { ColumnTypeSelector, DatabaseTableColumnConstraint, DatabaseSchemaChange, + DatabaseTriggerSchemaChange, + TriggerOperation, + TriggerWhen, } from "../base-driver"; import CommonSQLImplement from "../common-sql-imp"; import { escapeSqlValue } from "../sqlite/sql-helper"; import { generateMysqlDatabaseSchema, generateMySqlSchemaChange, + generateMysqlTriggerSchema, } from "./generate-schema"; import { MYSQL_COLLATION_LIST, @@ -68,6 +72,16 @@ export interface MySQLConstraintColumnResult { REFERENCED_COLUMN_NAME: string; } +export interface MySQLTriggerResult { + TRIGGER_NAME: string; + EVENT_OBJECT_SCHEMA: string; + EVENT_OBJECT_TABLE: string; + ACTION_TIMING: TriggerWhen; + ACTION_STATEMENT: string; + TRIGGER_SCHEMA: string; + EVENT_MANIPULATION: TriggerOperation; +} + function mapColumn(column: MySqlColumn): DatabaseTableColumn { const result: DatabaseTableColumn = { name: column.COLUMN_NAME, @@ -176,6 +190,11 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { const constraintColumnsResult = (await this.query(constraintColumnsSql)) .rows as unknown as MySQLConstraintColumnResult[]; + const triggerSql = + "SELECT * from information_schema.triggers WHERE TRIGGER_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')"; + const triggerResult = (await this.query(triggerSql)) + .rows as unknown as MySQLTriggerResult[]; + // Hash table of schema const schemaRecord: Record = {}; for (const s of schemaResult) { @@ -293,6 +312,26 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { } } + // Add triggers + for (const schema of schemaResult) { + const triggers = triggerResult + .filter((f) => f.TRIGGER_SCHEMA === schema.SCHEMA_NAME) + .map((t) => { + return { + name: t.TRIGGER_NAME, + tableName: t.EVENT_OBJECT_TABLE, + schemaName: t.EVENT_OBJECT_SCHEMA, + timing: t.ACTION_TIMING, + statement: t.ACTION_STATEMENT, + type: "trigger", + }; + }); + + schemaRecord[schema.SCHEMA_NAME] = schemaRecord[ + schema.SCHEMA_NAME + ].concat(triggers as DatabaseSchemaItem[]); + } + return schemaRecord; } @@ -375,8 +414,32 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { }; } - trigger(): Promise { - throw new Error("Not implemented"); + async trigger( + schemaName: string, + name: string + ): Promise { + const result = await this.query( + `SELECT * from information_schema.triggers WHERE TRIGGER_SCHEMA="${schemaName}" AND TRIGGER_NAME="${name}"` + ); + + const triggerRow = result.rows[0] as unknown as + | MySQLTriggerResult + | undefined; + if (!triggerRow) throw new Error("Trigger does not exist"); + + const statement = triggerRow.ACTION_STATEMENT.replace(/begin/i, "") + .replace(/end/i, "") + .trim(); + + return { + name: triggerRow.TRIGGER_NAME, + tableName: triggerRow.EVENT_OBJECT_TABLE, + operation: triggerRow.EVENT_MANIPULATION, + statement, + when: triggerRow.ACTION_TIMING, + whenExpression: "", + columnNames: [], + }; } createUpdateTableSchema(change: DatabaseTableSchemaChange): string[] { @@ -387,6 +450,10 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { return generateMysqlDatabaseSchema(this, change); } + createUpdateTriggerSchema(change: DatabaseTriggerSchemaChange): string[] { + return generateMysqlTriggerSchema(this, change); + } + inferTypeFromHeader(): TableColumnDataType | undefined { return undefined; } diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index f4a83698..b3869dc5 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -7,6 +7,7 @@ import type { DatabaseTableSchema, DatabaseTableSchemaChange, DatabaseTriggerSchema, + DatabaseTriggerSchemaChange, DatabaseValue, DriverFlags, SelectFromTableOptions, @@ -227,13 +228,30 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { } createUpdateTableSchema(change: DatabaseTableSchemaChange): string[] { - return generateSqlSchemaChange(change); + const value: DatabaseTableSchemaChange & DatabaseTriggerSchemaChange = { + ...change, + operation: "INSERT", + statement: "", + tableName: change.schemaName || "", + when: "AFTER", + whenExpression: "", + }; + return generateSqlSchemaChange(value); } createUpdateDatabaseSchema(): string[] { throw new Error("Not implemented"); } + createUpdateTriggerSchema(change: DatabaseTriggerSchemaChange): string[] { + const value: DatabaseTableSchemaChange & DatabaseTriggerSchemaChange = { + ...change, + columns: [], + constraints: [], + }; + return generateSqlSchemaChange(value, "trigger"); + } + override async findFirst( schemaName: string, tableName: string, diff --git a/src/drivers/sqlite/sqlite-generate-schema.ts b/src/drivers/sqlite/sqlite-generate-schema.ts index f0a30419..0bab6e57 100644 --- a/src/drivers/sqlite/sqlite-generate-schema.ts +++ b/src/drivers/sqlite/sqlite-generate-schema.ts @@ -3,6 +3,7 @@ import { DatabaseTableColumn, DatabaseTableColumnConstraint, DatabaseTableSchemaChange, + DatabaseTriggerSchemaChange, } from "@/drivers/base-driver"; import { omit, isEqual } from "lodash"; @@ -116,12 +117,19 @@ function generateConstraintScript(con: DatabaseTableColumnConstraint) { } export default function generateSqlSchemaChange( - change: DatabaseTableSchemaChange + change: DatabaseTableSchemaChange & DatabaseTriggerSchemaChange, + type: "table" | "trigger" = "table" ): string[] { const isCreateScript = !change.name.old; const lines = []; + if (type === "trigger") { + return [ + `CREATE TRIGGER ${escapeIdentity(change.name.new ?? "")} \n${change.when} ${change.operation} ON ${escapeIdentity(change.tableName)} \nFOR EACH ROW \nBEGIN \n\t${change.statement} \nEND`, + ]; + } + for (const col of change.columns) { if (col.new === null) lines.push(`DROP COLUMN ${col.old?.name}`); else if (col.old === null) { diff --git a/src/messages/open-tab.tsx b/src/messages/open-tab.tsx index 65d82874..ff3e37c0 100644 --- a/src/messages/open-tab.tsx +++ b/src/messages/open-tab.tsx @@ -147,7 +147,7 @@ function generateComponent(tab: OpenTabsProps, title: string) { if (tab.type === "erd") return ; if (tab.type === "mass-drop-table") return ; if (tab.type === "trigger") - return ; + return ; return
Unknown Tab
; }