Add support for one to one relationship in the ERD tool. #5128

This commit is contained in:
Pravesh Sharma 2025-02-10 14:40:20 +05:30 committed by GitHub
parent bf7f8cdd73
commit 2fc65589c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 334 additions and 19 deletions

View File

@ -207,6 +207,22 @@ The table node shows table details in a graphical representation:
* you can click on the node and drag to move on the canvas.
* Upon double click on the table node or by clicking the edit button from the toolbar, the table dialog opens where you can change the table details. Refer :ref:`table dialog <table_dialog>` for information on different fields.
The One to One Link Dialog
***************************
.. image:: images/erd_11_dialog.png
:alt: ERD tool 1-1 dialog
:align: center
The one to one link dialog allows you to:
* Add a one to one relationship between two tables.
* *Local Table* is the table that references a table and has the *one* end point.
* *Local Column* the column that references.
* *Select Constraint* To implement one to one relationship, the *Local Column* must have primaty key or unique constraint. The default is a unique constraint. Please note that this field is visible only when the selected *Local Column* does not have either of the mentioned constraints.
* *Referenced Table* is the table that is being referred and has the *one* end point.
* *Referenced Column* the column that is being referred.
The One to Many Link Dialog
***************************

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -246,6 +246,24 @@ class ERDModule(PgAdminModule):
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'one_to_one',
gettext('One to one link'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 66,
'char': 'b'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'one_to_many',

View File

@ -9,6 +9,7 @@ export const ERD_EVENTS = {
CLONE_NODE: 'CLONE_NODE',
DELETE_NODE: 'DELETE_NODE',
SHOW_NOTE: 'SHOW_NOTE',
ONE_TO_ONE: 'ONE_TO_ONE',
ONE_TO_MANY: 'ONE_TO_MANY',
MANY_TO_MANY: 'MANY_TO_MANY',
AUTO_DISTRIBUTE: 'AUTO_DISTRIBUTE',

View File

@ -22,6 +22,8 @@ import ForeignKeySchema from '../../../../../browser/server_groups/servers/datab
import diffArray from 'diff-arrays-of-objects';
import TableSchema from '../../../../../browser/server_groups/servers/databases/schemas/tables/static/js/table.ui';
import ColumnSchema from '../../../../../browser/server_groups/servers/databases/schemas/tables/columns/static/js/column.ui';
import UniqueConstraintSchema from '../../../../../browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/unique_constraint.ui';
import PrimaryKeySchema from '../../../../../browser/server_groups/servers/databases/schemas/tables/constraints/index_constraint/static/js/primary_key.ui';
import { boundingBoxFromPolygons } from '@projectstorm/geometry';
export default class ERDCore {
@ -337,18 +339,19 @@ export default class ERDCore {
let tableData = tableNode.getData();
/* Remove the links if column dropped or primary key removed */
_.differenceWith(oldTableData.columns, tableData.columns, function(existing, incoming) {
if(existing.attnum == incoming.attnum && existing.is_primary_key && !incoming.is_primary_key) {
return false;
}
return existing.attnum == incoming.attnum;
}).forEach((col)=>{
let existPort = tableNode.getPort(tableNode.getPortName(col.attnum));
if(existPort) {
Object.values(existPort.getLinks()).forEach((link)=>{
self.removeOneToManyLink(link);
});
tableNode.removePort(existPort);
}
this.getLeftRightPorts(tableNode, col.attnum).forEach(port => {
if (port) {
Object.values(port.getLinks()).forEach(link => {
self.removeOneToManyLink(link);
});
tableNode.removePort(port);
}
});
});
Object.values(tableNode.getLinks()).forEach(link=>{
link.fireEvent({},'updateLink');
});
}
@ -482,6 +485,31 @@ export default class ERDCore {
columns: [col],
})
);
// Below logic is to add one to one relationship
if(onetomanyData.constraint_type === 'primary_key') {
let newPk = new PrimaryKeySchema({},{});
let pkCol = {};
let column = _.find(targetNode.getColumns(), (colm)=>colm.attnum==onetomanyData.local_column_attnum);
column.is_primary_key = true;
pkCol.column =column.name;
tableData.primary_key = tableData.primary_key || [];
tableData.primary_key.push(
newPk.getNewData({
columns: [pkCol]
})
);
} else if (onetomanyData.constraint_type === 'unique') {
let newUk = new UniqueConstraintSchema({},{});
let ukCol = {};
ukCol.column = _.find(targetNode.getColumns(), (colm)=>colm.attnum==onetomanyData.local_column_attnum).name;
tableData.unique_constraint = tableData.unique_constraint || [];
tableData.unique_constraint.push(
newUk.getNewData({
columns: [ukCol]
})
);
}
targetNode.setData(tableData);
let newLink = this.addLink(onetomanyData, 'onetomany');
this.clearSelection();

View File

@ -145,7 +145,7 @@ export default class ERDTool extends React.Component {
_.bindAll(this, ['onLoadDiagram', 'onSaveDiagram', 'onSQLClick',
'onImageClick', 'onAddNewNode', 'onEditTable', 'onCloneNode', 'onDeleteNode', 'onNoteClick',
'onNoteClose', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
'onNoteClose', 'onOneToOneClick', 'onOneToManyClick', 'onManyToManyClick', 'onAutoDistribute', 'onDetailsToggle',
'onChangeColors', 'onDropNode', 'onNotationChange', 'closePanel'
]);
@ -220,6 +220,7 @@ export default class ERDTool extends React.Component {
this.eventBus.registerListener(ERD_EVENTS.CLONE_NODE, this.onCloneNode);
this.eventBus.registerListener(ERD_EVENTS.DELETE_NODE, this.onDeleteNode);
this.eventBus.registerListener(ERD_EVENTS.SHOW_NOTE, this.onNoteClick);
this.eventBus.registerListener(ERD_EVENTS.ONE_TO_ONE, this.onOneToOneClick);
this.eventBus.registerListener(ERD_EVENTS.ONE_TO_MANY, this.onOneToManyClick);
this.eventBus.registerListener(ERD_EVENTS.MANY_TO_MANY, this.onManyToManyClick);
this.eventBus.registerListener(ERD_EVENTS.AUTO_DISTRIBUTE, this.onAutoDistribute);
@ -265,6 +266,9 @@ export default class ERDTool extends React.Component {
[this.state.preferences.add_edit_note, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.SHOW_NOTE);
}],
[this.state.preferences.one_to_one, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.ONE_TO_ONE);
}],
[this.state.preferences.one_to_many, ()=>{
this.eventBus.fireEvent(ERD_EVENTS.ONE_TO_MANY);
}],
@ -397,7 +401,7 @@ export default class ERDTool extends React.Component {
serverInfo, callback
});
};
} else if(dialogName === 'onetomany_dialog' || dialogName === 'manytomany_dialog') {
} else if(dialogName === 'onetomany_dialog' || dialogName === 'manytomany_dialog' || dialogName === 'onetoone_dialog') {
return (title, attributes, callback)=>{
this.erdDialogs.showRelationDialog(dialogName, {
title, attributes, tableNodes: this.diagram.getModel().getNodesDict(),
@ -429,6 +433,17 @@ export default class ERDTool extends React.Component {
if(this.diagram.anyDuplicateNodeName(newData, oldData)) {
return gettext('Table name already exists');
}
// If a column that is part of a foreign key is removed, the foreign key constraint should also be removed.
_.differenceWith(oldData.columns, newData.columns, function(existing, incoming) {
return existing.attnum == incoming.attnum;
}).forEach(colm=>{
newData.foreign_key?.forEach((theFkRow, index)=>{
let fkCols = theFkRow.columns[0];
if (fkCols.local_column === colm.name) {
newData.foreign_key.splice(index,1);
}
});
});
node.setData(newData);
this.diagram.syncTableLinks(node, oldData);
this.diagram.repaint();
@ -774,6 +789,14 @@ export default class ERDTool extends React.Component {
}, 1000);
}
onOneToOneClick() {
let dialog = this.getDialog('onetoone_dialog');
let initData = {local_table_uid: this.diagram.getSelectedNodes()[0].getID()};
dialog(gettext('One to one relation'), initData, (newData)=>{
this.diagram.addOneToManyLink(newData);
});
}
onOneToManyClick() {
let dialog = this.getDialog('onetomany_dialog');
let initData = {local_table_uid: this.diagram.getSelectedNodes()[0].getID()};

View File

@ -54,6 +54,7 @@ export function MainToolBar({preferences, eventBus, fillColor, textColor, notati
'save': true,
'edit-table': true,
'clone-table': true,
'one-to-one': true,
'one-to-many': true,
'many-to-many': true,
'show-note': true,
@ -121,6 +122,7 @@ export function MainToolBar({preferences, eventBus, fillColor, textColor, notati
[ERD_EVENTS.SINGLE_NODE_SELECTED, (selected)=>{
setDisableButton('edit-table', !selected);
setDisableButton('clone-table', !selected);
setDisableButton('one-to-one', !selected);
setDisableButton('one-to-many', !selected);
setDisableButton('many-to-many', !selected);
setDisableButton('show-note', !selected);
@ -210,12 +212,17 @@ export function MainToolBar({preferences, eventBus, fillColor, textColor, notati
}} />
</PgButtonGroup>
<PgButtonGroup size="small">
<PgIconButton title={gettext('One-to-Many Relation')} icon={<span style={{letterSpacing: '-1px'}}>1M</span>}
<PgIconButton title={gettext('One-to-One Relation')} icon={<span style={{letterSpacing: '-1px'}}>1 - 1</span>}
shortcut={preferences.one_to_one} disabled={buttonsDisabled['one-to-one']}
onClick={()=>{
eventBus.fireEvent(ERD_EVENTS.ONE_TO_ONE);
}} />
<PgIconButton title={gettext('One-to-Many Relation')} icon={<span style={{letterSpacing: '-1px'}}>1 - M</span>}
shortcut={preferences.one_to_many} disabled={buttonsDisabled['one-to-many']}
onClick={()=>{
eventBus.fireEvent(ERD_EVENTS.ONE_TO_MANY);
}} />
<PgIconButton title={gettext('Many-to-Many Relation')} icon={<span style={{letterSpacing: '-1px'}}>MM</span>}
<PgIconButton title={gettext('Many-to-Many Relation')} icon={<span style={{letterSpacing: '-1px'}}>M - M</span>}
shortcut={preferences.many_to_many} disabled={buttonsDisabled['many-to-many']}
onClick={()=>{
eventBus.fireEvent(ERD_EVENTS.MANY_TO_MANY);

View File

@ -0,0 +1,106 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2024, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import { isEmptyString } from 'sources/validators';
import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import _ from 'lodash';
class OneToOneSchema extends BaseUISchema {
constructor(fieldOptions={}, initValues={}, localTableData={}) {
super({
local_table_uid: undefined,
local_column_attnum: undefined,
referenced_table_uid: undefined,
referenced_column_attnum: undefined,
constraint_type: undefined,
...initValues,
});
this.fieldOptions = fieldOptions;
this.localTableData = localTableData;
}
isVisible (state) {
let colName = _.find(this.localTableData.getData().columns, col => col.attnum === state.local_column_attnum)?.name;
let {pkCols, ukCols} = this.localTableData.getConstraintCols();
return !((pkCols.includes(colName) || ukCols.includes(colName)) || isEmptyString(state.local_column_attnum));
}
get baseFields() {
return [{
id: 'local_table_uid', label: gettext('Local Table'),
type: 'select', readonly: true, controlProps: {allowClear: false},
options: this.fieldOptions.local_table_uid,
},{
id: 'local_column_attnum', label: gettext('Local Column'),
type: 'select', options: this.fieldOptions.local_column_attnum,
controlProps: {allowClear: false}, noEmpty: true,
},{
id: 'constraint_type', label: gettext('Select constraint'),
type: 'toggle', deps: ['local_column_attnum'],
options: [
{label: 'Primary Key', value: 'primary_key'},
{label: 'Unique', value: 'unique'},
],
visible: this.isVisible,
depChange: (state, source)=>{
if (source[0] === 'local_column_attnum' && this.isVisible(state)) {
return {constraint_type: 'unique'};
} else if (source[0] === 'local_column_attnum') {
return {constraint_type: ''};
}
}, helpMessage: gettext('A constraint is required to implement One to One relationship.')
}, {
id: 'referenced_table_uid', label: gettext('Referenced Table'),
type: 'select', options: this.fieldOptions.referenced_table_uid,
controlProps: {allowClear: false}, noEmpty: true,
},{
id: 'referenced_column_attnum', label: gettext('Referenced Column'),
controlProps: {allowClear: false}, deps: ['referenced_table_uid'], noEmpty: true,
type: (state)=>({
type: 'select',
options: state.referenced_table_uid ? ()=>this.fieldOptions.getRefColumns(state.referenced_table_uid) : [],
optionsReloadBasis: state.referenced_table_uid,
}),
}];
}
validate(state, setError) {
let tableData = this.localTableData.getData();
if (tableData.primary_key.length && state.constraint_type === 'primary_key') {
setError('constraint_type', gettext('Primary key already exists, please select different constraint.'));
return true;
}
return false;
}
}
export function getOneToOneDialogSchema(attributes, tableNodesDict) {
let tablesData = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
tablesData.push({value: uid, label: `(${schema}) ${name}`, image: 'icon-table'});
});
return new OneToOneSchema({
local_table_uid: tablesData,
local_column_attnum: tableNodesDict[attributes.local_table_uid].getColumns().map((col)=>{
return {
value: col.attnum, label: col.name, 'image': 'icon-column',
};
}),
referenced_table_uid: tablesData,
getRefColumns: (uid)=>{
return tableNodesDict[uid].getColumns().map((col)=>{
return {
value: col.attnum, label: col.name, 'image': 'icon-column',
};
});
},
}, attributes, tableNodesDict[attributes.local_table_uid]);
}

View File

@ -10,6 +10,7 @@
import {getTableDialogSchema} from './TableDialog';
import {getOneToManyDialogSchema} from './OneToManyDialog';
import {getManyToManyDialogSchema} from './ManyToManyDialog';
import {getOneToOneDialogSchema} from './OneToOneDialog';
import pgAdmin from 'sources/pgadmin';
import SchemaView from '../../../../../../static/js/SchemaView';
@ -67,6 +68,8 @@ export default class ERDDialogs {
schema = getOneToManyDialogSchema(params.attributes, params.tableNodes);
} else if(dialogName === 'manytomany_dialog') {
schema = getManyToManyDialogSchema(params.attributes, params.tableNodes);
} else if(dialogName === 'onetoone_dialog') {
schema = getOneToOneDialogSchema(params.attributes, params.tableNodes);
}
this.modal.showModal(params.title, (closeModal)=>{

View File

@ -45,6 +45,10 @@ export class OneToManyLinkModel extends RightAngleLinkModel {
this._data = {
...data,
};
this._linkPointType = {
sourceType: 'one',
targetType: 'many'
};
}
getData() {
@ -77,6 +81,22 @@ export class OneToManyLinkModel extends RightAngleLinkModel {
data: this.getData(),
};
}
setPointType(nodesDict) {
let data = this.getData();
let target = nodesDict[data['local_table_uid']].getData();
let colName = _.find(target.columns, (col)=>data.local_column_attnum == col.attnum).name;
let {pkCols=[], ukCols=[]} = nodesDict[data['local_table_uid']].getConstraintCols();
let targetType = pkCols.includes(colName) || ukCols.includes(colName) ? 'one' : 'many';
this._linkPointType = {
...this._linkPointType,
targetType,
};
}
getPointType() {
return this._linkPointType;
}
}
const svgLinkSelected = keyframes`
@ -173,6 +193,22 @@ CustomLinkEndWidget.propTypes = {
export class OneToManyLinkWidget extends RightAngleLinkWidget {
constructor(props) {
super(props);
this.state = {};
this.setPointType();
this.updateLinkListener = this.props.link.registerListener({
updateLink: ()=>{
this.setPointType();
this.setState({});
}
});
}
componentWillUnmount() {
this.props.link.deregisterListener(this.updateLinkListener);
}
setPointType() {
this.props.link.setPointType(this.props.diagramEngine.getModel().getNodesDict());
}
endPointTranslation(alignment) {
@ -259,9 +295,10 @@ export class OneToManyLinkWidget extends RightAngleLinkWidget {
//ensure id is present for all points on the path
let points = this.props.link.getPoints();
let paths = [];
let {sourceType, targetType} = this.props.link.getPointType();
let onePoint = this.addCustomWidgetPoint('one', this.props.link.getSourcePort(), points[0]);
let manyPoint = this.addCustomWidgetPoint('many', this.props.link.getTargetPort(), points[points.length-1]);
let onePoint = this.addCustomWidgetPoint(sourceType, this.props.link.getSourcePort(), points[0]);
let manyPoint = this.addCustomWidgetPoint(targetType, this.props.link.getTargetPort(), points[points.length-1]);
if (!this.state.canDrag && points.length > 2) {
// Those points and its position only will be moved

View File

@ -44,6 +44,7 @@ export class TableNodeModel extends DefaultNodeModel {
is_promise: Boolean(otherInfo.data?.then || (otherInfo.metadata?.data_failed && !otherInfo.data)),
};
this._data = null;
this._constraintCols = {};
if(otherInfo.data?.then) {
otherInfo.data.then((data)=>{
/* Once the data is available, it is no more a promise */
@ -53,6 +54,7 @@ export class TableNodeModel extends DefaultNodeModel {
data_failed: false,
is_promise: false,
};
this.generateOnetoOneData(data);
this.fireEvent(this._metadata, 'dataAvaiable');
this.fireEvent({}, 'nodeUpdated');
this.fireEvent({}, 'selectionChanged');
@ -69,6 +71,7 @@ export class TableNodeModel extends DefaultNodeModel {
columns: [],
...otherInfo.data,
};
this.generateOnetoOneData(otherInfo.data);
}
}
@ -132,6 +135,7 @@ export class TableNodeModel extends DefaultNodeModel {
setData(data) {
this._data = data;
this.generateOnetoOneData(data);
this.fireEvent({}, 'nodeUpdated');
}
@ -164,6 +168,34 @@ export class TableNodeModel extends DefaultNodeModel {
},
};
}
setConstraintCols(colsData) {
this._constraintCols = colsData;
}
getConstraintCols() {
return this._constraintCols;
}
generateOnetoOneData = (tableData) => {
if (tableData){
let ukCols = [], pkCols = [];
(tableData.unique_constraint||[]).forEach((uk)=>{
if(uk.columns.length === 1){
ukCols.push(...uk.columns.map((c)=>c.column));
}
});
(tableData.primary_key||[]).forEach((pk)=>{
if(pk.columns.length === 1){
pkCols.push(...pk.columns.map((c)=>c.column));
}
});
this.setConstraintCols({
ukCols,
pkCols
});
}
};
}
function RowIcon({icon}) {
@ -239,7 +271,7 @@ export class TableNodeWidget extends React.Component {
show_details: true,
};
this.props.node.registerListener({
this.tableNodeEventListener = this.props.node.registerListener({
toggleDetails: (event) => {
this.setState({show_details: event.show_details});
},
@ -256,6 +288,10 @@ export class TableNodeWidget extends React.Component {
});
}
componentWillUnmount() {
this.props.node.deregisterListener(this.tableNodeEventListener);
}
generateColumn(col, localFkCols, localUkCols) {
let leftPort = this.props.node.getPort(this.props.node.getPortName(col.attnum, PortModelAlignment.LEFT));
let rightPort = this.props.node.getPort(this.props.node.getPortName(col.attnum, PortModelAlignment.RIGHT));

View File

@ -15,6 +15,7 @@ import {
import OneToManyPortModel from 'pgadmin.tools.erd/erd_tool/ports/OneToManyPort';
import {OneToManyLinkModel, OneToManyLinkWidget, OneToManyLinkFactory} from 'pgadmin.tools.erd/erd_tool/links/OneToManyLink';
import ERDModel from 'pgadmin.tools.erd/erd_tool/ERDModel';
import { render } from '@testing-library/react';
import Theme from '../../../pgadmin/static/js/Theme';
@ -108,14 +109,14 @@ describe('ERD OneToManyLinkModel', ()=>{
describe('ERD OneToManyLinkWidget', ()=>{
let linkFactory = new OneToManyLinkFactory();
let model = new ERDModel();
let engine = {
getFactoryForLink: ()=>linkFactory,
getModel: ()=>model
};
let link = null;
beforeEach(()=>{
link = new OneToManyLinkModel({
color: '#000',
data: {
@ -129,6 +130,45 @@ describe('ERD OneToManyLinkWidget', ()=>{
link.setTargetPort(new OneToManyPortModel({options: {}}));
});
jest.spyOn(model, 'getNodes').mockReturnValue([
{
name: 'test1',
getID: function() {
return 'id1';
},
getData: function(){ return {
'name': 'table1',
'schema': 'erd1',
'columns': [
{'name': 'col11', attnum: 0},
{'name': 'col12', attnum: 1},
],
};},
getConstraintCols: function(){ return {
ukCols: [],
pkCols: []
};}
},
{
name: 'test2',
getID: function() {
return 'id2';
},
getData: function(){ return {
'name': 'table2',
'schema': 'erd2',
'columns': [
{'name': 'col21', attnum: 0},
{'name': 'col22', attnum: 1},
],
};},
getConstraintCols: function(){ return {
ukCols: [],
pkCols: []
};}
},
]);
it('render', ()=>{
let linkWidget = render(
<Theme>