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:
Kishan_Singh
2025-07-04 16:27:20 +05:30
committed by GitHub
parent 2f23c09854
commit 47caa29f78
6 changed files with 208 additions and 119 deletions

View File

@@ -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={

View File

@@ -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}

View File

@@ -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"],
};

View File

@@ -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",

View File

@@ -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");
}

View File

@@ -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;
}