mirror of
https://github.com/drawdb-io/drawdb.git
synced 2025-09-01 18:35:24 +00:00
Add support for PostgreSQL "CREATE TABLE INHERITS" (#524)
* feat: add support for PostgreSQL table inheritance in schema export * fixed the suggested changes in the inheritance feature * Update src/components/EditorSidePanel/TablesTab/TableField.jsx Co-authored-by: 1ilit <1ilit@proton.me> * fixed all the comments * feat: finalize Postgres table inheritance support with fixes and formatting --------- Co-authored-by: kishansinghifs1 <kishansingh956196@gmai.com> Co-authored-by: 1ilit <1ilit@proton.me>
This commit is contained in:
@@ -8,7 +8,7 @@ import { dbToTypes } from "../../../data/datatypes";
|
||||
import { DragHandle } from "../../SortableList/DragHandle";
|
||||
import FieldDetails from "./FieldDetails";
|
||||
|
||||
export default function TableField({ data, tid, index }) {
|
||||
export default function TableField({ data, tid, index, inherited }) {
|
||||
const { updateField } = useDiagram();
|
||||
const { types } = useTypes();
|
||||
const { enums } = useEnums();
|
||||
@@ -21,12 +21,15 @@ export default function TableField({ data, tid, index }) {
|
||||
return (
|
||||
<div className="hover-1 my-2 flex gap-2 items-center">
|
||||
<DragHandle id={data.id} />
|
||||
|
||||
<div className="min-w-20 flex-1/3">
|
||||
<Input
|
||||
value={data.name}
|
||||
id={`scroll_table_${tid}_input_${index}`}
|
||||
validateStatus={data.name.trim() === "" ? "error" : "default"}
|
||||
placeholder="Name"
|
||||
validateStatus={
|
||||
data.name.trim() === "" || inherited ? "error" : "default"
|
||||
}
|
||||
placeholder={t("name")}
|
||||
onChange={(value) => updateField(tid, data.id, { name: value })}
|
||||
onFocus={(e) => setEditField({ name: e.target.value })}
|
||||
onBlur={(e) => {
|
||||
@@ -51,13 +54,14 @@ export default function TableField({ data, tid, index }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-24 flex-1/3">
|
||||
<Select
|
||||
className="w-full"
|
||||
optionList={[
|
||||
...Object.keys(dbToTypes[database]).map((value) => ({
|
||||
label: value,
|
||||
value: value,
|
||||
value,
|
||||
})),
|
||||
...types.map((type) => ({
|
||||
label: type.name.toUpperCase(),
|
||||
@@ -71,7 +75,7 @@ export default function TableField({ data, tid, index }) {
|
||||
filter
|
||||
value={data.type}
|
||||
validateStatus={data.type === "" ? "error" : "default"}
|
||||
placeholder="Type"
|
||||
placeholder={t("type")}
|
||||
onChange={(value) => {
|
||||
if (value === data.type) return;
|
||||
setUndoStack((prev) => [
|
||||
@@ -135,6 +139,7 @@ export default function TableField({ data, tid, index }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type={data.notNull ? "tertiary" : "primary"}
|
||||
@@ -164,11 +169,13 @@ export default function TableField({ data, tid, index }) {
|
||||
?
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
type={data.primary ? "primary" : "tertiary"}
|
||||
title={t("primary")}
|
||||
theme={data.primary ? "solid" : "light"}
|
||||
icon={<IconKeyStroked />}
|
||||
onClick={() => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
@@ -189,9 +196,9 @@ export default function TableField({ data, tid, index }) {
|
||||
setRedoStack([]);
|
||||
updateField(tid, data.id, { primary: !data.primary });
|
||||
}}
|
||||
icon={<IconKeyStroked />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Popover
|
||||
content={
|
||||
|
@@ -1,9 +1,16 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { Collapse, Input, TextArea, Button, Card } from "@douyinfe/semi-ui";
|
||||
import {
|
||||
Collapse,
|
||||
Input,
|
||||
TextArea,
|
||||
Button,
|
||||
Card,
|
||||
Select,
|
||||
} from "@douyinfe/semi-ui";
|
||||
import ColorPicker from "../ColorPicker";
|
||||
import { IconDeleteStroked } from "@douyinfe/semi-icons";
|
||||
import { useDiagram, useSaveState, useUndoRedo } from "../../../hooks";
|
||||
import { Action, ObjectType, State } from "../../../data/constants";
|
||||
import { Action, ObjectType, State, DB } from "../../../data/constants";
|
||||
import TableField from "./TableField";
|
||||
import IndexDetails from "./IndexDetails";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,6 +18,7 @@ import { SortableList } from "../../SortableList/SortableList";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
export default function TableInfo({ data }) {
|
||||
const { tables, database } = useDiagram();
|
||||
const { t } = useTranslation();
|
||||
const [indexActiveKey, setIndexActiveKey] = useState("");
|
||||
const { deleteTable, updateTable, setTables } = useDiagram();
|
||||
@@ -56,10 +64,20 @@ export default function TableInfo({ data }) {
|
||||
};
|
||||
undefined;
|
||||
|
||||
const inheritedFieldNames =
|
||||
Array.isArray(data.inherits) && data.inherits.length > 0
|
||||
? data.inherits
|
||||
.map((parentName) => {
|
||||
const parent = tables.find((t) => t.name === parentName);
|
||||
return parent ? parent.fields.map((f) => f.name) : [];
|
||||
})
|
||||
.flat()
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center mb-2.5">
|
||||
<div className="text-md font-semibold break-keep">{t("name")}: </div>
|
||||
<div className="text-md font-semibold break-keep">{t("name")}:</div>
|
||||
<Input
|
||||
value={data.name}
|
||||
validateStatus={data.name.trim() === "" ? "error" : "default"}
|
||||
@@ -88,21 +106,64 @@ export default function TableInfo({ data }) {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SortableList
|
||||
items={data.fields}
|
||||
keyPrefix={`table-${data.id}`}
|
||||
onChange={(newFields) => {
|
||||
setTables((prev) => {
|
||||
return prev.map((t) =>
|
||||
onChange={(newFields) =>
|
||||
setTables((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === data.id ? { ...t, fields: newFields } : t,
|
||||
);
|
||||
});
|
||||
}}
|
||||
),
|
||||
)
|
||||
}
|
||||
afterChange={() => setSaveState(State.SAVING)}
|
||||
renderItem={(item, i) => (
|
||||
<TableField data={item} tid={data.id} index={i} />
|
||||
<TableField
|
||||
data={item}
|
||||
tid={data.id}
|
||||
index={i}
|
||||
inherited={inheritedFieldNames.includes(item.name)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{database === DB.POSTGRES && (
|
||||
<div className="mb-2">
|
||||
<div className="text-md font-semibold break-keep">
|
||||
{t("inherits")}:
|
||||
</div>
|
||||
<Select
|
||||
multiple
|
||||
value={data.inherits || []}
|
||||
optionList={tables
|
||||
.filter((t) => t.id !== data.id)
|
||||
.map((t) => ({ label: t.name, value: t.name }))}
|
||||
onChange={(value) => {
|
||||
setUndoStack((prev) => [
|
||||
...prev,
|
||||
{
|
||||
action: Action.EDIT,
|
||||
element: ObjectType.TABLE,
|
||||
component: "self",
|
||||
tid: data.id,
|
||||
undo: { inherits: data.inherits },
|
||||
redo: { inherits: value },
|
||||
message: t("edit_table", {
|
||||
tableName: data.name,
|
||||
extra: "[inherits]",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
setRedoStack([]);
|
||||
updateTable(data.id, { inherits: value });
|
||||
}}
|
||||
placeholder={t("inherits")}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.indices.length > 0 && (
|
||||
<Card
|
||||
bodyStyle={{ padding: "4px" }}
|
||||
@@ -133,6 +194,7 @@ export default function TableInfo({ data }) {
|
||||
</Collapse>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
bodyStyle={{ padding: "4px" }}
|
||||
style={{ marginTop: "12px", marginBottom: "12px" }}
|
||||
@@ -173,6 +235,7 @@ export default function TableInfo({ data }) {
|
||||
</Collapse.Panel>
|
||||
</Collapse>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-between items-center gap-1 mb-2">
|
||||
<ColorPicker
|
||||
usePopover={true}
|
||||
|
@@ -56,6 +56,10 @@ export const tableSchema = {
|
||||
},
|
||||
color: { type: "string", pattern: "^#[0-9a-fA-F]{6}$" },
|
||||
},
|
||||
inherits: {
|
||||
type: "array",
|
||||
items: { type: ["string"] },
|
||||
},
|
||||
required: ["id", "name", "x", "y", "fields", "comment", "indices", "color"],
|
||||
};
|
||||
|
||||
|
@@ -8,6 +8,8 @@ const en = {
|
||||
translation: {
|
||||
report_bug: "Report a bug",
|
||||
import: "Import",
|
||||
inherits: "Inherits",
|
||||
merging_column_w_inherited_definition: "Column '{{fieldName}}' in table '{{tableName}}' with inherited definition will be merged",
|
||||
import_from: "Import from",
|
||||
file: "File",
|
||||
new: "New",
|
||||
|
@@ -1,12 +1,13 @@
|
||||
import { escapeQuotes, exportFieldComment, parseDefault } from "./shared";
|
||||
|
||||
import { dbToTypes } from "../../data/datatypes";
|
||||
|
||||
export function toPostgres(diagram) {
|
||||
const enumStatements = diagram.enums
|
||||
.map(
|
||||
(e) =>
|
||||
`CREATE TYPE "${e.name}" AS ENUM (\n${e.values.map((v) => `\t'${v}'`).join(",\n")}\n);\n`,
|
||||
`CREATE TYPE "${e.name}" AS ENUM (\n${e.values
|
||||
.map((v) => `\t'${v}'`)
|
||||
.join(",\n")}\n);\n`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
@@ -16,81 +17,98 @@ export function toPostgres(diagram) {
|
||||
`CREATE TYPE ${type.name} AS (\n${type.fields
|
||||
.map((f) => `\t${f.name} ${f.type}`)
|
||||
.join(",\n")}\n);\n\n${
|
||||
type.comment && type.comment.trim() !== ""
|
||||
? `\nCOMMENT ON TYPE "${type.name}" IS '${escapeQuotes(type.comment)}';\n\n`
|
||||
type.comment?.trim()
|
||||
? `COMMENT ON TYPE "${type.name}" IS '${escapeQuotes(type.comment)}';\n`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `${enumStatements}${enumStatements.trim() !== "" ? `\n${typeStatements}` : typeStatements}${diagram.tables
|
||||
.map(
|
||||
(table) =>
|
||||
`CREATE TABLE "${table.name}" (\n${table.fields
|
||||
.map(
|
||||
(field) =>
|
||||
`${exportFieldComment(field.comment)}\t"${
|
||||
field.name
|
||||
}" ${field.type}${
|
||||
field.size !== undefined && field.size !== ""
|
||||
? "(" + field.size + ")"
|
||||
: ""
|
||||
}${field.isArray ? " ARRAY" : ""}${field.notNull ? " NOT NULL" : ""}${field.unique ? " UNIQUE" : ""}${
|
||||
field.increment ? " GENERATED BY DEFAULT AS IDENTITY" : ""
|
||||
}${
|
||||
field.default.trim() !== ""
|
||||
? ` DEFAULT ${parseDefault(field, diagram.database)}`
|
||||
: ""
|
||||
}${
|
||||
field.check === "" ||
|
||||
!dbToTypes[diagram.database][field.type].hasCheck
|
||||
? ""
|
||||
: ` CHECK(${field.check})`
|
||||
}`,
|
||||
)
|
||||
.join(",\n")}${
|
||||
table.fields.filter((f) => f.primary).length > 0
|
||||
? `,\n\tPRIMARY KEY(${table.fields
|
||||
.filter((f) => f.primary)
|
||||
.map((f) => `"${f.name}"`)
|
||||
.join(", ")})`
|
||||
: ""
|
||||
}\n);${
|
||||
table.comment.trim() !== ""
|
||||
? `\nCOMMENT ON TABLE "${table.name}" IS '${escapeQuotes(table.comment)}';\n`
|
||||
: ""
|
||||
}${table.fields
|
||||
const tableStatements = diagram.tables
|
||||
.map((table) => {
|
||||
const inheritsClause =
|
||||
Array.isArray(table.inherits) && table.inherits.length > 0
|
||||
? `\n) INHERITS (${table.inherits.map((parent) => `"${parent}"`).join(", ")})`
|
||||
: "\n)";
|
||||
|
||||
const fieldDefinitions = table.fields
|
||||
.map(
|
||||
(field) =>
|
||||
`${exportFieldComment(field.comment)}\t"${
|
||||
field.name
|
||||
}" ${field.type}${
|
||||
field.size ? `(${field.size})` : ""
|
||||
}${field.isArray ? " ARRAY" : ""}${field.notNull ? " NOT NULL" : ""}${
|
||||
field.unique ? " UNIQUE" : ""
|
||||
}${field.increment ? " GENERATED BY DEFAULT AS IDENTITY" : ""}${
|
||||
field.default?.trim()
|
||||
? ` DEFAULT ${parseDefault(field, diagram.database)}`
|
||||
: ""
|
||||
}${
|
||||
field.check && dbToTypes[diagram.database][field.type]?.hasCheck
|
||||
? ` CHECK(${field.check})`
|
||||
: ""
|
||||
}`,
|
||||
)
|
||||
.join(",\n");
|
||||
|
||||
const primaryKeyClause = table.fields.some((f) => f.primary)
|
||||
? `,\n\tPRIMARY KEY(${table.fields
|
||||
.filter((f) => f.primary)
|
||||
.map((f) => `"${f.name}"`)
|
||||
.join(", ")})`
|
||||
: "";
|
||||
|
||||
const commentStatements = [
|
||||
table.comment?.trim()
|
||||
? `COMMENT ON TABLE "${table.name}" IS '${escapeQuotes(table.comment)}';`
|
||||
: "",
|
||||
...table.fields
|
||||
.map((field) =>
|
||||
field.comment.trim() !== ""
|
||||
? `COMMENT ON COLUMN ${table.name}.${field.name} IS '${escapeQuotes(field.comment)}';\n`
|
||||
field.comment?.trim()
|
||||
? `COMMENT ON COLUMN "${table.name}"."${field.name}" IS '${escapeQuotes(field.comment)}';`
|
||||
: "",
|
||||
)
|
||||
.join("")}${table.indices
|
||||
.map(
|
||||
(i) =>
|
||||
`\nCREATE ${i.unique ? "UNIQUE " : ""}INDEX "${
|
||||
i.name
|
||||
}"\nON "${table.name}" (${i.fields
|
||||
.map((f) => `"${f}"`)
|
||||
.join(", ")});`,
|
||||
)
|
||||
.join("\n")}\n`,
|
||||
)
|
||||
.join("\n")}${diagram.references
|
||||
.map((r) => {
|
||||
const { name: startName, fields: startFields } = diagram.tables.find(
|
||||
(t) => t.id === r.startTableId,
|
||||
);
|
||||
.filter(Boolean),
|
||||
].join("\n");
|
||||
|
||||
const { name: endName, fields: endFields } = diagram.tables.find(
|
||||
(t) => t.id === r.endTableId,
|
||||
);
|
||||
const indexStatements = table.indices
|
||||
.map(
|
||||
(i) =>
|
||||
`CREATE ${i.unique ? "UNIQUE " : ""}INDEX "${i.name}"\nON "${table.name}" (${i.fields
|
||||
.map((f) => `"${f}"`)
|
||||
.join(", ")});`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `\nALTER TABLE "${startName}"\nADD FOREIGN KEY("${
|
||||
startFields.find((f) => f.id === r.startFieldId)?.name
|
||||
}") REFERENCES "${endName}"("${
|
||||
endFields.find((f) => f.id === r.endFieldId)?.name
|
||||
}")\nON UPDATE ${r.updateConstraint.toUpperCase()} ON DELETE ${r.deleteConstraint.toUpperCase()};`;
|
||||
return `CREATE TABLE "${table.name}" (\n${fieldDefinitions}${primaryKeyClause}${inheritsClause};\n\n${commentStatements}\n${indexStatements}`;
|
||||
})
|
||||
.join("\n")}`;
|
||||
.join("\n\n");
|
||||
|
||||
const foreignKeyStatements = diagram.references
|
||||
.map((r) => {
|
||||
const startTable = diagram.tables.find((t) => t.id === r.startTableId);
|
||||
const endTable = diagram.tables.find((t) => t.id === r.endTableId);
|
||||
const startField = startTable?.fields.find(
|
||||
(f) => f.id === r.startFieldId,
|
||||
);
|
||||
const endField = endTable?.fields.find((f) => f.id === r.endFieldId);
|
||||
|
||||
if (!startTable || !endTable || !startField || !endField) return "";
|
||||
|
||||
return `ALTER TABLE "${startTable.name}"\nADD FOREIGN KEY("${startField.name}") REFERENCES "${endTable.name}"("${endField.name}")\nON UPDATE ${r.updateConstraint.toUpperCase()} ON DELETE ${r.deleteConstraint.toUpperCase()};`;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
|
||||
return [
|
||||
enumStatements,
|
||||
enumStatements.trim() && typeStatements
|
||||
? "\n" + typeStatements
|
||||
: typeStatements,
|
||||
tableStatements,
|
||||
foreignKeyStatements,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
@@ -4,11 +4,8 @@ import { isFunction } from "./utils";
|
||||
|
||||
function checkDefault(field, database) {
|
||||
if (field.default === "") return true;
|
||||
|
||||
if (isFunction(field.default)) return true;
|
||||
|
||||
if (!field.notNull && field.default.toLowerCase() === "null") return true;
|
||||
|
||||
if (!dbToTypes[database][field.type].checkDefault) return true;
|
||||
|
||||
return dbToTypes[database][field.type].checkDefault(field);
|
||||
@@ -32,10 +29,17 @@ export function getIssues(diagram) {
|
||||
const duplicateFieldNames = {};
|
||||
let hasPrimaryKey = false;
|
||||
|
||||
const inheritedFields =
|
||||
table.inherits
|
||||
?.map((parentName) => {
|
||||
const parent = diagram.tables.find((t) => t.name === parentName);
|
||||
return parent ? parent.fields.map((f) => f.name) : [];
|
||||
})
|
||||
.flat() || [];
|
||||
|
||||
table.fields.forEach((field) => {
|
||||
if (field.primary) {
|
||||
hasPrimaryKey = true;
|
||||
}
|
||||
if (field.primary) hasPrimaryKey = true;
|
||||
|
||||
if (field.name === "") {
|
||||
issues.push(i18n.t("empty_field_name", { tableName: table.name }));
|
||||
}
|
||||
@@ -82,6 +86,15 @@ export function getIssues(diagram) {
|
||||
} else {
|
||||
duplicateFieldNames[field.name] = true;
|
||||
}
|
||||
|
||||
if (inheritedFields.includes(field.name)) {
|
||||
issues.push(
|
||||
i18n.t("merging_column_w_inherited_definition", {
|
||||
fieldName: field.name,
|
||||
tableName: table.name,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const duplicateIndices = {};
|
||||
@@ -100,18 +113,10 @@ export function getIssues(diagram) {
|
||||
|
||||
table.indices.forEach((index) => {
|
||||
if (index.name.trim() === "") {
|
||||
issues.push(
|
||||
i18n.t("empty_index_name", {
|
||||
tableName: table.name,
|
||||
}),
|
||||
);
|
||||
issues.push(i18n.t("empty_index_name", { tableName: table.name }));
|
||||
}
|
||||
if (index.fields.length === 0) {
|
||||
issues.push(
|
||||
i18n.t("empty_index", {
|
||||
tableName: table.name,
|
||||
}),
|
||||
);
|
||||
issues.push(i18n.t("empty_index", { tableName: table.name }));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,19 +145,11 @@ export function getIssues(diagram) {
|
||||
const duplicateFieldNames = {};
|
||||
type.fields.forEach((field) => {
|
||||
if (field.name === "") {
|
||||
issues.push(
|
||||
i18n.t("empty_type_field_name", {
|
||||
typeName: type.name,
|
||||
}),
|
||||
);
|
||||
issues.push(i18n.t("empty_type_field_name", { typeName: type.name }));
|
||||
}
|
||||
|
||||
if (field.type === "") {
|
||||
issues.push(
|
||||
i18n.t("empty_type_field_type", {
|
||||
typeName: type.name,
|
||||
}),
|
||||
);
|
||||
issues.push(i18n.t("empty_type_field_type", { typeName: type.name }));
|
||||
} else if (field.type === "ENUM" || field.type === "SET") {
|
||||
if (!field.values || field.values.length === 0) {
|
||||
issues.push(
|
||||
@@ -166,10 +163,12 @@ export function getIssues(diagram) {
|
||||
}
|
||||
|
||||
if (duplicateFieldNames[field.name]) {
|
||||
i18n.t("duplicate_type_fields", {
|
||||
typeName: type.name,
|
||||
fieldName: field.name,
|
||||
});
|
||||
issues.push(
|
||||
i18n.t("duplicate_type_fields", {
|
||||
typeName: type.name,
|
||||
fieldName: field.name,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
duplicateFieldNames[field.name] = true;
|
||||
}
|
||||
@@ -197,11 +196,7 @@ export function getIssues(diagram) {
|
||||
const duplicateFKName = {};
|
||||
diagram.relationships.forEach((r) => {
|
||||
if (duplicateFKName[r.name]) {
|
||||
issues.push(
|
||||
i18n.t("duplicate_reference", {
|
||||
refName: r.name,
|
||||
}),
|
||||
);
|
||||
issues.push(i18n.t("duplicate_reference", { refName: r.name }));
|
||||
} else {
|
||||
duplicateFKName[r.name] = true;
|
||||
}
|
||||
|
Reference in New Issue
Block a user