diff --git a/src/components/gui/schema-sidebar-list.tsx b/src/components/gui/schema-sidebar-list.tsx index f6cb076c..7452ed89 100644 --- a/src/components/gui/schema-sidebar-list.tsx +++ b/src/components/gui/schema-sidebar-list.tsx @@ -8,6 +8,7 @@ import { useDatabaseDriver } from "@/context/driver-provider"; import { Table } from "@phosphor-icons/react"; import SchemaCreateDialog from "./schema-editor/schema-create"; import { scc } from "@/core/command"; +import { useConfig } from "@/context/config-provider"; interface SchemaListProps { search: string; @@ -123,6 +124,7 @@ function flattenSchemaGroup( export default function SchemaList({ search }: Readonly) { const { databaseDriver } = useDatabaseDriver(); + const { extensions } = useConfig(); const [selected, setSelected] = useState(""); const { refresh, schema, currentSchemaName } = useSchema(); const [editSchema, setEditSchema] = useState(null); @@ -141,7 +143,63 @@ export default function SchemaList({ search }: Readonly) { const isTable = item?.type === "table"; const isTrigger = item?.type === "trigger"; + const createMenuSection = { + title: "Create", + sub: [ + databaseDriver.getFlags().supportCreateUpdateTable && { + title: "Create Table", + onClick: () => { + scc.tabs.openBuiltinSchema({ + schemaName: item?.schemaName ?? currentSchemaName, + }); + }, + }, + databaseDriver.getFlags().supportCreateUpdateTrigger + ? { + title: "Create Trigger", + onClick: () => { + scc.tabs.openBuiltinTrigger({ + schemaName: item?.schemaName ?? currentSchemaName, + tableName: item?.tableSchema?.tableName, + }); + }, + } + : undefined, + ...extensions.getResourceCreateMenu(), + ], + }; + + const modificationSection = item + ? [ + isTable && databaseDriver.getFlags().supportCreateUpdateTable + ? { + title: "Edit Table", + onClick: () => { + scc.tabs.openBuiltinSchema({ + schemaName: item?.schemaName ?? currentSchemaName, + tableName: item?.name, + }); + }, + } + : undefined, + databaseDriver.getFlags().supportCreateUpdateTrigger && isTrigger + ? { + title: "Edit Trigger", + onClick: () => { + scc.tabs.openBuiltinTrigger({ + schemaName: item?.schemaName ?? currentSchemaName, + name: item.name, + tableName: item?.tableSchema?.tableName, + }); + }, + } + : undefined, + ...extensions.getResourceContextMenu(item, "modification"), + ] + : []; + return [ + createMenuSection, { title: "Copy Name", disabled: !selectedName, @@ -150,47 +208,15 @@ export default function SchemaList({ search }: Readonly) { }, }, { separator: true }, - databaseDriver.getFlags().supportCreateUpdateTable && { - title: "Create New Table", - onClick: () => { - scc.tabs.openBuiltinSchema({ - schemaName: item?.schemaName ?? currentSchemaName, - }); - }, - }, - isTable && databaseDriver.getFlags().supportCreateUpdateTable - ? { - title: "Edit Table", - onClick: () => { - scc.tabs.openBuiltinTable({ - schemaName: item?.schemaName ?? currentSchemaName, - tableName: item?.name, - }); - }, - } - : undefined, - databaseDriver.getFlags().supportCreateUpdateTrigger - ? { separator: true } - : undefined, - databaseDriver.getFlags().supportCreateUpdateTrigger - ? { - title: isTrigger ? "Edit Trigger" : "Create New Trigger", - onClick: () => { - scc.tabs.openBuiltinTrigger({ - schemaName: item?.schemaName ?? currentSchemaName, - name: isTrigger ? item.name : "", - tableName: item?.tableSchema?.tableName, - }); - }, - } - : undefined, - databaseDriver.getFlags().supportCreateUpdateTable - ? { separator: true } - : undefined, + + // Modification Section + ...modificationSection, + modificationSection.length > 0 ? { separator: true } : undefined, + { title: "Refresh", onClick: () => refresh() }, ].filter(Boolean) as OpenContextMenuList; }, - [refresh, databaseDriver, currentSchemaName] + [refresh, databaseDriver, currentSchemaName, extensions] ); const listViewItems = useMemo(() => { diff --git a/src/components/gui/schema-sidebar.tsx b/src/components/gui/schema-sidebar.tsx index fe7c549c..b2556d69 100644 --- a/src/components/gui/schema-sidebar.tsx +++ b/src/components/gui/schema-sidebar.tsx @@ -14,24 +14,25 @@ import { DropdownMenuTrigger, } from "../ui/dropdown-menu"; import { scc } from "@/core/command"; +import { StudioExtensionMenuItem } from "@/core/extension-manager"; +import { useConfig } from "@/context/config-provider"; export default function SchemaView() { const [search, setSearch] = useState(""); const { databaseDriver } = useDatabaseDriver(); const { currentSchemaName } = useSchema(); const [isCreateSchema, setIsCreateSchema] = useState(false); + const { extensions } = useConfig(); const contentMenu = useMemo(() => { - const items: { - name: string; - onClick: () => void; - }[] = []; + const items: StudioExtensionMenuItem[] = []; const flags = databaseDriver.getFlags(); if (flags.supportCreateUpdateTable) { items.push({ - name: "Create Table", + title: "Create Table", + key: "create-table", onClick: () => { scc.tabs.openBuiltinSchema({ schemaName: currentSchemaName }); }, @@ -40,7 +41,8 @@ export default function SchemaView() { if (flags.supportCreateUpdateDatabase) { items.push({ - name: "Create Database/Schema", + title: "Create Database/Schema", + key: "create-schema", onClick: () => { setIsCreateSchema(true); }, @@ -49,15 +51,16 @@ export default function SchemaView() { if (flags.supportCreateUpdateTrigger) { items.push({ - name: "Create Trigger", + title: "Create Trigger", + key: "create-trigger", onClick: () => { scc.tabs.openBuiltinTrigger({ schemaName: currentSchemaName }); }, }); } - return items; - }, [databaseDriver, currentSchemaName]); + return [...items, ...extensions.getResourceCreateMenu()]; + }, [databaseDriver, currentSchemaName, extensions]); const activatorButton = useMemo(() => { if (contentMenu.length === 0) return null; @@ -95,8 +98,8 @@ export default function SchemaView() { {contentMenu.map((menu) => { return ( - - {menu.name} + + {menu.title} ); })} diff --git a/src/components/gui/studio.tsx b/src/components/gui/studio.tsx index 8030520b..5376bb7d 100644 --- a/src/components/gui/studio.tsx +++ b/src/components/gui/studio.tsx @@ -78,7 +78,7 @@ export function Studio({ useEffect(() => { finalExtensionManager.init(); return () => finalExtensionManager.cleanup(); - }); + }, [finalExtensionManager]); const config = useMemo(() => { return { diff --git a/src/core/extension-base.tsx b/src/core/extension-base.tsx index 2afeb2b0..4d858f38 100644 --- a/src/core/extension-base.tsx +++ b/src/core/extension-base.tsx @@ -1,8 +1,8 @@ -import { StudioExtensionManager } from "./extension-manager"; +import { StudioExtensionContext } from "./extension-manager"; export abstract class IStudioExtension { abstract extensionName: string; - abstract init(studio: StudioExtensionManager): void; + abstract init(studio: StudioExtensionContext): void; abstract cleanup(): void; } diff --git a/src/core/extension-manager.tsx b/src/core/extension-manager.tsx index af751931..5a3bec38 100644 --- a/src/core/extension-manager.tsx +++ b/src/core/extension-manager.tsx @@ -1,5 +1,6 @@ import { ReactElement } from "react"; import { IStudioExtension } from "./extension-base"; +import { DatabaseSchemaItem } from "@/drivers/base-driver"; interface RegisterSidebarOption { key: string; @@ -47,35 +48,79 @@ export class BeforeQueryPipeline { type BeforeQueryHandler = (payload: BeforeQueryPipeline) => Promise; type AfterQueryHandler = () => Promise; -export class StudioExtensionManager { - private sidebars: RegisterSidebarOption[] = []; - private beforeQueryHandlers: BeforeQueryHandler[] = []; - private afterQueryHandlers: AfterQueryHandler[] = []; - constructor(private extensions: IStudioExtension[]) {} +export interface StudioExtensionMenuItem { + key: string; + title: string; + icon?: ReactElement; + onClick: () => void; +} - init() { - this.extensions.forEach((ext) => ext.init(this)); +type CreateResourceMenuHandler = ( + resource: DatabaseSchemaItem +) => StudioExtensionMenuItem | undefined; +export class StudioExtensionContext { + protected sidebars: RegisterSidebarOption[] = []; + protected beforeQueryHandlers: BeforeQueryHandler[] = []; + protected afterQueryHandlers: AfterQueryHandler[] = []; + protected resourceCreateMenu: StudioExtensionMenuItem[] = []; + protected resourceContextMenu: Record = + {}; + + constructor(protected extensions: IStudioExtension[]) {} + + registerBeforeQuery(handler: BeforeQueryHandler) { + this.beforeQueryHandlers.push(handler); } - cleanup() { - this.extensions.forEach((ext) => ext.cleanup()); + registerAfterQuery(handler: AfterQueryHandler) { + this.afterQueryHandlers.push(handler); } registerSidebar(option: RegisterSidebarOption) { this.sidebars.push(option); } + registerCreateResourceMenu(menu: StudioExtensionMenuItem) { + console.log("Register", menu); + this.resourceCreateMenu.push(menu); + } + + registerResourceContextMenu( + handler: CreateResourceMenuHandler, + group: "other" | "modification" = "other" + ) { + if (!this.resourceContextMenu[group]) { + this.resourceContextMenu[group] = [handler]; + } else { + this.resourceContextMenu[group].push(handler); + } + } +} +export class StudioExtensionManager extends StudioExtensionContext { + init() { + this.extensions.forEach((ext) => ext.init(this)); + } + + cleanup() { + this.extensions.forEach((ext) => ext.cleanup()); + } + getSidebars() { return this.sidebars; } - registerBeforeQuery(handler: BeforeQueryHandler) { - this.beforeQueryHandlers.push(handler); + getResourceCreateMenu() { + return this.resourceCreateMenu; } - registerAfterQuery(handler: AfterQueryHandler) { - this.afterQueryHandlers.push(handler); + getResourceContextMenu( + resource: DatabaseSchemaItem, + group: "other" | "modification" + ) { + return (this.resourceContextMenu[group] ?? []) + .map((handler) => handler(resource)) + .filter(Boolean) as StudioExtensionMenuItem[]; } async beforeQuery(payload: BeforeQueryPipeline) { diff --git a/src/core/extension-tab.tsx b/src/core/extension-tab.tsx index 4d6e6676..b6489c80 100644 --- a/src/core/extension-tab.tsx +++ b/src/core/extension-tab.tsx @@ -9,6 +9,7 @@ interface TabExtensionConfig { interface TabExtensionCommand { open: (options: T) => void; + generate: (options: T) => WindowTabItemProps; close: (options: T) => void; } @@ -16,9 +17,22 @@ export function createTabExtension( config: TabExtensionConfig ): TabExtensionCommand { return Object.freeze({ - open(options: T) { + generate: (options: T) => { const key = [config.name, config.key(options)].filter(Boolean).join("-"); + return { + ...config.generate(options), + key, + identifier: key, + type: config.name, + }; + }, + + open(options: T) { if (window.outerbaseOpenTab) { + const key = [config.name, config.key(options)] + .filter(Boolean) + .join("-"); + window.outerbaseOpenTab({ ...config.generate(options), key, diff --git a/src/core/standard-extension.tsx b/src/core/standard-extension.tsx index 171d45f6..9d338293 100644 --- a/src/core/standard-extension.tsx +++ b/src/core/standard-extension.tsx @@ -3,7 +3,8 @@ */ import QueryHistoryConsoleLogExtension from "@/extensions/query-console-log"; +import ViewEditorExtension from "@/extensions/view-editor"; export function createStandardExtensions() { - return [new QueryHistoryConsoleLogExtension()]; + return [new QueryHistoryConsoleLogExtension(), new ViewEditorExtension()]; } diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index b454f011..2c8d6217 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -178,6 +178,12 @@ export interface DatabaseTriggerSchema { statement: string; } +export interface DatabaseViewSchema { + name: string; + schemaName: string; + statement: string; +} + interface DatabaseTableOperationInsert { operation: "INSERT"; values: Record; @@ -344,4 +350,8 @@ export abstract class BaseDriver { abstract createTrigger(trigger: DatabaseTriggerSchema): string; abstract dropTrigger(schemaName: string, name: string): string; + abstract createView(view: DatabaseViewSchema): string; + abstract dropView(schemaName: string, name: string): string; + + abstract view(schemaName: string, name: string): Promise; } diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index b86af2fd..05e84ad9 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -1,3 +1,4 @@ +import { format } from "sql-formatter"; import { DatabaseSchemas, DatabaseTableSchema, @@ -12,6 +13,7 @@ import { DatabaseSchemaChange, TriggerOperation, TriggerWhen, + DatabaseViewSchema, } from "../base-driver"; import CommonSQLImplement from "../common-sql-imp"; import { escapeSqlValue } from "../sqlite/sql-helper"; @@ -458,6 +460,34 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { return `DROP TRIGGER IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; } + async view(schemaName: string, name: string): Promise { + const sql = `SELECT * FROM information_schema.views WHERE TABLE_SCHEMA=${this.escapeValue(schemaName)} AND TABLE_NAME=${this.escapeValue(name)}`; + const result = await this.query(sql); + + const viewRow = result.rows[0] as { VIEW_DEFINITION: string } | undefined; + if (!viewRow) throw new Error("View dose not exist"); + + //use sql-format for statement + const statement = format(viewRow.VIEW_DEFINITION.trim(), { + language: "mysql", + keywordCase: "upper", + }); + + return { + schemaName, + name, + statement, + }; + } + + createView(view: DatabaseViewSchema): string { + return `CREATE VIEW ${this.escapeId(view.schemaName)}.${this.escapeId(view.name)} AS ${view.statement}`; + } + + dropView(schemaName: string, name: string): string { + return `DROP VIEW IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; + } + inferTypeFromHeader(): TableColumnDataType | undefined { return undefined; } diff --git a/src/drivers/postgres/postgres-driver.ts b/src/drivers/postgres/postgres-driver.ts index a892c4c3..3d1f2b1c 100644 --- a/src/drivers/postgres/postgres-driver.ts +++ b/src/drivers/postgres/postgres-driver.ts @@ -6,6 +6,7 @@ import { DatabaseTableColumnConstraint, DatabaseTableSchema, DatabaseTriggerSchema, + DatabaseViewSchema, DriverFlags, TableColumnDataType, } from "../base-driver"; @@ -93,7 +94,7 @@ export default abstract class PostgresLikeDriver extends CommonSQLImplement { const tableResult = ( await this.query( - "SELECT *, pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) AS table_size FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast') AND table_type = 'BASE TABLE';" + "SELECT *, pg_total_relation_size(quote_ident(table_schema) || '.' || quote_ident(table_name)) AS table_size FROM information_schema.tables WHERE table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast');" ) ).rows as unknown as PostgresTableRow[]; @@ -360,11 +361,35 @@ WHERE } createTrigger(): string { - throw new Error("Not implemented") + throw new Error("Not implemented"); } dropTrigger(): string { - throw new Error("Not implemented") + throw new Error("Not implemented"); + } + + async view(schemaName: string, name: string): Promise { + const sql = `SELECT * FROM information_schema.views WHERE TABLE_SCHEMA=${this.escapeValue(schemaName)} AND TABLE_NAME=${this.escapeValue(name)}`; + const result = await this.query(sql); + + const viewRow = result.rows[0] as { view_definition: string } | undefined; + if (!viewRow) throw new Error("View dose not exist"); + + const statement = viewRow.view_definition.trim(); + + return { + schemaName, + name, + statement, + }; + } + + createView(view: DatabaseViewSchema): string { + return `CREATE VIEW ${this.escapeId(view.schemaName)}.${this.escapeId(view.name)} AS ${view.statement}`; + } + + dropView(schemaName: string, name: string): string { + return `DROP VIEW IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; } inferTypeFromHeader(): TableColumnDataType | undefined { diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index b41b479c..ee966ed0 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -8,6 +8,7 @@ import { DatabaseTableSchemaChange, DatabaseTriggerSchema, DatabaseValue, + DatabaseViewSchema, DriverFlags, SelectFromTableOptions, TableColumnDataType, @@ -22,6 +23,7 @@ import { parseCreateTableScript } from "@/drivers/sqlite/sql-parse-table"; import { parseCreateTriggerScript } from "@/drivers/sqlite/sql-parse-trigger"; import CommonSQLImplement from "./common-sql-imp"; import generateSqlSchemaChange from "./sqlite/sqlite-generate-schema"; +import { parseCreateViewScript } from "./sqlite/sql-parse-view"; export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { supportPragmaList = true; @@ -255,6 +257,24 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { return `DROP TRIGGER IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; } + async view(schemaName: string, name: string): Promise { + const sql = `SELECT * FROM ${this.escapeId(schemaName)}.sqlite_schema WHERE type = 'view' AND name = ${this.escapeId(name)};`; + const result = await this.query(sql); + + const viewRow = result.rows[0] as { sql: string } | undefined; + if (!viewRow) throw new Error("View dose not exist"); + + return parseCreateViewScript(schemaName, viewRow.sql); + } + + createView(view: DatabaseViewSchema): string { + return `CREATE VIEW ${this.escapeId(view.schemaName)}.${this.escapeId(view.name)} AS ${view.statement}`; + } + + dropView(schemaName: string, name: string): string { + return `DROP VIEW IF EXISTS ${this.escapeId(schemaName)}.${this.escapeId(name)}`; + } + override async findFirst( schemaName: string, tableName: string, diff --git a/src/drivers/sqlite/sql-parse-view.ts b/src/drivers/sqlite/sql-parse-view.ts new file mode 100644 index 00000000..812c3f0d --- /dev/null +++ b/src/drivers/sqlite/sql-parse-view.ts @@ -0,0 +1,44 @@ +import { sqliteDialect } from "@/drivers/sqlite/sqlite-dialect"; +import { DatabaseViewSchema } from "../base-driver"; +import { Cursor } from "./sql-parse-table"; + +export function parseCreateViewScript( + schemaName: string, + sql: string +): DatabaseViewSchema { + const tree = sqliteDialect.language.parser.parse(sql); + const ptr = tree.cursor(); + ptr.firstChild(); + ptr.firstChild(); + const cursor = new Cursor(ptr, sql); + cursor.expectKeyword("CREATE"); + cursor.expectKeywordOptional("TEMP"); + cursor.expectKeywordOptional("TEMPORARY"); + cursor.expectKeyword("VIEW"); + cursor.expectKeywordsOptional(["IF", "NOT", "EXIST"]); + const name = cursor.consumeIdentifier(); + + cursor.expectKeyword("AS"); + + let statement = ""; + const fromStatement = cursor.node()?.from; + let toStatement; + + while (!cursor.end()) { + toStatement = cursor.node()?.to; + if (cursor.matchKeyword(";")) { + break; + } + cursor.next(); + } + + if (fromStatement) { + statement = sql.substring(fromStatement, toStatement); + } + + return { + schemaName, + name, + statement, + }; +} diff --git a/src/extensions/dolt/index.tsx b/src/extensions/dolt/index.tsx index a7a54989..409dd397 100644 --- a/src/extensions/dolt/index.tsx +++ b/src/extensions/dolt/index.tsx @@ -1,6 +1,6 @@ import { DoltIcon } from "@/components/icons/outerbase-icon"; import { StudioExtension } from "@/core/extension-base"; -import { StudioExtensionManager } from "@/core/extension-manager"; +import { StudioExtensionContext } from "@/core/extension-manager"; import DoltSidebar from "./dolt-sidebar"; import { Table } from "lucide-react"; import { createTabExtension } from "@/core/extension-tab"; @@ -21,7 +21,7 @@ export const doltCommitTab = createTabExtension<{ export default class DoltExtension extends StudioExtension { extensionName = "dolt"; - init(studio: StudioExtensionManager): void { + init(studio: StudioExtensionContext): void { studio.registerSidebar({ key: "dolt", name: "Dolt", diff --git a/src/extensions/query-console-log/index.ts b/src/extensions/query-console-log/index.ts index f5637a6b..c1faf777 100644 --- a/src/extensions/query-console-log/index.ts +++ b/src/extensions/query-console-log/index.ts @@ -1,10 +1,10 @@ import { StudioExtension } from "@/core/extension-base"; -import { StudioExtensionManager } from "@/core/extension-manager"; +import { StudioExtensionContext } from "@/core/extension-manager"; export default class QueryHistoryConsoleLogExtension extends StudioExtension { extensionName = "query-history-console-log"; - init(studio: StudioExtensionManager): void { + init(studio: StudioExtensionContext): void { studio.registerBeforeQuery(async (payload) => { const statements = payload.getStatments(); diff --git a/src/extensions/view-editor/index.tsx b/src/extensions/view-editor/index.tsx new file mode 100644 index 00000000..cb9599bf --- /dev/null +++ b/src/extensions/view-editor/index.tsx @@ -0,0 +1,50 @@ +import { StudioExtension } from "@/core/extension-base"; +import { createTabExtension } from "@/core/extension-tab"; +import ViewTab from "./view-tab"; +import { LucideView } from "lucide-react"; +import { StudioExtensionContext } from "@/core/extension-manager"; + +export const viewEditorExtensionTab = createTabExtension<{ + schemaName?: string; + name?: string; +}>({ + name: "view", + key: (options) => { + return `${options.schemaName}.${options.name}`; + }, + generate: (options) => ({ + title: options.name || "New View", + component: ( + + ), + icon: LucideView, + }), +}); + +export default class ViewEditorExtension extends StudioExtension { + extensionName = "view-editor"; + + init(studio: StudioExtensionContext): void { + studio.registerCreateResourceMenu({ + key: "view", + title: "Create View", + onClick: () => { + viewEditorExtensionTab.open({}); + }, + }); + + studio.registerResourceContextMenu((resource) => { + if (resource.type !== "view") return; + return { + key: "view", + title: "Edit View", + onClick: () => { + viewEditorExtensionTab.open({ + schemaName: resource.schemaName, + name: resource.name, + }); + }, + }; + }, "modification"); + } +} diff --git a/src/extensions/view-editor/view-controller.tsx b/src/extensions/view-editor/view-controller.tsx new file mode 100644 index 00000000..0051cb82 --- /dev/null +++ b/src/extensions/view-editor/view-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 React from "react"; +import CodePreview from "../../components/gui/code-preview"; + +interface Props { + onSave: () => void; + onDiscard: () => void; + previewScript: string; + isExecuting?: boolean; + disabled?: boolean; +} + +export function ViewController(props: Props) { + const { onSave, onDiscard, isExecuting, disabled, previewScript } = props; + return ( +
+ + + +
+ +
+ + + +
+ + SQL Preview +
+
+ +
SQL Preview
+
+ +
+
+
+
+ ); +} diff --git a/src/extensions/view-editor/view-editor.tsx b/src/extensions/view-editor/view-editor.tsx new file mode 100644 index 00000000..c3cffe6d --- /dev/null +++ b/src/extensions/view-editor/view-editor.tsx @@ -0,0 +1,72 @@ +import { Input } from "@/components/ui/input"; +import { DatabaseViewSchema } from "@/drivers/base-driver"; +import { produce } from "immer"; +import SqlEditor from "../../components/gui/sql-editor"; +import { useDatabaseDriver } from "@/context/driver-provider"; +import { useMemo } from "react"; +import { useSchema } from "@/context/schema-provider"; +import SchemaNameSelect from "../../components/gui/schema-editor/schema-name-select"; + +interface Props { + value: DatabaseViewSchema; + onChange: (value: DatabaseViewSchema) => void; +} + +export default function ViewEditor(props: Props) { + const { value, onChange } = props; + const { databaseDriver } = useDatabaseDriver(); + const { autoCompleteSchema, schema } = useSchema(); + + const extendedAutoCompleteSchema = useMemo(() => { + const currentSchema = schema[value.schemaName]; + if (!currentSchema) return autoCompleteSchema; + + return autoCompleteSchema; + }, [autoCompleteSchema, schema, value.schemaName]); + + return ( + <> +
+ + onChange( + produce(value, (draft) => { + draft.name = e.currentTarget.value; + }) + ) + } + /> +
+ { + onChange( + produce(value, (draft) => { + draft.schemaName = schemaName; + }) + ); + }} + /> +
+
+
+
+ + onChange( + produce(value, (draft) => { + draft.statement = newStatement; + }) + ) + } + /> +
+
+ + ); +} diff --git a/src/extensions/view-editor/view-tab.tsx b/src/extensions/view-editor/view-tab.tsx new file mode 100644 index 00000000..95f9716e --- /dev/null +++ b/src/extensions/view-editor/view-tab.tsx @@ -0,0 +1,138 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { DatabaseViewSchema } from "@/drivers/base-driver"; +import { produce } from "immer"; +import { isEqual } from "lodash"; +import { useDatabaseDriver } from "@/context/driver-provider"; +import { useCommonDialog } from "@/components/common-dialog"; +import { LucideLoader, LucideSave } from "lucide-react"; +import { useSchema } from "@/context/schema-provider"; +import { useTabsContext } from "@/components/gui/windows-tab"; +import OpacityLoading from "@/components/gui/loading-opacity"; +import { ViewController } from "./view-controller"; +import ViewEditor from "./view-editor"; +import { viewEditorExtensionTab } from "."; + +export interface ViewTabProps { + name: string; + schemaName?: string; +} + +const EMPTY_DEFAULT_VIEW: DatabaseViewSchema = { + name: "", + statement: "", + schemaName: "", +}; + +export default function ViewTab(props: ViewTabProps) { + const { showDialog } = useCommonDialog(); + const { replaceCurrentTab } = useTabsContext(); + const { refresh: refreshSchema, currentSchemaName } = useSchema(); + const { databaseDriver } = useDatabaseDriver(); + + // If name is specified, it means the trigger is already exist + const [loading, setLoading] = useState(!!props.name); + + // Loading the inital value + const [initialValue, setInitialValue] = useState(() => { + return produce(EMPTY_DEFAULT_VIEW, (draft) => { + draft.schemaName = props.schemaName ?? currentSchemaName ?? ""; + }); + }); + const [value, setValue] = useState(initialValue); + const [isExecuting, setIsExecuting] = useState(false); + + const hasChanged = !isEqual(initialValue, value); + + const previewScript = useMemo(() => { + const drop = databaseDriver.dropView(value.schemaName, props.name); + const create = databaseDriver.createView(value); + return props.name ? [drop, create] : [create]; + }, [value, databaseDriver, props.name]); + + // Loading the view + useEffect(() => { + if (props.schemaName && props.name) { + databaseDriver + .view(props.schemaName, props.name) + .then((viewValue) => { + setValue(viewValue); + setInitialValue(viewValue); + }) + .finally(() => setLoading(false)); + } + }, [props.name, props.schemaName, databaseDriver]); + + const onContinue = useCallback(async () => { + setIsExecuting(true); + if ( + value.schemaName !== currentSchemaName && + databaseDriver.getFlags().supportUseStatement + ) { + const oldSchemaName = currentSchemaName; + await databaseDriver.query( + "USE " + databaseDriver.escapeId(value.schemaName) + ); + await databaseDriver.transaction(previewScript); + if (oldSchemaName !== "") { + await databaseDriver.query( + "USE " + databaseDriver.escapeId(oldSchemaName) + ); + } + } else { + await databaseDriver.transaction(previewScript); + } + }, [currentSchemaName, databaseDriver, previewScript, value.schemaName]); + + const onSave = useCallback(() => { + showDialog({ + title: props.name ? "Edit View" : "Create View", + content:

Are you sure you want to run this change?

, + previewCode: previewScript.join(";\n"), + actions: [ + { + text: "Continue", + icon: isExecuting ? LucideLoader : LucideSave, + onClick: onContinue, + onComplete: () => { + refreshSchema(); + replaceCurrentTab( + viewEditorExtensionTab.generate({ + schemaName: value.schemaName, + name: value.name, + }) + ); + setIsExecuting(false); + }, + }, + ], + }); + }, [ + showDialog, + props.name, + previewScript, + isExecuting, + onContinue, + refreshSchema, + replaceCurrentTab, + value.name, + value.schemaName, + ]); + + if (loading) { + return ; + } + + return ( +
+ { + setValue(initialValue); + }} + disabled={!hasChanged} + previewScript={previewScript.join(";\n")} + /> + +
+ ); +}