From 7aaf4990934584b76f2fda15448874504a898514 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 02:47:13 -0700 Subject: [PATCH 1/3] 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 f9f8be8cd1c54d8ec9c9b33e989321beb1833f99 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 16:57:44 +0700 Subject: [PATCH 2/3] 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 9b0789fc423da08b60646c486ff675aa5a41aa8a Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Tue, 1 Oct 2024 04:21:56 -0700 Subject: [PATCH 3/3] 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, }; }