Added ERD Diagram support with basic table fields, primary key, foreign key, and DDL SQL generation. Fixes

This commit is contained in:
Aditya Toshniwal 2021-01-16 17:06:50 +05:30 committed by Akshay Joshi
parent 065bda37b4
commit 0c8226ff39
78 changed files with 9289 additions and 1472 deletions
docs/en_US
web
.eslintrc.jspackage.json
pgadmin
regression/javascript
webpack.config.jswebpack.shim.jswebpack.test.config.jsyarn.lock

View File

@ -9,6 +9,7 @@ This release contains a number of bug fixes and new features since the release o
New features
************
| `Issue #1802 <https://redmine.postgresql.org/issues/1802>`_ - Added ERD Diagram support with basic table fields, primary key, foreign key, and DDL SQL generation.
Housekeeping
************

View File

@ -18,6 +18,7 @@ module.exports = {
'eslint:recommended',
'plugin:react/recommended',
],
'parser': 'babel-eslint',
'parserOptions': {
'ecmaVersion': 2018,
'ecmaFeatures': {

View File

@ -7,12 +7,15 @@
],
"license": "PostgreSQL",
"devDependencies": {
"@babel/core": "~7.6.0",
"@babel/preset-env": "~7.6.0",
"@babel/core": "^7.10.2",
"@babel/plugin-proposal-object-rest-spread": "^7.9.6",
"@babel/preset-env": "^7.10.2",
"@emotion/core": "^10.0.14",
"@emotion/styled": "^10.0.14",
"autoprefixer": "^9.6.4",
"axios-mock-adapter": "^1.17.0",
"babel-loader": "~8.0.5",
"babel-plugin-transform-object-rest-spread": "^7.0.0-beta.3",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.1.0",
"copy-webpack-plugin": "^5.1.0",
"core-js": "^3.2.1",
"cross-env": "^5.2.0",
@ -41,7 +44,8 @@
"popper.js": "^1.14.7",
"postcss-loader": "^3.0.0",
"prop-types": "^15.7.2",
"raw-loader": "^1.0.0",
"raw-loader": "^3.1.0",
"resize-observer-polyfill": "^1.5.1",
"sass": "^1.24.4",
"sass-loader": "^7.1.0",
"sass-resources-loader": "^2.0.0",
@ -50,7 +54,7 @@
"url-loader": "^1.1.2",
"webpack": "^4.41.2",
"webpack-bundle-analyzer": "^3.5.1",
"webpack-cli": "^3.2.3",
"webpack-cli": "^3.3.11",
"webpack-require-from": "^1.8.0",
"yarn-audit-html": "^1.1.0"
},
@ -58,12 +62,12 @@
"@babel/plugin-proposal-class-properties": "^7.10.4",
"@babel/preset-react": "^7.10.4",
"@fortawesome/fontawesome-free": "^5.14.0",
"@projectstorm/react-diagrams": "^6.3.0",
"@simonwep/pickr": "^1.5.1",
"@tippyjs/react": "^4.2.0",
"acitree": "git+https://github.com/imsurinder90/jquery-aciTree.git#rc.7",
"alertifyjs": "git+https://github.com/EnterpriseDB/AlertifyJS/#72c1d794f5b6d4ec13a68d123c08f19021afe263",
"axios": "^0.18.1",
"babel-plugin-transform-es2015-modules-amd": "^6.24.1",
"babel-preset-es2015-without-strict": "~0.0.4",
"babelify": "~10.0.0",
"backbone": "1.4.0",
"backform": "^0.2.0",
@ -75,12 +79,17 @@
"bootstrap4-toggle": "3.4.0",
"bowser": "2.1.2",
"browserify": "~16.2.3",
"canvg": "^3.0.7",
"chart.js": "^2.9.3",
"closest": "^0.0.1",
"codemirror": "^5.54.0",
"css-loader": "2.1.0",
"cssnano": "^4.1.10",
"dagre": "^0.8.4",
"dropzone": "^5.5.1",
"exports-loader": "~0.7.0",
"html-to-image": "^0.1.1",
"html2canvas": "^1.0.0-rc.7",
"immutability-helper": "^3.0.0",
"imports-loader": "^0.8.0",
"ip-address": "^5.8.9",
@ -91,11 +100,17 @@
"json-bignumber": "^1.0.1",
"karma-coverage": "^2.0.3",
"leaflet": "^1.5.1",
"lodash": "4.*",
"ml-matrix": "^6.5.0",
"moment": "^2.24.0",
"moment-timezone": "^0.5.23",
"mousetrap": "^1.6.3",
"pathfinding": "^0.4.18",
"paths-js": "^0.4.9",
"raf": "^3.4.1",
"react": "^16.13.1",
"react-dom": "^16.13.1",
"react-to-print": "^2.10.3",
"requirejs": "~2.3.6",
"select2": "^4.0.6-rc.1",
"shim-loader": "^1.0.1",

View File

@ -767,6 +767,7 @@ def utils():
editor_insert_pair_brackets=insert_pair_brackets,
editor_indent_with_tabs=editor_indent_with_tabs,
app_name=config.APP_NAME,
app_version_int=config.APP_VERSION_INT,
pg_libpq_version=pg_libpq_version,
support_ssh_tunnel=config.SUPPORT_SSH_TUNNEL,
logout_url=_get_logout_url()

View File

@ -497,7 +497,8 @@ def register_browser_preferences(self):
category_label=PREF_LABEL_OPTIONS,
options=[{'label': gettext('Query Tool'), 'value': 'qt'},
{'label': gettext('Debugger'), 'value': 'debugger'},
{'label': gettext('Schema Diff'), 'value': 'schema_diff'}],
{'label': gettext('Schema Diff'), 'value': 'schema_diff'},
{'label': gettext('ERD Tool'), 'value': 'erd_tool'}],
help_str=gettext('Select Query Tool, Debugger, or Schema Diff from '
'the drop-down to set open in new browser tab for '
'that particular module.'),

View File

@ -21,7 +21,6 @@ from pgadmin.browser.server_groups.servers.utils import parse_priv_to_db
from pgadmin.utils.ajax import make_json_response, internal_server_error, \
make_response as ajax_response, gone
from .utils import BaseTableView
from pgadmin.utils.preferences import Preferences
from pgadmin.tools.schema_diff.node_registry import SchemaDiffRegistry
from pgadmin.browser.server_groups.servers.databases.schemas.tables.\
constraints.foreign_key import utils as fkey_utils
@ -134,8 +133,7 @@ class TableModule(SchemaChildModule):
blueprint = TableModule(__name__)
class TableView(BaseTableView, DataTypeReader, VacuumSettings,
SchemaDiffTableCompare):
class TableView(BaseTableView, DataTypeReader, SchemaDiffTableCompare):
"""
This class is responsible for generating routes for Table node
@ -589,7 +587,7 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
Returns:
JSON of selected table node
"""
status, res = self._fetch_properties(did, scid, tid)
status, res = self._fetch_table_properties(did, scid, tid)
if not status:
return res
if not res['rows']:
@ -599,86 +597,6 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
gid, sid, did, scid, tid, res=res
)
@staticmethod
def _check_rlspolicy_support(res):
"""
This function is used to check whether 'rlspolicy' in response
as it supported for version 9.5 and above
:param res:
:return:
"""
if 'rlspolicy' in res['rows'][0]:
# Set the value of rls policy
if res['rows'][0]['rlspolicy'] == "true":
res['rows'][0]['rlspolicy'] = True
# Set the value of force rls policy for table owner
if res['rows'][0]['forcerlspolicy'] == "true":
res['rows'][0]['forcerlspolicy'] = True
def _fetch_properties(self, did, scid, tid):
"""
This function is used to fetch the properties of the specified object
:param did:
:param scid:
:param tid:
:return:
"""
sql = render_template(
"/".join([self.table_template_path, self._PROPERTIES_SQL]),
did=did, scid=scid, tid=tid,
datlastsysoid=self.datlastsysoid
)
status, res = self.conn.execute_dict(sql)
if not status:
return False, internal_server_error(errormsg=res)
elif len(res['rows']) == 0:
return False, gone(
gettext(self.not_found_error_msg()))
# Update autovacuum properties
self.update_autovacuum_properties(res['rows'][0])
# We will check the threshold set by user before executing
# the query because that can cause performance issues
# with large result set
pref = Preferences.module('browser')
table_row_count_pref = pref.preference('table_row_count_threshold')
table_row_count_threshold = table_row_count_pref.get()
estimated_row_count = int(res['rows'][0].get('reltuples', 0))
# Check whether 'rlspolicy' in response as it supported for
# version 9.5 and above
TableView._check_rlspolicy_support(res)
# If estimated rows are greater than threshold then
if estimated_row_count and \
estimated_row_count > table_row_count_threshold:
res['rows'][0]['rows_cnt'] = str(table_row_count_threshold) + '+'
# If estimated rows is lower than threshold then calculate the count
elif estimated_row_count and \
table_row_count_threshold >= estimated_row_count:
sql = render_template(
"/".join(
[self.table_template_path, 'get_table_row_count.sql']
), data=res['rows'][0]
)
status, count = self.conn.execute_scalar(sql)
if not status:
return False, internal_server_error(errormsg=count)
res['rows'][0]['rows_cnt'] = count
# If estimated_row_count is zero then set the row count with same
elif not estimated_row_count:
res['rows'][0]['rows_cnt'] = estimated_row_count
return True, res
@BaseTableView.check_precondition
def types(self, gid, sid, did, scid, tid=None, clid=None):
"""
@ -686,12 +604,8 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
This function will return list of types available for column node
for node-ajax-control
"""
condition = render_template(
"/".join([
self.table_template_path, 'get_types_where_condition.sql'
]),
show_system_objects=self.blueprint.show_system_objects
)
condition = self.get_types_condition_sql(
self.blueprint.show_system_objects)
status, types = self.get_types(self.conn, condition, True, sid)
@ -1073,7 +987,7 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
data[k] = v
try:
status, res = self._fetch_properties(did, scid, tid)
status, res = self._fetch_table_properties(did, scid, tid)
if not status:
return res
@ -1314,7 +1228,7 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
res = None
if tid is not None:
status, res = self._fetch_properties(did, scid, tid)
status, res = self._fetch_table_properties(did, scid, tid)
if not status:
return res
@ -1378,7 +1292,7 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
"""
main_sql = []
status, res = self._fetch_properties(did, scid, tid)
status, res = self._fetch_table_properties(did, scid, tid)
if not status:
return res
@ -1665,57 +1579,12 @@ class TableView(BaseTableView, DataTypeReader, VacuumSettings,
:return: Table dataset
"""
if tid:
status, data = self._fetch_properties(did, scid, tid)
status, res = BaseTableView.fetch_tables(self, sid, did, scid, tid)
if not status:
current_app.logger.error(res)
return False
if not status:
current_app.logger.error(data)
return False
data = super(TableView, self).properties(
0, sid, did, scid, tid, res=data, return_ajax_response=False
)
return data
else:
res = dict()
sql = render_template("/".join([self.table_template_path,
self._NODES_SQL]), scid=scid)
status, tables = self.conn.execute_2darray(sql)
if not status:
current_app.logger.error(tables)
return False
for row in tables['rows']:
status, data = self._fetch_properties(did, scid, row['oid'])
if status:
data = super(TableView, self).properties(
0, sid, did, scid, row['oid'], res=data,
return_ajax_response=False
)
# Get sub module data of a specified table for object
# comparison
self._get_sub_module_data_for_compare(sid, did, scid, data,
row)
res[row['name']] = data
return res
def _get_sub_module_data_for_compare(self, sid, did, scid, data, row):
# Get sub module data of a specified table for object
# comparison
for module in self.tables_sub_modules:
module_view = SchemaDiffRegistry.get_node_view(module)
if module_view.blueprint.server_type is None or \
self.manager.server_type in \
module_view.blueprint.server_type:
sub_data = module_view.fetch_objects_to_compare(
sid=sid, did=did, scid=scid, tid=row['oid'],
oid=None)
data[module] = sub_data
return res
def get_submodule_template_path(self, module_name):
"""

View File

@ -318,22 +318,23 @@ def _get_sql_for_create_fk_const(data, conn, template_path):
len(data['columns']) < 1):
return True, '-- definition incomplete', name, ''
if data['autoindex'] and \
if data.get('autoindex', False) and \
('coveringindex' not in data or data['coveringindex'] == ''):
return True, '-- definition incomplete', name, ''
# Get the parent schema and table.
schema, table = get_parent(conn,
data['columns'][0]['references'])
if 'references' in data['columns'][0]:
# Get the parent schema and table.
schema, table = get_parent(conn,
data['columns'][0]['references'])
# Below handling will be used in Schema diff in case
# of different database comparison
_checks_for_schema_diff(table, schema, data)
# Below handling will be used in Schema diff in case
# of different database comparison
_checks_for_schema_diff(table, schema, data)
sql = render_template("/".join([template_path, 'create.sql']),
data=data, conn=conn)
if data['autoindex']:
if data.get('autoindex', False):
sql += render_template(
"/".join([template_path, 'create_index.sql']),
data=data, conn=conn)

View File

@ -175,8 +175,7 @@ class PartitionsModule(CollectionNodeModule):
blueprint = PartitionsModule(__name__)
class PartitionsView(BaseTableView, DataTypeReader, VacuumSettings,
SchemaDiffObjectCompare):
class PartitionsView(BaseTableView, DataTypeReader, SchemaDiffObjectCompare):
"""
This class is responsible for generating routes for Partition node

View File

@ -2,21 +2,21 @@ ALTER TABLE {{ conn|qtIdent(data.schema, data.table) }}
ADD{% if data.name %} CONSTRAINT {{ conn|qtIdent(data.name) }}{% endif%} FOREIGN KEY ({% for columnobj in data.columns %}{% if loop.index != 1 %}
, {% endif %}{{ conn|qtIdent(columnobj.local_column)}}{% endfor %})
REFERENCES {{ conn|qtIdent(data.remote_schema, data.remote_table) }} ({% for columnobj in data.columns %}{% if loop.index != 1 %}
, {% endif %}{{ conn|qtIdent(columnobj.referenced)}}{% endfor %}) {% if data.confmatchtype %}MATCH FULL{% else %}MATCH SIMPLE{% endif%}
, {% endif %}{{ conn|qtIdent(columnobj.referenced)}}{% endfor %}){% if data.confmatchtype is defined %} {% if data.confmatchtype %}MATCH FULL{% else %}MATCH SIMPLE{% endif%}{% endif%}{% if data.confupdtype is defined %}
ON UPDATE{% if data.confupdtype == 'a' %}
NO ACTION{% elif data.confupdtype == 'r' %}
RESTRICT{% elif data.confupdtype == 'c' %}
CASCADE{% elif data.confupdtype == 'n' %}
SET NULL{% elif data.confupdtype == 'd' %}
SET DEFAULT{% endif %}
SET DEFAULT{% endif %}{% endif %}{% if data.confdeltype is defined %}
ON DELETE{% if data.confdeltype == 'a' %}
NO ACTION{% elif data.confdeltype == 'r' %}
RESTRICT{% elif data.confdeltype == 'c' %}
CASCADE{% elif data.confdeltype == 'n' %}
SET NULL{% elif data.confdeltype == 'd' %}
SET DEFAULT{% endif %}
SET DEFAULT{% endif %}{% endif %}
{% if data.condeferrable %}
DEFERRABLE{% if data.condeferred %}

View File

@ -45,9 +45,13 @@ from pgadmin.browser.server_groups.servers.databases.schemas.tables.\
from pgadmin.browser.server_groups.servers.databases.schemas. \
tables.row_security_policies import \
utils as row_security_policies_utils
from pgadmin.utils.preferences import Preferences
from pgadmin.browser.server_groups.servers.databases.schemas.utils \
import VacuumSettings
from pgadmin.tools.schema_diff.node_registry import SchemaDiffRegistry
class BaseTableView(PGChildNodeView, BasePartitionTable):
class BaseTableView(PGChildNodeView, BasePartitionTable, VacuumSettings):
"""
This class is base class for tables and partitioned tables.
@ -101,7 +105,11 @@ class BaseTableView(PGChildNodeView, BasePartitionTable):
driver = get_driver(PG_DEFAULT_DRIVER)
did = kwargs['did']
self.manager = driver.connection_manager(kwargs['sid'])
self.conn = self.manager.connection(did=kwargs['did'])
if "conn_id" in kwargs:
self.conn = self.manager.connection(
did=kwargs['did'], conn_id=kwargs['conn_id'])
else:
self.conn = self.manager.connection(did=kwargs['did'])
self.qtIdent = driver.qtIdent
self.qtTypeIdent = driver.qtTypeIdent
# We need datlastsysoid to check if current table is system table
@ -442,6 +450,161 @@ class BaseTableView(PGChildNodeView, BasePartitionTable):
status=200
)
def get_types_condition_sql(self, show_system_objects):
condition = render_template(
"/".join([
self.table_template_path, 'get_types_where_condition.sql'
]),
show_system_objects=show_system_objects
)
return condition
def fetch_tables(self, sid, did, scid, tid=None):
"""
This function will fetch the list of all the tables
and will be used by schema diff.
:param sid: Server Id
:param did: Database Id
:param scid: Schema Id
:param tid: Table Id
:return: Table dataset
"""
if tid:
status, data = self._fetch_table_properties(did, scid, tid)
if not status:
return False, data
data = BaseTableView.properties(
self, 0, sid, did, scid, tid, res=data,
return_ajax_response=False
)
return True, data
else:
res = dict()
sql = render_template("/".join([self.table_template_path,
self._NODES_SQL]), scid=scid)
status, tables = self.conn.execute_2darray(sql)
if not status:
return False, tables
for row in tables['rows']:
status, data = \
self._fetch_table_properties(did, scid, row['oid'])
if status:
data = BaseTableView.properties(
self, 0, sid, did, scid, row['oid'], res=data,
return_ajax_response=False
)
# Get sub module data of a specified table for object
# comparison
BaseTableView._get_sub_module_data_for_compare(
self, sid, did, scid, data, row)
res[row['name']] = data
res[row['name']] = data
return True, res
def _get_sub_module_data_for_compare(self, sid, did, scid, data, row):
# Get sub module data of a specified table for object
# comparison
for module in self.tables_sub_modules:
module_view = SchemaDiffRegistry.get_node_view(module)
if module_view.blueprint.server_type is None or \
self.manager.server_type in \
module_view.blueprint.server_type:
sub_data = module_view.fetch_objects_to_compare(
sid=sid, did=did, scid=scid, tid=row['oid'],
oid=None)
data[module] = sub_data
@staticmethod
def _check_rlspolicy_support(res):
"""
This function is used to check whether 'rlspolicy' in response
as it supported for version 9.5 and above
:param res:
:return:
"""
if 'rlspolicy' in res['rows'][0]:
# Set the value of rls policy
if res['rows'][0]['rlspolicy'] == "true":
res['rows'][0]['rlspolicy'] = True
# Set the value of force rls policy for table owner
if res['rows'][0]['forcerlspolicy'] == "true":
res['rows'][0]['forcerlspolicy'] = True
def _fetch_table_properties(self, did, scid, tid):
"""
This function is used to fetch the properties of the specified object
:param did:
:param scid:
:param tid:
:return:
"""
sql = render_template(
"/".join([self.table_template_path, self._PROPERTIES_SQL]),
did=did, scid=scid, tid=tid,
datlastsysoid=self.datlastsysoid
)
status, res = self.conn.execute_dict(sql)
if not status:
return False, internal_server_error(errormsg=res)
elif len(res['rows']) == 0:
return False, gone(
gettext(self.not_found_error_msg()))
# Update autovacuum properties
self.update_autovacuum_properties(res['rows'][0])
# We will check the threshold set by user before executing
# the query because that can cause performance issues
# with large result set
pref = Preferences.module('browser')
table_row_count_pref = pref.preference('table_row_count_threshold')
table_row_count_threshold = table_row_count_pref.get()
estimated_row_count = int(res['rows'][0].get('reltuples', 0))
# Check whether 'rlspolicy' in response as it supported for
# version 9.5 and above
BaseTableView._check_rlspolicy_support(res)
# If estimated rows are greater than threshold then
if estimated_row_count and \
estimated_row_count > table_row_count_threshold:
res['rows'][0]['rows_cnt'] = str(table_row_count_threshold) + '+'
# If estimated rows is lower than threshold then calculate the count
elif estimated_row_count and \
table_row_count_threshold >= estimated_row_count:
sql = render_template(
"/".join(
[self.table_template_path, 'get_table_row_count.sql']
), data=res['rows'][0]
)
status, count = self.conn.execute_scalar(sql)
if not status:
return False, internal_server_error(errormsg=count)
res['rows'][0]['rows_cnt'] = count
# If estimated_row_count is zero then set the row count with same
elif not estimated_row_count:
res['rows'][0]['rows_cnt'] = estimated_row_count
return True, res
def _format_column_list(self, data):
# Now we have all lis of columns which we need
# to include in our create definition, Let's format them

View File

@ -110,13 +110,15 @@ class DataTypeReader:
"""
# Check if template path is already set or not
# if not then we will set the template path here
manager = conn.manager if not hasattr(self, 'manager') \
else self.manager
if not hasattr(self, 'data_type_template_path'):
self.data_type_template_path = 'datatype/sql/' + (
'#{0}#{1}#'.format(
self.manager.server_type,
self.manager.version
) if self.manager.server_type == 'gpdb' else
'#{0}#'.format(self.manager.version)
manager.server_type,
manager.version
) if manager.server_type == 'gpdb' else
'#{0}#'.format(manager.version)
)
sql = render_template(
"/".join([self.data_type_template_path, 'get_types.sql']),
@ -701,3 +703,23 @@ def get_schema(sid, did, scid):
)
return status, schema_name
def get_schemas(conn, show_system_objects=False):
"""
This function will return the schemas.
"""
ver = conn.manager.version
server_type = conn.manager.server_type
SQL = render_template(
"/".join(['schemas',
'{0}/#{1}#'.format(server_type, ver),
'sql/nodes.sql']),
show_sysobj=show_system_objects,
schema_restrictions=None
)
status, rset = conn.execute_2darray(SQL)
return status, rset

View File

@ -82,6 +82,10 @@ define('pgadmin.node.database', [
applies: ['object', 'context'], callback: 'disconnect_database',
category: 'drop', priority: 5, label: gettext('Disconnect Database...'),
icon: 'fa fa-unlink', enable : 'is_connected',
},{
name: 'generate_erd', node: 'database', module: this,
applies: ['object', 'context'], callback: 'generate_erd',
category: 'erd', priority: 5, label: gettext('Generate ERD(Beta)...'),
}]);
_.bindAll(this, 'connection_lost');
@ -236,6 +240,15 @@ define('pgadmin.node.database', [
return false;
},
/* Generate the ERD */
generate_erd: function(args) {
var input = args || {},
t = pgBrowser.tree,
i = input.item || t.selected(),
d = i && i.length == 1 ? t.itemData(i) : undefined;
pgBrowser.erd.showErdTool(d, i, true);
},
/* Connect the database (if not connected), before opening this node */
beforeopen: function(item, data) {
if(!data || data._type != 'database' || data.label == 'template0') {

View File

@ -68,6 +68,7 @@ define('pgadmin.browser.utils',
braceMatching: '{{ editor_brace_matching }}' == 'True',
is_indent_with_tabs: '{{ editor_indent_with_tabs }}' == 'True',
app_name: '{{ app_name }}',
app_version_int: '{{ app_version_int}}',
pg_libpq_version: {{pg_libpq_version|e}},
support_ssh_tunnel: '{{ support_ssh_tunnel }}' == 'True',
logout_url: '{{logout_url}}',

View File

@ -10,6 +10,7 @@
define('bundled_browser',[
'pgadmin.browser',
'sources/browser/index',
'top/tools/erd/static/js/index',
], function(pgBrowser) {
pgBrowser.init();
});

View File

@ -1465,6 +1465,10 @@ define([
collection: collection,
node_info: self.model.node_info,
});
if(data.beforeAdd) {
m = data.beforeAdd.apply(self, [m]);
}
collection.add(m);
var idx = collection.indexOf(m),

View File

@ -10,10 +10,10 @@
define([
'sources/gettext', 'underscore', 'jquery', 'backbone', 'backform', 'backgrid', 'alertify',
'moment', 'bignumber', 'codemirror', 'sources/utils', 'sources/keyboard_shortcuts', 'sources/select2/configure_show_on_scroll',
'bootstrap.datetimepicker', 'backgrid.filter', 'bootstrap.toggle',
'sources/window', 'bootstrap.datetimepicker', 'backgrid.filter', 'bootstrap.toggle',
], function(
gettext, _, $, Backbone, Backform, Backgrid, Alertify, moment, BigNumber, CodeMirror,
commonUtils, keyboardShortcuts, configure_show_on_scroll
commonUtils, keyboardShortcuts, configure_show_on_scroll, pgWindow
) {
/*
* Add mechanism in backgrid to render different types of cells in
@ -43,7 +43,7 @@ define([
// bind shortcut in cell edit mode
_.extend(Backgrid.InputCellEditor.prototype.events, {
'keydown': function(e) {
let preferences = pgBrowser.get_preferences_for_module('browser');
let preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('browser');
if(preferences && keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,e)) {
pgBrowser.keyboardNavigation.bindAddGridRow();
} else {
@ -323,7 +323,7 @@ define([
},
events: {
'keydown': function (event) {
let preferences = pgBrowser.get_preferences_for_module('browser');
let preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('browser');
if(preferences && keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,event)) {
pgBrowser.keyboardNavigation.bindAddGridRow();
}
@ -764,7 +764,7 @@ define([
},
onKeyDown: function(e) {
let preferences = pgBrowser.get_preferences_for_module('browser');
let preferences = pgWindow.default.pgAdmin.Browser.get_preferences_for_module('browser');
if(keyboardShortcuts.validateShortcutKeys(preferences.add_grid_row,e)) {
pgBrowser.keyboardNavigation.bindAddGridRow();
}

View File

@ -0,0 +1,10 @@
import PropTypes from 'prop-types';
const CustomPropTypes = {
ref: PropTypes.oneOfType([
PropTypes.func,
PropTypes.shape({ current: PropTypes.instanceOf(Element) }),
]),
};
export default CustomPropTypes;

View File

@ -181,6 +181,15 @@ legend {
}
}
.btn-warning {
@include button-variant($color-warning, $color-warning-fg);
border-color: $color-warning;
@include hover() {
color: $color-warning-fg !important;
border-color: $color-warning !important;
}
}
.form-group fieldset {
background-color: $color-gray-lighter;
@ -371,6 +380,11 @@ td.switch-cell > div.toggle {
line-height: 0.7rem;
}
.btn-xs {
@extend .btn-sm;
padding: 0.125rem 0.25rem !important;
}
.btn-toolbar {
min-width: 100%;
}

View File

@ -886,6 +886,12 @@ table.table-noouter-border {
border-bottom: $table-border-width solid transparent;
}
}
&.table-noheader {
& > tbody tr:first-of-type td {
border-top: none !important;
}
}
}
table.table-bottom-border {

View File

@ -0,0 +1,9 @@
@import "~node_modules/tippy.js/dist/tippy.css";
.tippy-box {
background-color: $popover-bg;
color: $popover-body-color;
.tippy-arrow {
color: $popover-bg;
}
}

View File

@ -13,7 +13,6 @@ $theme-colors: (
}
@import "node_modules/bootstrap/scss/bootstrap";
@import 'webcabin.pgadmin';
@import 'bootstrap.overrides';
@import 'backgrid.overrides';
@ -27,3 +26,4 @@ $theme-colors: (
@import 'pgadmin.style';
@import 'bootstrap4-toggle.overrides';
@import 'pickr.overrides';
@import 'tippy.overrides';

View File

@ -111,8 +111,8 @@ $dropdown-spacer: .125rem; //no-change
$dropdown-link-disabled-color: $text-muted;
$nav-divider-margin-y: .25rem;
$popover-bg: $color-gray-dark !default;
$popover-body-color: $white !default;
$popover-bg: $color-fg !default;
$popover-body-color: $color-bg !default;
$popover-border-color: $dropdown-border-color;
$popover-box-shadow: $dropdown-box-shadow;
@ -349,3 +349,16 @@ $grid-hover-fg-color: $color-fg !default;
$btn-copied-color-fg: $active-color !default;
/** ERD **/
$erd-row-padding: 0.25rem;
$erd-node-border-color: $border-color !default;
$erd-canvas-bg: $color-bg !default;
$erd-canvas-grid: $color-gray !default;
$erd-link-color: $color-fg !default;
$erd-link-selected-color: $color-fg !default;
@function url-friendly-colour($colour) {
@return '%23' + str-slice('#{$colour}', 2, -1)
}
$erd-bg-grid: url("data:image/svg+xml, %3Csvg width='100%25' viewBox='0 0 45 45' style='background-color:#{url-friendly-colour($erd-canvas-bg)}' height='100%25' xmlns='http://www.w3.org/2000/svg'%3E%3Cdefs%3E%3Cpattern id='smallGrid' width='15' height='15' patternUnits='userSpaceOnUse'%3E%3Cpath d='M 15 0 L 0 0 0 15' fill='none' stroke='#{url-friendly-colour($erd-canvas-grid)}' stroke-width='0.5'/%3E%3C/pattern%3E%3Cpattern id='grid' width='45' height='45' patternUnits='userSpaceOnUse'%3E%3Crect width='100' height='100' fill='url(%23smallGrid)'/%3E%3Cpath d='M 100 0 L 0 0 0 100' fill='none' stroke='#{url-friendly-colour($erd-canvas-grid)}' stroke-width='1'/%3E%3C/pattern%3E%3C/defs%3E%3Crect width='100%25' height='100%25' fill='url(%23grid)' /%3E%3C/svg%3E%0A");

View File

@ -121,3 +121,11 @@ $color-success-hover-fg: $color-fg;
$datagrid-selected-color: $color-primary-fg;
$select2-placeholder: #999;
/* ERD */
$erd-row-padding: 0.25rem;
$erd-node-border-color: $border-color;
$erd-canvas-bg: $color-gray-light;
$erd-canvas-grid: #444952;
$erd-link-color: $color-fg;
$erd-link-selected-color: $color-fg;

View File

@ -20,16 +20,17 @@ function isServerInformationAvailable(parentData) {
return parentData.server === undefined;
}
export function getPanelTitle(pgBrowser, selected_item=null, custom_title=null) {
export function getPanelTitle(pgBrowser, selected_item=null, custom_title=null, parentData=null) {
var preferences = pgBrowser.get_preferences_for_module('browser');
if(selected_item == null) {
if(selected_item == null && parentData == null) {
selected_item = pgBrowser.treeMenu.selected();
}
const parentData = getTreeNodeHierarchyFromIdentifier
.call(pgBrowser, selected_item);
if (isServerInformationAvailable(parentData)) {
return;
if(parentData == null) {
parentData = getTreeNodeHierarchyFromIdentifier.call(pgBrowser, selected_item);
if (isServerInformationAvailable(parentData)) {
return;
}
}
const db_label = getDatabaseLabel(parentData);

View File

@ -18,7 +18,7 @@ function hasDatabaseInformation(parentData) {
return parentData.database;
}
function generateUrl(trans_id, title, parentData) {
function generateUrl(trans_id, title, parentData, sqlId) {
let url_endpoint = url_for('datagrid.panel', {
'trans_id': trans_id,
});
@ -32,6 +32,10 @@ function generateUrl(trans_id, title, parentData) {
url_endpoint += `&did=${parentData.database._id}`;
}
if(sqlId) {
url_endpoint += `&sql_id=${sqlId}`;
}
return url_endpoint;
}
@ -85,6 +89,25 @@ export function generateScript(parentData, datagrid, alertify) {
launchDataGrid(datagrid, transId, url_endpoint, queryToolTitle, '', alertify);
}
export function showERDSqlTool(parentData, erdSqlId, queryToolTitle, datagrid, alertify) {
const transId = getRandomInt(1, 9999999);
parentData = {
server_group: {
_id: parentData.sgid,
},
server: {
_id: parentData.sid,
server_type: parentData.stype,
},
database: {
_id: parentData.did,
},
};
const gridUrl = generateUrl(transId, queryToolTitle, parentData, erdSqlId);
launchDataGrid(datagrid, transId, gridUrl, queryToolTitle, '', alertify);
}
export function launchDataGrid(datagrid, transId, gridUrl, queryToolTitle, sURL, alertify) {
let retVal = datagrid.launch_grid(transId, gridUrl, true, queryToolTitle, sURL);

View File

@ -0,0 +1,553 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
"""A blueprint module implementing the erd tool."""
import simplejson as json
from flask import url_for, request
from flask import render_template, current_app as app
from flask_security import login_required
from flask_babelex import gettext
from werkzeug.useragents import UserAgent
from pgadmin.utils import PgAdminModule, \
SHORTCUT_FIELDS as shortcut_fields
from pgadmin.utils.ajax import make_json_response, bad_request, \
internal_server_error
from pgadmin.model import Server
from config import PG_DEFAULT_DRIVER
from pgadmin.utils.driver import get_driver
from pgadmin.browser.utils import underscore_unescape
from pgadmin.browser.server_groups.servers.databases.schemas.utils \
import get_schemas
from pgadmin.browser.server_groups.servers.databases.schemas.tables. \
constraints.foreign_key import utils as fkey_utils
from pgadmin.utils.constants import PREF_LABEL_KEYBOARD_SHORTCUTS, \
PREF_LABEL_DISPLAY
from .utils import ERDHelper
from pgadmin.utils.exception import ConnectionLost
MODULE_NAME = 'erd'
class ERDModule(PgAdminModule):
"""
class ERDModule(PgAdminModule)
A module class for ERD derived from PgAdminModule.
"""
LABEL = gettext("ERD tool")
def get_own_menuitems(self):
return {}
def get_own_javascripts(self):
return [{
'name': 'pgadmin.erd',
'path': url_for('erd.index') + "erd",
'when': None
}]
def get_panels(self):
return []
def get_exposed_url_endpoints(self):
"""
Returns:
list: URL endpoints
"""
return [
'erd.panel',
'erd.initialize',
'erd.prequisite',
'erd.sql',
'erd.tables',
'erd.close'
]
def register_preferences(self):
self.preference.register(
'keyboard_shortcuts',
'open_project',
gettext('Open project'),
'keyboardshortcut',
{
'alt': False,
'shift': False,
'control': True,
'key': {
'key_code': 79,
'char': 'o'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'save_project',
gettext('Save project'),
'keyboardshortcut',
{
'alt': False,
'shift': False,
'control': True,
'key': {
'key_code': 83,
'char': 's'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'save_project_as',
gettext('Save project as'),
'keyboardshortcut',
{
'alt': False,
'shift': True,
'control': True,
'key': {
'key_code': 83,
'char': 's'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'generate_sql',
gettext('Generate SQL'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 83,
'char': 's'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'add_table',
gettext('Add table'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 65,
'char': 'a'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'edit_table',
gettext('Edit table'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 69,
'char': 'e'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'clone_table',
gettext('Clone table'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 67,
'char': 'c'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'drop_table',
gettext('Drop table'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 68,
'char': 'd'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'add_edit_note',
gettext('Add/Edit note'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 78,
'char': 'n'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'one_to_many',
gettext('One to many link'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 79,
'char': 'o'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'many_to_many',
gettext('Many to many link'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 77,
'char': 'm'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'auto_align',
gettext('Auto align'),
'keyboardshortcut',
{
'alt': True,
'shift': False,
'control': True,
'key': {
'key_code': 76,
'char': 'l'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'zoom_to_fit',
gettext('Zoom to fit'),
'keyboardshortcut',
{
'alt': True,
'shift': True,
'control': False,
'key': {
'key_code': 70,
'char': 'f'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'zoom_in',
gettext('Zoom in'),
'keyboardshortcut',
{
'alt': True,
'shift': True,
'control': False,
'key': {
'key_code': 187,
'char': '+'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
self.preference.register(
'keyboard_shortcuts',
'zoom_out',
gettext('Zoom out'),
'keyboardshortcut',
{
'alt': True,
'shift': True,
'control': False,
'key': {
'key_code': 189,
'char': '-'
}
},
category_label=PREF_LABEL_KEYBOARD_SHORTCUTS,
fields=shortcut_fields
)
blueprint = ERDModule(MODULE_NAME, __name__, static_url_path='/static')
@blueprint.route(
'/panel/<int:trans_id>',
methods=["POST"],
endpoint='panel'
)
@login_required
def panel(trans_id):
"""
This method calls index.html to render the erd tool.
Args:
panel_title: Title of the panel
"""
params = {
'trans_id': trans_id,
'title': request.form['title']
}
if request.args:
params.update({k: v for k, v in request.args.items()})
if 'gen' in params:
params['gen'] = True if params['gen'] == 'true' else False
close_url = request.form['close_url']
# We need client OS information to render correct Keyboard shortcuts
user_agent = UserAgent(request.headers.get('User-Agent'))
"""
Animations and transitions are not automatically GPU accelerated and by
default use browser's slow rendering engine. We need to set 'translate3d'
value of '-webkit-transform' property in order to use GPU. After applying
this property under linux, Webkit calculates wrong position of the
elements so panel contents are not visible. To make it work, we need to
explicitly set '-webkit-transform' property to 'none' for .ajs-notifier,
.ajs-message, .ajs-modal classes.
This issue is only with linux runtime application and observed in Query
tool and debugger. When we open 'Open File' dialog then whole Query tool
panel content is not visible though it contains HTML element in back end.
The port number should have already been set by the runtime if we're
running in desktop mode.
"""
is_linux_platform = False
from sys import platform as _platform
if "linux" in _platform:
is_linux_platform = True
s = Server.query.filter_by(id=params['sid']).first()
params.update({
'bgcolor': s.bgcolor,
'fgcolor': s.fgcolor,
'client_platform': user_agent.platform,
'is_desktop_mode': app.PGADMIN_RUNTIME,
'is_linux': is_linux_platform
})
return render_template(
"erd/index.html",
title=underscore_unescape(params['title']),
close_url=close_url,
params=json.dumps(params),
)
@blueprint.route(
'/initialize/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
methods=["POST"], endpoint='initialize'
)
@login_required
def initialize_erd(trans_id, sgid, sid, did):
"""
This method is responsible for instantiating and initializing
the erd tool object. It will also create a unique
transaction id and store the information into session variable.
Args:
sgid: Server group Id
sid: Server Id
did: Database Id
"""
# Read the data if present. Skipping read may cause connection
# reset error if data is sent from the client
if request.data:
_ = request.data
conn = _get_connection(sid, did, trans_id)
return make_json_response(
data={
'connId': str(trans_id),
'serverVersion': conn.manager.version,
}
)
def _get_connection(sid, did, trans_id):
"""
Get the connection object of ERD.
:param sid:
:param did:
:param trans_id:
:return:
"""
manager = get_driver(PG_DEFAULT_DRIVER).connection_manager(sid)
try:
conn = manager.connection(did=did, conn_id=trans_id,
auto_reconnect=True,
use_binary_placeholder=True)
status, msg = conn.connect()
if not status:
app.logger.error(msg)
raise ConnectionLost(sid, conn.db, trans_id)
return conn
except Exception as e:
app.logger.error(e)
raise
@blueprint.route('/prequisite/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
methods=["GET"],
endpoint='prequisite')
@login_required
def prequisite(trans_id, sgid, sid, did):
conn = _get_connection(sid, did, trans_id)
helper = ERDHelper(trans_id, sid, did)
status, col_types = helper.get_types()
if not status:
return internal_server_error(errormsg=col_types)
status, schemas = get_schemas(conn, show_system_objects=False)
if not status:
return internal_server_error(errormsg=schemas)
return make_json_response(
data={
'col_types': col_types,
'schemas': schemas['rows']
},
status=200
)
@blueprint.route('/sql/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
methods=["POST"],
endpoint='sql')
@login_required
def sql(trans_id, sgid, sid, did):
data = json.loads(request.data, encoding='utf-8')
helper = ERDHelper(trans_id, sid, did)
conn = _get_connection(sid, did, trans_id)
sql = ''
for tab_key, tab_data in data.get('nodes', {}).items():
sql += '\n\n' + helper.get_table_sql(tab_data)
for link_key, link_data in data.get('links', {}).items():
link_sql, name = fkey_utils.get_sql(conn, link_data, None)
sql += '\n\n' + link_sql
return make_json_response(
data=sql,
status=200
)
@blueprint.route('/tables/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
methods=["GET"],
endpoint='tables')
@login_required
def tables(trans_id, sgid, sid, did):
helper = ERDHelper(trans_id, sid, did)
status, tables = helper.get_all_tables()
if not status:
return internal_server_error(errormsg=tables)
return make_json_response(
data=tables,
status=200
)
@blueprint.route('/close/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
methods=["DELETE"],
endpoint='close')
@login_required
def close(trans_id, sgid, sid, did):
manager = get_driver(
PG_DEFAULT_DRIVER).connection_manager(sid)
if manager is not None:
conn = manager.connection(did=did, conn_id=trans_id)
# Release the connection
if conn.connected():
conn.cancel_transaction(trans_id, did=did)
manager.release(did=did, conn_id=trans_id)
return make_json_response(data={'status': True})

View File

@ -0,0 +1,215 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import Alertify from 'pgadmin.alertifyjs';
import {getTreeNodeHierarchyFromIdentifier} from 'sources/tree/pgadmin_tree_node';
import {getPanelTitle} from 'tools/datagrid/static/js/datagrid_panel_title';
import {getRandomInt} from 'sources/utils';
export function setPanelTitle(erdToolPanel, panelTitle) {
erdToolPanel.title('<span title="'+panelTitle+'">'+panelTitle+'</span>');
}
export function initialize(gettext, url_for, $, _, pgAdmin, csrfToken, pgBrowser, wcDocker) {
/* Return back, this has been called more than once */
if (pgBrowser.erd)
return pgBrowser.erd;
pgBrowser.erd = {
init: function() {
if (this.initialized)
return;
this.initialized = true;
csrfToken.setPGCSRFToken(pgAdmin.csrf_token_header, pgAdmin.csrf_token);
// Define the nodes on which the menus to be appear
var menus = [{
name: 'erd',
module: this,
applies: ['tools'],
callback: 'showErdTool',
priority: 1,
label: gettext('New ERD project(Beta)'),
enable: this.erdToolEnabled,
}];
pgBrowser.add_menus(menus);
// Creating a new pgBrowser frame to show the data.
var erdFrameType = new pgBrowser.Frame({
name: 'frm_erdtool',
showTitle: true,
isCloseable: true,
isPrivate: true,
url: 'about:blank',
});
let self = this;
/* Cache may take time to load for the first time
* Keep trying till available
*/
let cacheIntervalId = setInterval(function() {
if(pgBrowser.preference_version() > 0) {
self.preferences = pgBrowser.get_preferences_for_module('erd');
clearInterval(cacheIntervalId);
}
},0);
pgBrowser.onPreferencesChange('erd', function() {
self.preferences = pgBrowser.get_preferences_for_module('erd');
});
// Load the newly created frame
erdFrameType.load(pgBrowser.docker);
return this;
},
erdToolEnabled: function(obj) {
/* Same as query tool */
var isEnabled = (() => {
if (!_.isUndefined(obj) && !_.isNull(obj)) {
if (_.indexOf(pgAdmin.unsupported_nodes, obj._type) == -1) {
if (obj._type == 'database' && obj.allowConn) {
return true;
} else if (obj._type != 'database') {
return true;
} else {
return false;
}
} else {
return false;
}
} else {
return false;
}
})();
return isEnabled;
},
// Callback to draw schema diff for objects
showErdTool: function(data, aciTreeIdentifier, gen=false) {
const node = pgBrowser.treeMenu.findNodeByDomElement(aciTreeIdentifier);
if (node === undefined || !node.getData()) {
Alertify.alert(
gettext('ERD Error'),
gettext('No object selected.')
);
return;
}
const parentData = getTreeNodeHierarchyFromIdentifier.call(
pgBrowser,
aciTreeIdentifier
);
if(_.isUndefined(parentData.database)) {
Alertify.alert(
gettext('ERD Error'),
gettext('Please select a database/database object.')
);
return;
}
const transId = getRandomInt(1, 9999999);
const panelTitle = getPanelTitle(pgBrowser, aciTreeIdentifier);
const [panelUrl, panelCloseUrl] = this.getPanelUrls(transId, panelTitle, parentData, gen);
let erdToolForm = `
<form id="erdToolForm" action="${panelUrl}" method="post">
<input id="title" name="title" hidden />
<input name="close_url" value="${panelCloseUrl}" hidden />
</form>
<script>
document.getElementById("title").value = "${_.escape(panelTitle)}";
document.getElementById("erdToolForm").submit();
</script>
`;
var open_new_tab = pgBrowser.get_preferences_for_module('browser').new_browser_tab_open;
if (open_new_tab && open_new_tab.includes('erd_tool')) {
var newWin = window.open('', '_blank');
newWin.document.write(erdToolForm);
newWin.document.title = panelTitle;
} else {
/* On successfully initialization find the dashboard panel,
* create new panel and add it to the dashboard panel.
*/
var propertiesPanel = pgBrowser.docker.findPanels('properties');
var erdToolPanel = pgBrowser.docker.addPanel('frm_erdtool', wcDocker.DOCK.STACKED, propertiesPanel[0]);
// Set panel title and icon
setPanelTitle(erdToolPanel, 'Untitled');
erdToolPanel.icon('fa fa-sitemap');
erdToolPanel.focus();
// Listen on the panel closed event.
erdToolPanel.on(wcDocker.EVENT.CLOSED, function() {
$.ajax({
url: panelCloseUrl,
method: 'DELETE',
});
});
var openErdToolURL = function(j) {
// add spinner element
let $spinner_el =
$(`<div class="pg-sp-container">
<div class="pg-sp-content">
<div class="row">
<div class="col-12 pg-sp-icon"></div>
</div>
</div>
</div>`).appendTo($(j).data('embeddedFrame').$container);
let init_poller_id = setInterval(function() {
var frameInitialized = $(j).data('frameInitialized');
if (frameInitialized) {
clearInterval(init_poller_id);
var frame = $(j).data('embeddedFrame');
if (frame) {
frame.onLoaded(()=>{
$spinner_el.remove();
});
frame.openHTML(erdToolForm);
}
}
}, 100);
};
openErdToolURL(erdToolPanel);
}
},
getPanelUrls: function(transId, panelTitle, parentData, gen) {
let openUrl = url_for('erd.panel', {
trans_id: transId,
});
openUrl += `?sgid=${parentData.server_group._id}`
+`&sid=${parentData.server._id}`
+`&server_type=${parentData.server.server_type}`
+`&did=${parentData.database._id}`
+`&gen=${gen}`;
let closeUrl = url_for('erd.close', {
trans_id: transId,
sgid: parentData.server_group._id,
sid: parentData.server._id,
did: parentData.database._id,
});
return [openUrl, closeUrl];
},
};
return pgBrowser.erd;
}

View File

@ -0,0 +1,395 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
/*
* The ERDCore is the middleware between the canvas engine and the UI DOM.
*/
import createEngine from '@projectstorm/react-diagrams';
import {DagreEngine, PathFindingLinkFactory, PortModelAlignment} from '@projectstorm/react-diagrams';
import { ZoomCanvasAction } from '@projectstorm/react-canvas-core';
import {TableNodeFactory, TableNodeModel } from './nodes/TableNode';
import {OneToManyLinkFactory, OneToManyLinkModel } from './links/OneToManyLink';
import { OneToManyPortFactory } from './ports/OneToManyPort';
import ERDModel from './ERDModel';
export default class ERDCore {
constructor() {
this._cache = {};
this.table_counter = 1;
this.node_position_updating = false;
this.link_position_updating = false;
this.initializeEngine();
this.initializeModel();
this.computeTableCounter();
}
initializeEngine() {
this.engine = createEngine({
registerDefaultDeleteItemsAction: false,
registerDefaultZoomCanvasAction: false,
});
this.dagre_engine = new DagreEngine({
graph: {
marginx: 5,
marginy: 5,
},
includeLinks: true,
});
this.engine.getNodeFactories().registerFactory(new TableNodeFactory());
this.engine.getLinkFactories().registerFactory(new OneToManyLinkFactory());
this.engine.getPortFactories().registerFactory(new OneToManyPortFactory());
this.registerKeyAction(new ZoomCanvasAction({inverseZoom: true}));
}
initializeModel(data, callback=()=>{}) {
let model = new ERDModel();
if(data) {
model.deserializeModel(data, this.engine);
}
const registerNodeEvents = (node) => {
node.registerListener({
eventDidFire: (e) => {
if(e.function === 'selectionChanged') {
this.fireEvent({}, 'nodesSelectionChanged', true);
}
else if(e.function === 'showNote') {
this.fireEvent({node: e.entity}, 'showNote', true);
}
else if(e.function === 'editNode') {
this.fireEvent({node: e.entity}, 'editNode', true);
}
else if(e.function === 'nodeUpdated') {
this.fireEvent({}, 'nodesUpdated', true);
}
else if(e.function === 'positionChanged') {
/* Eat up the excessive positionChanged events if node is dragged continuosly */
if(!this.node_position_updating) {
this.node_position_updating = true;
this.fireEvent({}, 'nodesUpdated', true);
setTimeout(()=>{
this.node_position_updating = false;
}, 500);
}
}
},
});
};
const registerLinkEvents = (link) => {
link.registerListener({
eventDidFire: (e) => {
if(e.function === 'selectionChanged') {
this.fireEvent({}, 'linksSelectionChanged', true);
}
else if(e.function === 'positionChanged') {
/* positionChanged is triggered manually in Link */
/* Eat up the excessive positionChanged events if link is dragged continuosly */
if(!this.link_position_updating) {
this.link_position_updating = true;
this.fireEvent({}, 'linksUpdated', true);
setTimeout(()=>{
this.link_position_updating = false;
}, 500);
}
}
},
});
};
/* Register events for deserialized data */
model.getNodes().forEach(node => {
registerNodeEvents(node);
});
model.getLinks().forEach(link => {
registerLinkEvents(link);
});
/* Listen and register events for new data */
model.registerListener({
'nodesUpdated': (e)=>{
if(e.isCreated) {
registerNodeEvents(e.node);
}
},
'linksUpdated': (e)=>{
if(e.isCreated) {
registerLinkEvents(e.link);
}
},
});
model.setGridSize(15);
this.engine.setModel(model);
callback();
}
computeTableCounter() {
/* Some inteligence can be added to set the counter */
this.table_counter = 1;
}
setCache(data, value) {
if(typeof(data) == 'string') {
this._cache[data] = value;
} else {
this._cache = {
...this._cache,
...data,
};
}
}
getCache(key) {
return key ? this._cache[key]: this._cache;
}
registerModelEvent(eventName, callback) {
this.getModel().registerListener({
[eventName]: callback,
});
}
getNextTableName() {
let newTableName = `newtable${this.table_counter}`;
this.table_counter++;
return newTableName;
}
getEngine() {return this.engine;}
getModel() {return this.getEngine().getModel();}
getNewNode(initData) {
return this.getEngine().getNodeFactories().getFactory('table').generateModel({
initialConfig: {
otherInfo: {
data:initData,
},
},
});
}
getNewLink(type, initData) {
return this.getEngine().getLinkFactories().getFactory(type).generateModel({
initialConfig: {
data:initData,
},
});
}
getNewPort(type, initData, initOptions) {
return this.getEngine().getPortFactories().getFactory(type).generateModel({
initialConfig: {
data:initData,
options:initOptions,
},
});
}
addNode(data, position=[50, 50]) {
let newNode = this.getNewNode(data);
this.clearSelection();
newNode.setPosition(position[0], position[1]);
this.getModel().addNode(newNode);
return newNode;
}
addLink(data, type) {
let tableNodesDict = this.getModel().getNodesDict();
let sourceNode = tableNodesDict[data.referenced_table_uid];
let targetNode = tableNodesDict[data.local_table_uid];
let portName = sourceNode.getPortName(data.referenced_column_attnum);
let sourcePort = sourceNode.getPort(portName);
/* Create the port if not there */
if(!sourcePort) {
sourcePort = sourceNode.addPort(this.getNewPort(type, null, {name:portName, alignment:PortModelAlignment.RIGHT}));
}
portName = targetNode.getPortName(data.local_column_attnum);
let targetPort = targetNode.getPort(portName);
/* Create the port if not there */
if(!targetPort) {
targetPort = targetNode.addPort(this.getNewPort(type, null, {name:portName, alignment:PortModelAlignment.RIGHT}));
}
/* Link the ports */
let newLink = this.getNewLink(type, data);
newLink.setSourcePort(sourcePort);
newLink.setTargetPort(targetPort);
this.getModel().addLink(newLink);
return newLink;
}
serialize(version) {
return {
version: version||0,
data: this.getModel().serialize(),
};
}
deserialize(json_data) {
if(json_data.version) {
this.initializeModel(json_data.data);
}
}
serializeData() {
let nodes = {}, links = {};
let nodesDict = this.getModel().getNodesDict();
Object.keys(nodesDict).forEach((id)=>{
nodes[id] = nodesDict[id].serializeData();
});
this.getModel().getLinks().map((link)=>{
links[link.getID()] = link.serializeData(nodesDict);
});
/* Separate the links from nodes so that we don't have any dependancy issues */
return {
'nodes': nodes,
'links': links,
};
}
deserializeData(data){
let oidUidMap = {};
let uidFks = [];
data.forEach((node)=>{
let newData = {
name: node.name,
schema: node.schema,
description: node.description,
columns: node.columns,
primary_key: node.primary_key,
};
let newNode = this.addNode(newData);
oidUidMap[node.oid] = newNode.getID();
if(node.foreign_key) {
node.foreign_key.forEach((a_fk)=>{
uidFks.push({
uid: newNode.getID(),
data: a_fk.columns[0],
});
});
}
});
/* Lets use the oidUidMap for creating the links */
uidFks.forEach((fkData)=>{
let tableNodesDict = this.getModel().getNodesDict();
let newData = {
local_table_uid: fkData.uid,
local_column_attnum: undefined,
referenced_table_uid: oidUidMap[fkData.data.references],
referenced_column_attnum: undefined,
};
let sourceNode = tableNodesDict[newData.referenced_table_uid];
let targetNode = tableNodesDict[newData.local_table_uid];
newData.local_column_attnum = _.find(targetNode.getColumns(), (col)=>col.name==fkData.data.local_column).attnum;
newData.referenced_column_attnum = _.find(sourceNode.getColumns(), (col)=>col.name==fkData.data.referenced).attnum;
this.addLink(newData, 'onetomany');
});
setTimeout(this.dagreDistributeNodes.bind(this), 0);
}
repaint() {
this.getEngine().repaintCanvas();
}
clearSelection() {
this.getEngine()
.getModel()
.clearSelection();
}
getNodesData() {
return this.getEngine().getModel().getNodes().map((node)=>{
return node.getData();
});
}
getSelectedNodes() {
return this.getEngine()
.getModel()
.getSelectedEntities()
.filter(entity => entity instanceof TableNodeModel);
}
getSelectedLinks() {
return this.getEngine()
.getModel()
.getSelectedEntities()
.filter(entity => entity instanceof OneToManyLinkModel);
}
dagreDistributeNodes() {
this.dagre_engine.redistribute(this.getModel());
this.getEngine()
.getLinkFactories()
.getFactory(PathFindingLinkFactory.NAME)
.calculateRoutingMatrix();
this.repaint();
}
zoomIn() {
let model = this.getEngine().getModel();
if(model){
model.setZoomLevel(model.getZoomLevel() + 25);
this.repaint();
}
}
zoomOut() {
let model = this.getEngine().getModel();
if(model) {
model.setZoomLevel(model.getZoomLevel() - 25);
this.repaint();
}
}
zoomToFit() {
this.getEngine().zoomToFit();
}
// Sample call: this.fireAction({ type: 'keydown', ctrlKey: true, code: 'KeyN' });
fireAction(event) {
this.getEngine().getActionEventBus().fireAction({
event: {
...event,
key: '',
preventDefault: () => {},
stopPropagation: () => {},
},
});
}
fireEvent(data, eventName, model=false) {
if(model) {
this.getEngine().getModel().fireEvent(data, eventName);
} else {
this.getEngine().fireEvent(data, eventName);
}
}
registerKeyAction(action) {
this.getEngine().getActionEventBus().registerAction(action);
}
deregisterKeyAction(action) {
this.getEngine().getActionEventBus().deregisterAction(action);
}
}

View File

@ -0,0 +1,21 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { DiagramModel } from '@projectstorm/react-diagrams';
import _ from 'lodash';
export default class ERDModel extends DiagramModel {
constructor(options) {
super(options);
}
getNodesDict() {
return _.fromPairs(this.getNodes().map(node => [node.getID(), node]));
}
}

View File

@ -0,0 +1,158 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import * as commonUtils from 'sources/utils';
export default class DialogWrapper {
constructor(dialogContainerSelector, dialogTitle, typeOfDialog,
jquery, pgBrowser, alertify, backform, backgrid) {
this.dialogContainerSelector = dialogContainerSelector;
this.dialogTitle = dialogTitle;
this.jquery = jquery;
this.pgBrowser = pgBrowser;
this.alertify = alertify;
this.backform = backform;
this.backgrid = backgrid;
this.typeOfDialog = typeOfDialog;
}
main(title, dialogModel, okCallback) {
this.set('title', title);
this.dialogModel = dialogModel;
this.okCallback = okCallback;
}
build() {
this.alertify.pgDialogBuild.apply(this);
}
disableOKButton() {
this.__internal.buttons[1].element.disabled = true;
}
enableOKButton() {
this.__internal.buttons[1].element.disabled = false;
}
focusOnDialog(alertifyDialog) {
let backform_tab = this.jquery(alertifyDialog.elements.body).find('.backform-tab');
backform_tab.attr('tabindex', -1);
this.pgBrowser.keyboardNavigation.getDialogTabNavigator(this.jquery(alertifyDialog.elements.dialog));
let container = backform_tab.find('.tab-content:first > .tab-pane.active:first');
if(container.length === 0 && alertifyDialog.elements.content.innerHTML) {
container = this.jquery(alertifyDialog.elements.content);
}
commonUtils.findAndSetFocus(container);
}
setup() {
return {
buttons: [{
text: gettext('Cancel'),
key: 27,
className: 'btn btn-secondary fa fa-lg fa-times pg-alertify-button',
'data-btn-name': 'cancel',
}, {
text: gettext('OK'),
key: 13,
className: 'btn btn-primary fa fa-lg fa-save pg-alertify-button',
'data-btn-name': 'ok',
}],
// Set options for dialog
options: {
title: this.dialogTitle,
//disable both padding and overflow control.
padding: !1,
overflow: !1,
model: 0,
resizable: true,
maximizable: true,
pinnable: false,
closableByDimmer: false,
modal: false,
},
};
}
prepare() {
const $container = this.jquery(this.dialogContainerSelector);
const dialog = this.createDialog($container);
dialog.render();
this.elements.content.innerHTML = '';
this.elements.content.appendChild($container.get(0));
this.jquery(this.elements.body.childNodes[0]).addClass(
'alertify_tools_dialog_properties obj_properties'
);
const statusBar = this.jquery(
'<div class=\'pg-prop-status-bar pg-prop-status-bar-absolute pg-el-xs-12 d-none\'>' +
' <div class="error-in-footer"> ' +
' <div class="d-flex px-2 py-1"> ' +
' <div class="pr-2"> ' +
' <i class="fa fa-exclamation-triangle text-danger" aria-hidden="true"></i> ' +
' </div> ' +
' <div class="alert-text" role="alert"></div> ' +
' <div class="ml-auto close-error-bar"> ' +
' <a aria-label="' + gettext('Close error bar') + '" class="close-error fa fa-times text-danger"></a> ' +
' </div> ' +
' </div> ' +
' </div> ' +
'</div>').appendTo($container);
statusBar.find('.close-error').on('click', ()=>{
statusBar.addClass('d-none');
});
var onSessionInvalid = (msg) => {
statusBar.find('.alert-text').text(msg);
statusBar.removeClass('d-none');
this.disableOKButton();
return true;
};
var onSessionValidated = () => {
statusBar.find('.alert-text').text('');
statusBar.addClass('d-none');
this.enableOKButton();
return true;
};
this.dialogModel.on('pgadmin-session:valid', onSessionValidated);
this.dialogModel.on('pgadmin-session:invalid', onSessionInvalid);
this.dialogModel.startNewSession();
this.disableOKButton();
this.focusOnDialog(this);
}
callback(event) {
if (this.wasOkButtonPressed(event)) {
this.okCallback(this.view.model.toJSON(true));
}
}
createDialog($container) {
let fields = this.backform.generateViewSchema(
null, this.dialogModel, 'create', null, null, true, null
);
this.view = new this.backform.Dialog({
el: $container,
model: this.dialogModel,
schema: fields,
});
return this.view;
}
wasOkButtonPressed(event) {
return event.button['data-btn-name'] === 'ok';
}
}

View File

@ -0,0 +1,140 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import DialogWrapper from './DialogWrapper';
import _ from 'lodash';
export default class ManyToManyDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'manytomany_dialog';
}
getDataModel(attributes, tableNodesDict) {
const parseColumns = (columns)=>{
return columns.map((col)=>{
return {
value: col.attnum, label: col.name,
};
});
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
left_table_uid: undefined,
left_table_column_attnum: undefined,
right_table_uid: undefined,
right_table_column_attnum: undefined,
},
schema: [{
id: 'left_table_uid', label: gettext('Left Table'),
type: 'select2', readonly: true,
options: ()=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
}, {
id: 'left_table_column_attnum', label: gettext('Left table Column'),
type: 'select2', disabled: false, first_empty: false,
editable: true, options: (view)=>{
return parseColumns(tableNodesDict[view.model.get('left_table_uid')].getColumns());
},
},{
id: 'right_table_uid', label: gettext('Right Table'),
type: 'select2', disabled: false,
editable: true, options: (view)=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
if(uid === view.model.get('left_table_uid')) {
return;
}
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
},{
id: 'right_table_column_attnum', label: gettext('Right table Column'),
type: 'select2', disabled: false, deps: ['right_table_uid'],
editable: true, options: (view)=>{
if(view.model.get('right_table_uid')) {
return parseColumns(tableNodesDict[view.model.get('right_table_uid')].getColumns());
}
return [];
},
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('left_table_column_attnum')) || this.get('left_table_column_attnum') == '') {
msg = gettext('Select the left table column.');
this.errorModel.set('left_table_column_attnum', msg);
return msg;
}
if (_.isUndefined(this.get('right_table_uid')) || this.get('right_table_uid') == '') {
msg = gettext('Select the right table.');
this.errorModel.set('right_table_uid', msg);
return msg;
}
if (_.isUndefined(this.get('right_table_column_attnum')) || this.get('right_table_column_attnum') == '') {
msg = gettext('Select the right table column.');
this.errorModel.set('right_table_column_attnum', msg);
return msg;
}
},
});
return new dialogModel(attributes);
}
createOrGetDialog(title) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
title,
null,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, tablesData, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('manytomany_dialog');
dialog(dialogTitle, this.getDataModel(attributes, tablesData), callback).resizeTo(this.pgBrowser.stdW.sm, this.pgBrowser.stdH.md);
}
}

View File

@ -0,0 +1,140 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import DialogWrapper from './DialogWrapper';
import _ from 'lodash';
export default class OneToManyDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'onetomany_dialog';
}
getDataModel(attributes, tableNodesDict) {
const parseColumns = (columns)=>{
return columns.map((col)=>{
return {
value: col.attnum, label: col.name,
};
});
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
local_table_uid: undefined,
local_column_attnum: undefined,
referenced_table_uid: undefined,
referenced_column_attnum: undefined,
},
schema: [{
id: 'local_table_uid', label: gettext('Local Table'),
type: 'select2', readonly: true,
options: ()=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
}, {
id: 'local_column_attnum', label: gettext('Local Column'),
type: 'select2', disabled: false, first_empty: false,
editable: true, options: (view)=>{
return parseColumns(tableNodesDict[view.model.get('local_table_uid')].getColumns());
},
},{
id: 'referenced_table_uid', label: gettext('Referenced Table'),
type: 'select2', disabled: false,
editable: true, options: (view)=>{
let retOpts = [];
_.forEach(tableNodesDict, (node, uid)=>{
if(uid === view.model.get('local_table_uid')) {
return;
}
let [schema, name] = node.getSchemaTableName();
retOpts.push({value: uid, label: `(${schema}) ${name}`});
});
return retOpts;
},
},{
id: 'referenced_column_attnum', label: gettext('Referenced Column'),
type: 'select2', disabled: false, deps: ['referenced_table_uid'],
editable: true, options: (view)=>{
if(view.model.get('referenced_table_uid')) {
return parseColumns(tableNodesDict[view.model.get('referenced_table_uid')].getColumns());
}
return [];
},
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('local_column_attnum')) || this.get('local_column_attnum') == '') {
msg = gettext('Select the local column.');
this.errorModel.set('local_column_attnum', msg);
return msg;
}
if (_.isUndefined(this.get('referenced_table_uid')) || this.get('referenced_table_uid') == '') {
msg = gettext('Select the referenced table.');
this.errorModel.set('referenced_table_uid', msg);
return msg;
}
if (_.isUndefined(this.get('referenced_column_attnum')) || this.get('referenced_column_attnum') == '') {
msg = gettext('Select the referenced table column.');
this.errorModel.set('referenced_column_attnum', msg);
return msg;
}
},
});
return new dialogModel(attributes);
}
createOrGetDialog(title) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
title,
null,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, tablesData, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('onetomany_dialog');
dialog(dialogTitle, this.getDataModel(attributes, tablesData), callback).resizeTo(this.pgBrowser.stdW.sm, this.pgBrowser.stdH.md);
}
}

View File

@ -0,0 +1,739 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import Backgrid from 'sources/backgrid.pgadmin';
import Backform from 'sources/backform.pgadmin';
import Alertify from 'pgadmin.alertifyjs';
import $ from 'jquery';
import _ from 'lodash';
import DialogWrapper from './DialogWrapper';
export function transformToSupported(data) {
/* Table fields */
data = _.pick(data, ['oid', 'name', 'schema', 'description', 'columns', 'primary_key', 'foreign_key']);
/* Columns */
data['columns'] = data['columns'].map((column)=>{
return _.pick(column,[
'name','description','attowner','attnum','cltype','min_val_attlen','min_val_attprecision','max_val_attlen',
'max_val_attprecision', 'is_primary_key','attnotnull','attlen','attprecision','attidentity','colconstype',
'seqincrement','seqstart','seqmin','seqmax','seqcache','seqcycle',
]);
});
/* Primary key */
data['primary_key'] = data['primary_key'].map((primary_key)=>{
primary_key = _.pick(primary_key, ['columns']);
primary_key['columns'] = primary_key['columns'].map((column)=>{
return _.pick(column, ['column']);
});
return primary_key;
});
return data;
}
export default class TableDialog {
constructor(pgBrowser) {
this.pgBrowser = pgBrowser;
}
dialogName() {
return 'entity_dialog';
}
getDataModel(attributes, colTypes, schemas, sVersion) {
let dialogObj = this;
let columnsModel = this.pgBrowser.DataModel.extend({
idAttribute: 'attnum',
defaults: {
name: undefined,
description: undefined,
attowner: undefined,
attnum: undefined,
cltype: undefined,
min_val_attlen: undefined,
min_val_attprecision: undefined,
max_val_attlen: undefined,
max_val_attprecision: undefined,
is_primary_key: false,
attnotnull: false,
attlen: null,
attprecision: null,
attidentity: 'a',
colconstype: 'n',
seqincrement: undefined,
seqstart: undefined,
seqmin: undefined,
seqmax: undefined,
seqcache: undefined,
seqcycle: undefined,
},
initialize: function(attrs) {
if (_.size(attrs) !== 0) {
this.set({
'old_attidentity': this.get('attidentity'),
}, {silent: true});
}
dialogObj.pgBrowser.DataModel.prototype.initialize.apply(this, arguments);
if(!this.get('cltype') && colTypes.length > 0) {
this.set({
'cltype': colTypes[0]['value'],
}, {silent: true});
}
},
schema: [{
id: 'name', label: gettext('Name'), cell: 'string',
type: 'text', disabled: false,
cellHeaderClasses: 'width_percent_30',
editable: true,
}, {
// Need to show this field only when creating new table
// [in SubNode control]
id: 'is_primary_key', label: gettext('Primary key?'),
cell: Backgrid.Extension.TableChildSwitchCell, type: 'switch',
deps: ['name'], cellHeaderClasses: 'width_percent_5',
options: {
onText: gettext('Yes'), offText: gettext('No'),
onColor: 'success', offColor: 'ternary',
},
visible: function () {
return true;
},
disabled: false,
editable: true,
}, {
id: 'description', label: gettext('Comment'), cell: 'string', type: 'multiline',
}, {
id: 'cltype', label: gettext('Data type'),
cell: 'select2',
type: 'select2', disabled: false,
control: 'select2',
cellHeaderClasses: 'width_percent_30',
select2: { allowClear: false, first_empty: false }, group: gettext('Definition'),
options: function () {
return colTypes;
},
}, {
id: 'attlen', label: gettext('Length/Precision'), cell: Backgrid.Extension.IntegerDepCell,
deps: ['cltype'], type: 'int', group: gettext('Definition'), cellHeaderClasses: 'width_percent_20',
disabled: function (m) {
var of_type = m.get('cltype'),
flag = true;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.length) {
m.set('min_val_attlen', o.min_val, { silent: true });
m.set('max_val_attlen', o.max_val, { silent: true });
flag = false;
}
}
});
flag && setTimeout(function () {
if (m.get('attlen')) {
m.set('attlen', null);
}
}, 10);
return flag;
},
editable: function (m) {
var of_type = m.get('cltype'),
flag = false;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.length) {
m.set('min_val_attlen', o.min_val, { silent: true });
m.set('max_val_attlen', o.max_val, { silent: true });
flag = true;
}
}
});
!flag && setTimeout(function () {
if (m.get('attlen')) {
m.set('attlen', null);
}
}, 10);
return flag;
},
}, {
id: 'attprecision', label: gettext('Scale'), cell: Backgrid.Extension.IntegerDepCell,
deps: ['cltype'], type: 'int', group: gettext('Definition'), cellHeaderClasses: 'width_percent_20',
disabled: function (m) {
var of_type = m.get('cltype'),
flag = true;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.precision) {
m.set('min_val_attprecision', 0, { silent: true });
m.set('max_val_attprecision', o.max_val, { silent: true });
flag = false;
}
}
});
flag && setTimeout(function () {
if (m.get('attprecision')) {
m.set('attprecision', null);
}
}, 10);
return flag;
},
editable: function (m) {
if (!colTypes) {
// datatypes not loaded yet, may be this call is from CallByNeed from backgrid cell initialize.
return true;
}
var of_type = m.get('cltype'),
flag = false;
_.each(colTypes, function (o) {
if (of_type == o.value) {
if (o.precision) {
m.set('min_val_attprecision', 0, { silent: true });
m.set('max_val_attprecision', o.max_val, { silent: true });
flag = true;
}
}
});
!flag && setTimeout(function () {
if (m.get('attprecision')) {
m.set('attprecision', null);
}
}, 10);
return flag;
},
}, {
id: 'attnotnull', label: gettext('Not NULL?'), cell: 'switch',
type: 'switch', cellHeaderClasses: 'width_percent_20',
group: gettext('Constraints'),
options: { onText: gettext('Yes'), offText: gettext('No'), onColor: 'success', offColor: 'ternary' },
disabled: function(m) {
if (m.get('colconstype') == 'i') {
setTimeout(function () {
m.set('attnotnull', true);
}, 10);
}
return false;
},
}, {
id: 'colconstype',
label: gettext('Type'),
cell: 'string',
type: 'radioModern',
controlsClassName: 'pgadmin-controls col-12 col-sm-9',
controlLabelClassName: 'control-label col-sm-3 col-12',
group: gettext('Constraints'),
options: function() {
var opt_array = [
{'label': gettext('NONE'), 'value': 'n'},
{'label': gettext('IDENTITY'), 'value': 'i'},
];
if (sVersion >= 120000) {
opt_array.push({
'label': gettext('GENERATED'),
'value': 'g',
});
}
return opt_array;
},
disabled: false,
visible: function() {
if (sVersion >= 100000) {
return true;
}
return false;
},
}, {
id: 'attidentity', label: gettext('Identity'), control: 'select2',
cell: 'select2',
select2: {placeholder: 'Select identity', allowClear: false, width: '100%'},
group: gettext('Constraints'),
'options': [
{label: gettext('ALWAYS'), value: 'a'},
{label: gettext('BY DEFAULT'), value: 'd'},
],
deps: ['colconstype'],
visible: function(m) {
if (sVersion >= 100000 && m.isTypeIdentity(m)) {
return true;
}
return false;
},
disabled: function() {
return false;
},
}, {
id: 'seqincrement', label: gettext('Increment'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
min: 1, deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqstart', label: gettext('Start'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
disabled: function(m) {
let isIdentity = m.get('attidentity');
if(!_.isUndefined(isIdentity) && !_.isNull(isIdentity) && !_.isEmpty(isIdentity))
return false;
return true;
}, deps: ['attidentity', 'colconstype'],
visible: 'isTypeIdentity',
},{
id: 'seqmin', label: gettext('Minimum'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqmax', label: gettext('Maximum'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqcache', label: gettext('Cache'), type: 'int',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
min: 1, deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
},{
id: 'seqcycle', label: gettext('Cycled'), type: 'switch',
mode: ['properties', 'create', 'edit'], group: gettext('Constraints'),
deps: ['attidentity', 'colconstype'], disabled: 'isIdentityColumn',
visible: 'isTypeIdentity',
}],
validate: function(keys) {
var msg = undefined;
// Nothing to validate
if (keys && keys.length == 0) {
this.errorModel.clear();
return null;
} else {
this.errorModel.clear();
}
if (_.isUndefined(this.get('name'))
|| String(this.get('name')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Column name cannot be empty.');
this.errorModel.set('name', msg);
return msg;
}
if (_.isUndefined(this.get('cltype'))
|| String(this.get('cltype')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Column type cannot be empty.');
this.errorModel.set('cltype', msg);
return msg;
}
if (!_.isUndefined(this.get('cltype'))
&& !_.isUndefined(this.get('attlen'))
&& !_.isNull(this.get('attlen'))
&& this.get('attlen') !== '') {
// Validation for Length field
if (this.get('attlen') < this.get('min_val_attlen'))
msg = gettext('Length/Precision should not be less than: ') + this.get('min_val_attlen');
if (this.get('attlen') > this.get('max_val_attlen'))
msg = gettext('Length/Precision should not be greater than: ') + this.get('max_val_attlen');
// If we have any error set then throw it to user
if(msg) {
this.errorModel.set('attlen', msg);
return msg;
}
}
if (!_.isUndefined(this.get('cltype'))
&& !_.isUndefined(this.get('attprecision'))
&& !_.isNull(this.get('attprecision'))
&& this.get('attprecision') !== '') {
// Validation for precision field
if (this.get('attprecision') < this.get('min_val_attprecision'))
msg = gettext('Scale should not be less than: ') + this.get('min_val_attprecision');
if (this.get('attprecision') > this.get('max_val_attprecision'))
msg = gettext('Scale should not be greater than: ') + this.get('max_val_attprecision');
// If we have any error set then throw it to user
if(msg) {
this.errorModel.set('attprecision', msg);
return msg;
}
}
var minimum = this.get('seqmin'),
maximum = this.get('seqmax'),
start = this.get('seqstart');
if (!this.isNew() && this.get('colconstype') == 'i' &&
(this.get('old_attidentity') == 'a' || this.get('old_attidentity') == 'd') &&
(this.get('attidentity') == 'a' || this.get('attidentity') == 'd')) {
if (_.isUndefined(this.get('seqincrement'))
|| String(this.get('seqincrement')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Increment value cannot be empty.');
this.errorModel.set('seqincrement', msg);
return msg;
} else {
this.errorModel.unset('seqincrement');
}
if (_.isUndefined(this.get('seqmin'))
|| String(this.get('seqmin')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Minimum value cannot be empty.');
this.errorModel.set('seqmin', msg);
return msg;
} else {
this.errorModel.unset('seqmin');
}
if (_.isUndefined(this.get('seqmax'))
|| String(this.get('seqmax')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Maximum value cannot be empty.');
this.errorModel.set('seqmax', msg);
return msg;
} else {
this.errorModel.unset('seqmax');
}
if (_.isUndefined(this.get('seqcache'))
|| String(this.get('seqcache')).replace(/^\s+|\s+$/g, '') == '') {
msg = gettext('Cache value cannot be empty.');
this.errorModel.set('seqcache', msg);
return msg;
} else {
this.errorModel.unset('seqcache');
}
}
var min_lt = gettext('Minimum value must be less than maximum value.'),
start_lt = gettext('Start value cannot be less than minimum value.'),
start_gt = gettext('Start value cannot be greater than maximum value.');
if (_.isEmpty(minimum) || _.isEmpty(maximum))
return null;
if ((minimum == 0 && maximum == 0) ||
(parseInt(minimum, 10) >= parseInt(maximum, 10))) {
this.errorModel.set('seqmin', min_lt);
return min_lt;
} else {
this.errorModel.unset('seqmin');
}
if (start && minimum && parseInt(start) < parseInt(minimum)) {
this.errorModel.set('seqstart', start_lt);
return start_lt;
} else {
this.errorModel.unset('seqstart');
}
if (start && maximum && parseInt(start) > parseInt(maximum)) {
this.errorModel.set('seqstart', start_gt);
return start_gt;
} else {
this.errorModel.unset('seqstart');
}
return null;
},
// Check whether the column is identity column or not
isIdentityColumn: function(m) {
let isIdentity = m.get('attidentity');
if(!_.isUndefined(isIdentity) && !_.isNull(isIdentity) && !_.isEmpty(isIdentity))
return false;
return true;
},
// Check whether the column is a identity column
isTypeIdentity: function(m) {
let colconstype = m.get('colconstype');
if (!_.isUndefined(colconstype) && !_.isNull(colconstype) && colconstype == 'i') {
return true;
}
return false;
},
// Check whether the column is a generated column
isTypeGenerated: function(m) {
let colconstype = m.get('colconstype');
if (!_.isUndefined(colconstype) && !_.isNull(colconstype) && colconstype == 'g') {
return true;
}
return false;
},
});
const formatSchemaItem = function(opt) {
if (!opt.id) {
return opt.text;
}
var optimage = $(opt.element).data('image');
if (!optimage) {
return opt.text;
} else {
return $('<span></span>').append(
$('<span></span>', {
class: 'wcTabIcon ' + optimage,
})
).append($('<span></span>').text(opt.text));
}
};
let dialogModel = this.pgBrowser.DataModel.extend({
defaults: {
name: undefined,
schema: undefined,
description: undefined,
columns: [],
primary_key: [],
},
initialize: function() {
dialogObj.pgBrowser.DataModel.prototype.initialize.apply(this, arguments);
if(!this.get('schema') && schemas.length > 0) {
this.set({
'schema': schemas[0]['name'],
}, {silent: true});
}
},
schema: [{
id: 'name', label: gettext('Name'), type: 'text', disabled: false,
},{
id: 'schema', label: gettext('Schema'), type: 'text',
control: 'select2', select2: {
allowClear: false, first_empty: false,
templateResult: formatSchemaItem,
templateSelection: formatSchemaItem,
},
options: function () {
return schemas.map((schema)=>{
return {
'value': schema['name'],
'image': 'icon-schema',
'label': schema['name'],
};
});
},
filter: function(d) {
// If schema name start with pg_* then we need to exclude them
if(d && d.label.match(/^pg_/))
{
return false;
}
return true;
},
},{
id: 'description', label: gettext('Comment'), type: 'multiline',
},{
id: 'columns', label: gettext('Columns'), type: 'collection', mode: ['create'],
group: gettext('Columns'),
model: columnsModel,
subnode: columnsModel,
disabled: false,
uniqueCol : ['name'],
columns : ['name' , 'cltype', 'attlen', 'attprecision', 'attnotnull', 'is_primary_key'],
control: Backform.UniqueColCollectionControl.extend({
initialize: function() {
Backform.UniqueColCollectionControl.prototype.initialize.apply(this, arguments);
var self = this,
collection = self.model.get(self.field.get('name'));
if(collection.isEmpty()) {
self.last_attnum = -1;
} else {
var lastCol = collection.max(function(col) {
return col.get('attnum');
});
self.last_attnum = lastCol.get('attnum');
}
collection.on('change:is_primary_key', function(m) {
var primary_key_coll = self.model.get('primary_key'),
column_name = m.get('name'),
primary_key, primary_key_column_coll;
if(m.get('is_primary_key')) {
// Add column to primary key.
if (primary_key_coll.length < 1) {
primary_key = new (primary_key_coll.model)({}, {
top: self.model,
collection: primary_key_coll,
handler: primary_key_coll,
});
primary_key_coll.add(primary_key);
} else {
primary_key = primary_key_coll.first();
}
primary_key_column_coll = primary_key.get('columns');
var primary_key_column_exist = primary_key_column_coll.where({column:column_name});
if (primary_key_column_exist.length == 0) {
var primary_key_column = new (
primary_key_column_coll.model
)({column: column_name}, {
silent: true,
top: self.model,
collection: primary_key_coll,
handler: primary_key_coll,
});
primary_key_column_coll.add(primary_key_column);
}
primary_key_column_coll.trigger(
'pgadmin:multicolumn:updated', primary_key_column_coll
);
} else {
// remove column from primary key.
if (primary_key_coll.length > 0) {
primary_key = primary_key_coll.first();
// Do not alter existing primary key columns.
if (!_.isUndefined(primary_key.get('oid'))) {
return;
}
primary_key_column_coll = primary_key.get('columns');
var removedCols = primary_key_column_coll.where({column:column_name});
if (removedCols.length > 0) {
primary_key_column_coll.remove(removedCols);
_.each(removedCols, function(local_model) {
local_model.destroy();
});
if (primary_key_column_coll.length == 0) {
/* Ideally above line of code should be "primary_key_coll.reset()".
* But our custom DataCollection (extended from Backbone collection in datamodel.js)
* does not respond to reset event, it only supports add, remove, change events.
* And hence no custom event listeners/validators get called for reset event.
*/
primary_key_coll.remove(primary_key_coll.first());
}
}
primary_key_column_coll.trigger('pgadmin:multicolumn:updated', primary_key_column_coll);
}
}
});
collection.on('change:name', function(m) {
let primary_key = self.model.get('primary_key').first();
if(primary_key) {
let updatedCols = primary_key.get('columns').where(
{column: m.previous('name')}
);
if (updatedCols.length > 0) {
/*
* Table column name has changed so update
* column name in primary key as well.
*/
updatedCols[0].set(
{'column': m.get('name')},
{silent: true});
}
}
});
collection.on('remove', function(m) {
let primary_key = self.model.get('primary_key').first();
if(primary_key) {
let removedCols = primary_key.get('columns').where(
{column: m.get('name')}
);
primary_key.get('columns').remove(removedCols);
}
});
},
}),
canAdd: true,
canEdit: true, canDelete: true,
// For each row edit/delete button enable/disable
canEditRow: true,
canDeleteRow: true,
allowMultipleEmptyRow: false,
beforeAdd: function(newModel) {
this.last_attnum++;
newModel.set('attnum', this.last_attnum);
return newModel;
},
},{
// Here we will create tab control for constraints
// We will hide the tab for ERD
type: 'nested', control: 'tab', group: gettext('Constraints'), mode: ['properties'],
schema: [{
id: 'primary_key', label: '',
model: this.pgBrowser.Nodes['primary_key'].model.extend({
validate: ()=>{},
}),
subnode: this.pgBrowser.Nodes['primary_key'].model.extend({
validate: ()=>{},
}),
editable: false, type: 'collection',
},
],
}],
validate: function() {
var msg,
name = this.get('name'),
schema = this.get('schema');
if (
_.isUndefined(name) || _.isNull(name) ||
String(name).replace(/^\s+|\s+$/g, '') == ''
) {
msg = gettext('Table name cannot be empty.');
this.errorModel.set('name', msg);
return msg;
}
this.errorModel.unset('name');
if (
_.isUndefined(schema) || _.isNull(schema) ||
String(schema).replace(/^\s+|\s+$/g, '') == ''
) {
msg = gettext('Table schema cannot be empty.');
this.errorModel.set('schema', msg);
return msg;
}
this.errorModel.unset('schema');
return null;
},
});
return new dialogModel(attributes);
}
createOrGetDialog(type) {
const dialogName = this.dialogName();
if (!Alertify[dialogName]) {
Alertify.dialog(dialogName, () => {
return new DialogWrapper(
`<div class="${dialogName}"></div>`,
null,
type,
$,
this.pgBrowser,
Alertify,
Backform
);
});
}
return Alertify[dialogName];
}
show(title, attributes, colTypes, schemas, sVersion, callback) {
let dialogTitle = title || gettext('Unknown');
const dialog = this.createOrGetDialog('table_dialog');
dialog(dialogTitle, this.getDataModel(attributes, colTypes, schemas, sVersion), callback).resizeTo(this.pgBrowser.stdW.md, this.pgBrowser.stdH.md);
}
}

View File

@ -0,0 +1,32 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import TableDialog, {transformToSupported as transformToSupportedTable} from './TableDialog';
import OneToManyDialog from './OneToManyDialog';
import ManyToManyDialog from './ManyToManyDialog';
import pgBrowser from 'top/browser/static/js/browser';
import 'sources/backgrid.pgadmin';
import 'sources/backform.pgadmin';
export default function getDialog(dialogName) {
if(dialogName === 'entity_dialog') {
return new TableDialog(pgBrowser);
} else if(dialogName === 'onetomany_dialog') {
return new OneToManyDialog(pgBrowser);
} else if(dialogName === 'manytomany_dialog') {
return new ManyToManyDialog(pgBrowser);
}
}
export function transformToSupported(type, data) {
if(type == 'table') {
return transformToSupportedTable(data);
}
return data;
}

View File

@ -0,0 +1,30 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import ReactDOM from 'react-dom';
import BodyWidget from './ui_components/BodyWidget';
import getDialog, {transformToSupported} from './dialogs';
import Alertify from 'pgadmin.alertifyjs';
import pgWindow from 'sources/window';
export default class ERDTool {
constructor(container, params) {
this.container = document.querySelector(container);
this.params = params;
}
render() {
/* Mount the React ERD tool to the container */
ReactDOM.render(
<BodyWidget params={this.params} getDialog={getDialog} transformToSupported={transformToSupported} pgAdmin={pgWindow.pgAdmin} alertify={Alertify} />,
this.container
);
}
}

View File

@ -0,0 +1,288 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import {
RightAngleLinkModel,
RightAngleLinkWidget,
DefaultLinkFactory,
PortModelAlignment,
LinkWidget,
PointModel
} from '@projectstorm/react-diagrams';
import {Point} from '@projectstorm/geometry';
import _ from 'lodash';
export const OneToManyModel = {
local_table_uid: undefined,
local_column_attnum: undefined,
referenced_table_uid: undefined,
referenced_column_attnum: undefined,
}
export class OneToManyLinkModel extends RightAngleLinkModel {
constructor({data, ...options}) {
super({
type: 'onetomany',
width: 1,
class: 'link-onetomany',
locked: true,
...options
});
this._data = {
...data,
};
}
getData() {
return this._data;
}
setData(data) {
this._data = data;
}
serializeData(nodesDict) {
let data = this.getData();
let target = nodesDict[data['local_table_uid']].getData();
let source = nodesDict[data['referenced_table_uid']].getData();
return {
'schema': target.schema,
'table': target.name,
'remote_schema': source.schema,
'remote_table': source.name,
'columns': [{
'local_column': _.find(target.columns, (col)=>data.local_column_attnum == col.attnum).name,
'referenced': _.find(source.columns, (col)=>data.referenced_column_attnum == col.attnum).name,
}],
}
}
serialize() {
return {
...super.serialize(),
data: this.getData()
};
}
}
const CustomLinkEndWidget = props => {
const { point, rotation, tx, ty, type } = props;
const svgForType = (itype) => {
if(itype == 'many') {
return (
<>
<circle className="svg-link-ele svg-otom-circle" cx="0" cy="16" r={props.width*1.75} strokeWidth={props.width} />
<polyline className="svg-link-ele" points="-8,0 0,15 0,0 0,30 0,15 8,0" fill="none" strokeWidth={props.width} />
</>
)
} else if (type == 'one') {
return (
<polyline className="svg-link-ele" points="-8,15 0,15 0,0 0,30 0,15 8,15" fill="none" strokeWidth={props.width} />
)
}
}
return (
<g transform={'translate(' + point.getPosition().x + ', ' + point.getPosition().y + ')'}>
<g transform={'translate('+tx+','+ty+')'}>
<g style={{ transform: 'rotate(' + rotation + 'deg)' }}>
{svgForType(type)}
</g>
</g>
</g>
);
};
export class OneToManyLinkWidget extends RightAngleLinkWidget {
constructor(props) {
super(props);
}
endPointTranslation(alignment, offset) {
let degree = 0;
let tx = 0, ty = 0;
switch(alignment) {
case PortModelAlignment.BOTTOM:
ty = -offset;
break;
case PortModelAlignment.LEFT:
degree = 90;
tx = offset
break;
case PortModelAlignment.TOP:
degree = 180;
ty = offset;
break;
case PortModelAlignment.RIGHT:
degree = -90;
tx = -offset;
break;
}
return [degree, tx, ty];
}
addCustomWidgetPoint(type, endpoint, point) {
let offset = 30;
const [rotation, tx, ty] = this.endPointTranslation(endpoint.options.alignment, offset);
if(!point) {
point = this.props.link.point(
endpoint.getX()-tx, endpoint.getY()-ty, {'one': 1, 'many': 2}[type]
);
} else {
point.setPosition(endpoint.getX()-tx, endpoint.getY()-ty);
}
return {
type: type,
point: point,
rotation: rotation,
tx: tx,
ty: ty
}
}
generateCustomEndWidget({type, point, rotation, tx, ty}) {
return (
<CustomLinkEndWidget
key={point.getID()}
point={point}
rotation={rotation}
tx={tx}
ty={ty}
type={type}
colorSelected={this.props.link.getOptions().selectedColor}
color={this.props.link.getOptions().color}
width={this.props.width}
/>
);
}
draggingEvent(event, index) {
let points = this.props.link.getPoints();
// get moving difference. Index + 1 will work because links indexes has
// length = points.lenght - 1
let dx = Math.abs(points[index].getX() - points[index + 1].getX());
let dy = Math.abs(points[index].getY() - points[index + 1].getY());
// moving with y direction
if (dx === 0) {
this.calculatePositions(points, event, index, 'x');
} else if (dy === 0) {
this.calculatePositions(points, event, index, 'y');
}
this.props.link.setFirstAndLastPathsDirection();
}
handleMove = function(event) {
this.props.link.getTargetPort()
this.draggingEvent(event, this.dragging_index);
this.props.link.fireEvent({}, 'positionChanged');
}.bind(this);
render() {
//ensure id is present for all points on the path
let points = this.props.link.getPoints();
let paths = [];
let onePoint = this.addCustomWidgetPoint('one', this.props.link.getSourcePort(), points[0]);
let manyPoint = this.addCustomWidgetPoint('many', this.props.link.getTargetPort(), points[points.length-1]);
if (!this.state.canDrag && points.length > 2) {
// Those points and its position only will be moved
for (let i = 1; i < points.length; i += points.length - 2) {
if (i - 1 === 0) {
if (this.props.link.getFirstPathXdirection()) {
points[i].setPosition(points[i].getX(), points[i - 1].getY());
} else {
points[i].setPosition(points[i - 1].getX(), points[i].getY());
}
} else {
if (this.props.link.getLastPathXdirection()) {
points[i - 1].setPosition(points[i - 1].getX(), points[i].getY());
} else {
points[i - 1].setPosition(points[i].getX(), points[i - 1].getY());
}
}
}
}
// If there is existing link which has two points add one
if (points.length === 2 && !this.state.canDrag) {
this.props.link.addPoint(
new PointModel({
link: this.props.link,
position: new Point(onePoint.point.getX(), manyPoint.point.getY())
})
);
}
paths.push(this.generateCustomEndWidget(onePoint));
for (let j = 0; j < points.length - 1; j++) {
paths.push(
this.generateLink(
LinkWidget.generateLinePath(points[j], points[j + 1]),
{
'data-linkid': this.props.link.getID(),
'data-point': j,
onMouseDown: (event) => {
if (event.button === 0) {
this.setState({ canDrag: true });
this.dragging_index = j;
// Register mouse move event to track mouse position
// On mouse up these events are unregistered check "this.handleUp"
window.addEventListener('mousemove', this.handleMove);
window.addEventListener('mouseup', this.handleUp);
}
},
onMouseEnter: (event) => {
this.setState({ selected: true });
this.props.link.lastHoverIndexOfPath = j;
}
},
j
)
);
}
paths.push(this.generateCustomEndWidget(manyPoint));
this.refPaths = [];
return <g data-default-link-test={this.props.link.getOptions().testName}>{paths}</g>;
}
}
export class OneToManyLinkFactory extends DefaultLinkFactory {
constructor() {
super('onetomany');
}
generateModel(event) {
return new OneToManyLinkModel(event.initialConfig);
}
generateReactWidget(event) {
return <OneToManyLinkWidget color='#fff' width={1} smooth={true} link={event.model} diagramEngine={this.engine} factory={this} />;
}
generateLinkSegment(model, selected, path) {
return (
<path
className={'svg-link-ele path ' + (selected ? 'selected' : '')}
stroke={model.getOptions().color}
selected={selected}
strokeWidth={model.getOptions().width}
d={path}
>
</path>
);
}
}

View File

@ -0,0 +1,202 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import { DefaultNodeModel, PortWidget } from '@projectstorm/react-diagrams';
import { AbstractReactFactory } from '@projectstorm/react-canvas-core';
import _ from 'lodash';
import { IconButton, DetailsToggleButton } from '../ui_components/ToolBar';
const TYPE = 'table';
export class TableNodeModel extends DefaultNodeModel {
constructor({otherInfo, ...options}) {
super({
...options,
type: TYPE
});
this._note = otherInfo.note || '';
this._data = {
columns: [],
...otherInfo.data,
};
}
getPortName(attnum) {
return `coll-port-${attnum}`;
}
setNote(note) {
this._note = note;
}
getNote() {
return this._note;
}
addColumn(col) {
this._data.columns.push(col);
}
getColumnAt(attnum) {
return _.find(this.getColumns(), (col)=>col.attnum==attnum);
}
getColumns() {
return this._data.columns;
}
setName(name) {
this._data['name'] = name;
}
cloneData(name) {
let newData = {
...this.getData()
};
if(name) {
newData['name'] = name
}
return newData;
}
setData(data) {
let self = this;
/* Remove the links if column dropped */
_.differenceWith(this._data.columns, data.columns, function(existing, incoming) {
return existing.attnum == incoming.attnum;
}).forEach((col)=>{
let existPort = self.getPort(self.getPortName(col.attnum));
if(existPort) {
existPort.removeAllLinks();
self.removePort(existPort);
}
});
this._data = data;
this.fireEvent({}, 'nodeUpdated');
}
getData() {
return this._data;
}
getSchemaTableName() {
return [this._data.schema, this._data.name];
}
remove() {
Object.values(this.getPorts()).forEach((port)=>{
port.removeAllLinks();
});
super.remove();
}
serializeData() {
return this.getData();
}
serialize() {
return {
...super.serialize(),
otherInfo: {
data: this.getData(),
note: this.getNote(),
}
};
}
}
export class TableNodeWidget extends React.Component {
constructor(props) {
super(props);
this.state = {
show_details: true
}
this.props.node.registerListener({
toggleDetails: (event) => {
this.setState({show_details: event.show_details});
},
});
}
generateColumn(col) {
let port = this.props.node.getPort(this.props.node.getPortName(col.attnum));
return (
<div className='d-flex col-row' key={col.attnum}>
<div className='d-flex col-row-data'>
<div><span className={'wcTabIcon ' + (col.is_primary_key?'icon-primary_key':'icon-column')}></span></div>
<div>
<span className='col-name'>{col.name}</span>&nbsp;
{this.state.show_details &&
<span className='col-datatype'>{col.cltype}{col.attlen ? ('('+ col.attlen + (col.attprecision ? ','+col.attprecision : '') +')') : ''}</span>}
</div>
</div>
<div className="ml-auto col-row-port">{this.generatePort(port)}</div>
</div>
)
}
generatePort = port => {
if(port) {
return (
<PortWidget engine={this.props.engine} port={port} key={port.getID()} className={"port-" + port.options.alignment} />
);
}
return <></>;
};
toggleShowDetails = (e) => {
e.preventDefault();
this.setState((prevState)=>({show_details: !prevState.show_details}));
}
render() {
let node_data = this.props.node.getData();
return (
<div className={"table-node " + (this.props.node.isSelected() ? 'selected': '') } onDoubleClick={()=>{this.props.node.fireEvent({}, 'editNode')}}>
<div className="table-toolbar">
<DetailsToggleButton className='btn-xs' showDetails={this.state.show_details} onClick={this.toggleShowDetails} onDoubleClick={(e)=>{e.stopPropagation();}} />
{this.props.node.getNote() &&
<IconButton icon="far fa-sticky-note" className="btn-xs btn-warning ml-auto" onClick={()=>{
this.props.node.fireEvent({}, 'showNote')
}} title="Check note" />}
</div>
<div className="table-schema">
<span className="wcTabIcon icon-schema"></span>
{node_data.schema}
</div>
<div className="table-name">
<span className="wcTabIcon icon-table"></span>
{node_data.name}
</div>
<div className="table-cols">
{_.map(node_data.columns, (col)=>this.generateColumn(col))}
</div>
</div>
);
}
}
export class TableNodeFactory extends AbstractReactFactory {
constructor() {
super(TYPE);
}
generateModel(event) {
return new TableNodeModel(event.initialConfig);
}
generateReactWidget(event) {
return <TableNodeWidget engine={this.engine} node={event.model} />;
}
}

View File

@ -0,0 +1,43 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import { PortModel } from '@projectstorm/react-diagrams-core';
import {OneToManyLinkModel} from '../links/OneToManyLink';
import { AbstractModelFactory } from '@projectstorm/react-canvas-core';
const TYPE = 'onetomany';
export default class OneToManyPortModel extends PortModel {
constructor({options}) {
super({
...options,
type: TYPE,
});
}
removeAllLinks() {
Object.values(this.getLinks()).forEach((link)=>{
link.remove();
});
}
createLinkModel() {
return new OneToManyLinkModel({});
}
}
export class OneToManyPortFactory extends AbstractModelFactory {
constructor() {
super(TYPE);
}
generateModel(event) {
return new OneToManyPortModel(event.initialConfig||{});
}
}

View File

@ -0,0 +1,681 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import * as React from 'react';
import { CanvasWidget } from '@projectstorm/react-canvas-core';
import axios from 'axios';
import { Action, InputType } from '@projectstorm/react-canvas-core';
import PropTypes from 'prop-types';
import ERDCore from '../ERDCore';
import ToolBar, {IconButton, DetailsToggleButton, ButtonGroup} from './ToolBar';
import ConnectionBar, { STATUS as CONNECT_STATUS } from './ConnectionBar';
import Loader from './Loader';
import FloatingNote from './FloatingNote';
import {setPanelTitle} from '../../erd_module';
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import {showERDSqlTool} from 'tools/datagrid/static/js/show_query_tool';
/* Custom react-diagram action for keyboard events */
export class KeyboardShortcutAction extends Action {
constructor(shortcut_handlers=[]) {
super({
type: InputType.KEY_DOWN,
fire: ({ event })=>{
this.callHandler(event);
}
});
this.shortcuts = {};
for(let i=0; i<shortcut_handlers.length; i++){
let [key, handler] = shortcut_handlers[i];
this.shortcuts[this.shortcutKey(key.alt, key.control, key.shift, false, key.key.key_code)] = handler;
}
}
shortcutKey(altKey, ctrlKey, shiftKey, metaKey, keyCode) {
return `${altKey}:${ctrlKey}:${shiftKey}:${metaKey}:${keyCode}`;
}
callHandler(event) {
let handler = this.shortcuts[this.shortcutKey(event.altKey, event.ctrlKey, event.shiftKey, event.metaKey, event.keyCode)];
if(handler) {
handler();
}
}
}
/* The main body container for the ERD */
export default class BodyWidget extends React.Component {
constructor() {
super();
this.state = {
conn_status: CONNECT_STATUS.DISCONNECTED,
server_version: null,
any_item_selected: false,
single_node_selected: false,
single_link_selected: false,
coll_types: [],
loading_msg: null,
note_open: false,
note_node: null,
current_file: null,
dirty: false,
show_details: true,
preferences: {},
}
this.diagram = new ERDCore();
this.fileInputRef = React.createRef();
this.diagramContainerRef = React.createRef();
this.canvasEle = null;
this.noteRefEle = null;
this.noteNode = null;
this.keyboardActionObj = null;
this.onLoadDiagram = this.onLoadDiagram.bind(this);
this.onSaveDiagram = this.onSaveDiagram.bind(this);
this.onSaveAsDiagram = this.onSaveAsDiagram.bind(this);
this.onSQLClick = this.onSQLClick.bind(this);
this.onAddNewNode = this.onAddNewNode.bind(this);
this.onEditNode = this.onEditNode.bind(this);
this.onCloneNode = this.onCloneNode.bind(this);
this.onDeleteNode = this.onDeleteNode.bind(this);
this.onNoteClick = this.onNoteClick.bind(this);
this.onNoteClose = this.onNoteClose.bind(this);
this.onOneToManyClick = this.onOneToManyClick.bind(this);
this.onManyToManyClick = this.onManyToManyClick.bind(this);
this.onAutoDistribute = this.onAutoDistribute.bind(this);
this.onDetailsToggle = this.onDetailsToggle.bind(this);
this.onHelpClick = this.onHelpClick.bind(this);
this.diagram.zoomToFit = this.diagram.zoomToFit.bind(this.diagram);
this.diagram.zoomIn = this.diagram.zoomIn.bind(this.diagram);
this.diagram.zoomOut = this.diagram.zoomOut.bind(this.diagram);
}
registerModelEvents() {
let diagramEvents = {
'offsetUpdated': (event)=>{
this.realignGrid({backgroundPosition: `${event.offsetX}px ${event.offsetY}px`});
event.stopPropagation();
},
'zoomUpdated': (event)=>{
let { gridSize } = this.diagram.getModel().getOptions();
let bgSize = gridSize*event.zoom/100;
this.realignGrid({backgroundSize: `${bgSize*3}px ${bgSize*3}px`});
},
'nodesSelectionChanged': ()=>{
this.setState({
single_node_selected: this.diagram.getSelectedNodes().length == 1,
any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0,
});
},
'linksSelectionChanged': ()=>{
this.setState({
single_link_selected: this.diagram.getSelectedLinks().length == 1,
any_item_selected: this.diagram.getSelectedNodes().length > 0 || this.diagram.getSelectedLinks().length > 0,
});
},
'linksUpdated': () => {
this.setState({dirty: true});
},
'nodesUpdated': ()=>{
this.setState({dirty: true});
},
'showNote': (event)=>{
this.showNote(event.node);
},
'editNode': (event) => {
this.addEditNode(event.node);
}
};
Object.keys(diagramEvents).forEach(eventName => {
this.diagram.registerModelEvent(eventName, diagramEvents[eventName]);
});
}
registerKeyboardShortcuts() {
/* First deregister to avoid double events */
this.keyboardActionObj && this.diagram.deregisterKeyAction(this.keyboardActionObj);
this.keyboardActionObj = new KeyboardShortcutAction([
[this.state.preferences.open_project, this.onLoadDiagram],
[this.state.preferences.save_project, this.onSaveDiagram],
[this.state.preferences.save_project_as, this.onSaveAsDiagram],
[this.state.preferences.generate_sql, this.onSQLClick],
[this.state.preferences.add_table, this.onAddNewNode],
[this.state.preferences.edit_table, this.onEditNode],
[this.state.preferences.clone_table, this.onCloneNode],
[this.state.preferences.drop_table, this.onDeleteNode],
[this.state.preferences.add_edit_note, this.onNoteClick],
[this.state.preferences.one_to_many, this.onOneToManyClick],
[this.state.preferences.many_to_many, this.onManyToManyClick],
[this.state.preferences.auto_align, this.onAutoDistribute],
[this.state.preferences.zoom_to_fit, this.diagram.zoomToFit],
[this.state.preferences.zoom_in, this.diagram.zoomIn],
[this.state.preferences.zoom_out, this.diagram.zoomOut]
]);
this.diagram.registerKeyAction(this.keyboardActionObj);
}
handleAxiosCatch(err) {
let alert = this.props.alertify.alert().set('title', gettext('Error'));
if (err.response) {
// client received an error response (5xx, 4xx)
alert.set('message', `${err.response.statusText} - ${err.response.data.errormsg}`).show();
console.error('response error', err.response);
} else if (err.request) {
// client never received a response, or request never left
alert.set('message', gettext('Client error') + ':' + err).show();
console.error('client eror', err);
} else {
alert.set('message', err.message).show();
console.error('other error', err);
}
}
async componentDidMount() {
this.setLoading('Preparing');
this.setTitle(this.state.current_file);
this.setState({
preferences: this.props.pgAdmin.Browser.get_preferences_for_module('erd')
}, this.registerKeyboardShortcuts);
this.registerModelEvents();
this.realignGrid({
backgroundSize: '45px 45px',
backgroundPosition: '0px 0px',
});
this.props.pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:select_file', this.openFile, this);
this.props.pgAdmin.Browser.Events.on('pgadmin-storage:finish_btn:create_file', this.saveFile, this);
this.props.pgAdmin.Browser.onPreferencesChange('erd', () => {
this.setState({
preferences: this.props.pgAdmin.Browser.get_preferences_for_module('erd')
}, ()=>this.registerKeyboardShortcuts());
});
let done = await this.initConnection();
if(!done) return;
done = await this.loadPrequisiteData();
if(!done) return;
if(this.props.params.gen) {
await this.loadTablesData();
}
}
componentDidUpdate() {
if(this.state.dirty) {
this.setTitle(this.state.current_file, true);
}
}
getDialog(dialogName) {
if(dialogName === 'entity_dialog') {
return (title, attributes, callback)=>{
this.props.getDialog(dialogName).show(
title, attributes, this.diagram.getCache('colTypes'), this.diagram.getCache('schemas'), this.state.server_version, callback
);
};
} else if(dialogName === 'onetomany_dialog' || dialogName === 'manytomany_dialog') {
return (title, attributes, callback)=>{
this.props.getDialog(dialogName).show(
title, attributes, this.diagram.getModel().getNodesDict(), this.state.server_version, callback
);
};
}
}
setLoading(message) {
this.setState({loading_msg: message});
}
realignGrid({backgroundSize, backgroundPosition}) {
if(backgroundSize) {
this.canvasEle.style.backgroundSize = backgroundSize;
}
if(backgroundPosition) {
this.canvasEle.style.backgroundPosition = backgroundPosition;
}
}
addEditNode(node) {
let dialog = this.getDialog('entity_dialog');
if(node) {
let [schema, table] = node.getSchemaTableName();
dialog(_.escape(`Table: ${table} (${schema})`), node.getData(), (newData)=>{
node.setData(newData);
this.diagram.repaint();
});
} else {
dialog('New table', {name: this.diagram.getNextTableName()}, (newData)=>{
let newNode = this.diagram.addNode(newData);
newNode.setSelected(true);
});
}
}
onEditNode() {
const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) {
this.addEditNode(selected[0]);
}
}
onAddNewNode() {
this.addEditNode();
}
onCloneNode() {
const selected = this.diagram.getSelectedNodes();
if(selected.length == 1) {
let newData = selected[0].cloneData(this.diagram.getNextTableName());
let {x, y} = selected[0].getPosition();
let newNode = this.diagram.addNode(newData, [x+20, y+20]);
newNode.setSelected(true);
}
}
onDeleteNode() {
this.props.alertify.confirm(
gettext('Delete ?'),
gettext('You have selected %s tables and %s links.', this.diagram.getSelectedNodes().length, this.diagram.getSelectedLinks().length)
+ '<br />' + gettext('Are you sure you want to delete ?'),
() => {
this.diagram.getSelectedNodes().forEach((node)=>{
node.setSelected(false);
node.remove();
});
this.diagram.getSelectedLinks().forEach((link)=>{
link.getTargetPort().remove();
link.getSourcePort().remove();
link.setSelected(false);
link.remove();
});
this.diagram.repaint();
},
() => {}
);
}
onAutoDistribute() {
this.diagram.dagreDistributeNodes();
}
onDetailsToggle() {
this.setState((prevState)=>({
show_details: !prevState.show_details
}), ()=>{
this.diagram.getModel().getNodes().forEach((node)=>{
node.fireEvent({show_details: this.state.show_details}, 'toggleDetails');
})
});
}
onHelpClick() {
let url = url_for('help.static', {'filename': 'erd.html'});
window.open(url, 'pgadmin_help');
}
onLoadDiagram() {
var params = {
'supported_types': ['pgerd'], // file types allowed
'dialog_type': 'select_file', // open select file dialog
};
this.props.pgAdmin.FileManager.init();
this.props.pgAdmin.FileManager.show_dialog(params);
}
openFile(fileName) {
axios.post(url_for('sqleditor.load_file'), {
'file_name': decodeURI(fileName)
}).then((res)=>{
this.setState({
current_file: fileName,
dirty: false,
});
this.setTitle(fileName);
this.diagram.deserialize(res.data);
this.registerModelEvents();
}).catch((err)=>{
this.handleAxiosCatch(err);
});
}
onSaveDiagram(isSaveAs=false) {
if(this.state.current_file && !isSaveAs) {
this.saveFile(this.state.current_file);
} else {
var params = {
'supported_types': ['pgerd'],
'dialog_type': 'create_file',
'dialog_title': 'Save File',
'btn_primary': 'Save',
};
this.props.pgAdmin.FileManager.init();
this.props.pgAdmin.FileManager.show_dialog(params);
}
}
onSaveAsDiagram() {
this.onSaveDiagram(true);
}
saveFile(fileName) {
axios.post(url_for('sqleditor.save_file'), {
'file_name': decodeURI(fileName),
'file_content': JSON.stringify(this.diagram.serialize(this.props.pgAdmin.Browser.utils.app_version_int))
}).then(()=>{
this.props.alertify.success(gettext('Project saved successfully.'));
this.setState({
current_file: fileName,
dirty: false,
});
this.setTitle(fileName);
}).catch((err)=>{
this.handleAxiosCatch(err);
});
}
getCurrentProjectName(path) {
let currPath = path || this.state.current_file || 'Untitled';
return currPath.split('\\').pop().split('/').pop();
}
setTitle(title, dirty=false) {
if(title === null || title === '') {
title = 'Untitled';
}
title = this.getCurrentProjectName(title) + (dirty ? '*': '');
if (this.new_browser_tab) {
window.document.title = title;
} else {
_.each(this.props.pgAdmin.Browser.docker.findPanels('frm_erdtool'), function(p) {
if (p.isVisible()) {
setPanelTitle(p, title);
}
});
}
}
onSQLClick() {
let scriptHeader = gettext('-- This script was generated by a beta version of the ERD tool in pgAdmin 4.\n');
scriptHeader += gettext('-- Please log an issue at https://redmine.postgresql.org/projects/pgadmin4/issues/new if you find any bugs, including reproduction steps.\n');
let url = url_for('erd.sql', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
this.setLoading(gettext('Preparing the SQL...'));
axios.post(url, this.diagram.serializeData())
.then((resp)=>{
let sqlScript = resp.data.data;
sqlScript = scriptHeader + 'BEGIN;\n' + sqlScript + '\nEND;';
let parentData = {
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did,
stype: this.props.params.server_type,
}
let sqlId = `erd${this.props.params.trans_id}`;
localStorage.setItem(sqlId, sqlScript);
showERDSqlTool(parentData, sqlId, this.props.params.title, this.props.pgAdmin.DataGrid, this.props.alertify);
})
.catch((error)=>{
this.handleAxiosCatch(error);
})
.then(()=>{
this.setLoading(null);
})
}
onOneToManyClick() {
let dialog = this.getDialog('onetomany_dialog');
let initData = {local_table_uid: this.diagram.getSelectedNodes()[0].getID()};
dialog('One to many relation', initData, (newData)=>{
let newLink = this.diagram.addLink(newData, 'onetomany');
this.diagram.clearSelection();
newLink.setSelected(true);
this.diagram.repaint();
});
}
onManyToManyClick() {
let dialog = this.getDialog('manytomany_dialog');
let initData = {left_table_uid: this.diagram.getSelectedNodes()[0].getID()};
dialog('Many to many relation', initData, (newData)=>{
let nodes = this.diagram.getModel().getNodesDict();
let left_table = nodes[newData.left_table_uid];
let right_table = nodes[newData.right_table_uid];
let tableData = {
name: `${left_table.getData().name}_${right_table.getData().name}`,
schema: left_table.getData().schema,
columns: [{
...left_table.getColumnAt(newData.left_table_column_attnum),
'name': `${left_table.getData().name}_${left_table.getColumnAt(newData.left_table_column_attnum).name}`,
'is_primary_key': false,
'attnum': 0,
},{
...right_table.getColumnAt(newData.right_table_column_attnum),
'name': `${right_table.getData().name}_${right_table.getColumnAt(newData.right_table_column_attnum).name}`,
'is_primary_key': false,
'attnum': 1,
}]
}
let newNode = this.diagram.addNode(tableData);
this.diagram.clearSelection();
newNode.setSelected(true);
let linkData = {
local_table_uid: newNode.getID(),
local_column_attnum: newNode.getColumns()[0].attnum,
referenced_table_uid: newData.left_table_uid,
referenced_column_attnum : newData.left_table_column_attnum,
}
this.diagram.addLink(linkData, 'onetomany');
linkData = {
local_table_uid: newNode.getID(),
local_column_attnum: newNode.getColumns()[1].attnum,
referenced_table_uid: newData.right_table_uid,
referenced_column_attnum : newData.right_table_column_attnum,
}
this.diagram.addLink(linkData, 'onetomany');
this.diagram.repaint();
});
}
showNote(noteNode) {
if(noteNode) {
this.noteRefEle = this.diagram.getEngine().getNodeElement(noteNode);
this.setState({
note_node: noteNode,
note_open: true
});
}
}
onNoteClick(e) {
let noteNode = this.diagram.getSelectedNodes()[0];
this.showNote(noteNode);
}
onNoteClose(updated) {
this.setState({note_open: false});
updated && this.diagram.fireEvent({}, 'nodesUpdated', true);
}
async initConnection() {
this.setLoading(gettext('Initializing connection...'));
this.setState({conn_status: CONNECT_STATUS.CONNECTING});
let initUrl = url_for('erd.initialize', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.post(initUrl);
this.setState({
conn_status: CONNECT_STATUS.CONNECTED,
server_version: response.data.data.serverVersion
});
return true;
} catch (error) {
this.setState({conn_status: CONNECT_STATUS.FAILED});
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
/* Get all prequisite in one conn since
* we have only one connection
*/
async loadPrequisiteData() {
this.setLoading(gettext('Fetching required data...'));
let url = url_for('erd.prequisite', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.get(url);
let data = response.data.data;
this.diagram.setCache('colTypes', data['col_types']);
this.diagram.setCache('schemas', data['schemas']);
return true;
} catch (error) {
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
async loadTablesData() {
this.setLoading(gettext('Fetching schema data...'));
let url = url_for('erd.tables', {
trans_id: this.props.params.trans_id,
sgid: this.props.params.sgid,
sid: this.props.params.sid,
did: this.props.params.did
});
try {
let response = await axios.get(url);
let tables = response.data.data.map((table)=>{
return this.props.transformToSupported('table', table);
});
this.diagram.deserializeData(tables);
return true;
} catch (error) {
this.handleAxiosCatch(error);
return false;
} finally {
this.setLoading(null);
}
}
render() {
return (
<>
<ToolBar id="btn-toolbar">
<ButtonGroup>
<IconButton id="open-file" icon="fa fa-folder-open" onClick={this.onLoadDiagram} title={gettext('Load from file')}
shortcut={this.state.preferences.open_project}/>
<IconButton id="save-erd" icon="fa fa-save" onClick={()=>{this.onSaveDiagram()}} title={gettext('Save project')}
shortcut={this.state.preferences.save_project} disabled={!this.state.dirty}/>
<IconButton id="save-as-erd" icon="fa fa-share-square" onClick={this.onSaveAsDiagram} title={gettext('Save as')}
shortcut={this.state.preferences.save_project_as}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="save-sql" icon="fa fa-file-code" onClick={this.onSQLClick} title={gettext('Generate SQL')}
shortcut={this.state.preferences.generate_sql}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="add-node" icon="fa fa-plus-square" onClick={this.onAddNewNode} title={gettext('Add table')}
shortcut={this.state.preferences.add_table}/>
<IconButton id="edit-node" icon="fa fa-pencil-alt" onClick={this.onEditNode} title={gettext('Edit table')}
shortcut={this.state.preferences.edit_table} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="clone-node" icon="fa fa-clone" onClick={this.onCloneNode} title={gettext('Clone table')}
shortcut={this.state.preferences.clone_table} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="delete-node" icon="fa fa-trash-alt" onClick={this.onDeleteNode} title={gettext('Drop table/link')}
shortcut={this.state.preferences.drop_table} disabled={!this.state.any_item_selected}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="add-note" icon="fa fa-sticky-note" onClick={this.onNoteClick} title={gettext('Add/Edit note')}
shortcut={this.state.preferences.add_edit_note} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="add-onetomany" text="1M" onClick={this.onOneToManyClick} title={gettext('One-to-Many link')}
shortcut={this.state.preferences.one_to_many} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
<IconButton id="add-manytomany" text="MM" onClick={this.onManyToManyClick} title={gettext('Many-to-Many link')}
shortcut={this.state.preferences.many_to_many} disabled={!this.state.single_node_selected || this.state.single_link_selected}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="auto-align" icon="fa fa-magic" onClick={this.onAutoDistribute} title={gettext('Auto align')}
shortcut={this.state.preferences.auto_align} />
<DetailsToggleButton id="more-details" onClick={this.onDetailsToggle} showDetails={this.state.show_details} />
</ButtonGroup>
<ButtonGroup>
<IconButton id="zoom-to-fit" icon="fa fa-compress" onClick={this.diagram.zoomToFit} title={gettext('Zoom to fit')}
shortcut={this.state.preferences.zoom_to_fit}/>
<IconButton id="zoom-in" icon="fa fa-search-plus" onClick={this.diagram.zoomIn} title={gettext('Zoom in')}
shortcut={this.state.preferences.zoom_in}/>
<IconButton id="zoom-out" icon="fa fa-search-minus" onClick={this.diagram.zoomOut} title={gettext('Zoom out')}
shortcut={this.state.preferences.zoom_out}/>
</ButtonGroup>
<ButtonGroup>
<IconButton id="help" icon="fa fa-question" onClick={this.onHelpClick} title={gettext('Help')} />
</ButtonGroup>
</ToolBar>
<ConnectionBar statusId="btn-conn-status" status={this.state.conn_status} bgcolor={this.props.params.bgcolor}
fgcolor={this.props.params.fgcolor} title={this.props.params.title}/>
<FloatingNote open={this.state.note_open} onClose={this.onNoteClose}
reference={this.noteRefEle} noteNode={this.state.note_node} appendTo={this.diagramContainerRef.current} rows={8}/>
<div className="diagram-container" ref={this.diagramContainerRef}>
<Loader message={this.state.loading_msg} autoEllipsis={true}/>
<CanvasWidget className="diagram-canvas flex-grow-1" ref={(ele)=>{this.canvasEle = ele?.ref?.current}} engine={this.diagram.getEngine()} />
</div>
</>
);
}
}
BodyWidget.propTypes = {
params:PropTypes.shape({
trans_id: PropTypes.number.isRequired,
sgid: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
sid: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
did: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
server_type: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
bgcolor: PropTypes.string,
fgcolor: PropTypes.string,
gen: PropTypes.bool.isRequired
}),
getDialog: PropTypes.func.isRequired,
transformToSupported: PropTypes.func.isRequired,
pgAdmin: PropTypes.object.isRequired,
alertify: PropTypes.object.isRequired
};

View File

@ -0,0 +1,57 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
export const STATUS = {
CONNECTED: 1,
DISCONNECTED: 2,
CONNECTING: 3,
FAILED: 4,
}
/* The connection bar component */
export default function ConnectionBar({statusId, status, bgcolor, fgcolor, title}) {
return (
<div className="connection_status_wrapper d-flex">
<div id={statusId}
role="status"
className="connection_status d-flex justify-content-center align-items-center" data-container="body"
data-toggle="popover" data-placement="bottom"
data-content=""
data-panel-visible="visible"
tabIndex="0">
<span className={'pg-font-icon d-flex m-auto '
+ (status == STATUS.CONNECTED ? 'icon-query-tool-connected' : '')
+ (status == (STATUS.DISCONNECTED || STATUS.FAILED) ? 'icon-query-tool-disconnected ' : '')
+ (status == STATUS.CONNECTING ? 'obtaining-conn' : '')}
aria-hidden="true" title="" role="img">
</span>
</div>
<div className="connection-info btn-group" role="group" aria-label="">
<div className="editor-title"
style={{backgroundColor: bgcolor, color: fgcolor}}>
{status == STATUS.CONNECTING ? '(' + gettext('Obtaining connection...') + ') ' : ''}
{status == STATUS.FAILED ? '(' + gettext('Connection failed') + ') ' : ''}
{title}
</div>
</div>
</div>
)
}
ConnectionBar.propTypes = {
statusId: PropTypes.string.isRequired,
status: PropTypes.oneOf(Object.values(STATUS)).isRequired,
bgcolor: PropTypes.string,
fgcolor: PropTypes.string,
title: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,71 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { useEffect, useState } from 'react';
import Tippy from '@tippyjs/react';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
import { TableNodeModel } from '../nodes/TableNode';
import CustomPropTypes from 'sources/custom_prop_types';
/* The note component of ERD. It uses tippy to create the floating note */
export default function FloatingNote({open, onClose, reference, rows, noteNode, ...tippyProps}) {
const textRef = React.useRef(null);
const [text, setText] = useState('');
const [header, setHeader] = useState('');
useEffect(()=>{
if(noteNode) {
setText(noteNode.getNote());
let [schema, name] = noteNode.getSchemaTableName();
setHeader(`${name} (${schema})`);
}
if(open) {
textRef?.current.focus();
textRef?.current.dispatchEvent(new KeyboardEvent('keypress'));
}
}, [noteNode, open]);
return (
<Tippy render={(attrs)=>(
<div className="floating-note" {...attrs}>
<div className="note-header">{gettext('Note')}:</div>
<div className="note-body">
<div className="p-1">{header}</div>
<textarea ref={textRef} className="pg-textarea" value={text} rows={rows} onChange={(e)=>setText(e.target.value)}></textarea>
<div className="pg_buttons">
<button className="btn btn-primary long_text_editor pg-alertify-button" data-label="OK"
onClick={()=>{
let updated = (noteNode.getNote() != text);
noteNode.setNote(text);
if(onClose) onClose(updated);
}}>
<span className="fa fa-check pg-alertify-button"></span>&nbsp;{gettext('OK')}
</button>
</div>
</div>
</div>
)}
visible={open}
interactive={true}
animation={false}
reference={reference}
placement='auto-end'
{...tippyProps}
/>
);
}
FloatingNote.propTypes = {
open: PropTypes.bool.isRequired,
onClose: PropTypes.func.isRequired,
reference: CustomPropTypes.ref,
rows: PropTypes.number,
noteNode: PropTypes.object,
};

View File

@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React from 'react';
import PropTypes from 'prop-types';
/* The loader/spinner component */
export default function Loader({message, autoEllipsis=false}) {
if(message || message == '') {
return (
<div className="pg-sp-container">
<div className="pg-sp-content">
<div className="row">
<div className="col-12 pg-sp-icon"></div>
</div>
<div className="row"><div className="col-12 pg-sp-text">{message}{autoEllipsis ? '...':''}</div></div>
</div>
</div>
);
} else {
return null;
}
}
Loader.propTypes = {
message: PropTypes.string,
autoEllipsis: PropTypes.bool,
};

View File

@ -0,0 +1,136 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import React, { forwardRef } from 'react';
import Tippy from '@tippyjs/react';
import {isMac} from 'sources/keyboard_shortcuts';
import gettext from 'sources/gettext';
import PropTypes from 'prop-types';
import CustomPropTypes from 'sources/custom_prop_types';
/* The base icon button.
React does not pass ref prop to child component hierarchy.
Use forwardRef for the same
*/
const BaseIconButton = forwardRef((props, ref)=>{
const {icon, text, className, ...otherProps} = props;
return(
<button ref={ref} className={className} {...otherProps}>
{icon && <span className={`${icon} sql-icon-lg`} aria-hidden="true" role="img"></span>}
{text && <span className="text-icon">{text}</span>}
</button>
);
});
BaseIconButton.propTypes = {
icon: PropTypes.string,
text: PropTypes.string,
className: PropTypes.string,
ref: CustomPropTypes.ref,
}
/* The tooltip content to show shortcut details */
export function Shortcut({shortcut}) {
let keys = [];
shortcut.alt && keys.push((isMac() ? 'Option' : 'Alt'));
shortcut.control && keys.push('Ctrl');
shortcut.shift && keys.push('Shift');
keys.push(shortcut.key.char.toUpperCase());
return (
<div style={{justifyContent: 'center', marginTop: '0.125rem'}} className="d-flex">
{keys.map((key, i)=>{
return <div key={i} className="shortcut-key">{key}</div>
})}
</div>
)
}
const shortcutPropType = PropTypes.shape({
alt: PropTypes.bool,
control: PropTypes.bool,
shift: PropTypes.bool,
key: PropTypes.shape({
char: PropTypes.string,
}),
});
Shortcut.propTypes = {
shortcut: shortcutPropType,
};
/* The icon button component which can have a tooltip based on props.
React does not pass ref prop to child component hierarchy.
Use forwardRef for the same
*/
export const IconButton = forwardRef((props, ref) => {
const {title, shortcut, className, ...otherProps} = props;
if (title) {
return (
<Tippy content={
<>
{<div style={{textAlign: 'center'}}>{title}</div>}
{shortcut && <Shortcut shortcut={shortcut} />}
</>
}>
<BaseIconButton ref={ref} className={'btn btn-sm btn-primary-icon ' + (className || '')} {...otherProps}/>
</Tippy>
);
} else {
return <BaseIconButton ref={ref} className='btn btn-sm btn-primary-icon' {...otherProps}/>
}
});
IconButton.propTypes = {
title: PropTypes.string,
shortcut: shortcutPropType,
className: PropTypes.string,
}
/* Toggle button, icon changes based on value */
export function DetailsToggleButton({showDetails, ...props}) {
return (
<IconButton
icon={showDetails ? 'far fa-eye' : 'fas fa-low-vision'}
title={showDetails ? gettext('Show fewer details') : gettext("Show more details") }
{...props} />
);
}
DetailsToggleButton.propTypes = {
showDetails: PropTypes.bool,
}
/* Button group container */
export function ButtonGroup({className, children}) {
return (
<div className={'btn-group mr-1 ' + (className ? className : '')} role="group" aria-label="save group">
{children}
</div>
)
}
ButtonGroup.propTypes = {
className: PropTypes.string,
}
/* Toolbar container */
export default function ToolBar({id, children}) {
return (
<div id={id} className="editor-toolbar d-flex" role="toolbar" aria-label="">
{children}
</div>
)
}
ButtonGroup.propTypes = {
id: PropTypes.string,
}

View File

@ -0,0 +1,35 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
define([
'sources/pgadmin', 'pgadmin.tools.erd/erd_tool', 'pgadmin.browser',
'pgadmin.browser.server.privilege', 'pgadmin.node.database', 'pgadmin.node.primary_key',
'pgadmin.node.foreign_key', 'pgadmin.browser.datamodel', 'pgadmin.file_manager',
], function(
pgAdmin, ERDToolModule
) {
var pgTools = pgAdmin.Tools = pgAdmin.Tools || {};
var ERDTool = ERDToolModule.default;
/* Return back, this has been called more than once */
if (pgTools.ERDToolHook)
return pgTools.ERDToolHook;
pgTools.ERDToolHook = {
load: function(params) {
/* Create the ERD Tool object and render it */
let erdObj = new ERDTool('#erd-tool-container', params);
erdObj.render();
},
};
return pgTools.ERDToolHook;
});

View File

@ -0,0 +1,23 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import gettext from 'sources/gettext';
import url_for from 'sources/url_for';
import $ from 'jquery';
import _ from 'underscore';
import pgAdmin from 'sources/pgadmin';
import pgBrowser from 'top/browser/static/js/browser';
import * as csrfToken from 'sources/csrf';
import {initialize} from './erd_module';
var wcDocker = window.wcDocker;
let pgBrowserOut = initialize(gettext, url_for, $, _, pgAdmin, csrfToken, pgBrowser, wcDocker);
module.exports = {
pgBrowser: pgBrowserOut,
};

View File

@ -0,0 +1,189 @@
.shortcut-key {
padding: 0 0.25rem;
border: 1px solid $border-color;
margin-right: 0.125rem;
border-radius: $btn-border-radius;
}
#erd-tool-container {
width: 100%;
height: 100%;
.file-input-hidden {
height: 0;
width: 0;
visibility: hidden;
}
.text-icon {
font-weight: bold;
}
.erd-hint-bar {
background: $sql-gutters-bg;
padding: 0.25rem 0.5rem;
}
.diagram-container {
position: relative;
width: 100%;
height: 100%;
}
.floating-note {
width: 250px;
border: $panel-border;
border-radius: $panel-border-radius;
box-shadow: $dialog-box-shadow;
background-color: $alert-dialog-body-bg !important;
color: $color-fg !important;
.note-header {
padding: 0.25rem 0.5rem;
background-color: $alert-header-bg;
font-size: $font-size-base;
font-weight: bold;
color: $alert-header-fg;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
border-radius: 0rem;
border-top-left-radius: $panel-border-radius;
border-top-right-radius: $panel-border-radius;
border-bottom: none;
margin: -$alertify-borderremove-margin; //-24px is default by alertify
margin-bottom: 0px;
}
.note-body {
& textarea {
width: 100%;
border: none;
border-bottom: $border-width solid $erd-node-border-color;
border-top: $border-width solid $erd-node-border-color;
}
& .pg_buttons {
padding: 0.25rem;
}
}
}
.diagram-canvas{
width: 100%;
height: 100%;
color: $color-fg;
font-family: sans-serif;
background-image: $erd-bg-grid;
cursor: unset;
.table-node {
background-color: $input-bg;
border: $border-width solid $erd-node-border-color;
border-radius: $input-border-radius;
position: relative;
width: 175px;
font-size: 0.8em;
&.selected {
border-color: $input-focus-border-color;
box-shadow: $input-btn-focus-box-shadow;
}
.table-toolbar {
background: $editor-toolbar-bg;
border-bottom: $border-width solid $erd-node-border-color;
padding: 0.125rem;
border-top-left-radius: inherit;
border-top-right-radius: inherit;
display: flex;
.btn {
&:not(:first-of-type) {
margin-left: 0.125rem;
}
}
}
.table-schema {
border-bottom: $border-width solid $erd-node-border-color;
padding: $erd-row-padding;
font-weight: bold;
}
.table-name {
border-bottom: $border-width*2 solid $erd-node-border-color;
padding: $erd-row-padding;
font-weight: bold;
}
.table-cols {
.col-row {
border-bottom: $border-width solid $erd-node-border-color;
.col-row-data {
padding: $erd-row-padding;
width: 100%;
.col-name {
word-break: break-all;
}
}
.col-row-port {
padding: 0;
min-height: 0;
}
}
}
}
.svg-link-ele {
stroke: $erd-link-color;
}
.svg-link-ele.path {
pointer-events: all;
}
@keyframes svg-link-ele-selected {
from { stroke-dashoffset: 24; } to { stroke-dashoffset: 0; }
}
.svg-link-ele.selected {
stroke: $erd-link-selected-color;
stroke-dasharray: 10, 2;
animation: svg-link-ele-selected 1s linear infinite;
}
.svg-link-ele.svg-otom-circle {
fill: $erd-link-color;
}
.custom-node-color{
position: absolute;
top: 50%;
left: 50%;
width: 20px;
height: 20px;
transform: translate(-50%, -50%);
border-radius: 10px;
}
.circle-port{
width: 12px;
height: 12px;
margin: 2px;
border-radius: 4px;
background: darkgray;
cursor: pointer;
}
.circle-port:hover{
background: mediumpurple;
}
.port {
display: inline-block;
margin: auto;
}
}
}

View File

@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}{{title}}{% endblock %}
{% block css_link %}
<link type="text/css" rel="stylesheet" href="{{ url_for('browser.browser_css')}}"/>
{% endblock %}
{% block body %}
<style>
body {padding: 0px;}
{% if is_desktop_mode and is_linux %}
.alertify .ajs-dimmer,.alertify .ajs-modal{-webkit-transform: none;}
.alertify-notifier{-webkit-transform: none;}
.alertify-notifier .ajs-message{-webkit-transform: none;}
.alertify .ajs-dialog.ajs-shake{-webkit-animation-name: none;}
.sql-editor-busy-icon.fa-pulse{-webkit-animation: none;}
{% endif %}
</style>
<div id="erd-tool-container" class="d-flex flex-column">
</div>
{% endblock %}
{% block init_script %}
try {
require(
['sources/generated/browser_nodes', 'sources/generated/codemirror'],
function() {
require(['sources/generated/erd_tool'], function(erdToolHook) {
var erdToolHook = erdToolHook || pgAdmin.Tools.ERDToolHook;
erdToolHook.load({{ params|safe }});
if(window.opener) {
$(window).on('unload', function(ev) {
$.ajax({
method: 'DELETE',
url: '{{close_url}}'
});
});
} else {
$(window).on('beforeunload', function(ev) {
$.ajax({
method: 'DELETE',
url: '{{close_url}}'
});
});
}
}, function() {
console.log(arguments);
});
},
function() {
console.log(arguments);
});
} catch (err) {
console.log(err);
}
{% endblock %}

View File

@ -0,0 +1,15 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
from pgadmin.utils.route import BaseTestGenerator
class ERDGenerateTestCase(BaseTestGenerator):
def runTest(self):
return

View File

@ -0,0 +1,25 @@
CREATE TABLE public.newtable1
(
id integer,
col1 character varying(50),
PRIMARY KEY (id)
);
CREATE TABLE public.newtable2
(
table1_id integer,
col2 character varying(50),
PRIMARY KEY (id)
);
CREATE TABLE public.newtable3
(
)
;
ALTER TABLE public.newtable2
ADD FOREIGN KEY (table1_id)
REFERENCES public.newtable1 (id)
NOT VALID;

View File

@ -0,0 +1,34 @@
CREATE TABLE public.newtable1
(
id integer,
col1 character varying(50),
PRIMARY KEY (id)
)
WITH (
OIDS = FALSE
);
CREATE TABLE public.newtable2
(
table1_id integer,
col2 character varying(50),
PRIMARY KEY (id)
)
WITH (
OIDS = FALSE
);
CREATE TABLE public.newtable3
(
)
WITH (
OIDS = FALSE
);
ALTER TABLE public.newtable2
ADD FOREIGN KEY (table1_id)
REFERENCES public.newtable1 (id)
NOT VALID;

View File

@ -0,0 +1,55 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
class ERDClose(BaseTestGenerator):
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
self.sgid = config_data["server_group"]
def runTest(self):
db_con = database_utils.connect_database(self,
self.sgid,
self.sid,
self.did)
if not db_con["info"] == "Database connected.":
raise Exception("Could not connect to database to add the schema.")
url = '/erd/initialize/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.post(url)
self.assertEqual(response.status_code, 200)
url = '/erd/close/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.delete(url)
self.assertEqual(response.status_code, 200)
def tearDown(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)

View File

@ -0,0 +1,54 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
class ERDInitialize(BaseTestGenerator):
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
self.sgid = config_data["server_group"]
def runTest(self):
db_con = database_utils.connect_database(self,
self.sgid,
self.sid,
self.did)
if not db_con["info"] == "Database connected.":
raise Exception("Could not connect to database to add the schema.")
url = '/erd/initialize/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.post(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.data.decode('utf-8'))
self.assertEqual(response_data['data'], {
'connId': '123344',
'serverVersion': self.server_information['server_version'],
})
def tearDown(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)

View File

@ -0,0 +1,44 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
class ERDPanel(BaseTestGenerator):
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
self.sgid = config_data["server_group"]
def runTest(self):
url = '/erd/panel/{trans_id}?sgid={sgid}&sid={sid}&server_type=pg' \
'&did={did}&gen=false'.\
format(trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.post(
url, data={"title": "panel_title", "close_url": "the/close/url"},
content_type="application/x-www-form-urlencoded")
self.assertEqual(response.status_code, 200)
def tearDown(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)

View File

@ -0,0 +1,52 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
class ERDPrequisite(BaseTestGenerator):
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
self.sgid = config_data["server_group"]
def runTest(self):
db_con = database_utils.connect_database(self,
self.sgid,
self.sid,
self.did)
if not db_con["info"] == "Database connected.":
raise Exception("Could not connect to database to add the schema.")
url = '/erd/prequisite/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.get(url)
self.assertEqual(response.status_code, 200)
response_data = json.loads(response.data.decode('utf-8'))
self.assertIn('col_types', response_data['data'])
self.assertIn('schemas', response_data['data'])
def tearDown(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)

View File

@ -0,0 +1,90 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
from pgadmin.utils.versioned_template_loader import \
get_version_mapping_directories
from os import path
class ERDSql(BaseTestGenerator):
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
self.sgid = config_data["server_group"]
self.maxDiff = None
def get_expected_sql(self):
sql_base_path = path.join(
path.dirname(path.realpath(__file__)), 'sql')
# Iterate the version mapping directories.
for version_mapping in \
get_version_mapping_directories(self.server['type']):
if version_mapping['number'] > \
self.server_information['server_version']:
continue
complete_path = path.join(
sql_base_path, version_mapping['name'])
if not path.exists(complete_path):
complete_path = path.join(sql_base_path, 'default')
break
data_sql = ''
with open(path.join(complete_path, 'test_sql_output.sql')) as fp:
data_sql = fp.read()
return data_sql
def runTest(self):
db_con = database_utils.connect_database(self,
self.sgid,
self.sid,
self.did)
if not db_con["info"] == "Database connected.":
raise Exception("Could not connect to database to add the schema.")
url = '/erd/sql/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
curr_dir = path.dirname(__file__)
data_json = None
with open(path.join(curr_dir, 'test_sql_input_data.json')) as fp:
data_json = fp.read()
response = self.tester.post(url,
data=data_json,
content_type='html/json')
self.assertEqual(response.status_code, 200)
data_sql = self.get_expected_sql()
resp_sql = json.loads(response.data.decode('utf-8'))['data']
self.assertEqual(resp_sql, data_sql)
def tearDown(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)

View File

@ -0,0 +1,106 @@
{
"nodes": {
"1d9dc56e-e4f9-48b9-889b-6084ec6446bf": {
"columns": [
{
"name": "id",
"attnum": 0,
"cltype": "integer",
"is_primary_key": true,
"attnotnull": false,
"attlen": null,
"attprecision": null,
"attidentity": "a",
"colconstype": "n"
},
{
"name": "col1",
"attnum": 1,
"cltype": "character varying",
"min_val_attlen": 1,
"max_val_attlen": 2147483647,
"is_primary_key": false,
"attnotnull": false,
"attlen": 50,
"attprecision": null,
"attidentity": "a",
"colconstype": "n"
}
],
"name": "newtable1",
"schema": "public",
"primary_key": [
{
"columns": [
{
"column": "id"
}
],
"include": []
}
]
},
"c4fee4ad-cf32-4fc6-bb87-98b896bcab60": {
"name": "newtable2",
"schema": "public",
"columns": [
{
"name": "table1_id",
"attnum": 0,
"cltype": "integer",
"is_primary_key": false,
"attnotnull": false,
"attlen": null,
"attprecision": null,
"attidentity": "a",
"colconstype": "n",
"old_attidentity": "a"
},
{
"name": "col2",
"attnum": 1,
"cltype": "character varying",
"min_val_attlen": 1,
"max_val_attlen": 2147483647,
"is_primary_key": false,
"attnotnull": false,
"attlen": 50,
"attprecision": null,
"attidentity": "a",
"colconstype": "n",
"old_attidentity": "a"
}
],
"primary_key": [
{
"columns": [
{
"column": "id"
}
],
"include": []
}
]
},
"f001a770-d6fa-4572-b88b-11dd5e38d30c": {
"columns": [],
"name": "newtable3",
"schema": "public",
"primary_key": []
}
},
"links": {
"998de19a-caa0-431e-9cf7-97827f01022b": {
"schema": "public",
"table": "newtable2",
"remote_schema": "public",
"remote_table": "newtable1",
"columns": [
{
"local_column": "table1_id",
"referenced": "id"
}
]
}
}
}

View File

@ -0,0 +1,79 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
import json
import uuid
from pgadmin.utils.route import BaseTestGenerator
from regression.python_test_utils import test_utils as utils
from regression import parent_node_dict
from regression.test_setup import config_data
from pgadmin.browser.server_groups.servers.databases.tests import utils as \
database_utils
from pgadmin.browser.server_groups.servers.databases.schemas.tables.tests \
import utils as tables_utils
from pgadmin.browser.server_groups.servers.databases.schemas.tests import \
utils as schema_utils
class ERDTables(BaseTestGenerator):
def dropDB(self):
connection = utils.get_db_connection(self.server['db'],
self.server['username'],
self.server['db_password'],
self.server['host'],
self.server['port'])
utils.drop_database(connection, self.db_name)
def setUp(self):
self.db_name = "erdtestdb"
self.sid = parent_node_dict["server"][-1]["server_id"]
self.did = utils.create_database(self.server, self.db_name)
try:
self.sgid = config_data["server_group"]
self.tables = [
["erd1", "table_1"], ["erd2", "table_2"]
]
for tab in self.tables:
connection = utils.get_db_connection(
self.db_name, self.server['username'],
self.server['db_password'], self.server['host'],
self.server['port'])
schema_utils.create_schema(connection, tab[0])
tables_utils.create_table(self.server, self.db_name, tab[0],
tab[1])
connection.close()
except Exception as _:
self.dropDB()
raise
def runTest(self):
db_con = database_utils.connect_database(self,
self.sgid,
self.sid,
self.did)
if not db_con["info"] == "Database connected.":
raise Exception("Could not connect to database to add the schema.")
url = '/erd/tables/{trans_id}/{sgid}/{sid}/{did}'.format(
trans_id=123344, sgid=self.sgid, sid=self.sid, did=self.did)
response = self.tester.get(url)
self.assertEqual(response.status_code, 200)
response = json.loads(response.data.decode('utf-8'))
self.assertEqual(self.tables, [[tab['schema'], tab['name']]
for tab in response['data']])
def tearDown(self):
self.dropDB()

View File

@ -0,0 +1,71 @@
##########################################################################
#
# pgAdmin 4 - PostgreSQL Tools
#
# Copyright (C) 2013 - 2020, The pgAdmin Development Team
# This software is released under the PostgreSQL Licence
#
##########################################################################
from pgadmin.browser.server_groups.servers.databases.schemas.tables.utils \
import BaseTableView
from pgadmin.browser.server_groups.servers.databases.schemas.utils \
import get_schemas
from pgadmin.browser.server_groups.servers.databases.schemas.utils \
import DataTypeReader
class ERDTableView(BaseTableView, DataTypeReader):
def __init__(self):
super(BaseTableView, self).__init__(cmd='erd')
@BaseTableView.check_precondition
def sql(self, conn_id=None, did=None, sid=None, data={}):
return BaseTableView.get_sql(self, did, None, None, data, None)
@BaseTableView.check_precondition
def get_types(self, conn_id=None, did=None, sid=None):
condition = self.get_types_condition_sql(False)
return DataTypeReader.get_types(self, self.conn, condition, True)
@BaseTableView.check_precondition
def fetch_all_tables(self, conn_id=None, did=None, sid=None):
status, schemas = get_schemas(self.conn, show_system_objects=False)
if not status:
return status, schemas
all_tables = []
for row in schemas['rows']:
status, res = \
BaseTableView.fetch_tables(self, sid, did, row['oid'])
if not status:
return status, res
all_tables.extend(res.values())
return True, all_tables
class ERDHelper:
def __init__(self, conn_id, sid, did):
self.conn_id = conn_id
self.did = did
self.sid = sid
self.table_view = ERDTableView()
self.link_view = None
def get_types(self):
return self.table_view.get_types(
conn_id=self.conn_id, did=self.did, sid=self.sid)
def get_table_sql(self, data):
SQL, name = self.table_view.sql(
conn_id=self.conn_id, did=self.did, sid=self.sid,
data=data)
return SQL
def get_all_tables(self):
status, res = self.table_view.fetch_all_tables(
conn_id=self.conn_id, did=self.did, sid=self.sid)
return status, res

View File

@ -2651,6 +2651,12 @@ define('tools.querytool', [
}
});
} else if(url_params.sql_id) {
let sqlValue = localStorage.getItem(url_params.sql_id);
localStorage.removeItem(url_params.sql_id);
if(sqlValue) {
self.gridView.query_tool_obj.setValue(sqlValue);
}
}
}
else {
@ -2668,7 +2674,7 @@ define('tools.querytool', [
},
set_value_to_editor: function(query) {
if (this.gridView && this.gridView.query_tool_obj && !_.isUndefined(query)) {
if (this.gridView && this.gridView.query_tool_obj && !_.isUndefined(query) && query != '') {
this.gridView.query_tool_obj.setValue(query);
}
},

View File

@ -36,7 +36,8 @@ class _PGCSRFProtect(CSRFProtect):
'pgadmin.tools.debugger.direct_new',
'pgadmin.tools.schema_diff.panel',
'pgadmin.tools.schema_diff.ddl_compare',
'pgadmin.authenticate.login'
'pgadmin.authenticate.login',
'pgadmin.tools.erd.panel',
]
for exempt in exempt_views:

View File

@ -0,0 +1,382 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import ERDCore from 'pgadmin.tools.erd/erd_tool/ERDCore';
import * as createEngineLib from '@projectstorm/react-diagrams';
import TEST_TABLES_DATA from './test_tables';
describe('ERDCore', ()=>{
let eleFactory = jasmine.createSpyObj('nodeFactories', {
'registerFactory': null,
'getFactory': jasmine.createSpyObj('getFactory', ['generateModel', 'calculateRoutingMatrix']),
});
let erdEngine = jasmine.createSpyObj('engine', {
'getNodeFactories': eleFactory,
'getLinkFactories': eleFactory,
'getPortFactories': eleFactory,
'getActionEventBus': jasmine.createSpyObj('actionBus', ['fireAction', 'deregisterAction', 'registerAction']),
'setModel': null,
'getModel': jasmine.createSpyObj('modelObj', {
'addNode': null,
'clearSelection': null,
'getNodesDict': null,
'getLinks': null,
'serialize': ()=>({
'data': 'serialized',
}),
'addLink': null,
'getNodes': null,
'setZoomLevel': null,
'getZoomLevel': null,
'fireEvent': null,
'registerListener': null,
}),
'repaintCanvas': null,
'zoomToFit': null,
'fireEvent': null,
});
beforeAll(()=>{
spyOn(createEngineLib, 'default').and.returnValue(erdEngine);
});
it('initialization', ()=>{
spyOn(ERDCore.prototype, 'initializeEngine').and.callThrough();
spyOn(ERDCore.prototype, 'initializeModel').and.callThrough();
spyOn(ERDCore.prototype, 'computeTableCounter').and.callThrough();
let erdCoreObj = new ERDCore();
expect(erdCoreObj.initializeEngine).toHaveBeenCalled();
expect(erdCoreObj.initializeModel).toHaveBeenCalled();
expect(erdCoreObj.computeTableCounter).toHaveBeenCalled();
});
describe('functions', ()=>{
let erdCoreObj;
beforeAll(()=>{
erdCoreObj = new ERDCore();
});
describe('cache check', ()=>{
it('for single value', ()=>{
erdCoreObj.setCache('key1', 'value1');
expect(erdCoreObj.getCache('key1')).toEqual('value1');
});
it('for multiple value', ()=>{
erdCoreObj.setCache({'key1': 'valuem1', 'key2': 'valuem2'});
expect(erdCoreObj.getCache('key1')).toEqual('valuem1');
expect(erdCoreObj.getCache('key2')).toEqual('valuem2');
});
});
it('registerModelEvent', ()=>{
let fn = ()=>{};
erdCoreObj.registerModelEvent('someEvent', fn);
expect(erdCoreObj.getModel().registerListener).toHaveBeenCalledWith({
'someEvent': fn,
});
});
it('getNextTableName', ()=>{
expect(erdCoreObj.getNextTableName()).toEqual('newtable1');
expect(erdCoreObj.getNextTableName()).toEqual('newtable2');
});
it('getEngine', ()=>{
expect(erdCoreObj.getEngine()).toBe(erdEngine);
});
it('getNewNode', ()=>{
let data = {name: 'table1'};
erdCoreObj.getNewNode(data);
expect(erdEngine.getNodeFactories().getFactory().generateModel).toHaveBeenCalledWith({
initialConfig: {
otherInfo: {
data:data,
},
},
});
});
it('getNewLink', ()=>{
let data = {name: 'link1'};
erdCoreObj.getNewLink('linktype', data);
expect(erdEngine.getLinkFactories().getFactory).toHaveBeenCalledWith('linktype');
expect(erdEngine.getLinkFactories().getFactory().generateModel).toHaveBeenCalledWith({
initialConfig: {
data: data,
},
});
});
it('getNewPort', ()=>{
let data = {name: 'link1'};
let options = {opt1: 'val1'};
erdCoreObj.getNewPort('porttype', data, options);
expect(erdEngine.getPortFactories().getFactory).toHaveBeenCalledWith('porttype');
expect(erdEngine.getPortFactories().getFactory().generateModel).toHaveBeenCalledWith({
initialConfig: {
data:data,
options:options,
},
});
});
it('addNode', ()=>{
let newNode = jasmine.createSpyObj('newNode', ['setPosition']);
spyOn(erdCoreObj, 'getNewNode').and.returnValue(newNode);
spyOn(erdCoreObj, 'clearSelection');
let data = {name: 'link1'};
/* Without position */
erdCoreObj.addNode(data);
expect(erdCoreObj.getNewNode).toHaveBeenCalledWith(data);
expect(erdEngine.getModel().addNode).toHaveBeenCalledWith(newNode);
expect(erdCoreObj.clearSelection).toHaveBeenCalled();
/* With position */
erdCoreObj.addNode(data, [108, 108]);
expect(erdCoreObj.getNewNode().setPosition).toHaveBeenCalledWith(108, 108);
});
it('addLink', ()=>{
let nodesDict = {
'id1': {
serializeData: function(){ return {
'name': 'table1',
};},
getPortName: function(attnum) {
return `port-${attnum}`;
},
getPort: function() {
return null;
},
addPort: jasmine.createSpy('addPort').and.callFake((obj)=>obj),
},
'id2': {
serializeData: function(){ return {
'name': 'table2',
};},
getPortName: function(attnum) {
return `port-${attnum}`;
},
getPort: function() {
return null;
},
addPort: jasmine.createSpy('addPort').and.callFake((obj)=>obj),
},
};
let link = jasmine.createSpyObj('link', ['setSourcePort', 'setTargetPort']);
spyOn(erdEngine.getModel(), 'getNodesDict').and.returnValue(nodesDict);
spyOn(erdCoreObj, 'getNewLink').and.callFake(function() {
return link;
});
spyOn(erdCoreObj, 'getNewPort').and.callFake(function(type, initData, options) {
return {
name: options.name,
};
});
erdCoreObj.addLink({
'referenced_column_attnum': 1,
'referenced_table_uid': 'id1',
'local_column_attnum': 3,
'local_table_uid': 'id2',
}, 'onetomany');
expect(nodesDict['id1'].addPort).toHaveBeenCalledWith({name: 'port-1'});
expect(nodesDict['id2'].addPort).toHaveBeenCalledWith({name: 'port-3'});
expect(link.setSourcePort).toHaveBeenCalledWith({name: 'port-1'});
expect(link.setTargetPort).toHaveBeenCalledWith({name: 'port-3'});
});
it('serialize', ()=>{
let retVal = erdCoreObj.serialize();
expect(retVal.hasOwnProperty('version')).toBeTruthy();
expect(retVal.hasOwnProperty('data')).toBeTruthy();
expect(erdEngine.getModel().serialize).toHaveBeenCalled();
});
it('deserialize', ()=>{
let deserialValue = {
'version': 123,
'data': {
'key': 'serialized',
},
};
spyOn(erdCoreObj, 'initializeModel');
erdCoreObj.deserialize(deserialValue);
expect(erdCoreObj.initializeModel).toHaveBeenCalledWith(deserialValue.data);
});
it('serializeData', ()=>{
spyOn(erdEngine.getModel(), 'getNodesDict').and.returnValue({
'id1': {
serializeData: function(){ return {
'name': 'table1',
};},
},
'id2': {
serializeData: function(){ return {
'name': 'table2',
};},
},
});
spyOn(erdEngine.getModel(), 'getLinks').and.returnValue([
{
serializeData: function(){ return {
'name': 'link1',
};},
getID: function(){ return 'lid1'; },
},
{
serializeData: function(){ return {
'name': 'link2',
};},
getID: function(){ return 'lid2'; },
},
]);
expect(JSON.stringify(erdCoreObj.serializeData())).toEqual(JSON.stringify({
nodes: {
'id1': {'name': 'table1'},
'id2': {'name': 'table2'},
},
links: {
'lid1': {'name': 'link1'},
'lid2': {'name': 'link2'},
},
}));
});
it('deserializeData', (done)=>{
let nodesDict = {};
TEST_TABLES_DATA.forEach((table)=>{
nodesDict[`id-${table.name}`] = {
getColumns: function() {
return table.columns;
},
getPortName: function(attnum) {
return `port-${attnum}`;
},
getPort: function(name) {
return {'name': name};
},
addPort: function() {
},
};
});
spyOn(erdEngine.getModel(), 'getNodesDict').and.returnValue(nodesDict);
spyOn(erdCoreObj, 'getNewLink').and.callFake(function() {
return {
setSourcePort: function() {},
setTargetPort: function() {},
};
});
spyOn(erdCoreObj, 'getNewPort').and.returnValue({id: 'id'});
spyOn(erdCoreObj, 'addNode').and.callFake(function(data) {
return {
getID: function() {
return `id-${data.name}`;
},
};
});
spyOn(erdCoreObj, 'addLink');
spyOn(erdCoreObj, 'dagreDistributeNodes');
erdCoreObj.deserializeData(TEST_TABLES_DATA);
expect(erdCoreObj.addNode).toHaveBeenCalledTimes(TEST_TABLES_DATA.length);
expect(erdCoreObj.addLink).toHaveBeenCalledTimes(1);
setTimeout(()=>{
expect(erdCoreObj.dagreDistributeNodes).toHaveBeenCalled();
done();
}, 10);
});
it('clearSelection', ()=>{
erdCoreObj.clearSelection();
expect(erdEngine.getModel().clearSelection).toHaveBeenCalled();
});
it('repaint', ()=>{
erdCoreObj.repaint();
expect(erdEngine.repaintCanvas).toHaveBeenCalled();
});
it('getNodesData', ()=>{
spyOn(erdEngine.getModel(), 'getNodes').and.returnValue([
{getData: function () {return {name:'node1'};}},
{getData: function () {return {name:'node2'};}},
]);
expect(JSON.stringify(erdCoreObj.getNodesData())).toEqual(JSON.stringify([
{name:'node1'}, {name:'node2'},
]));
});
it('dagreDistributeNodes', ()=>{
spyOn(erdCoreObj.dagre_engine, 'redistribute');
erdCoreObj.dagreDistributeNodes();
expect(erdEngine.getLinkFactories().getFactory().calculateRoutingMatrix).toHaveBeenCalled();
expect(erdCoreObj.dagre_engine.redistribute).toHaveBeenCalledWith(erdEngine.getModel());
});
it('zoomIn', ()=>{
spyOn(erdEngine.getModel(), 'getZoomLevel').and.returnValue(100);
spyOn(erdCoreObj, 'repaint');
erdCoreObj.zoomIn();
expect(erdEngine.getModel().setZoomLevel).toHaveBeenCalledWith(125);
expect(erdCoreObj.repaint).toHaveBeenCalled();
});
it('zoomOut', ()=>{
spyOn(erdEngine.getModel(), 'getZoomLevel').and.returnValue(100);
spyOn(erdCoreObj, 'repaint');
erdCoreObj.zoomOut();
expect(erdEngine.getModel().setZoomLevel).toHaveBeenCalledWith(75);
expect(erdCoreObj.repaint).toHaveBeenCalled();
});
it('zoomToFit', ()=>{
erdCoreObj.zoomToFit();
expect(erdEngine.zoomToFit).toHaveBeenCalled();
});
it('fireAction', ()=>{
erdCoreObj.fireAction({key: 'xyz'});
expect(erdEngine.getActionEventBus().fireAction).toHaveBeenCalled();
});
it('fireEvent', ()=>{
erdCoreObj.fireEvent({key: 'xyz'}, 'someevent', false);
expect(erdEngine.fireEvent).toHaveBeenCalledWith({key: 'xyz'}, 'someevent');
erdCoreObj.fireEvent({key: 'xyz'}, 'someevent', true);
expect(erdEngine.getModel().fireEvent).toHaveBeenCalledWith({key: 'xyz'}, 'someevent');
});
it('registerKeyAction', ()=>{
erdCoreObj.registerKeyAction({key: 'xyz'});
expect(erdEngine.getActionEventBus().registerAction).toHaveBeenCalledWith({key: 'xyz'});
});
it('deregisterKeyAction', ()=>{
let action = {key: 'xyz'};
erdCoreObj.deregisterKeyAction(action);
expect(erdEngine.getActionEventBus().deregisterAction).toHaveBeenCalledWith({key: 'xyz'});
});
});
});

View File

@ -0,0 +1,34 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import ERDModel from 'pgadmin.tools.erd/erd_tool/ERDModel';
describe('ERDModel', ()=>{
it('getNodesDict', ()=>{
let model = new ERDModel();
spyOn(model, 'getNodes').and.returnValue([
{
name: 'test1',
getID: function() {
return 'id1';
},
},
{
name: 'test2',
getID: function() {
return 'id2';
},
},
]);
expect(JSON.stringify(model.getNodesDict())).toBe(JSON.stringify({
'id1': {name: 'test1'},
'id2': {name: 'test2'},
}));
});
});

View File

@ -0,0 +1,61 @@
/////////////////////////////////////////////////////////////
//
// pgAdmin 4 - PostgreSQL Tools
//
// Copyright (C) 2013 - 2020, The pgAdmin Development Team
// This software is released under the PostgreSQL Licence
//
//////////////////////////////////////////////////////////////
import {KeyboardShortcutAction} from 'pgadmin.tools.erd/erd_tool/ui_components/BodyWidget';
describe('KeyboardShortcutAction', ()=>{
let keyAction = null;
let key1 = {
alt: true,
control: true,
shift: false,
key: {
key_code: 65,
},
};
let key2 = {
alt: false,
control: true,
shift: false,
key: {
key_code: 66,
},
};
let handler1 = jasmine.createSpy('handler1');
let handler2 = jasmine.createSpy('handler2');
beforeAll(()=>{
spyOn(KeyboardShortcutAction.prototype, 'shortcutKey').and.callThrough();
keyAction = new KeyboardShortcutAction([
[key1, handler1],
[key2, handler2],
]);
});
it('init', ()=>{
expect(Object.keys(keyAction.shortcuts).length).toBe(2);
});
it('shortcutKey', ()=>{
expect(keyAction.shortcutKey(true, true, true, true, 65)).toBe('true:true:true:true:65');
expect(keyAction.shortcutKey(true, false, true, true, 65)).toBe('true:false:true:true:65');
expect(keyAction.shortcutKey(true, true, false, true, 65)).toBe('true:true:false:true:65');
expect(keyAction.shortcutKey(true, true, true, false, 65)).toBe('true:true:true:false:65');
expect(keyAction.shortcutKey(false, true, true, true, 65)).toBe('false:true:true:true:65');
});
it('callHandler', ()=>{
let keyEvent = {altKey: key1.alt, ctrlKey: key1.control, shiftKey: key1.shift, metaKey: false, keyCode:key1.key.key_code};
keyAction.callHandler(keyEvent);
expect(handler1).toHaveBeenCalled();
keyEvent = {altKey: key2.alt, ctrlKey: key2.control, shiftKey: key2.shift, metaKey: false, keyCode:key2.key.key_code};
keyAction.callHandler(keyEvent);
expect(handler2).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,133 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../helper/enzyme.helper';
import {
RightAngleLinkModel,
} from '@projectstorm/react-diagrams';
import OneToManyPortModel from 'pgadmin.tools.erd/erd_tool/ports/OneToManyPort';
import {OneToManyLinkModel, OneToManyLinkWidget, OneToManyLinkFactory} from 'pgadmin.tools.erd/erd_tool/links/OneToManyLink';
describe('ERD OneToManyLinkModel', ()=>{
let modelObj = null;
beforeAll(()=>{
spyOn(RightAngleLinkModel.prototype, 'serialize').and.returnValue({'key': 'value'});
});
beforeEach(()=>{
modelObj = new OneToManyLinkModel({
data: {
local_table_uid: 'id1',
local_column_attnum: 0,
referenced_table_uid: 'id2',
referenced_column_attnum: 1,
},
});
});
it('init', ()=>{
expect(modelObj.getData()).toEqual({
local_table_uid: 'id1',
local_column_attnum: 0,
referenced_table_uid: 'id2',
referenced_column_attnum: 1,
});
});
it('setData', ()=>{
modelObj.setData({
local_column_attnum: 2,
referenced_column_attnum: 4,
});
expect(modelObj.getData()).toEqual({
local_column_attnum: 2,
referenced_column_attnum: 4,
});
});
it('serializeData', ()=>{
let nodesDict = {
'id1': {
getData: function(){ return {
'name': 'table1',
'schema': 'erd1',
'columns': [
{'name': 'col11', attnum: 0},
{'name': 'col12', attnum: 1},
],
};},
},
'id2': {
getData: function(){ return {
'name': 'table2',
'schema': 'erd2',
'columns': [
{'name': 'col21', attnum: 0},
{'name': 'col22', attnum: 1},
],
};},
},
};
expect(modelObj.serializeData(nodesDict)).toEqual({
'schema': 'erd1',
'table': 'table1',
'remote_schema': 'erd2',
'remote_table': 'table2',
'columns': [{
'local_column': 'col11',
'referenced': 'col22',
}],
});
});
it('serialize', ()=>{
let retVal = modelObj.serialize();
expect(RightAngleLinkModel.prototype.serialize).toHaveBeenCalled();
expect(retVal).toEqual({
key: 'value',
data: {
local_table_uid: 'id1',
local_column_attnum: 0,
referenced_table_uid: 'id2',
referenced_column_attnum: 1,
},
});
});
});
describe('ERD OneToManyLinkWidget', ()=>{
let linkFactory = new OneToManyLinkFactory();
let engine = {
getFactoryForLink: ()=>linkFactory,
};
let link = null;
beforeEach(()=>{
jasmineEnzyme();
link = new OneToManyLinkModel({
color: '#000',
data: {
local_table_uid: 'id1',
local_column_attnum: 0,
referenced_table_uid: 'id2',
referenced_column_attnum: 1,
},
});
link.setSourcePort(new OneToManyPortModel({options: {}}));
link.setTargetPort(new OneToManyPortModel({options: {}}));
});
it('render', ()=>{
let linkWidget = mount(
<svg><OneToManyLinkWidget link={link} diagramEngine={engine} factory={linkFactory} /></svg>
);
let paths = linkWidget.find('g g');
expect(paths.at(0).find('polyline').length).toBe(1);
expect(paths.at(paths.length-1).find('polyline').length).toBe(1);
expect(paths.at(paths.length-1).find('circle').length).toBe(1);
});
});

View File

@ -0,0 +1,21 @@
import { PortModel } from '@projectstorm/react-diagrams-core';
import OneToManyPortModel from 'pgadmin.tools.erd/erd_tool/ports/OneToManyPort';
import {OneToManyLinkModel} from 'pgadmin.tools.erd/erd_tool/links/OneToManyLink';
describe('ERD OneToManyPortModel', ()=>{
it('removeAllLinks', ()=>{
let link1 = jasmine.createSpyObj('link1', ['remove']);
let link2 = jasmine.createSpyObj('link2', ['remove']);
spyOn(PortModel.prototype, 'getLinks').and.returnValue([link1, link2]);
let portObj = new OneToManyPortModel({options: {}});
portObj.removeAllLinks();
expect(link1.remove).toHaveBeenCalled();
expect(link2.remove).toHaveBeenCalled();
});
it('createLinkModel', ()=>{
let portObj = new OneToManyPortModel({options: {}});
expect(portObj.createLinkModel()).toBeInstanceOf(OneToManyLinkModel);
});
});

View File

@ -0,0 +1,305 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../helper/enzyme.helper';
import { DefaultNodeModel } from '@projectstorm/react-diagrams';
import {TableNodeModel, TableNodeWidget} from 'pgadmin.tools.erd/erd_tool/nodes/TableNode';
import { IconButton, DetailsToggleButton } from 'pgadmin.tools.erd/erd_tool/ui_components/ToolBar';
describe('ERD TableNodeModel', ()=>{
let modelObj = null;
beforeAll(()=>{
spyOn(DefaultNodeModel.prototype, 'serialize').and.returnValue({'key': 'value'});
});
beforeEach(()=>{
modelObj = new TableNodeModel({
color: '#000',
otherInfo: {
note: 'some note',
data: {
name: 'table1',
schema: 'erd',
},
},
});
});
it('init', ()=>{
expect(modelObj.getData()).toEqual({
columns: [],
name: 'table1',
schema: 'erd',
});
expect(modelObj.getNote()).toBe('some note');
expect(modelObj.getColumns()).toEqual([]);
});
it('getPortName', ()=>{
expect(modelObj.getPortName(2)).toBe('coll-port-2');
});
it('setNote', ()=>{
modelObj.setNote('some note to test');
expect(modelObj.getNote()).toBe('some note to test');
});
it('addColumn', ()=>{
modelObj.addColumn({name: 'col1', not_null:false, attnum: 0});
expect(modelObj.getColumns()).toEqual([{name: 'col1', not_null:false, attnum: 0}]);
});
it('getColumnAt', ()=>{
modelObj.addColumn({name: 'col1', not_null:false, attnum: 0});
modelObj.addColumn({name: 'col2', not_null:false, attnum: 1});
expect(modelObj.getColumnAt(0)).toEqual({name: 'col1', not_null:false, attnum: 0});
expect(modelObj.getColumnAt(1)).toEqual({name: 'col2', not_null:false, attnum: 1});
expect(modelObj.getColumnAt(2)).toBeUndefined();
});
it('setName', ()=>{
modelObj.setName('changedName');
expect(modelObj.getData().name).toBe('changedName');
});
it('cloneData', ()=>{
modelObj.addColumn({name: 'col1', not_null:false, attnum: 0});
expect(modelObj.cloneData('clonedNode')).toEqual({
name: 'clonedNode',
schema: 'erd',
columns: [{name: 'col1', not_null:false, attnum: 0}],
});
});
describe('setData', ()=>{
let existPort = jasmine.createSpyObj('port', ['removeAllLinks']);
beforeEach(()=>{
modelObj._data.columns = [
{name: 'col1', not_null:false, attnum: 0},
{name: 'col2', not_null:false, attnum: 1},
{name: 'col3', not_null:false, attnum: 2},
];
spyOn(modelObj, 'getPort').and.callFake((portName)=>{
/* If new port added there will not be any port */
if(portName !== 'coll-port-3') {
return existPort;
}
});
spyOn(modelObj, 'removePort');
spyOn(modelObj, 'getPortName');
});
it('add columns', ()=>{
existPort.removeAllLinks.calls.reset();
modelObj.setData({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col1', not_null:false, attnum: 0},
{name: 'col2', not_null:false, attnum: 1},
{name: 'col3', not_null:false, attnum: 2},
{name: 'col4', not_null:false, attnum: 3},
],
});
expect(modelObj.getData()).toEqual({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col1', not_null:false, attnum: 0},
{name: 'col2', not_null:false, attnum: 1},
{name: 'col3', not_null:false, attnum: 2},
{name: 'col4', not_null:false, attnum: 3},
],
});
expect(existPort.removeAllLinks).not.toHaveBeenCalled();
});
it('update columns', ()=>{
existPort.removeAllLinks.calls.reset();
modelObj.setData({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col1', not_null:false, attnum: 0},
{name: 'col2updated', not_null:false, attnum: 1},
{name: 'col3', not_null:true, attnum: 2},
],
});
expect(modelObj.getData()).toEqual({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col1', not_null:false, attnum: 0},
{name: 'col2updated', not_null:false, attnum: 1},
{name: 'col3', not_null:true, attnum: 2},
],
});
expect(existPort.removeAllLinks).not.toHaveBeenCalled();
});
it('remove columns', ()=>{
existPort.removeAllLinks.calls.reset();
modelObj.setData({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col2', not_null:false, attnum: 1},
{name: 'col3', not_null:false, attnum: 2},
],
});
expect(modelObj.getData()).toEqual({
name: 'noname',
schema: 'erd',
columns: [
{name: 'col2', not_null:false, attnum: 1},
{name: 'col3', not_null:false, attnum: 2},
],
});
expect(modelObj.getPortName).toHaveBeenCalledWith(0);
expect(existPort.removeAllLinks).toHaveBeenCalled();
expect(modelObj.removePort).toHaveBeenCalledWith(existPort);
});
});
it('getSchemaTableName', ()=>{
expect(modelObj.getSchemaTableName()).toEqual(['erd', 'table1']);
});
it('serializeData', ()=>{
modelObj.addColumn({name: 'col1', not_null:false, attnum: 0});
expect(modelObj.serializeData()).toEqual({
name: 'table1',
schema: 'erd',
columns: [{name: 'col1', not_null:false, attnum: 0}],
});
});
it('serialize', ()=>{
let retVal = modelObj.serialize();
expect(DefaultNodeModel.prototype.serialize).toHaveBeenCalled();
expect(retVal).toEqual({
key: 'value',
otherInfo: {
data: {
columns: [],
name: 'table1',
schema: 'erd',
},
note: 'some note',
},
});
});
});
describe('ERD TableNodeWidget', ()=>{
let node = null;
beforeEach(()=>{
jasmineEnzyme();
node = new TableNodeModel({
color: '#000',
otherInfo: {
note: 'some note',
data: {
name: 'table1',
schema: 'erd',
columns: [{
attnum: 0,
is_primary_key: true,
name: 'id',
cltype: 'integer',
attlen: null,
attprecision: null,
}, {
attnum: 1,
is_primary_key: false,
name: 'amount',
cltype: 'number',
attlen: 10,
attprecision: 5,
}, {
attnum: 2,
is_primary_key: false,
name: 'desc',
cltype: 'character varrying',
attlen: 50,
attprecision: null,
}],
},
},
});
});
it('render', ()=>{
let nodeWidget = mount(<TableNodeWidget node={node}/>);
expect(nodeWidget.getDOMNode().className).toBe('table-node ');
expect(nodeWidget.find('.table-node .table-toolbar').length).toBe(1);
expect(nodeWidget.find('.table-node .table-schema').text()).toBe('erd');
expect(nodeWidget.find('.table-node .table-name').text()).toBe('table1');
expect(nodeWidget.find('.table-node .table-cols').length).toBe(1);
expect(nodeWidget.find(DetailsToggleButton).length).toBe(1);
expect(nodeWidget.find(IconButton).findWhere(n => n.prop('title')=='Check note').length).toBe(1);
});
it('node selected', ()=>{
spyOn(node, 'isSelected').and.returnValue(true);
let nodeWidget = mount(<TableNodeWidget node={node}/>);
expect(nodeWidget.getDOMNode().className).toBe('table-node selected');
});
it('remove note', ()=>{
node.setNote('');
let nodeWidget = mount(<TableNodeWidget node={node}/>);
expect(nodeWidget.find(IconButton).findWhere(n => n.prop('title')=='Check note').length).toBe(0);
});
describe('generateColumn', ()=>{
let nodeWidget = null;
beforeEach(()=>{
nodeWidget = mount(<TableNodeWidget node={node}/>);
});
it('count', ()=>{
expect(nodeWidget.find('.table-node .table-cols .col-row').length).toBe(3);
});
it('icons', ()=>{
let cols = nodeWidget.find('.table-node .table-cols .col-row-data');
expect(cols.at(0).find('.wcTabIcon').hasClass('icon-primary_key')).toBeTrue();
expect(cols.at(1).find('.wcTabIcon').hasClass('icon-column')).toBeTrue();
expect(cols.at(2).find('.wcTabIcon').hasClass('icon-column')).toBeTrue();
});
it('column names', ()=>{
let cols = nodeWidget.find('.table-node .table-cols .col-row-data');
expect(cols.at(0).find('.col-name').text()).toBe('id');
expect(cols.at(1).find('.col-name').text()).toBe('amount');
expect(cols.at(2).find('.col-name').text()).toBe('desc');
});
it('data types', ()=>{
let cols = nodeWidget.find('.table-node .table-cols .col-row-data');
expect(cols.at(0).find('.col-datatype').text()).toBe('integer');
expect(cols.at(1).find('.col-datatype').text()).toBe('number(10,5)');
expect(cols.at(2).find('.col-datatype').text()).toBe('character varrying(50)');
});
it('show_details', (done)=>{
nodeWidget.setState({show_details: false});
expect(nodeWidget.find('.table-node .table-cols .col-row-data .col-datatype').length).toBe(0);
nodeWidget.instance().toggleShowDetails(jasmine.createSpyObj('event', ['preventDefault']));
/* Dummy set state to wait for toggleShowDetails -> setState to complete */
nodeWidget.setState({}, ()=>{
expect(nodeWidget.find('.table-node .table-cols .col-row-data .col-datatype').length).toBe(3);
done();
});
});
});
});

View File

@ -0,0 +1,651 @@
export default [
{
'oid': 123456,
'name': 'test1',
'spcoid': 0,
'relacl_str': null,
'spcname': 'pg_default',
'schema': 'schema1',
'relowner': 'postgres',
'relkind': 'r',
'is_partitioned': false,
'relhassubclass': false,
'reltuples': '0',
'description': null,
'conname': null,
'conkey': null,
'isrepl': false,
'triggercount': '0',
'coll_inherits': [],
'inherited_tables_cnt': '0',
'relpersistence': false,
'fillfactor': null,
'parallel_workers': null,
'toast_tuple_target': null,
'autovacuum_enabled': 'x',
'autovacuum_vacuum_threshold': null,
'autovacuum_vacuum_scale_factor': null,
'autovacuum_analyze_threshold': null,
'autovacuum_analyze_scale_factor': null,
'autovacuum_vacuum_cost_delay': null,
'autovacuum_vacuum_cost_limit': null,
'autovacuum_freeze_min_age': null,
'autovacuum_freeze_max_age': null,
'autovacuum_freeze_table_age': null,
'toast_autovacuum_enabled': 'x',
'toast_autovacuum_vacuum_threshold': null,
'toast_autovacuum_vacuum_scale_factor': null,
'toast_autovacuum_analyze_threshold': null,
'toast_autovacuum_analyze_scale_factor': null,
'toast_autovacuum_vacuum_cost_delay': null,
'toast_autovacuum_vacuum_cost_limit': null,
'toast_autovacuum_freeze_min_age': null,
'toast_autovacuum_freeze_max_age': null,
'toast_autovacuum_freeze_table_age': null,
'reloptions': null,
'toast_reloptions': null,
'reloftype': 0,
'typname': null,
'typoid': null,
'rlspolicy': false,
'forcerlspolicy': false,
'hastoasttable': false,
'seclabels': null,
'is_sys_table': false,
'partition_scheme': '',
'autovacuum_custom': false,
'toast_autovacuum': false,
'rows_cnt': 0,
'vacuum_settings_str': '',
'vacuum_table': [
{
'name': 'autovacuum_analyze_scale_factor',
'setting': '0.1',
'label': 'ANALYZE scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_analyze_threshold',
'setting': '50',
'label': 'ANALYZE base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_max_age',
'setting': '200000000',
'label': 'FREEZE maximum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_delay',
'setting': '2',
'label': 'VACUUM cost delay',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_limit',
'setting': '-1',
'label': 'VACUUM cost limit',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_scale_factor',
'setting': '0.2',
'label': 'VACUUM scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_vacuum_threshold',
'setting': '50',
'label': 'VACUUM base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_min_age',
'setting': '50000000',
'label': 'FREEZE minimum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_table_age',
'setting': '150000000',
'label': 'FREEZE table age',
'column_type': 'integer',
},
],
'vacuum_toast': [
{
'name': 'autovacuum_freeze_max_age',
'setting': '200000000',
'label': 'FREEZE maximum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_delay',
'setting': '2',
'label': 'VACUUM cost delay',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_limit',
'setting': '-1',
'label': 'VACUUM cost limit',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_scale_factor',
'setting': '0.2',
'label': 'VACUUM scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_vacuum_threshold',
'setting': '50',
'label': 'VACUUM base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_min_age',
'setting': '50000000',
'label': 'FREEZE minimum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_table_age',
'setting': '150000000',
'label': 'FREEZE table age',
'column_type': 'integer',
},
],
'columns': [{
'name': 'id',
'atttypid': 23,
'attlen': null,
'attnum': 1,
'attndims': 0,
'atttypmod': -1,
'attacl': [],
'attnotnull': true,
'attoptions': null,
'attstattarget': -1,
'attstorage': 'p',
'attidentity': '',
'defval': null,
'typname': 'integer',
'displaytypname': 'integer',
'cltype': 'integer',
'elemoid': 23,
'typnspname': 'pg_catalog',
'defaultstorage': 'p',
'description': null,
'indkey': '1',
'isdup': false,
'collspcname': '',
'is_fk': false,
'seclabels': null,
'is_sys_column': false,
'colconstype': 'n',
'genexpr': null,
'relname': 'tab1',
'is_view_only': false,
'seqrelid': null,
'seqtypid': null,
'seqstart': null,
'seqincrement': null,
'seqmax': null,
'seqmin': null,
'seqcache': null,
'seqcycle': null,
'is_pk': true,
'is_primary_key': true,
'attprecision': null,
'edit_types': [
'bigint',
'double precision',
'information_schema.cardinal_number',
'integer',
'money',
'numeric',
'oid',
'real',
'regclass',
'regconfig',
'regdictionary',
'regnamespace',
'regoper',
'regoperator',
'regproc',
'regprocedure',
'regrole',
'regtype',
'smallint',
],
}],
'primary_key': [],
'unique_constraint': [],
'check_constraint': [],
'index': {},
'rule': {},
'trigger': {},
'row_security_policy': {},
},
{
'oid': 408229,
'name': 'test2',
'spcoid': 0,
'relacl_str': null,
'spcname': 'pg_default',
'schema': 'erd',
'relowner': 'postgres',
'relkind': 'r',
'is_partitioned': false,
'relhassubclass': false,
'reltuples': '0',
'description': null,
'conname': 'tab1_pkey',
'conkey': [
1,
],
'isrepl': false,
'triggercount': '0',
'coll_inherits': [],
'inherited_tables_cnt': '0',
'relpersistence': false,
'fillfactor': null,
'parallel_workers': null,
'toast_tuple_target': null,
'autovacuum_enabled': 'x',
'autovacuum_vacuum_threshold': null,
'autovacuum_vacuum_scale_factor': null,
'autovacuum_analyze_threshold': null,
'autovacuum_analyze_scale_factor': null,
'autovacuum_vacuum_cost_delay': null,
'autovacuum_vacuum_cost_limit': null,
'autovacuum_freeze_min_age': null,
'autovacuum_freeze_max_age': null,
'autovacuum_freeze_table_age': null,
'toast_autovacuum_enabled': 'x',
'toast_autovacuum_vacuum_threshold': null,
'toast_autovacuum_vacuum_scale_factor': null,
'toast_autovacuum_analyze_threshold': null,
'toast_autovacuum_analyze_scale_factor': null,
'toast_autovacuum_vacuum_cost_delay': null,
'toast_autovacuum_vacuum_cost_limit': null,
'toast_autovacuum_freeze_min_age': null,
'toast_autovacuum_freeze_max_age': null,
'toast_autovacuum_freeze_table_age': null,
'reloptions': null,
'toast_reloptions': null,
'reloftype': 0,
'typname': null,
'typoid': null,
'rlspolicy': false,
'forcerlspolicy': false,
'hastoasttable': false,
'seclabels': null,
'is_sys_table': false,
'partition_scheme': '',
'autovacuum_custom': false,
'toast_autovacuum': false,
'rows_cnt': 0,
'vacuum_settings_str': '',
'vacuum_table': [
{
'name': 'autovacuum_analyze_scale_factor',
'setting': '0.1',
'label': 'ANALYZE scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_analyze_threshold',
'setting': '50',
'label': 'ANALYZE base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_max_age',
'setting': '200000000',
'label': 'FREEZE maximum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_delay',
'setting': '2',
'label': 'VACUUM cost delay',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_limit',
'setting': '-1',
'label': 'VACUUM cost limit',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_scale_factor',
'setting': '0.2',
'label': 'VACUUM scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_vacuum_threshold',
'setting': '50',
'label': 'VACUUM base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_min_age',
'setting': '50000000',
'label': 'FREEZE minimum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_table_age',
'setting': '150000000',
'label': 'FREEZE table age',
'column_type': 'integer',
},
],
'vacuum_toast': [
{
'name': 'autovacuum_freeze_max_age',
'setting': '200000000',
'label': 'FREEZE maximum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_delay',
'setting': '2',
'label': 'VACUUM cost delay',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_cost_limit',
'setting': '-1',
'label': 'VACUUM cost limit',
'column_type': 'integer',
},
{
'name': 'autovacuum_vacuum_scale_factor',
'setting': '0.2',
'label': 'VACUUM scale factor',
'column_type': 'number',
},
{
'name': 'autovacuum_vacuum_threshold',
'setting': '50',
'label': 'VACUUM base threshold',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_min_age',
'setting': '50000000',
'label': 'FREEZE minimum age',
'column_type': 'integer',
},
{
'name': 'autovacuum_freeze_table_age',
'setting': '150000000',
'label': 'FREEZE table age',
'column_type': 'integer',
},
],
'columns': [
{
'name': 'id',
'atttypid': 23,
'attlen': null,
'attnum': 1,
'attndims': 0,
'atttypmod': -1,
'attacl': [],
'attnotnull': true,
'attoptions': null,
'attstattarget': -1,
'attstorage': 'p',
'attidentity': '',
'defval': null,
'typname': 'integer',
'displaytypname': 'integer',
'cltype': 'integer',
'elemoid': 23,
'typnspname': 'pg_catalog',
'defaultstorage': 'p',
'description': null,
'indkey': '1',
'isdup': false,
'collspcname': '',
'is_fk': false,
'seclabels': null,
'is_sys_column': false,
'colconstype': 'n',
'genexpr': null,
'relname': 'tab1',
'is_view_only': false,
'seqrelid': null,
'seqtypid': null,
'seqstart': null,
'seqincrement': null,
'seqmax': null,
'seqmin': null,
'seqcache': null,
'seqcycle': null,
'is_pk': true,
'is_primary_key': true,
'attprecision': null,
'edit_types': [
'bigint',
'double precision',
'information_schema.cardinal_number',
'integer',
'money',
'numeric',
'oid',
'real',
'regclass',
'regconfig',
'regdictionary',
'regnamespace',
'regoper',
'regoperator',
'regproc',
'regprocedure',
'regrole',
'regtype',
'smallint',
],
},
{
'name': 'col1col1col1col1col1col1col1col1',
'atttypid': 23,
'attlen': null,
'attnum': 2,
'attndims': 0,
'atttypmod': -1,
'attacl': [],
'attnotnull': true,
'attoptions': null,
'attstattarget': -1,
'attstorage': 'p',
'attidentity': '',
'defval': null,
'typname': 'integer',
'displaytypname': 'integer',
'cltype': 'integer',
'elemoid': 23,
'typnspname': 'pg_catalog',
'defaultstorage': 'p',
'description': null,
'indkey': '1',
'isdup': false,
'collspcname': '',
'is_fk': true,
'seclabels': null,
'is_sys_column': false,
'colconstype': 'n',
'genexpr': null,
'relname': 'tab1',
'is_view_only': false,
'seqrelid': null,
'seqtypid': null,
'seqstart': null,
'seqincrement': null,
'seqmax': null,
'seqmin': null,
'seqcache': null,
'seqcycle': null,
'is_pk': false,
'is_primary_key': false,
'attprecision': null,
'edit_types': [
'bigint',
'double precision',
'information_schema.cardinal_number',
'integer',
'integer',
'money',
'numeric',
'oid',
'real',
'regclass',
'regconfig',
'regdictionary',
'regnamespace',
'regoper',
'regoperator',
'regproc',
'regprocedure',
'regrole',
'regtype',
'smallint',
],
},
{
'name': 'col2',
'atttypid': 23,
'attlen': null,
'attnum': 3,
'attndims': 0,
'atttypmod': -1,
'attacl': [],
'attnotnull': false,
'attoptions': null,
'attstattarget': -1,
'attstorage': 'p',
'attidentity': '',
'defval': null,
'typname': 'integer',
'displaytypname': 'integer',
'cltype': 'integer',
'elemoid': 23,
'typnspname': 'pg_catalog',
'defaultstorage': 'p',
'description': null,
'indkey': '1',
'isdup': false,
'collspcname': '',
'is_fk': false,
'seclabels': null,
'is_sys_column': false,
'colconstype': 'n',
'genexpr': null,
'relname': 'tab1',
'is_view_only': false,
'seqrelid': null,
'seqtypid': null,
'seqstart': null,
'seqincrement': null,
'seqmax': null,
'seqmin': null,
'seqcache': null,
'seqcycle': null,
'is_pk': false,
'is_primary_key': false,
'attprecision': null,
'edit_types': [
'bigint',
'double precision',
'information_schema.cardinal_number',
'integer',
'integer',
'integer',
'money',
'numeric',
'oid',
'real',
'regclass',
'regconfig',
'regdictionary',
'regnamespace',
'regoper',
'regoperator',
'regproc',
'regprocedure',
'regrole',
'regtype',
'smallint',
],
},
],
'primary_key': [
{
'oid': 408232,
'name': 'tab1_pkey',
'col_count': 1,
'spcname': 'pg_default',
'comment': null,
'condeferrable': false,
'condeferred': false,
'fillfactor': null,
'columns': [
{
'column': 'id',
},
],
'include': [],
},
],
'unique_constraint': [],
'foreign_key': [
{
'oid': 408239,
'name': 'tab1_col1_fkey',
'condeferrable': false,
'condeferred': false,
'confupdtype': 'a',
'confdeltype': 'a',
'confmatchtype': false,
'conkey': [
2,
],
'confkey': [
1,
],
'confrelid': 408234,
'fknsp': 'erd',
'fktab': 'tab1',
'refnsp': 'erd',
'reftab': 'tab2',
'comment': null,
'convalidated': false,
'columns': [
{
'local_column': 'col1col1col1col1col1col1col1col1',
'references': 123456,
'referenced': 'id',
'references_table_name': 'schema1.test1',
},
],
'remote_schema': 'schema1',
'remote_table': 'test1',
'coveringindex': null,
'autoindex': true,
'hasindex': false,
},
],
'check_constraint': [],
'index': {},
'rule': {},
'trigger': {},
'row_security_policy': {},
},
];

View File

@ -0,0 +1,514 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../../helper/enzyme.helper';
import MockAdapter from 'axios-mock-adapter';
import axios from 'axios/index';
import ERDCore from 'pgadmin.tools.erd/erd_tool/ERDCore';
import * as erdModule from 'pgadmin.tools.erd/erd_module';
import erdPref from './erd_preferences';
import BodyWidget from 'pgadmin.tools.erd/erd_tool/ui_components/BodyWidget';
import * as ERDSqlTool from 'tools/datagrid/static/js/show_query_tool';
let pgAdmin = {
Browser: {
Events: {
on: jasmine.createSpy('on'),
},
get_preferences_for_module: function() {
return erdPref;
},
docker: {
findPanels: function() {
return [
{
isVisible: function() {
return true;
},
},
];
},
},
onPreferencesChange: ()=>{},
utils: {
app_version_int: 1234,
},
},
FileManager: {
init: jasmine.createSpy(),
show_dialog: jasmine.createSpy(),
},
};
let alertify = jasmine.createSpyObj('alertify', {
'success': null,
'error': null,
'confirm': null,
'alert': {
'set': ()=>{},
},
});
let tableDialog = jasmine.createSpyObj('TableDialog', ['show']);
let otmDialog = jasmine.createSpyObj('otmDialog', ['show']);
let mtmDialog = jasmine.createSpyObj('mtmDialog', ['show']);
let getDialog = (dialogName)=>{
switch(dialogName) {
case 'entity_dialog': return tableDialog;
case 'onetomany_dialog': return otmDialog;
case 'manytomany_dialog': return mtmDialog;
}
};
describe('ERD BodyWidget', ()=>{
let body = null;
let bodyInstance = null;
let networkMock = null;
let serverVersion = 120000;
let colTypes = [
{'label': 'integer', 'value': 'integer'},
{'label': 'character varrying', 'value': 'character varrying'},
];
let schemas = [
{'oid': 111, 'name': 'erd1'},
{'oid': 222, 'name': 'erd2'},
];
let params = {
bgcolor: null,
client_platform: 'macos',
did: '13637',
fgcolor: null,
gen: true,
is_desktop_mode: true,
is_linux: false,
server_type: 'pg',
sgid: '1',
sid: '5',
title: 'postgres/postgres@PostgreSQL 12',
trans_id: 110008,
};
beforeAll(()=>{
spyOn(erdModule, 'setPanelTitle');
spyOn(ERDCore.prototype, 'repaint');
spyOn(ERDCore.prototype, 'deserializeData');
spyOn(ERDCore.prototype, 'addNode').and.returnValue({
setSelected: ()=>{},
getColumns: ()=>([{attnum: 0}, {attnum: 1}]),
getID: ()=>'newid1',
});
spyOn(ERDCore.prototype, 'addLink').and.returnValue({
setSelected: ()=>{},
});
spyOn(alertify, 'confirm').and.callFake((arg1, arg2, okCallback)=>{
okCallback();
});
networkMock = new MockAdapter(axios);
networkMock.onPost('/erd/initialize/110008/1/5/13637').reply(200, {'data': {
serverVersion: serverVersion,
}});
networkMock.onGet('/erd/prequisite/110008/1/5/13637').reply(200, {'data': {
'col_types': colTypes,
'schemas': schemas,
}});
networkMock.onGet('/erd/tables/110008/1/5/13637').reply(200, {'data': []});
networkMock.onPost('/erd/sql/110008/1/5/13637').reply(200, {'data': 'SELECT 1;'});
networkMock.onPost('/sqleditor/load_file/').reply(200, {'data': 'data'});
networkMock.onPost('/sqleditor/save_file/').reply(200, {'data': 'data'});
});
beforeEach(()=>{
jasmineEnzyme();
body = mount(<BodyWidget params={params} pgAdmin={pgAdmin} getDialog={getDialog} transformToSupported={()=>{}} alertify={alertify}/>);
bodyInstance = body.instance();
});
afterAll(() => {
networkMock.restore();
if(body) {
body.unmount();
}
});
it('constructor', (done)=>{
expect(body.find('ToolBar').length).toBe(1);
expect(body.find('ConnectionBar').length).toBe(1);
expect(body.find('FloatingNote').length).toBe(1);
expect(body.find('.diagram-container Loader').length).toBe(1);
expect(body.find('.diagram-container CanvasWidget').length).toBe(1);
body.instance().setState({}, ()=>{
let instance = body.instance();
setTimeout(()=>{
expect(body.state()).toEqual(jasmine.objectContaining({
server_version: serverVersion,
preferences: erdPref,
}));
expect(instance.diagram.getCache('colTypes')).toEqual(colTypes);
expect(instance.diagram.getCache('schemas')).toEqual(schemas);
done();
});
});
});
it('event offsetUpdated', (done)=>{
bodyInstance.diagram.fireEvent({offsetX: 4, offsetY: 5}, 'offsetUpdated', true);
setTimeout(()=>{
expect(bodyInstance.canvasEle.style.backgroundPosition).toBe('4px 5px');
done();
});
});
it('event zoomUpdated', (done)=>{
spyOn(bodyInstance.diagram.getModel(), 'getOptions').and.returnValue({gridSize: 15});
bodyInstance.diagram.fireEvent({zoom: 20}, 'zoomUpdated', true);
setTimeout(()=>{
expect(bodyInstance.canvasEle.style.backgroundSize).toBe('9px 9px');
done();
});
});
it('event nodesSelectionChanged', (done)=>{
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([{key:'value'}]);
bodyInstance.diagram.fireEvent({}, 'nodesSelectionChanged', true);
setTimeout(()=>{
expect(body.state().single_node_selected).toBe(true);
expect(body.state().any_item_selected).toBe(true);
done();
});
});
it('event linksSelectionChanged', (done)=>{
spyOn(bodyInstance.diagram, 'getSelectedLinks').and.returnValue([{key:'value'}]);
bodyInstance.diagram.fireEvent({}, 'linksSelectionChanged', true);
setTimeout(()=>{
expect(body.state().single_link_selected).toBe(true);
expect(body.state().any_item_selected).toBe(true);
done();
});
});
it('event linksUpdated', (done)=>{
bodyInstance.diagram.fireEvent({}, 'linksUpdated', true);
setTimeout(()=>{
expect(body.state().dirty).toBe(true);
done();
});
});
it('event nodesUpdated', (done)=>{
bodyInstance.diagram.fireEvent({}, 'nodesUpdated', true);
setTimeout(()=>{
expect(body.state().dirty).toBe(true);
done();
});
});
it('event showNote', (done)=>{
let noteNode = {key: 'value', getNote: ()=>'a note'};
spyOn(bodyInstance, 'showNote');
bodyInstance.diagram.fireEvent({node: noteNode}, 'showNote', true);
setTimeout(()=>{
expect(bodyInstance.showNote).toHaveBeenCalledWith(noteNode);
done();
});
});
it('event editNode', (done)=>{
let node = {key: 'value', getNote: ()=>'a note'};
spyOn(bodyInstance, 'addEditNode');
bodyInstance.diagram.fireEvent({node: node}, 'editNode', true);
setTimeout(()=>{
expect(bodyInstance.addEditNode).toHaveBeenCalledWith(node);
done();
});
});
it('getDialog', ()=>{
bodyInstance.getDialog('entity_dialog')();
expect(tableDialog.show).toHaveBeenCalled();
bodyInstance.getDialog('onetomany_dialog')();
expect(otmDialog.show).toHaveBeenCalled();
bodyInstance.getDialog('manytomany_dialog')();
expect(mtmDialog.show).toHaveBeenCalled();
});
it('addEditNode', ()=>{
/* New */
tableDialog.show.calls.reset();
bodyInstance.addEditNode();
expect(tableDialog.show).toHaveBeenCalled();
let saveCallback = tableDialog.show.calls.mostRecent().args[5];
let newData = {key: 'value'};
saveCallback(newData);
expect(bodyInstance.diagram.addNode).toHaveBeenCalledWith(newData);
/* Existing */
tableDialog.show.calls.reset();
let node = jasmine.createSpyObj('node',{
getSchemaTableName: ['erd1', 'table1'],
setData: null,
getData: null,
});
bodyInstance.addEditNode(node);
expect(tableDialog.show).toHaveBeenCalled();
saveCallback = tableDialog.show.calls.mostRecent().args[5];
newData = {key: 'value'};
saveCallback(newData);
expect(node.setData).toHaveBeenCalledWith(newData);
});
it('onEditNode', ()=>{
let node = {key: 'value'};
spyOn(bodyInstance, 'addEditNode');
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([node]);
bodyInstance.onEditNode();
expect(bodyInstance.addEditNode).toHaveBeenCalledWith(node);
});
it('onAddNewNode', ()=>{
spyOn(bodyInstance, 'addEditNode');
bodyInstance.onAddNewNode();
expect(bodyInstance.addEditNode).toHaveBeenCalled();
});
it('onCloneNode', ()=>{
let node = jasmine.createSpyObj('node',{
getSchemaTableName: ['erd1', 'table1'],
setData: null,
getData: null,
cloneData: {key: 'value'},
getPosition: {x: 30, y: 30},
});
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([node]);
spyOn(bodyInstance.diagram, 'getNextTableName').and.returnValue('newtable1');
bodyInstance.onCloneNode();
expect(bodyInstance.diagram.addNode).toHaveBeenCalledWith({key: 'value'}, [50, 50]);
});
it('onDeleteNode', (done)=>{
let node = jasmine.createSpyObj('node',{
getSchemaTableName: ['erd1', 'table1'],
setData: null,
getData: null,
cloneData: {key: 'value'},
getPosition: {x: 30, y: 30},
remove: null,
setSelected: null,
});
let link = jasmine.createSpyObj('link', {
remove: null,
setSelected: null,
getTargetPort: jasmine.createSpyObj('port', ['remove']),
getSourcePort: jasmine.createSpyObj('port', ['remove']),
});
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([node]);
spyOn(bodyInstance.diagram, 'getSelectedLinks').and.returnValue([link]);
bodyInstance.onDeleteNode();
setTimeout(()=>{
expect(node.remove).toHaveBeenCalled();
expect(link.remove).toHaveBeenCalled();
done();
});
});
it('onAutoDistribute', ()=>{
spyOn(bodyInstance.diagram, 'dagreDistributeNodes');
bodyInstance.onAutoDistribute();
expect(bodyInstance.diagram.dagreDistributeNodes).toHaveBeenCalled();
});
it('onDetailsToggle', (done)=>{
let node = jasmine.createSpyObj('node',['fireEvent']);
spyOn(bodyInstance.diagram, 'getModel').and.returnValue({
'getNodes': ()=>[node],
});
let show_details = body.state().show_details;
bodyInstance.onDetailsToggle();
body.setState({}, ()=>{
expect(body.state().show_details).toBe(!show_details);
expect(node.fireEvent).toHaveBeenCalledWith({show_details: !show_details}, 'toggleDetails');
done();
});
});
it('onLoadDiagram', ()=>{
bodyInstance.onLoadDiagram();
expect(pgAdmin.FileManager.show_dialog).toHaveBeenCalled();
});
it('openFile', (done)=>{
spyOn(bodyInstance.diagram, 'deserialize');
bodyInstance.openFile('test.pgerd');
setTimeout(()=>{
expect(body.state()).toEqual(jasmine.objectContaining({
current_file: 'test.pgerd',
dirty: false,
}));
expect(bodyInstance.diagram.deserialize).toHaveBeenCalledWith({data: 'data'});
done();
});
});
it('onSaveDiagram', (done)=>{
body.setState({
current_file: 'newfile.pgerd',
});
bodyInstance.onSaveDiagram();
setTimeout(()=>{
expect(body.state()).toEqual(jasmine.objectContaining({
current_file: 'newfile.pgerd',
dirty: false,
}));
done();
});
bodyInstance.onSaveDiagram(true);
expect(pgAdmin.FileManager.show_dialog).toHaveBeenCalledWith({
'supported_types': ['pgerd'],
'dialog_type': 'create_file',
'dialog_title': 'Save File',
'btn_primary': 'Save',
});
});
it('onSaveAsDiagram', ()=>{
spyOn(bodyInstance, 'onSaveDiagram');
bodyInstance.onSaveAsDiagram();
expect(bodyInstance.onSaveDiagram).toHaveBeenCalledWith(true);
});
it('onSQLClick', (done)=>{
spyOn(bodyInstance.diagram, 'serializeData').and.returnValue({key: 'value'});
spyOn(ERDSqlTool, 'showERDSqlTool');
spyOn(localStorage, 'setItem');
bodyInstance.onSQLClick();
setTimeout(()=>{
let sql = '-- This script was generated by a beta version of the ERD tool in pgAdmin 4.\n'
+ '-- Please log an issue at https://redmine.postgresql.org/projects/pgadmin4/issues/new if you find any bugs, including reproduction steps.\n'
+ 'BEGIN;\nSELECT 1;\nEND;';
expect(localStorage.setItem).toHaveBeenCalledWith('erd'+params.trans_id, sql);
expect(ERDSqlTool.showERDSqlTool).toHaveBeenCalled();
done();
});
});
it('onOneToManyClick', ()=>{
let node = jasmine.createSpyObj('node',{
getID: 'id1',
});
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([node]);
otmDialog.show.calls.reset();
bodyInstance.onOneToManyClick();
expect(otmDialog.show).toHaveBeenCalled();
let saveCallback = otmDialog.show.calls.mostRecent().args[4];
let newData = {key: 'value'};
saveCallback(newData);
expect(bodyInstance.diagram.addLink).toHaveBeenCalledWith(newData, 'onetomany');
});
it('onManyToManyClick', ()=>{
let node = jasmine.createSpyObj('node',{
getID: 'id1',
});
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([node]);
mtmDialog.show.calls.reset();
bodyInstance.onManyToManyClick();
expect(mtmDialog.show).toHaveBeenCalled();
/* onSave */
let nodesDict = {
'id1': {
getID: ()=>'id1',
getData: ()=>({name: 'table1', schema: 'erd1'}),
getColumnAt: ()=>({name: 'col1', type: 'type1', attnum: 0}),
addPort: jasmine.createSpy('addPort').and.callFake((obj)=>obj),
},
'id2': {
getID: ()=>'id2',
getData: ()=>({name: 'table2', schema: 'erd2'}),
getColumnAt: ()=>({name: 'col2', type: 'type2', attnum: 1}),
addPort: jasmine.createSpy('addPort').and.callFake((obj)=>obj),
},
};
spyOn(bodyInstance.diagram, 'getModel').and.returnValue({
'getNodesDict': ()=>nodesDict,
});
spyOn(bodyInstance.diagram, 'addLink');
let saveCallback = mtmDialog.show.calls.mostRecent().args[4];
let newData = {
left_table_uid: 'id1',
left_table_column_attnum: 1,
right_table_uid: 'id2',
right_table_column_attnum: 2,
};
bodyInstance.diagram.addNode.calls.reset();
bodyInstance.diagram.addLink.calls.reset();
saveCallback(newData);
expect(bodyInstance.diagram.addNode).toHaveBeenCalledWith({
name: 'table1_table2',
schema: 'erd1',
columns: [
{
type: 'type1',
name: 'table1_col1',
is_primary_key: false,
attnum: 0,
},
{
type: 'type2',
name: 'table2_col2',
is_primary_key: false,
attnum: 1,
},
],
});
let linkData = {
local_table_uid: 'newid1',
local_column_attnum: 0,
referenced_table_uid: 'id1',
referenced_column_attnum : 1,
};
expect(bodyInstance.diagram.addLink.calls.argsFor(0)).toEqual([linkData, 'onetomany']);
linkData = {
local_table_uid: 'newid1',
local_column_attnum: 1,
referenced_table_uid: 'id2',
referenced_column_attnum : 2,
};
expect(bodyInstance.diagram.addLink.calls.argsFor(1)).toEqual([linkData, 'onetomany']);
});
it('onNoteClick', ()=>{
let noteNode = {key: 'value', getNote: ()=>'a note'};
spyOn(bodyInstance.diagram, 'getSelectedNodes').and.returnValue([noteNode]);
spyOn(bodyInstance.diagram.getEngine(), 'getNodeElement').and.returnValue(null);
spyOn(bodyInstance.diagram.getEngine(), 'getNodeElement').and.returnValue(null);
spyOn(bodyInstance, 'setState');
bodyInstance.onNoteClick();
expect(bodyInstance.setState).toHaveBeenCalledWith({
note_node: noteNode,
note_open: true,
});
});
});

View File

@ -0,0 +1,25 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../../helper/enzyme.helper';
import ConnectionBar, {STATUS} from 'pgadmin.tools.erd/erd_tool/ui_components/ConnectionBar';
describe('ERD ConnectionBar', ()=>{
beforeEach(()=>{
jasmineEnzyme();
});
it('<ConnectionBar /> comp', ()=>{
let connBar = mount(<ConnectionBar statusId="conn-bar" status={STATUS.DISCONNECTED} title="test title"/>);
expect(connBar.find('.editor-title').text()).toBe('test title');
connBar.setProps({status: STATUS.CONNECTING});
expect(connBar.find('.editor-title').text()).toBe('(Obtaining connection...) test title');
connBar.setProps({bgcolor: '#000', fgcolor: '#fff'});
expect(connBar.find('.editor-title').prop('style').backgroundColor).toBe('#000');
expect(connBar.find('.editor-title').prop('style').color).toBe('#fff');
});
});

View File

@ -0,0 +1,147 @@
export default {
'erd_new_browser_tab': false,
'open_project': {
'alt': false,
'shift': false,
'control': true,
'key': {
'key_code': 79,
'char': 'o',
},
},
'save_project': {
'alt': false,
'shift': false,
'control': true,
'key': {
'key_code': 83,
'char': 's',
},
},
'save_project_as': {
'alt': false,
'shift': true,
'control': true,
'key': {
'key_code': 83,
'char': 's',
},
},
'generate_sql': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 83,
'char': 's',
},
},
'download_image': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 73,
'char': 'i',
},
},
'add_table': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 65,
'char': 'a',
},
},
'edit_table': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 69,
'char': 'e',
},
},
'clone_table': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 67,
'char': 'c',
},
},
'drop_table': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 68,
'char': 'd',
},
},
'add_edit_note': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 78,
'char': 'n',
},
},
'one_to_many': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 79,
'char': 'o',
},
},
'many_to_many': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 77,
'char': 'm',
},
},
'auto_align': {
'alt': true,
'shift': false,
'control': true,
'key': {
'key_code': 76,
'char': 'l',
},
},
'zoom_to_fit': {
'alt': true,
'shift': true,
'control': false,
'key': {
'key_code': 70,
'char': 'f',
},
},
'zoom_in': {
'alt': true,
'shift': true,
'control': false,
'key': {
'key_code': 187,
'char': '+',
},
},
'zoom_out': {
'alt': true,
'shift': true,
'control': false,
'key': {
'key_code': 189,
'char': '-',
},
},
};

View File

@ -0,0 +1,39 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {mount} from 'enzyme';
import '../../helper/enzyme.helper';
import FloatingNote from 'pgadmin.tools.erd/erd_tool/ui_components/FloatingNote';
describe('ERD FloatingNote', ()=>{
beforeEach(()=>{
jasmineEnzyme();
});
it('<FloatingNote /> on OK click', ()=>{
let floatNote = null;
let onClose = jasmine.createSpy('onClose');
let noteNode = {
getNote: function() {
return 'some note';
},
setNote: jasmine.createSpy('setNote'),
getSchemaTableName: function() {
return ['schema1', 'table1'];
},
};
floatNote = mount(<FloatingNote open={false} onClose={onClose}
reference={null} noteNode={noteNode} appendTo={document.body} rows={8}/>);
floatNote.find('textarea').simulate('change', {
target: {
value: 'the new note',
},
});
floatNote.find('button[data-label="OK"]').simulate('click');
expect(noteNode.setNote).toHaveBeenCalledWith('the new note');
expect(onClose).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,23 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import {shallow} from 'enzyme';
import '../../helper/enzyme.helper';
import Loader from 'pgadmin.tools.erd/erd_tool/ui_components/Loader';
describe('ERD Loader', ()=>{
beforeEach(()=>{
jasmineEnzyme();
});
it('<Loader /> comp', ()=>{
let loaderComp = shallow(<Loader />);
expect(loaderComp.isEmptyRender()).toBeTrue();
loaderComp.setProps({message: 'test message'});
expect(loaderComp.find('.pg-sp-text').text()).toBe('test message');
loaderComp.setProps({autoEllipsis: true});
expect(loaderComp.find('.pg-sp-text').text()).toBe('test message...');
});
});

View File

@ -0,0 +1,76 @@
import jasmineEnzyme from 'jasmine-enzyme';
import React from 'react';
import Tippy from '@tippyjs/react';
import {mount, shallow} from 'enzyme';
import '../../helper/enzyme.helper';
import ToolBar, {ButtonGroup, DetailsToggleButton, IconButton, Shortcut} from 'pgadmin.tools.erd/erd_tool/ui_components/ToolBar';
describe('ERD Toolbar', ()=>{
beforeEach(()=>{
jasmineEnzyme();
});
it('<Toolbar /> comp', ()=>{
let toolBar = mount(<ToolBar id="id1"><div className="test"></div></ToolBar>);
expect(toolBar.getDOMNode().id).toBe('id1');
expect(toolBar.find('.test').length).toBe(1);
});
it('<ButtonGroup /> comp', ()=>{
let btnGrp = mount(<ButtonGroup><div className="test"></div></ButtonGroup>);
expect(btnGrp.getDOMNode().className).toBe('btn-group mr-1 ');
expect(btnGrp.find('.test').length).toBe(1);
btnGrp.unmount();
btnGrp = mount(<ButtonGroup className="someclass"></ButtonGroup>);
expect(btnGrp.getDOMNode().className).toBe('btn-group mr-1 someclass');
});
it('<DetailsToggleButton /> comp', ()=>{
let toggle = shallow(<DetailsToggleButton showDetails={true} />);
let btn = toggle.find(IconButton);
expect(btn.prop('icon')).toBe('far fa-eye');
expect(btn.prop('title')).toBe('Show fewer details');
toggle.setProps({showDetails: false});
btn = toggle.find(IconButton);
expect(btn.prop('icon')).toBe('fas fa-low-vision');
expect(btn.prop('title')).toBe('Show more details');
});
it('<IconButton /> comp', ()=>{
let btn = mount(<IconButton />);
let tippy = btn.find(Tippy);
expect(tippy.length).toBe(0);
btn.setProps({title: 'test title'});
tippy = btn.find(Tippy);
expect(tippy.length).toBe(1);
expect(btn.find('button').getDOMNode().className).toBe('btn btn-sm btn-primary-icon ');
btn.setProps({icon: 'fa fa-icon'});
expect(btn.find('button .sql-icon-lg').getDOMNode().className).toBe('fa fa-icon sql-icon-lg');
});
it('<Shortcut /> comp', ()=>{
let key = {
alt: true,
control: true,
shift: false,
key: {
key_code: 65,
char: 'a',
},
};
let shortcutComp = mount(<Shortcut shortcut={key}/>);
expect(shortcutComp.find('.shortcut-key').length).toBe(3);
key.alt = false;
shortcutComp.setProps({shortcut: key});
expect(shortcutComp.find('.shortcut-key').length).toBe(2);
});
});

View File

@ -22,5 +22,11 @@ define(function () {
'search_objects.types': '/search_objects/types/<int:sid>/<int:did>',
'search_objects.search': '/search_objects/search/<int:sid>/<int:did>',
'dashboard.dashboard_stats': '/dashboard/dashboard_stats',
'sqleditor.load_file': '/sqleditor/load_file/',
'sqleditor.save_file': '/sqleditor/save_file/',
'erd.initialize': '/erd/initialize/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
'erd.sql': '/erd/sql/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
'erd.prequisite': '/erd/prequisite/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
'erd.tables': '/erd/tables/<int:trans_id>/<int:sgid>/<int:sid>/<int:did>',
};
});

View File

@ -355,6 +355,7 @@ module.exports = [{
sqleditor: './pgadmin/tools/sqleditor/static/js/sqleditor.js',
debugger_direct: './pgadmin/tools/debugger/static/js/direct.js',
schema_diff: './pgadmin/tools/schema_diff/static/js/schema_diff_hook.js',
erd_tool: './pgadmin/tools/erd/static/js/erd_tool_hook.js',
file_utils: './pgadmin/misc/file_manager/static/js/utility.js',
'pgadmin.style': pgadminCssStyles,
pgadmin: pgadminScssStyles,
@ -501,7 +502,8 @@ module.exports = [{
',pgadmin.node.pga_job' +
',pgadmin.tools.schema_diff' +
',pgadmin.tools.storage_manager' +
',pgadmin.tools.search_objects',
',pgadmin.tools.search_objects' +
',pgadmin.tools.erd_module',
},
}, {
test: require.resolve('snapsvg'),

View File

@ -147,6 +147,10 @@ var webpackShimConfig = {
'color-picker': path.join(__dirname, './node_modules/@simonwep/pickr/dist/pickr.es5.min'),
'mousetrap': path.join(__dirname, './node_modules/mousetrap'),
'tablesorter-metric': path.join(__dirname, './node_modules/tablesorter/dist/js/parsers/parser-metric.min'),
'pathfinding': path.join(__dirname, 'node_modules/pathfinding'),
'dagre': path.join(__dirname, 'node_modules/dagre'),
'graphlib': path.join(__dirname, 'node_modules/graphlib'),
'react': path.join(__dirname, 'node_modules/react'),
// AciTree
'jquery.acitree': path.join(__dirname, './node_modules/acitree/js/jquery.aciTree.min'),
@ -275,6 +279,8 @@ var webpackShimConfig = {
'pgadmin.tools.schema_diff_ui': path.join(__dirname, './pgadmin/tools/schema_diff/static/js/schema_diff_ui'),
'pgadmin.tools.search_objects': path.join(__dirname, './pgadmin/tools/search_objects/static/js/search_objects'),
'pgadmin.tools.storage_manager': path.join(__dirname, './pgadmin/tools/storage_manager/static/js/storage_manager'),
'pgadmin.tools.erd_module': path.join(__dirname, './pgadmin/tools/erd/static/js/erd_module'),
'pgadmin.tools.erd': path.join(__dirname, './pgadmin/tools/erd/static/js'),
'pgadmin.search_objects': path.join(__dirname, './pgadmin/tools/search_objects/static/js'),
'pgadmin.tools.user_management': path.join(__dirname, './pgadmin/tools/user_management/static/js/user_management'),
'pgadmin.user_management.current_user': '/user_management/current_user',

View File

@ -113,6 +113,7 @@ module.exports = {
'pgadmin.browser.layout': path.join(__dirname, './pgadmin/browser/static/js/layout'),
'pgadmin.browser.preferences': path.join(__dirname, './pgadmin/browser/static/js/preferences'),
'pgadmin.browser.activity': path.join(__dirname, './pgadmin/browser/static/js/activity'),
'pgadmin.tools.erd': path.join(__dirname, './pgadmin/tools/erd/static/js'),
'bundled_codemirror': path.join(__dirname, './pgadmin/static/bundle/codemirror'),
'tools': path.join(__dirname, './pgadmin/tools/'),
},

File diff suppressed because it is too large Load Diff