diff --git a/src/app/storybook/column-type/page.tsx b/src/app/storybook/column-type/page.tsx new file mode 100644 index 00000000..595660b0 --- /dev/null +++ b/src/app/storybook/column-type/page.tsx @@ -0,0 +1,18 @@ +"use client"; +import ColumnTypeSelector from "@/components/gui/schema-editor/column-type-selector"; +import { MYSQL_DATA_TYPE_SUGGESTION } from "@/drivers/mysql/mysql-data-type"; +import { useState } from "react"; + +export default function ColumnTypeStorybook() { + const [value, setValue] = useState(""); + + return ( +
+ +
+ ); +} diff --git a/src/app/storybook/layout.tsx b/src/app/storybook/layout.tsx new file mode 100644 index 00000000..d8229341 --- /dev/null +++ b/src/app/storybook/layout.tsx @@ -0,0 +1,7 @@ +export default async function StorybookRootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/components/gui/schema-editor/column-type-selector.tsx b/src/components/gui/schema-editor/column-type-selector.tsx new file mode 100644 index 00000000..98bb838d --- /dev/null +++ b/src/components/gui/schema-editor/column-type-selector.tsx @@ -0,0 +1,193 @@ +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { ColumnTypeSuggestionGroup } from "@/drivers/base-driver"; +import { useMemo, useState } from "react"; + +function ColumnTypeList({ + items, + onChange, +}: { + items: ColumnTypeSuggestionGroup[]; + onChange: (text: string) => void; +}) { + return items.map((group) => ( +
+
+ {group.name} +
+
+ {group.suggestions.map((type) => { + const itemClassName = + "py-0.5 px-3 pl-8 cursor-pointer hover:bg-gray-100"; + + const parameters = type.parameters ?? []; + let content = {type.name.toUpperCase()}; + + if (parameters.length > 0) { + content = ( + <> + {type.name.toUpperCase()} + ( + + {parameters.map((p) => p.name).join(", ")} + + ) + + ); + } + + return ( +
{ + e.preventDefault(); + }} + onClick={() => { + if (parameters.length > 0) { + onChange( + `${type.name.toUpperCase()}(${parameters.map((p) => p.default).join(",")})` + ); + } else { + onChange(`${type.name.toUpperCase()}`); + } + }} + > + {content} +
+ ); + })} +
+
+ )); +} + +export default function ColumnTypeSelector({ + value, + onChange, + suggestions, +}: { + value: string; + onChange: (type: string) => void; + disabled?: boolean; + suggestions: ColumnTypeSuggestionGroup[]; +}) { + const [showSuggestion, setShowSuggestion] = useState(false); + + // Parse the value into type and parameters + const { parsedType, parsedParameters } = useMemo(() => { + const match = value.match(/([a-zA-Z]+)(\((.*)\))?/); + + if (!match) { + return { + parsedType: "", + }; + } + + const type = match[1]; + const parameters = match[3]?.split(",").map((p) => p.trim()); + + return { parsedType: type ?? "", parsedParameters: parameters }; + }, [value]); + + const filteredSuggestions = suggestions + .map((group) => { + { + return { + ...group, + suggestions: group.suggestions.filter((type) => { + return type.name.toLowerCase().startsWith(parsedType.toLowerCase()); + }), + }; + } + }) + .filter((group) => group.suggestions.length > 0); + + let suggestionDom = ( + + ); + + // If there is only one suggestion left + if ( + filteredSuggestions.length === 1 && + filteredSuggestions[0].suggestions.length === 1 && + filteredSuggestions[0].suggestions[0].name.toLowerCase() === + parsedType.toLowerCase() + ) { + const typeSuggestion = filteredSuggestions[0].suggestions[0]; + + suggestionDom = ( +
+
+ {typeSuggestion.name.toUpperCase()} + {typeSuggestion.parameters && + typeSuggestion.parameters.length > 0 && ( + <> + ( + + {typeSuggestion?.parameters.map((p) => p.name).join(", ")} + + ) + + )} +
+ + {typeof typeSuggestion.description === "string" && ( +
+ {typeSuggestion.description} +
+ )} + {typeof typeSuggestion.description === "function" && ( +
+ )} + + {typeSuggestion.parameters && ( +
    + {typeSuggestion.parameters.map((p) => ( +
  • + {p.name} +

    {p.description}

    +
  • + ))} +
+ )} +
+ ); + } + + return ( +
+ setShowSuggestion(true)} + onBlur={() => { + setShowSuggestion(false); + console.log("blur"); + }} + value={value} + onChange={(e) => { + onChange(e.target.value); + }} + /> + {showSuggestion && ( + + + e.preventDefault()} + > + {suggestionDom} + + + )} +
+ ); +} 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 1bb26cb3..8cc88506 100644 --- a/src/components/gui/schema-editor/schema-editor-column-list.tsx +++ b/src/components/gui/schema-editor/schema-editor-column-list.tsx @@ -16,7 +16,6 @@ import { 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"; import { checkSchemaColumnChange } from "@/components/lib/sql-generate.schema"; @@ -25,7 +24,6 @@ import { DatabaseTableColumnChange, DatabaseTableColumnConstraint, DatabaseTableSchemaChange, - TableColumnDataType, } from "@/drivers/base-driver"; import { cn } from "@/lib/utils"; import ColumnPrimaryKeyPopup from "./column-pk-popup"; @@ -42,6 +40,8 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { restrictToVerticalAxis } from "@dnd-kit/modifiers"; +import { useDatabaseDriver } from "@/context/driver-provider"; +import ColumnTypeSelector from "./column-type-selector"; export type ColumnChangeEvent = ( newValue: Partial | null @@ -90,6 +90,46 @@ function changeColumnOnIndex( }); } +function ColumnItemType({ + value, + onChange, + disabled, +}: { + value: string; + onChange: (type: string) => void; + disabled?: boolean; +}) { + const { databaseDriver } = useDatabaseDriver(); + + if ( + databaseDriver.columnTypeSelector.type === "dropdown" && + databaseDriver.columnTypeSelector.dropdownOptions + ) { + return ( + + ); + } + + return ( + + ); +} + function ColumnItem({ value, idx, @@ -128,13 +168,6 @@ function ColumnItem({ const column = value.new || value.old; if (!column) return null; - const normalizeType = convertSqliteType(column.type); - let type = "TEXT"; - - if (normalizeType === TableColumnDataType.INTEGER) type = "INTEGER"; - if (normalizeType === TableColumnDataType.REAL) type = "REAL"; - if (normalizeType === TableColumnDataType.BLOB) type = "BLOB"; - let highlightClassName = ""; if (value.new === null) { highlightClassName = "bg-red-400 dark:bg-red-800"; @@ -173,21 +206,11 @@ function ColumnItem({ /> - + /> ; } +export interface ColumnTypeSuggestionGroup { + name: string; + suggestions: ColumnTypeSuggestion[]; +} +export interface ColumnTypeSuggestion { + name: string; + parameters?: { + name: string; + description?: string; + default: string; + }[]; + description?: string | ((type: string, parameters?: string[]) => string); +} +export interface ColumnTypeSelector { + type: "dropdown" | "text"; + dropdownOptions?: { value: string; text: string }[]; + typeSuggestions?: ColumnTypeSuggestionGroup[]; +} + export interface DriverFlags { defaultSchema: string; optionalSchema: boolean; @@ -246,6 +265,7 @@ export abstract class BaseDriver { // Flags abstract getFlags(): DriverFlags; abstract getCurrentSchema(): Promise; + abstract columnTypeSelector: ColumnTypeSelector; // Helper class abstract escapeId(id: string): string; diff --git a/src/drivers/mysql/generate-schema.ts b/src/drivers/mysql/generate-schema.ts new file mode 100644 index 00000000..60c8cc9e --- /dev/null +++ b/src/drivers/mysql/generate-schema.ts @@ -0,0 +1,187 @@ +import { + BaseDriver, + DatabaseTableColumn, + DatabaseTableColumnConstraint, + DatabaseTableSchemaChange, +} from "../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( + driver: BaseDriver, + col: DatabaseTableColumn +): string { + const tokens: string[] = [driver.escapeId(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 ? "AUTO_INCREMENT" : 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", driver.escapeValue(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", + driver.escapeId(foreignTableName) + + `(${driver.escapeId(foreignColumnName)})`, + ].join(" ") + ); + } + + return tokens.join(" "); +} + +function generateConstraintScript( + driver: BaseDriver, + con: DatabaseTableColumnConstraint +) { + if (con.primaryKey) { + return `PRIMARY KEY (${con.primaryColumns?.map(driver.escapeId).join(", ")})`; + } else if (con.unique) { + return `UNIQUE (${con.uniqueColumns?.map(driver.escapeId).join(", ")})`; + } else if (con.checkExpression !== undefined) { + return `CHECK (${con.checkExpression})`; + } else if (con.foreignKey) { + return ( + `FOREIGN KEY (${con.foreignKey.columns?.map(driver.escapeId).join(", ")}) ` + + `REFERENCES ${driver.escapeId(con.foreignKey.foreignTableName ?? "")} ` + + `(${con.foreignKey.foreignColumns?.map(driver.escapeId).join(", ")})` + ); + } +} + +// https://dev.mysql.com/doc/refman/8.4/en/create-table.html +export function generateMySqlSchemaChange( + driver: BaseDriver, + 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(driver, col.new)); + } else { + lines.push("ADD " + generateCreateColumn(driver, col.new)); + } + } else { + // check if there is rename + if (col.new.name !== col.old.name) { + lines.push( + `RENAME COLUMN ${driver.escapeId(col.old.name)} TO ${driver.escapeId( + col.new.name + )}` + ); + } + + console.log(col.old, col.new); + + // check if there is any changed except name + if (!isEqual(omit(col.old, ["name"]), omit(col.new, ["name"]))) { + lines.push(`MODIFY COLUMN ${generateCreateColumn(driver, col.new)}`); + } + } + } + + for (const con of change.constraints) { + if (con.new) { + if (isCreateScript) { + lines.push(generateConstraintScript(driver, con.new)); + } + } + } + + if (!isCreateScript) { + if (change.name.new !== change.name.old) { + lines.push( + `RENAME TO ${driver.escapeId(change.schemaName ?? "main")}.${driver.escapeId(change.name.new ?? "")}` + ); + } + } + + if (isCreateScript) { + return [ + `CREATE TABLE ${driver.escapeId(change.schemaName ?? "main")}.${driver.escapeId( + change.name.new || "no_table_name" + )}(\n${lines.map((line) => " " + line).join(",\n")}\n)`, + ]; + } else { + const alter = `ALTER TABLE ${driver.escapeId(change.schemaName ?? "main")}.${driver.escapeId(change.name.old ?? "")} `; + return lines.map((line) => alter + line); + } +} diff --git a/src/drivers/mysql/mysql-data-type.ts b/src/drivers/mysql/mysql-data-type.ts new file mode 100644 index 00000000..8a14b3fa --- /dev/null +++ b/src/drivers/mysql/mysql-data-type.ts @@ -0,0 +1,195 @@ +import { ColumnTypeSelector } from "../base-driver"; + +export const MYSQL_DATA_TYPE_SUGGESTION: ColumnTypeSelector = { + type: "text", + typeSuggestions: [ + { + name: "Integer", + suggestions: [ + { + name: "tinyint", + description: + "A very small integer. The signed range is -128 to 127. The unsigned range is 0 to 255", + }, + { + name: "smallint", + description: + "A small integer. The signed range is -32,768 to 32,767. The unsigned range is 0 to 65,535", + }, + { + name: "mediumint", + description: + "A medium-sized integer. The signed range is -8,388,608 to 8,388,607. The unsigned range is 0 to 16,777,215", + }, + { + name: "int", + description: + "A normal-size integer. The signed range is -2,147,483,648 to 2,147,483,647. The unsigned range is 0 to 4,294,967,295", + }, + { + name: "bigint", + description: + "A large integer. The signed range is -9,223,372,036,854,775,808 to 9,223,372,036,854,775,807. The unsigned range is 0 to 18,446,744,073,709,551,615", + }, + ], + }, + { + name: "Real", + suggestions: [ + { name: "float", description: "4 byte float" }, + { name: "double", description: "8 byte float" }, + { + name: "decimal", + parameters: [ + { + name: "precision", + description: "Total number of digits", + default: "10", + }, + { + name: "scale", + description: "Number of digits after the decimal point", + default: "2", + }, + ], + description: decimalDescription, + }, + ], + }, + { + name: "Text", + suggestions: [ + { + name: "char", + parameters: [{ name: "length", default: "255" }], + description: "Fixed-length string", + }, + { + name: "varchar", + parameters: [{ name: "length", default: "255" }], + description: "Variable-length string", + }, + { + name: "tinytext", + description: "A text column with a maximum length of 255 characters.", + }, + { + name: "text", + description: "A text column with a maximum length of 65,535.", + }, + { + name: "mediumtext", + description: "A text column with a maximum length of 16,777,215", + }, + { + name: "longtext", + description: "A text column with a maximum length of 4,294,960,295", + }, + { + name: "json", + description: + "Document stored in JSON column are converted to an internal format that permits quick read access to document elements", + }, + { + name: "uuid", + description: + "The UUID data type is intended for the storage of 128.bit UUID.", + }, + ], + }, + { + name: "Binary", + suggestions: [ + { + name: "binary", + parameters: [{ name: "length", default: "255" }], + description: "Fixed-length binary", + }, + { + name: "varbinary", + parameters: [{ name: "length", default: "255" }], + description: "Variable-length binary", + }, + { + name: "tinyblob", + description: "A blob column with a maximum length of 255 bytes", + }, + { + name: "blob", + description: "A blob column with a maximum length of 65,535 bytes", + }, + { + name: "mediumblob", + description: + "A blob column with a maximum length of 16,777,215 bytes", + }, + { + name: "longblob", + description: + "A blob column with a maximum length of 4,294,967,295 bytes", + }, + ], + }, + { + name: "Date", + suggestions: [ + { name: "date", description: "Date" }, + { name: "time", description: "Time" }, + { name: "datetime", description: "Date and time" }, + { name: "timestamp", description: "Date and time" }, + { name: "year", description: "Year" }, + ], + }, + { + name: "Geometry", + suggestions: [ + { name: "geometry", description: "Geometry" }, + { name: "point", description: "Point" }, + { name: "linestring", description: "Line string" }, + { name: "polygon", description: "Polygon" }, + { name: "multipoint", description: "Multi point" }, + { name: "multilinestring", description: "Multi line string" }, + { name: "multipolygon", description: "Multi polygon" }, + { name: "geometrycollection", description: "Geometry collection" }, + ], + }, + { + name: "Miscellaneous", + suggestions: [ + { name: "enum", description: "Enumerated string" }, + { name: "set", description: "" }, + ], + }, + ], +}; + +function decimalDescription(_: string, parameters?: string[]) { + const precision = Number(parameters?.[0]); + const scale = Number(parameters?.[1] ?? 0); + + if ( + Number.isFinite(precision) && + Number.isFinite(scale) && + Number.isInteger(precision) && + Number.isInteger(scale) && + precision > 0 && + precision < 20 + ) { + const exampleNumber = "12345678901234567890".substring(0, precision); + const exampleBeforeDot = exampleNumber.substring(0, precision - scale); + const exampleAfterDot = exampleNumber.substring(precision - scale); + + return `
+
Fixed-point number
+
+
Precision
+
+ ${exampleBeforeDot}.${exampleAfterDot} +
+
Scale
+
+
`; + } + + return `
Fixed-point number
`; +} diff --git a/src/drivers/mysql/mysql-driver.ts b/src/drivers/mysql/mysql-driver.ts index 1afe3d13..363cb413 100644 --- a/src/drivers/mysql/mysql-driver.ts +++ b/src/drivers/mysql/mysql-driver.ts @@ -6,9 +6,13 @@ import { DatabaseSchemaItem, DatabaseTableColumn, TableColumnDataType, + DatabaseTableSchemaChange, + ColumnTypeSelector, } from "../base-driver"; import CommonSQLImplement from "../common-sql-imp"; import { escapeSqlValue } from "../sqlite/sql-helper"; +import { generateMySqlSchemaChange } from "./generate-schema"; +import { MYSQL_DATA_TYPE_SUGGESTION } from "./mysql-data-type"; interface MySqlDatabase { SCHEMA_NAME: string; @@ -37,6 +41,8 @@ interface MySqlTable { } export default abstract class MySQLLikeDriver extends CommonSQLImplement { + columnTypeSelector: ColumnTypeSelector = MYSQL_DATA_TYPE_SUGGESTION; + escapeId(id: string) { return `\`${id.replace(/`/g, "``")}\``; } @@ -50,9 +56,9 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { defaultSchema: "", optionalSchema: false, supportBigInt: false, - supportModifyColumn: false, + supportModifyColumn: true, mismatchDetection: false, - supportCreateUpdateTable: false, + supportCreateUpdateTable: true, dialect: "mysql", supportUseStatement: true, @@ -82,7 +88,7 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { .rows as unknown as MySqlTable[]; const columnSql = - "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, EXTRA FROM information_schema.columns WHERE TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')"; + "SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, EXTRA FROM information_schema.columns WHERE TABLE_SCHEMA NOT IN ('mysql', 'information_schema', 'performance_schema', 'sys')"; const columnResult = (await this.query(columnSql)) .rows as unknown as MySqlColumn[]; @@ -120,6 +126,7 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { const column: DatabaseTableColumn = { name: c.COLUMN_NAME, type: c.COLUMN_TYPE, + constraint: undefined, }; const tableKey = c.TABLE_SCHEMA + "." + c.TABLE_NAME; @@ -136,7 +143,7 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { schemaName: string, tableName: string ): Promise { - const columnSql = `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE, EXTRA, COLUMN_KEY FROM information_schema.columns WHERE TABLE_NAME=${escapeSqlValue(tableName)} AND TABLE_SCHEMA=${escapeSqlValue(schemaName)} ORDER BY ORDINAL_POSITION`; + const columnSql = `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, DATA_TYPE, EXTRA, COLUMN_KEY FROM information_schema.columns WHERE TABLE_NAME=${escapeSqlValue(tableName)} AND TABLE_SCHEMA=${escapeSqlValue(schemaName)} ORDER BY ORDINAL_POSITION`; const columnResult = (await this.query(columnSql)) .rows as unknown as MySqlColumn[]; @@ -156,6 +163,7 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { columns: columnResult.map((c) => ({ name: c.COLUMN_NAME, type: c.COLUMN_TYPE, + constraint: undefined, })), }; } @@ -164,8 +172,8 @@ export default abstract class MySQLLikeDriver extends CommonSQLImplement { throw new Error("Not implemented"); } - createUpdateTableSchema(): string[] { - throw new Error("Not implemented"); + createUpdateTableSchema(change: DatabaseTableSchemaChange): string[] { + return generateMySqlSchemaChange(this, change); } inferTypeFromHeader(): TableColumnDataType | undefined { diff --git a/src/drivers/postgres/postgres-driver.ts b/src/drivers/postgres/postgres-driver.ts index 0114efb0..cf9e3bdd 100644 --- a/src/drivers/postgres/postgres-driver.ts +++ b/src/drivers/postgres/postgres-driver.ts @@ -1,4 +1,5 @@ import { + ColumnTypeSelector, DatabaseSchemaItem, DatabaseSchemas, DatabaseTableColumn, @@ -54,6 +55,10 @@ interface PostgresConstraintRow { } export default abstract class PostgresLikeDriver extends CommonSQLImplement { + columnTypeSelector: ColumnTypeSelector = { + type: "text", + }; + escapeId(id: string) { return `"${id.replace(/"/g, '""')}"`; } diff --git a/src/drivers/sqlite-base-driver.ts b/src/drivers/sqlite-base-driver.ts index 4ce5d765..e6be8ad3 100644 --- a/src/drivers/sqlite-base-driver.ts +++ b/src/drivers/sqlite-base-driver.ts @@ -1,4 +1,5 @@ import type { + ColumnTypeSelector, DatabaseResultSet, DatabaseSchemaItem, DatabaseSchemas, @@ -21,6 +22,16 @@ import generateSqlSchemaChange from "./sqlite/sqlite-generate-schema"; export abstract class SqliteLikeBaseDriver extends CommonSQLImplement { supportPragmaList = true; + columnTypeSelector: ColumnTypeSelector = { + type: "dropdown", + dropdownOptions: [ + { text: "Integer", value: "INTEGER" }, + { text: "Real", value: "REAL" }, + { text: "Text", value: "TEXT" }, + { text: "Blob", value: "BLOB" }, + ], + }; + escapeId(id: string) { return `"${id.replace(/"/g, '""')}"`; }