From 58226fe2f4437051756ebba4baf26340aba89732 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 02:47:13 -0700 Subject: [PATCH 1/5] feat: support rearrange column (#167) * feat: support rearrange column * fixing the lint --- package-lock.json | 14 +++ package.json | 1 + src/components/gui/schema-editor/index.tsx | 1 + .../schema-editor-column-list.tsx | 96 ++++++++++++++++--- src/components/gui/tabs/schema-editor-tab.tsx | 2 + src/drivers/base-driver.ts | 1 + 6 files changed, 102 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 62041ab..acaf602 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.5.5", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@justmiracle/result": "^1.2.0", "@lezer/common": "^1.2.1", @@ -2105,6 +2106,19 @@ "react-dom": ">=16.8.0" } }, + "node_modules/@dnd-kit/modifiers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@dnd-kit/modifiers/-/modifiers-7.0.0.tgz", + "integrity": "sha512-BG/ETy3eBjFap7+zIti53f0PCLGDzNXyTmn6fSdrudORf+OH04MxrW4p5+mPu4mgMk9kM41iYONjc3DOUWTcfg==", + "dependencies": { + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "@dnd-kit/core": "^6.1.0", + "react": ">=16.8.0" + } + }, "node_modules/@dnd-kit/sortable": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz", diff --git a/package.json b/package.json index dc90a12..12f6dfa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-sql": "^6.5.5", "@dnd-kit/core": "^6.1.0", + "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", "@justmiracle/result": "^1.2.0", "@lezer/common": "^1.2.1", diff --git a/src/components/gui/schema-editor/index.tsx b/src/components/gui/schema-editor/index.tsx index adb4045..0fdb85b 100644 --- a/src/components/gui/schema-editor/index.tsx +++ b/src/components/gui/schema-editor/index.tsx @@ -51,6 +51,7 @@ export default function SchemaEditor({ columns: [ ...value.columns, { + key: window.crypto.randomUUID(), old: null, new: newColumn, }, diff --git a/src/components/gui/schema-editor/schema-editor-column-list.tsx b/src/components/gui/schema-editor/schema-editor-column-list.tsx index f67de98..86fe98b 100644 --- a/src/components/gui/schema-editor/schema-editor-column-list.tsx +++ b/src/components/gui/schema-editor/schema-editor-column-list.tsx @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from "../../ui/select"; +import { CSS } from "@dnd-kit/utilities"; import { convertSqliteType } from "@/drivers/sqlite/sql-helper"; import { Checkbox } from "@/components/ui/checkbox"; import ColumnDefaultValueInput from "./column-default-value-input"; @@ -33,6 +34,14 @@ import ColumnForeignKeyPopup from "./column-fk-popup"; import ColumnGeneratingPopup from "./column-generate-popup"; import ColumnCheckPopup from "./column-check-popup"; import { Button } from "@/components/ui/button"; +import { DndContext, DragEndEvent } from "@dnd-kit/core"; +import { + SortableContext, + arrayMove, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; export type ColumnChangeEvent = ( newValue: Partial | null @@ -92,8 +101,21 @@ function ColumnItem({ schemaName?: string; onChange: Dispatch>; }) { + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + setActivatorNodeRef, + } = useSortable({ id: value.key, disabled: !!value.old }); const disabled = !!value.old; + const style = { + transform: CSS.Transform.toString(transform), + transition, + }; + const change = useCallback( (newValue: Partial | null) => { changeColumnOnIndex(idx, newValue, onChange); @@ -121,13 +143,30 @@ function ColumnItem({ } return ( - - + + +
+ change({ name: e.currentTarget.value })} - className="p-2 text-sm outline-none bg-background w-[150px]" + className="p-2 text-sm outline-none w-[150px] bg-inherit" spellCheck={false} /> @@ -137,7 +176,7 @@ function ColumnItem({ onValueChange={(newType) => change({ type: newType })} disabled={disabled} > - + @@ -307,6 +346,27 @@ export default function SchemaEditorColumnList({ }>) { const headerStyle = "text-xs p-2 text-left bg-secondary border"; + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + if (active.id !== over?.id) { + const oldIndex = columns.findIndex((c) => c.key === active.id); + const newIndex = columns.findIndex((c) => c.key === over?.id); + + // You cannot change the order of existing column + if (columns[newIndex].old) return; + + const newColumns = arrayMove(columns, oldIndex, newIndex); + + onChange((prev) => ({ + ...prev, + columns: newColumns, + })); + } + }, + [columns, onChange] + ); + return (
@@ -322,15 +382,25 @@ export default function SchemaEditorColumnList({ - {columns.map((col, idx) => ( - - ))} + + c.key)} + strategy={verticalListSortingStrategy} + > + {columns.map((col, idx) => ( + + ))} + + diff --git a/src/components/gui/tabs/schema-editor-tab.tsx b/src/components/gui/tabs/schema-editor-tab.tsx index 038dcb1..0779e19 100644 --- a/src/components/gui/tabs/schema-editor-tab.tsx +++ b/src/components/gui/tabs/schema-editor-tab.tsx @@ -44,6 +44,7 @@ export default function SchemaEditorTab({ new: schema.tableName, }, columns: schema.columns.map((col) => ({ + key: window.crypto.randomUUID(), old: col, new: structuredClone(col), })), @@ -82,6 +83,7 @@ export default function SchemaEditorTab({ name: { ...prev.name, new: prev.name.old }, columns: prev.columns .map((col) => ({ + key: col.key, old: col.old, new: structuredClone(col.old), })) diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index bd7db86..791cfb3 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -209,6 +209,7 @@ export interface DriverFlags { } export interface DatabaseTableColumnChange { + key: string; old: DatabaseTableColumn | null; new: DatabaseTableColumn | null; } From b6cb386633bf48424179a04cab05f8b9def4f59f Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 16:57:44 +0700 Subject: [PATCH 2/5] fix: sorting the table by name --- src/components/gui/schema-sidebar-list.tsx | 28 +++++++++++++--------- 1 file changed, 17 insertions(+), 11 deletions(-) diff --git a/src/components/gui/schema-sidebar-list.tsx b/src/components/gui/schema-sidebar-list.tsx index e44039b..fb403c6 100644 --- a/src/components/gui/schema-sidebar-list.tsx +++ b/src/components/gui/schema-sidebar-list.tsx @@ -89,6 +89,10 @@ function groupByFtsTable(items: ListViewItem[]) { return items.filter((item) => !excludes.has(item.data.name)); } +function sortTable(items: ListViewItem[]) { + return items.sort((a, b) => a.name.localeCompare(b.name)); +} + function flattenSchemaGroup( schemaGroup: ListViewItem[] ): ListViewItem[] { @@ -154,17 +158,19 @@ export default function SchemaList({ search }: Readonly) { ); const listViewItems = useMemo(() => { - const r = Object.entries(schema).map(([s, tables]) => { - return { - data: { type: "schema", schemaName: s }, - icon: LucideDatabase, - name: s, - key: s.toString(), - children: groupByFtsTable( - groupTriggerByTable(prepareListViewItem(tables)) - ), - } as ListViewItem; - }); + const r = sortTable( + Object.entries(schema).map(([s, tables]) => { + return { + data: { type: "schema", schemaName: s }, + icon: LucideDatabase, + name: s, + key: s.toString(), + children: sortTable( + groupByFtsTable(groupTriggerByTable(prepareListViewItem(tables))) + ), + } as ListViewItem; + }) + ); if (databaseDriver.getFlags().optionalSchema) { // For SQLite, the default schema is main and From 76bc9322c82dd1411e769a069131ea0f4e893169 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 04:21:56 -0700 Subject: [PATCH 3/5] allow user to modify table column for LibSQL (#168) * allow user to modify table column for libsql * add more test cases --- jest.config.ts | 5 +- jest.setup.ts | 7 + package-lock.json | 22 +- package.json | 3 + .../(theme)/connect/saved-connection-card.tsx | 12 +- src/components/gui/schema-editor/index.tsx | 5 +- .../schema-editor-column-list.tsx | 7 +- src/components/gui/tabs/schema-editor-tab.tsx | 25 +-- src/components/lib/sql-generate.schema.ts | 188 +++--------------- src/drivers/base-driver.ts | 1 + src/drivers/mysql/mysql-driver.ts | 1 + src/drivers/sqlite-base-driver.ts | 3 +- .../sqlite/sqlite-generate-schema.test.ts | 45 +++++ src/drivers/sqlite/sqlite-generate-schema.ts | 178 +++++++++++++++++ src/drivers/turso-driver.tsx | 1 + 15 files changed, 309 insertions(+), 194 deletions(-) create mode 100644 jest.setup.ts create mode 100644 src/drivers/sqlite/sqlite-generate-schema.test.ts create mode 100644 src/drivers/sqlite/sqlite-generate-schema.ts diff --git a/jest.config.ts b/jest.config.ts index 1d691a2..851a11c 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -9,9 +9,12 @@ const createJestConfig = nextJest({ // Add any custom config to be passed to Jest const config: Config = { coveragePathIgnorePatterns: ["/.*.tsx$"], + globals: { + window: {}, + }, testEnvironment: "node", // Add more setup options before each test is run - // setupFilesAfterEnv: ['/jest.setup.ts'], + setupFilesAfterEnv: ["/jest.setup.ts"], }; // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..7bd9186 --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,7 @@ +import crypto from "crypto"; + +Object.defineProperty(window, "crypto", { + value: { + randomUUID: crypto.randomUUID, + }, +}); diff --git a/package-lock.json b/package-lock.json index acaf602..34c6887 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,7 +66,9 @@ "drizzle-orm": "^0.30.1", "eslint-plugin-jest": "^27.6.3", "file-saver": "^2.0.5", + "immer": "^10.1.1", "libsql-stateless-easy": "^1.6.11", + "lodash": "^4.17.21", "lucia": "^3.2.0", "lucide-react": "^0.309.0", "magic-bytes.js": "^1.10.0", @@ -87,6 +89,7 @@ "@types/deep-equal": "^1.0.4", "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.11", + "@types/lodash": "^4.17.9", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", @@ -8337,6 +8340,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.9", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.9.tgz", + "integrity": "sha512-w9iWudx1XWOHW5lQRS9iKpK/XuRhnN+0T7HvdCCd802FYkT1AMTnxndJHGrNJwRoRHkslGr4S29tjm1cT7x/7w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/mdast": { "version": "3.0.15", "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", @@ -13464,6 +13474,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", @@ -15680,7 +15700,7 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", diff --git a/package.json b/package.json index 12f6dfa..252d1b1 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,9 @@ "drizzle-orm": "^0.30.1", "eslint-plugin-jest": "^27.6.3", "file-saver": "^2.0.5", + "immer": "^10.1.1", "libsql-stateless-easy": "^1.6.11", + "lodash": "^4.17.21", "lucia": "^3.2.0", "lucide-react": "^0.309.0", "magic-bytes.js": "^1.10.0", @@ -106,6 +108,7 @@ "@types/deep-equal": "^1.0.4", "@types/file-saver": "^2.0.7", "@types/jest": "^29.5.11", + "@types/lodash": "^4.17.9", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/src/app/(theme)/connect/saved-connection-card.tsx b/src/app/(theme)/connect/saved-connection-card.tsx index 66ea332..1e581ff 100644 --- a/src/app/(theme)/connect/saved-connection-card.tsx +++ b/src/app/(theme)/connect/saved-connection-card.tsx @@ -51,14 +51,16 @@ export default function ConnectionItemCard({ : `/client/r?p=${conn.id}` } > -
-
-
+
+
+
-
-
{conn.name}
+
+
+ {conn.name} +
{DRIVER_DETAIL[conn.driver ?? "turso"].displayName}
diff --git a/src/components/gui/schema-editor/index.tsx b/src/components/gui/schema-editor/index.tsx index 0fdb85b..5dbd447 100644 --- a/src/components/gui/schema-editor/index.tsx +++ b/src/components/gui/schema-editor/index.tsx @@ -62,7 +62,7 @@ export default function SchemaEditor({ const hasChange = checkSchemaChange(value); const previewScript = useMemo(() => { - return databaseDriver.createUpdateTableSchema(value).join("\n"); + return databaseDriver.createUpdateTableSchema(value).join(";\n"); }, [value, databaseDriver]); return ( @@ -188,6 +188,9 @@ export default function SchemaEditor({ onChange={onChange} onAddColumn={onAddColumn} schemaName={value.schemaName} + disabledEditExistingColumn={ + !databaseDriver.getFlags().supportModifyColumn + } /> >; + disabledEditExistingColumn?: boolean; }) { const { setNodeRef, @@ -109,7 +111,7 @@ function ColumnItem({ transition, setActivatorNodeRef, } = useSortable({ id: value.key, disabled: !!value.old }); - const disabled = !!value.old; + const disabled = !!disabledEditExistingColumn && !!value.old; const style = { transform: CSS.Transform.toString(transform), @@ -338,11 +340,13 @@ export default function SchemaEditorColumnList({ onChange, schemaName, onAddColumn, + disabledEditExistingColumn, }: Readonly<{ columns: DatabaseTableColumnChange[]; onChange: Dispatch>; schemaName?: string; onAddColumn: () => void; + disabledEditExistingColumn?: boolean; }>) { const headerStyle = "text-xs p-2 text-left bg-secondary border"; @@ -397,6 +401,7 @@ export default function SchemaEditorColumnList({ key={col.key} onChange={onChange} schemaName={schemaName} + disabledEditExistingColumn={disabledEditExistingColumn} /> ))} diff --git a/src/components/gui/tabs/schema-editor-tab.tsx b/src/components/gui/tabs/schema-editor-tab.tsx index 0779e19..f22a6db 100644 --- a/src/components/gui/tabs/schema-editor-tab.tsx +++ b/src/components/gui/tabs/schema-editor-tab.tsx @@ -4,6 +4,8 @@ import { useDatabaseDriver } from "@/context/driver-provider"; import SchemaSaveDialog from "../schema-editor/schema-save-dialog"; import { DatabaseTableSchemaChange } from "@/drivers/base-driver"; import SchemaEditor from "../schema-editor"; +import { createTableSchemaDraft } from "@/components/lib/sql-generate.schema"; +import { cloneDeep } from "lodash"; interface SchemaEditorTabProps { tableName?: string; @@ -37,24 +39,7 @@ export default function SchemaEditorTab({ databaseDriver .tableSchema(schemaName, name) .then((schema) => { - setSchema({ - schemaName, - name: { - old: schema.tableName, - new: schema.tableName, - }, - columns: schema.columns.map((col) => ({ - key: window.crypto.randomUUID(), - old: col, - new: structuredClone(col), - })), - constraints: (schema.constraints ?? []).map((con) => ({ - id: window.crypto.randomUUID(), - old: con, - new: structuredClone(con), - })), - createScript: schema.createScript, - }); + setSchema(createTableSchemaDraft(schemaName, schema)); }) .catch(console.error) .finally(() => setLoading(false)); @@ -85,13 +70,13 @@ export default function SchemaEditorTab({ .map((col) => ({ key: col.key, old: col.old, - new: structuredClone(col.old), + new: cloneDeep(col.old), })) .filter((col) => col.old), constraints: prev.constraints.map((con) => ({ id: window.crypto.randomUUID(), old: con.old, - new: structuredClone(con.old), + new: cloneDeep(con.old), })), }; }); diff --git a/src/components/lib/sql-generate.schema.ts b/src/components/lib/sql-generate.schema.ts index 0434ea9..3a8d9d4 100644 --- a/src/components/lib/sql-generate.schema.ts +++ b/src/components/lib/sql-generate.schema.ts @@ -1,11 +1,10 @@ -import { escapeIdentity, escapeSqlValue } from "@/drivers/sqlite/sql-helper"; import deepEqual from "deep-equal"; import { - DatabaseTableColumn, DatabaseTableColumnChange, - DatabaseTableColumnConstraint, + DatabaseTableSchema, DatabaseTableSchemaChange, } from "@/drivers/base-driver"; +import { cloneDeep } from "lodash"; export function checkSchemaColumnChange(change: DatabaseTableColumnChange) { return !deepEqual(change.old, change.new); @@ -23,165 +22,26 @@ export function checkSchemaChange(change: DatabaseTableSchemaChange) { return false; } -function wrapParen(str: string) { - if (str.length >= 2 && str.startsWith("(") && str.endsWith(")")) return str; - return "(" + str + ")"; -} - -function geneateCreateColumn(col: DatabaseTableColumn): string { - const tokens: string[] = [escapeIdentity(col.name), col.type]; - - if (col.constraint?.primaryKey) { - tokens.push( - [ - "PRIMARY KEY", - col.constraint.primaryKeyOrder, - col.constraint.primaryKeyConflict - ? `ON CONFLICT ${col.constraint.primaryKeyConflict}` - : undefined, - col.constraint.autoIncrement ? "AUTOINCREMENT" : undefined, - ] - .filter(Boolean) - .join(" ") - ); - } - - if (col.constraint?.unique) { - tokens.push( - [ - "UNIQUE", - col.constraint.uniqueConflict - ? `ON CONFLICT ${col.constraint.uniqueConflict}` - : undefined, - ] - .filter(Boolean) - .join(" ") - ); - } - - if (col.constraint?.notNull) { - tokens.push( - [ - "NOT NULL", - col.constraint.notNullConflict - ? `ON CONFLICT ${col.constraint.notNullConflict}` - : undefined, - ] - .filter(Boolean) - .join(" ") - ); - } - - if (col.constraint?.defaultValue) { - tokens.push( - ["DEFAULT", escapeSqlValue(col.constraint.defaultValue)].join(" ") - ); - } - - if (col.constraint?.defaultExpression) { - tokens.push( - ["DEFAULT", wrapParen(col.constraint.defaultExpression)].join(" ") - ); - } - - if (col.constraint?.generatedExpression) { - tokens.push( - [ - "GENERATED ALWAYS AS", - wrapParen(col.constraint.generatedExpression), - col.constraint.generatedType, - ].join(" ") - ); - } - - if (col.constraint?.checkExpression) { - tokens.push("CHECK " + wrapParen(col.constraint.checkExpression)); - } - - const foreignTableName = col.constraint?.foreignKey?.foreignTableName; - const foreignColumnName = (col.constraint?.foreignKey?.foreignColumns ?? [ - undefined, - ])[0]; - - if (foreignTableName && foreignColumnName) { - tokens.push( - [ - "REFERENCES", - escapeIdentity(foreignTableName) + - `(${escapeIdentity(foreignColumnName)})`, - ].join(" ") - ); - } - - return tokens.join(" "); -} - -function generateConstraintScript(con: DatabaseTableColumnConstraint) { - if (con.primaryKey) { - return `PRIMARY KEY (${con.primaryColumns?.map(escapeIdentity).join(", ")})`; - } else if (con.unique) { - return `UNIQUE (${con.uniqueColumns?.map(escapeIdentity).join(", ")})`; - } else if (con.checkExpression !== undefined) { - return `CHECK (${con.checkExpression})`; - } else if (con.foreignKey) { - return ( - `FOREIGN KEY (${con.foreignKey.columns?.map(escapeIdentity).join(", ")}) ` + - `REFERENCES ${escapeIdentity(con.foreignKey.foreignTableName ?? "")} ` + - `(${con.foreignKey.foreignColumns?.map(escapeIdentity).join(", ")})` - ); - } -} - -export default function generateSqlSchemaChange( - change: DatabaseTableSchemaChange -): string[] { - const isCreateScript = !change.name.old; - - const lines = []; - - for (const col of change.columns) { - if (col.new === null) lines.push(`DROP COLUMN ${col.old?.name}`); - else if (col.old === null) { - if (isCreateScript) { - lines.push(geneateCreateColumn(col.new)); - } else { - lines.push("ADD " + geneateCreateColumn(col.new)); - } - } else { - if (col.new.name !== col.old.name) { - lines.push( - `RENAME COLUMN ${escapeIdentity(col.old.name)} TO ${escapeIdentity( - col.new.name - )}` - ); - } - } - } - - for (const con of change.constraints) { - if (con.new) { - if (isCreateScript) { - lines.push(generateConstraintScript(con.new)); - } - } - } - - if (!isCreateScript) { - if (change.name.new !== change.name.old) { - lines.push( - `RENAME TO ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity(change.name.new ?? "")}` - ); - } - } - - if (isCreateScript) { - return [ - `CREATE TABLE ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity( - change.name.new || "no_table_name" - )}(\n${lines.map((line) => " " + line).join(",\n")}\n)`, - ]; - } else { - const alter = `ALTER TABLE ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity(change.name.old ?? "")} `; - return lines.map((line) => alter + line); - } +export function createTableSchemaDraft( + schemaName: string, + schema: DatabaseTableSchema +): DatabaseTableSchemaChange { + return { + schemaName, + name: { + old: schema.tableName, + new: schema.tableName, + }, + columns: schema.columns.map((col) => ({ + key: window.crypto.randomUUID(), + old: col, + new: cloneDeep(col), + })), + constraints: (schema.constraints ?? []).map((con) => ({ + id: window.crypto.randomUUID(), + old: con, + new: cloneDeep(con), + })), + createScript: schema.createScript, + }; } diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index 791cfb3..f099088 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -204,6 +204,7 @@ export interface DriverFlags { optionalSchema: boolean; supportBigInt: boolean; supportCreateUpdateTable: boolean; + supportModifyColumn: boolean; mismatchDetection: boolean; dialect: SupportedDialect; } diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index fadad57..2ab51ef 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -49,6 +49,7 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { defaultSchema: "", optionalSchema: false, supportBigInt: false, + supportModifyColumn: false, mismatchDetection: false, supportCreateUpdateTable: false, dialect: "mysql", diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index 4faf6ea..84d62c0 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -15,7 +15,7 @@ import { escapeSqlValue } from "@/drivers/sqlite/sql-helper"; 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 "@/components/lib/sql-generate.schema"; +import generateSqlSchemaChange from "./sqlite/sqlite-generate-schema"; export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { supportPragmaList = true; @@ -31,6 +31,7 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { getFlags(): DriverFlags { return { supportBigInt: false, + supportModifyColumn: false, defaultSchema: "main", optionalSchema: true, mismatchDetection: false, diff --git a/src/drivers/sqlite/sqlite-generate-schema.test.ts b/src/drivers/sqlite/sqlite-generate-schema.test.ts new file mode 100644 index 0000000..ca4b1c5 --- /dev/null +++ b/src/drivers/sqlite/sqlite-generate-schema.test.ts @@ -0,0 +1,45 @@ +import { createTableSchemaDraft } from "@/components/lib/sql-generate.schema"; +import { parseCreateTableScript } from "./sql-parse-table"; +import { produce } from "immer"; +import generateSqlSchemaChange from "./sqlite-generate-schema"; + +function c(sql: string) { + return createTableSchemaDraft("main", parseCreateTableScript("main", sql)); +} + +describe("sqlite - generate table schema", () => { + test("rename column name", () => { + let t = c(`create table testing(id integer, qty integer, amount real)`); + t = produce(t, (draft) => { + if (draft.columns[1]?.new) { + draft.columns[1].new.name = "quantity"; + } + }); + + const code = generateSqlSchemaChange(t); + expect(code).toEqual([ + `ALTER TABLE "main"."testing" RENAME COLUMN "qty" TO "quantity"`, + ]); + }); + + test("rename column name and change some data type", () => { + let t = c(`create table testing(id integer, qty integer, amount real)`); + t = produce(t, (draft) => { + if (draft.columns[1]?.new) { + draft.columns[1].new.name = "quantity"; + draft.columns[1].new.type = "REAL"; + } + + if (draft.columns[2]?.new) { + draft.columns[2].new.name = "amt"; + } + }); + + const code = generateSqlSchemaChange(t); + expect(code).toEqual([ + `ALTER TABLE "main"."testing" RENAME COLUMN "qty" TO "quantity"`, + `ALTER TABLE "main"."testing" ALTER COLUMN "quantity" TO "quantity" REAL`, + `ALTER TABLE "main"."testing" RENAME COLUMN "amount" TO "amt"`, + ]); + }); +}); diff --git a/src/drivers/sqlite/sqlite-generate-schema.ts b/src/drivers/sqlite/sqlite-generate-schema.ts new file mode 100644 index 0000000..00ebe14 --- /dev/null +++ b/src/drivers/sqlite/sqlite-generate-schema.ts @@ -0,0 +1,178 @@ +import { escapeIdentity, escapeSqlValue } from "@/drivers/sqlite/sql-helper"; +import { + DatabaseTableColumn, + DatabaseTableColumnConstraint, + DatabaseTableSchemaChange, +} from "@/drivers/base-driver"; +import { omit, isEqual } from "lodash"; + +function wrapParen(str: string) { + if (str.length >= 2 && str.startsWith("(") && str.endsWith(")")) return str; + return "(" + str + ")"; +} + +function generateCreateColumn(col: DatabaseTableColumn): string { + const tokens: string[] = [escapeIdentity(col.name), col.type]; + + if (col.constraint?.primaryKey) { + tokens.push( + [ + "PRIMARY KEY", + col.constraint.primaryKeyOrder, + col.constraint.primaryKeyConflict + ? `ON CONFLICT ${col.constraint.primaryKeyConflict}` + : undefined, + col.constraint.autoIncrement ? "AUTOINCREMENT" : undefined, + ] + .filter(Boolean) + .join(" ") + ); + } + + if (col.constraint?.unique) { + tokens.push( + [ + "UNIQUE", + col.constraint.uniqueConflict + ? `ON CONFLICT ${col.constraint.uniqueConflict}` + : undefined, + ] + .filter(Boolean) + .join(" ") + ); + } + + if (col.constraint?.notNull) { + tokens.push( + [ + "NOT NULL", + col.constraint.notNullConflict + ? `ON CONFLICT ${col.constraint.notNullConflict}` + : undefined, + ] + .filter(Boolean) + .join(" ") + ); + } + + if (col.constraint?.defaultValue) { + tokens.push( + ["DEFAULT", escapeSqlValue(col.constraint.defaultValue)].join(" ") + ); + } + + if (col.constraint?.defaultExpression) { + tokens.push( + ["DEFAULT", wrapParen(col.constraint.defaultExpression)].join(" ") + ); + } + + if (col.constraint?.generatedExpression) { + tokens.push( + [ + "GENERATED ALWAYS AS", + wrapParen(col.constraint.generatedExpression), + col.constraint.generatedType, + ].join(" ") + ); + } + + if (col.constraint?.checkExpression) { + tokens.push("CHECK " + wrapParen(col.constraint.checkExpression)); + } + + const foreignTableName = col.constraint?.foreignKey?.foreignTableName; + const foreignColumnName = (col.constraint?.foreignKey?.foreignColumns ?? [ + undefined, + ])[0]; + + if (foreignTableName && foreignColumnName) { + tokens.push( + [ + "REFERENCES", + escapeIdentity(foreignTableName) + + `(${escapeIdentity(foreignColumnName)})`, + ].join(" ") + ); + } + + return tokens.join(" "); +} + +function generateConstraintScript(con: DatabaseTableColumnConstraint) { + if (con.primaryKey) { + return `PRIMARY KEY (${con.primaryColumns?.map(escapeIdentity).join(", ")})`; + } else if (con.unique) { + return `UNIQUE (${con.uniqueColumns?.map(escapeIdentity).join(", ")})`; + } else if (con.checkExpression !== undefined) { + return `CHECK (${con.checkExpression})`; + } else if (con.foreignKey) { + return ( + `FOREIGN KEY (${con.foreignKey.columns?.map(escapeIdentity).join(", ")}) ` + + `REFERENCES ${escapeIdentity(con.foreignKey.foreignTableName ?? "")} ` + + `(${con.foreignKey.foreignColumns?.map(escapeIdentity).join(", ")})` + ); + } +} + +export default function generateSqlSchemaChange( + change: DatabaseTableSchemaChange +): string[] { + const isCreateScript = !change.name.old; + + const lines = []; + + for (const col of change.columns) { + if (col.new === null) lines.push(`DROP COLUMN ${col.old?.name}`); + else if (col.old === null) { + if (isCreateScript) { + lines.push(generateCreateColumn(col.new)); + } else { + lines.push("ADD " + generateCreateColumn(col.new)); + } + } else { + // check if there is rename + if (col.new.name !== col.old.name) { + lines.push( + `RENAME COLUMN ${escapeIdentity(col.old.name)} TO ${escapeIdentity( + col.new.name + )}` + ); + } + + // check if there is any changed except name + if (!isEqual(omit(col.old, ["name"]), omit(col.new, ["name"]))) { + lines.push( + `ALTER COLUMN ${escapeIdentity(col.new.name)} TO ${generateCreateColumn(col.new)}` + ); + } + } + } + + for (const con of change.constraints) { + if (con.new) { + if (isCreateScript) { + lines.push(generateConstraintScript(con.new)); + } + } + } + + if (!isCreateScript) { + if (change.name.new !== change.name.old) { + lines.push( + `RENAME TO ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity(change.name.new ?? "")}` + ); + } + } + + if (isCreateScript) { + return [ + `CREATE TABLE ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity( + change.name.new || "no_table_name" + )}(\n${lines.map((line) => " " + line).join(",\n")}\n)`, + ]; + } else { + const alter = `ALTER TABLE ${escapeIdentity(change.schemaName ?? "main")}.${escapeIdentity(change.name.old ?? "")} `; + return lines.map((line) => alter + line); + } +} diff --git a/src/drivers/turso-driver.tsx b/src/drivers/turso-driver.tsx index 0ab9efd..1b546bb 100644 --- a/src/drivers/turso-driver.tsx +++ b/src/drivers/turso-driver.tsx @@ -102,6 +102,7 @@ export default class TursoDriver extends SqliteLikeBaseDriver { return { ...super.getFlags(), supportBigInt: this.bigInt, + supportModifyColumn: true, mismatchDetection: this.bigInt, }; } From 3a4b30975a91d855fbab079011b114ad15ec5ff9 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Sat, 5 Oct 2024 08:35:21 +0700 Subject: [PATCH 4/5] always use returning statement if supported (#170) --- src/components/gui/main-connection.tsx | 2 +- src/components/gui/query-result.tsx | 5 +- .../table-optimized/OptimizeTableState.tsx | 12 +++-- src/components/gui/tabs/table-data-tab.tsx | 1 + src/components/gui/toolbar.tsx | 2 +- src/drivers/base-driver.ts | 10 ++++ src/drivers/common-sql-imp.ts | 49 ++++++++++++++----- src/drivers/mysql/mysql-driver.ts | 8 +++ src/drivers/query-builder.ts | 8 ++- src/drivers/sqlite-base-driver.ts | 12 ++++- src/drivers/sqljs-driver.ts | 6 --- 11 files changed, 86 insertions(+), 29 deletions(-) diff --git a/src/components/gui/main-connection.tsx b/src/components/gui/main-connection.tsx index 55fc1cf..ad84ef1 100644 --- a/src/components/gui/main-connection.tsx +++ b/src/components/gui/main-connection.tsx @@ -38,7 +38,7 @@ function MainConnectionContainer() { */ useLayoutEffect(() => { console.info("Injecting message into window object"); - window.internalPubSub = new InternalPubSub(); + if (!window.internalPubSub) window.internalPubSub = new InternalPubSub(); }, [driver]); useEffect(() => { diff --git a/src/components/gui/query-result.tsx b/src/components/gui/query-result.tsx index c778c6e..a037955 100644 --- a/src/components/gui/query-result.tsx +++ b/src/components/gui/query-result.tsx @@ -20,7 +20,10 @@ export default function QueryResult({ return { _tag: "EXPLAIN", value: result.result } as const; } - const state = OptimizeTableState.createFromResult(result.result); + const state = OptimizeTableState.createFromResult( + databaseDriver, + result.result + ); state.setReadOnlyMode(true); state.mismatchDetection = databaseDriver.getFlags().mismatchDetection; return { _tag: "QUERY", value: state } as const; diff --git a/src/components/gui/table-optimized/OptimizeTableState.tsx b/src/components/gui/table-optimized/OptimizeTableState.tsx index 7e374d2..edb58c4 100644 --- a/src/components/gui/table-optimized/OptimizeTableState.tsx +++ b/src/components/gui/table-optimized/OptimizeTableState.tsx @@ -2,6 +2,7 @@ import { selectArrayFromIndexList } from "@/components/lib/export-helper"; import { OptimizeTableHeaderProps } from "."; import { LucideKey, LucideKeySquare, LucideSigma } from "lucide-react"; import { + BaseDriver, DatabaseResultSet, DatabaseTableSchema, TableColumnDataType, @@ -37,14 +38,19 @@ export default class OptimizeTableState { protected changeLogs: Record = {}; static createFromResult( + driver: BaseDriver, dataResult: DatabaseResultSet, schemaResult?: DatabaseTableSchema ) { return new OptimizeTableState( dataResult.headers.map((header) => { + const headerData = schemaResult + ? schemaResult.columns.find((c) => c.name === header.name) + : undefined; + let initialSize = 150; const headerName = header.name; - const dataType = header.type; + const dataType = header.type ?? driver.inferTypeFromHeader(headerData); if ( dataType === TableColumnDataType.INTEGER || @@ -67,10 +73,6 @@ export default class OptimizeTableState { initialSize = Math.max(150, Math.min(500, maxSize * 8)); } - const headerData = schemaResult - ? schemaResult.columns.find((c) => c.name === header.name) - : undefined; - // -------------------------------------- // Matching foreign key // -------------------------------------- diff --git a/src/components/gui/tabs/table-data-tab.tsx b/src/components/gui/tabs/table-data-tab.tsx index 9ab0a70..a625fc0 100644 --- a/src/components/gui/tabs/table-data-tab.tsx +++ b/src/components/gui/tabs/table-data-tab.tsx @@ -89,6 +89,7 @@ export default function TableDataWindow({ }); const tableState = OptimizeTableState.createFromResult( + databaseDriver, dataResult, schemaResult ); diff --git a/src/components/gui/toolbar.tsx b/src/components/gui/toolbar.tsx index 0c01049..4ba1997 100644 --- a/src/components/gui/toolbar.tsx +++ b/src/components/gui/toolbar.tsx @@ -48,7 +48,7 @@ export function ToolbarButton({ if (tooltip) { return ( - {buttonContent} + {buttonContent} {tooltip} ); diff --git a/src/drivers/base-driver.ts b/src/drivers/base-driver.ts index f099088..bdcb5b7 100644 --- a/src/drivers/base-driver.ts +++ b/src/drivers/base-driver.ts @@ -207,6 +207,12 @@ export interface DriverFlags { supportModifyColumn: boolean; mismatchDetection: boolean; dialect: SupportedDialect; + + // If database supports this, we don't need + // to make a separate request to get updated + // data when update + supportInsertReturning: boolean; + supportUpdateReturning: boolean; } export interface DatabaseTableColumnChange { @@ -252,6 +258,10 @@ export abstract class BaseDriver { tableName: string ): Promise; + abstract inferTypeFromHeader( + header?: DatabaseTableColumn + ): TableColumnDataType | undefined; + abstract trigger( schemaName: string, name: string diff --git a/src/drivers/common-sql-imp.ts b/src/drivers/common-sql-imp.ts index 217dffa..aa8c162 100644 --- a/src/drivers/common-sql-imp.ts +++ b/src/drivers/common-sql-imp.ts @@ -36,11 +36,25 @@ export default abstract class CommonSQLImplement extends BaseDriver { const sqls = ops.map((op) => { if (op.operation === "INSERT") - return insertInto(this, schemaName, tableName, op.values); + return insertInto( + this, + schemaName, + tableName, + op.values, + this.getFlags().supportInsertReturning + ); + if (op.operation === "DELETE") return deleteFrom(this, schemaName, tableName, op.where); - return updateTable(this, schemaName, tableName, op.values, op.where); + return updateTable( + this, + schemaName, + tableName, + op.values, + op.where, + this.getFlags().supportInsertReturning + ); }); const result = await this.transaction(sqls); @@ -57,18 +71,29 @@ export default abstract class CommonSQLImplement extends BaseDriver { } if (op.operation === "UPDATE") { - const selectResult = await this.findFirst( - schemaName, - tableName, - op.where - ); + if (r.rows.length === 1) + // This is when database support RETURNING + tmp.push({ + record: r.rows[0], + }); + else { + const selectResult = await this.findFirst( + schemaName, + tableName, + op.where + ); - tmp.push({ - lastId: r.lastInsertRowid, - record: selectResult.rows[0], - }); + tmp.push({ + lastId: r.lastInsertRowid, + record: selectResult.rows[0], + }); + } } else if (op.operation === "INSERT") { - if (op.autoIncrementPkColumn) { + if (r.rows.length === 1) { + tmp.push({ + record: r.rows[0], + }); + } else if (op.autoIncrementPkColumn) { const selectResult = await this.findFirst(schemaName, tableName, { [op.autoIncrementPkColumn]: r.lastInsertRowid, }); diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index 2ab51ef..bf01efb 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -5,6 +5,7 @@ import { DriverFlags, DatabaseSchemaItem, DatabaseTableColumn, + TableColumnDataType, } from "../base-driver"; import CommonSQLImplement from "../common-sql-imp"; import { escapeSqlValue } from "../sqlite/sql-helper"; @@ -53,6 +54,9 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { mismatchDetection: false, supportCreateUpdateTable: false, dialect: "mysql", + + supportInsertReturning: false, + supportUpdateReturning: false, }; } @@ -153,4 +157,8 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { createUpdateTableSchema(): string[] { throw new Error("Not implemented"); } + + inferTypeFromHeader(): TableColumnDataType | undefined { + return undefined; + } } diff --git a/src/drivers/query-builder.ts b/src/drivers/query-builder.ts index 34db110..e6f82ea 100644 --- a/src/drivers/query-builder.ts +++ b/src/drivers/query-builder.ts @@ -64,12 +64,14 @@ export function insertInto( dialect: BaseDriver, schema: string, table: string, - value: Record + value: Record, + supportReturning: boolean ) { return [ "INSERT INTO", `${dialect.escapeId(schema)}.${dialect.escapeId(table)}`, generateInsertValue(dialect, value), + supportReturning ? "RETURNING *" : "", ].join(" "); } @@ -78,7 +80,8 @@ export function updateTable( schema: string, table: string, value: Record, - where: Record + where: Record, + supportReturning: boolean ): string { return [ "UPDATE", @@ -86,6 +89,7 @@ export function updateTable( "SET", generateSet(dialect, value), generateWhere(dialect, where), + supportReturning ? "RETURNING *" : "", ] .filter(Boolean) .join(" "); diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index 84d62c0..065ea80 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -9,8 +9,9 @@ import type { DatabaseValue, DriverFlags, SelectFromTableOptions, + TableColumnDataType, } from "./base-driver"; -import { escapeSqlValue } from "@/drivers/sqlite/sql-helper"; +import { convertSqliteType, escapeSqlValue } from "@/drivers/sqlite/sql-helper"; import { parseCreateTableScript } from "@/drivers/sqlite/sql-parse-table"; import { parseCreateTriggerScript } from "@/drivers/sqlite/sql-parse-trigger"; @@ -32,6 +33,8 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { return { supportBigInt: false, supportModifyColumn: false, + supportInsertReturning: true, + supportUpdateReturning: true, defaultSchema: "main", optionalSchema: true, mismatchDetection: false, @@ -170,6 +173,13 @@ export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { // do nothing } + inferTypeFromHeader( + header?: DatabaseTableColumn + ): TableColumnDataType | undefined { + if (!header) return undefined; + return convertSqliteType(header.type); + } + async tableSchema( schemaName: string, tableName: string diff --git a/src/drivers/sqljs-driver.ts b/src/drivers/sqljs-driver.ts index 950a682..11e721e 100644 --- a/src/drivers/sqljs-driver.ts +++ b/src/drivers/sqljs-driver.ts @@ -84,12 +84,6 @@ export default class SqljsDriver extends SqliteLikeBaseDriver { rowsWritten: null, queryDurationMs: endTime - startTime, }, - lastInsertRowid: - headers.length > 0 - ? undefined - : (this.db.exec("select last_insert_rowid();")[0].values[0][0] as - | number - | undefined), }; } From e6bcbb41a497f9c2ad3aaae4ecf58b8a20b75348 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Sat, 5 Oct 2024 09:31:49 +0700 Subject: [PATCH 5/5] add starbase driver (#171) --- .../client/[[...driver]]/page-client.tsx | 7 ++ .../client/s/[[...driver]]/page-client.tsx | 6 + src/app/(theme)/connect/driver-dropdown.tsx | 11 ++ .../connect/saved-connection-storage.ts | 37 +++++++ src/app/proxy/starbase/route.ts | 62 +++++++++++ src/drivers/starbase-driver.ts | 103 ++++++++++++++++++ 6 files changed, 226 insertions(+) create mode 100644 src/app/proxy/starbase/route.ts create mode 100644 src/drivers/starbase-driver.ts diff --git a/src/app/(theme)/client/[[...driver]]/page-client.tsx b/src/app/(theme)/client/[[...driver]]/page-client.tsx index 0017a1a..cafaf05 100644 --- a/src/app/(theme)/client/[[...driver]]/page-client.tsx +++ b/src/app/(theme)/client/[[...driver]]/page-client.tsx @@ -10,6 +10,7 @@ import ValtownDriver from "@/drivers/valtown-driver"; import MyStudio from "@/components/my-studio"; import CloudflareD1Driver from "@/drivers/cloudflare-d1-driver"; +import StarbaseDriver from "@/drivers/starbase-driver"; export default function ClientPageBody() { const driver = useMemo(() => { @@ -27,7 +28,13 @@ export default function ClientPageBody() { "x-account-id": config.username ?? "", "x-database-id": config.database ?? "", }); + } else if (config.driver === "starbase") { + return new StarbaseDriver("/proxy/starbase", { + Authorization: "Bearer " + (config.token ?? ""), + "x-starbase-url": config.url ?? "", + }); } + return new TursoDriver(config.url, config.token as string, true); }, []); diff --git a/src/app/(theme)/client/s/[[...driver]]/page-client.tsx b/src/app/(theme)/client/s/[[...driver]]/page-client.tsx index a3b2538..233a6e6 100644 --- a/src/app/(theme)/client/s/[[...driver]]/page-client.tsx +++ b/src/app/(theme)/client/s/[[...driver]]/page-client.tsx @@ -8,6 +8,7 @@ import { useMemo } from "react"; import MyStudio from "@/components/my-studio"; import IndexdbSavedDoc from "@/drivers/saved-doc/indexdb-saved-doc"; import CloudflareD1Driver from "@/drivers/cloudflare-d1-driver"; +import StarbaseDriver from "@/drivers/starbase-driver"; export default function ClientPageBody() { const params = useSearchParams(); @@ -35,6 +36,11 @@ export default function ClientPageBody() { "x-account-id": conn.config.username ?? "", "x-database-id": conn.config.database ?? "", }); + } else if (conn.driver === "starbase") { + return new StarbaseDriver("/proxy/starbase", { + Authorization: "Bearer " + (conn.config.token ?? ""), + "x-starbase-url": conn.config.url ?? "", + }); } return new TursoDriver(conn.config.url, conn.config.token, true); diff --git a/src/app/(theme)/connect/driver-dropdown.tsx b/src/app/(theme)/connect/driver-dropdown.tsx index b2d51e1..de6ed00 100644 --- a/src/app/(theme)/connect/driver-dropdown.tsx +++ b/src/app/(theme)/connect/driver-dropdown.tsx @@ -84,6 +84,17 @@ export default function DriverDropdown({
+ { + onSelect("starbase"); + }} + > +
+ +
StarbaseDB
+
+
+ = }, ], }, + starbase: { + name: "starbase", + displayName: "Starbase", + icon: SQLiteIcon, + disableRemote: true, + prefill: "", + fields: [ + { + name: "url", + title: "Endpoint", + required: true, + type: "text", + secret: false, + invalidate: (url: string): null | string => { + const trimmedUrl = url.trim(); + const valid = + trimmedUrl.startsWith("https://") || + trimmedUrl.startsWith("http://"); + + if (!valid) { + return "Endpoint must start with https:// or http://"; + } + + return null; + }, + }, + { + name: "token", + title: "API Token", + required: true, + type: "text", + secret: true, + }, + ], + }, "cloudflare-d1": { name: "cloudflare-d1", displayName: "Cloudflare D1", @@ -211,8 +246,10 @@ export type SupportedDriver = | "turso" | "rqlite" | "valtown" + | "starbase" | "cloudflare-d1" | "sqlite-filehandler"; + export type SavedConnectionStorage = "remote" | "local"; export type SavedConnectionLabel = "gray" | "red" | "yellow" | "green" | "blue"; diff --git a/src/app/proxy/starbase/route.ts b/src/app/proxy/starbase/route.ts new file mode 100644 index 0000000..37178df --- /dev/null +++ b/src/app/proxy/starbase/route.ts @@ -0,0 +1,62 @@ +import { HttpStatus } from "@/constants/http-status"; +import { headers } from "next/headers"; +import { NextRequest, NextResponse } from "next/server"; + +export const runtime = "edge"; + +export async function POST(req: NextRequest) { + // Get the account id and database id from header + const endpoint = headers().get("x-starbase-url"); + + if (!endpoint) { + return NextResponse.json( + { + error: "Please provide account id or database id", + }, + { status: HttpStatus.BAD_REQUEST } + ); + } + + const authorizationHeader = headers().get("Authorization"); + if (!authorizationHeader) { + return NextResponse.json( + { + error: "Please provide authorization header", + }, + { status: HttpStatus.BAD_REQUEST } + ); + } + + try { + const url = `${endpoint.replace(/\/$/, "")}/query/raw`; + + const response: { errors: { message: string }[] } = await ( + await fetch(url, { + method: "POST", + headers: { + Authorization: authorizationHeader, + "Content-Type": "application/json", + }, + body: JSON.stringify(await req.json()), + }) + ).json(); + + if (response.errors && response.errors.length > 0) { + return NextResponse.json( + { + error: response.errors[0].message, + }, + { status: HttpStatus.INTERNAL_SERVER_ERROR } + ); + } + + return NextResponse.json(response); + } catch (e) { + return NextResponse.json( + { + error: (e as Error).message, + }, + { status: HttpStatus.BAD_REQUEST } + ); + } +} diff --git a/src/drivers/starbase-driver.ts b/src/drivers/starbase-driver.ts new file mode 100644 index 0000000..ee5ba06 --- /dev/null +++ b/src/drivers/starbase-driver.ts @@ -0,0 +1,103 @@ +import { + DatabaseHeader, + DatabaseResultSet, + DatabaseRow, + TableColumnDataType, +} from "./base-driver"; +import { SqliteLikeBaseDriver } from "./sqlite-base-driver"; + +interface StarbaseResult { + columns: string[]; + rows: unknown[][]; + meta: { + rows_read: number; + rows_written: number; + }; +} + +interface StarbaseResponse { + result: StarbaseResult | StarbaseResult[]; +} + +function transformRawResult(raw: StarbaseResult): DatabaseResultSet { + const columns = raw.columns ?? []; + const values = raw.rows; + const headerSet = new Set(); + + const headers: DatabaseHeader[] = columns.map((colName) => { + let renameColName = colName; + + for (let i = 0; i < 20; i++) { + if (!headerSet.has(renameColName)) break; + renameColName = `__${colName}_${i}`; + } + + return { + name: renameColName, + displayName: colName, + originalType: "text", + type: TableColumnDataType.TEXT, + }; + }); + + const rows = values + ? values.map((r) => + headers.reduce((a, b, idx) => { + a[b.name] = r[idx]; + return a; + }, {} as DatabaseRow) + ) + : []; + + return { + rows, + stat: { + queryDurationMs: 0, + rowsAffected: 0, + rowsRead: raw.meta.rows_read, + rowsWritten: raw.meta.rows_written, + }, + headers, + }; +} + +export default class StarbaseDriver extends SqliteLikeBaseDriver { + supportPragmaList: boolean = false; + protected headers: Record = {}; + protected url: string; + + constructor(url: string, headers: Record) { + super(); + this.headers = headers; + this.url = url; + } + + async transaction(stmts: string[]): Promise { + const r = await fetch(this.url, { + method: "POST", + headers: { ...this.headers, "Content-Type": "application/json" }, + body: JSON.stringify({ + transaction: stmts.map((s) => ({ sql: s })), + }), + }); + + const json: StarbaseResponse = await r.json(); + return (Array.isArray(json.result) ? json.result : [json.result]).map( + transformRawResult + ); + } + + async query(stmt: string): Promise { + const r = await fetch(this.url, { + method: "POST", + headers: { ...this.headers, "Content-Type": "application/json" }, + body: JSON.stringify({ sql: stmt }), + }); + + const json: StarbaseResponse = await r.json(); + + return transformRawResult( + Array.isArray(json.result) ? json.result[0] : json.result + ); + } +}