diff --git a/docs/en_US/images/server_advanced.png b/docs/en_US/images/server_advanced.png index fcc27187e..532f7d2cb 100644 Binary files a/docs/en_US/images/server_advanced.png and b/docs/en_US/images/server_advanced.png differ diff --git a/docs/en_US/release_notes.rst b/docs/en_US/release_notes.rst index 6eb00634f..a0518cf43 100644 --- a/docs/en_US/release_notes.rst +++ b/docs/en_US/release_notes.rst @@ -12,6 +12,7 @@ notes for it. :maxdepth: 1 + release_notes_9_3 release_notes_9_2 release_notes_9_1 release_notes_9_0 diff --git a/docs/en_US/release_notes_9_3.rst b/docs/en_US/release_notes_9_3.rst new file mode 100644 index 000000000..41528fe29 --- /dev/null +++ b/docs/en_US/release_notes_9_3.rst @@ -0,0 +1,34 @@ +*********** +Version 9.3 +*********** + +Release date: 2025-04-30 + +This release contains a number of bug fixes and new features since the release of pgAdmin 4 v9.2. + +Supported Database Servers +************************** +**PostgreSQL**: 13, 14, 15, 16 and 17 + +**EDB Advanced Server**: 13, 14, 15, 16 and 17 + +Bundled PostgreSQL Utilities +**************************** +**psql**, **pg_dump**, **pg_dumpall**, **pg_restore**: 17.2 + + +New features +************ + + | `Issue #2767 `_ - Added ability to use SQL in the "DB Restriction" field. + | `Issue #8629 `_ - Added support for font ligatures. + +Housekeeping +************ + + +Bug fixes +********* + + | `Issue #8443 `_ - Fixed an issue where the debugger hangs when stepping into nested function/procedure. + | `Issue #8556 `_ - Ensure that graph data is updated even when the Dashboard tab is inactive. \ No newline at end of file diff --git a/docs/en_US/server_dialog.rst b/docs/en_US/server_dialog.rst index eed3faf0c..c9db6df7c 100644 --- a/docs/en_US/server_dialog.rst +++ b/docs/en_US/server_dialog.rst @@ -195,11 +195,19 @@ Click the *Advanced* tab to continue. Use the fields in the *Advanced* tab to configure a connection: -* Use the *DB restriction* field to provide a SQL restriction that will be used - against the pg_database table to limit the databases that you see. For - example, you might enter: *live_db test_db* so that only live_db and test_db - are shown in the pgAdmin browser. Separate entries with a comma or tab as you - type. +* Specify the type of the database restriction that will be used to filter + out the databases for restriction in the *DB restriction type* field: + + * Select the *Databases* option to specify the name of the databases + that will be used against the pg_database table to limit the databases + that you see. This is the default. + * Select the *SQL* option to provide a SQL restriction that will be used + against the pg_database table to limit the databases that you see. + +* Use the *DB restriction* field to provide a SQL restriction OR Database names + that will be used against the pg_database table to limit the databases that you see. + For example, you might enter: *live_db test_db* so that only live_db and test_db + are shown in the pgAdmin object explorer. * Use the *Password exec command* field to specify a shell command to be executed to retrieve a password to be used for SQL authentication. The ``stdout`` of the command will be used as the SQL password. This may be useful when the password diff --git a/web/migrations/versions/1f0eddc8fc79_.py b/web/migrations/versions/1f0eddc8fc79_.py new file mode 100644 index 000000000..536dd6afc --- /dev/null +++ b/web/migrations/versions/1f0eddc8fc79_.py @@ -0,0 +1,36 @@ +########################################################################## +# +# pgAdmin 4 - PostgreSQL Tools +# +# Copyright (C) 2013 - 2025, The pgAdmin Development Team +# This software is released under the PostgreSQL Licence +# +########################################################################## + +""" + +Revision ID: 1f0eddc8fc79 +Revises: e982c040d9b5 +Create Date: 2025-03-26 15:58:24.131719 + +""" +from alembic import op +import sqlalchemy as sa +from pgadmin.utils.constants import RESTRICTION_TYPE_DATABASES + +# revision identifiers, used by Alembic. +revision = '1f0eddc8fc79' +down_revision = 'e982c040d9b5' +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column('server', + sa.Column('db_res_type', sa.String(length=32), + server_default=RESTRICTION_TYPE_DATABASES)) + + +def downgrade(): + # pgAdmin only upgrades, downgrade not implemented. + pass diff --git a/web/pgadmin/browser/server_groups/servers/__init__.py b/web/pgadmin/browser/server_groups/servers/__init__.py index 33b1a3d61..63ae49fd5 100644 --- a/web/pgadmin/browser/server_groups/servers/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/__init__.py @@ -34,9 +34,9 @@ from pgadmin.utils.exception import CryptKeyMissing from pgadmin.tools.schema_diff.node_registry import SchemaDiffRegistry from pgadmin.browser.server_groups.servers.utils import \ (is_valid_ipaddress, get_replication_type, convert_connection_parameter, - check_ssl_fields) + check_ssl_fields, get_db_restriction) from pgadmin.utils.constants import UNAUTH_REQ, MIMETYPE_APP_JS, \ - SERVER_CONNECTION_CLOSED + SERVER_CONNECTION_CLOSED, RESTRICTION_TYPE_SQL from sqlalchemy import or_ from sqlalchemy.orm.attributes import flag_modified from pgadmin.utils.preferences import Preferences @@ -746,6 +746,7 @@ class ServerNode(PGChildNodeView): 'comment': 'comment', 'role': 'role', 'db_res': 'db_res', + 'db_res_type': 'db_res_type', 'passexec_cmd': 'passexec_cmd', 'passexec_expiration': 'passexec_expiration', 'bgcolor': 'bgcolor', @@ -776,12 +777,11 @@ class ServerNode(PGChildNodeView): 'role': gettext('Role') } - idx = 0 data = request.form if request.form else json.loads( request.data ) - if 'db_res' in data: + if 'db_res' in data and isinstance(data['db_res'], list): data['db_res'] = ','.join(data['db_res']) # Update connection parameter if any. @@ -948,7 +948,7 @@ class ServerNode(PGChildNodeView): 'connected': connected, 'version': manager.ver, 'server_type': manager.server_type if connected else 'pg', - 'db_res': server.db_res.split(',') if server.db_res else None + 'db_res': get_db_restriction(server.db_res_type, server.db_res) }) return ajax_response( @@ -1031,7 +1031,8 @@ class ServerNode(PGChildNodeView): 'server_type': manager.server_type if connected else 'pg', 'bgcolor': server.bgcolor, 'fgcolor': server.fgcolor, - 'db_res': server.db_res.split(',') if server.db_res else None, + 'db_res': get_db_restriction(server.db_res_type, server.db_res), + 'db_res_type': server.db_res_type, 'passexec_cmd': server.passexec_cmd if server.passexec_cmd else None, 'passexec_expiration': @@ -1137,6 +1138,12 @@ class ServerNode(PGChildNodeView): data['connection_params'] = connection_params server = None + db_restriction = None + if 'db_res' in data and isinstance(data['db_res'], list): + db_restriction = ','.join(data['db_res']) + elif 'db_res' in data and 'db_res_type' in data and \ + data['db_res_type'] == RESTRICTION_TYPE_SQL: + db_restriction = data['db_res'] try: server = Server( @@ -1151,8 +1158,8 @@ class ServerNode(PGChildNodeView): config.ALLOW_SAVE_PASSWORD else 0, comment=data.get('comment', None), role=data.get('role', None), - db_res=','.join(data['db_res']) if 'db_res' in data and - isinstance(data['db_res'], list) else None, + db_res=db_restriction, + db_res_type=data.get('db_res_type', None), bgcolor=data.get('bgcolor', None), fgcolor=data.get('fgcolor', None), service=data.get('service', None), diff --git a/web/pgadmin/browser/server_groups/servers/databases/__init__.py b/web/pgadmin/browser/server_groups/servers/databases/__init__.py index 2a47cd131..3f1470656 100644 --- a/web/pgadmin/browser/server_groups/servers/databases/__init__.py +++ b/web/pgadmin/browser/server_groups/servers/databases/__init__.py @@ -13,7 +13,7 @@ import re from functools import wraps import json -from flask import render_template, current_app, request, jsonify +from flask import render_template, current_app, request, jsonify, Response from flask_babel import gettext as _ from flask_security import current_user @@ -24,7 +24,7 @@ from pgadmin.browser.server_groups.servers.databases.utils import \ parse_sec_labels_from_db, parse_variables_from_db, \ get_attributes_from_db_info from pgadmin.browser.server_groups.servers.utils import parse_priv_from_db, \ - parse_priv_to_db + parse_priv_to_db, get_db_disp_restriction from pgadmin.browser.utils import PGChildNodeView from pgadmin.utils.ajax import gone from pgadmin.utils.ajax import make_json_response, \ @@ -266,14 +266,7 @@ class DatabaseView(PGChildNodeView): def list(self, gid, sid): last_system_oid = self.retrieve_last_system_oid() - db_disp_res = None - params = None - if self.manager and self.manager.db_res: - db_disp_res = ", ".join( - ['%s'] * len(self.manager.db_res.split(',')) - ) - params = tuple(self.manager.db_res.split(',')) - + db_disp_res, params = get_db_disp_restriction(self.manager) SQL = render_template( "/".join([self.template_path, self._PROPERTIES_SQL]), conn=self.conn, @@ -351,15 +344,7 @@ class DatabaseView(PGChildNodeView): self.manager.did in self.manager.db_info: last_system_oid = self._DATABASE_LAST_SYSTEM_OID - server_node_res = self.manager - - db_disp_res = None - params = None - if server_node_res and server_node_res.db_res: - db_disp_res = ", ".join( - ['%s'] * len(server_node_res.db_res.split(',')) - ) - params = tuple(server_node_res.db_res.split(',')) + db_disp_res, params = get_db_disp_restriction(self.manager) SQL = render_template( "/".join([self.template_path, self._NODES_SQL]), last_system_oid=last_system_oid, @@ -411,6 +396,8 @@ class DatabaseView(PGChildNodeView): @check_precondition(action="nodes") def nodes(self, gid, sid, is_schema_diff=False): res = self.get_nodes(gid, sid, is_schema_diff) + if isinstance(res, Response): + return res return make_json_response( data=res, @@ -1251,13 +1238,7 @@ class DatabaseView(PGChildNodeView): """ last_system_oid = self.retrieve_last_system_oid() - db_disp_res = None - params = None - if self.manager and self.manager.db_res: - db_disp_res = ", ".join( - ['%s'] * len(self.manager.db_res.split(',')) - ) - params = tuple(self.manager.db_res.split(',')) + db_disp_res, params = get_db_disp_restriction(self.manager) conn = self.manager.connection() status, res = conn.execute_dict(render_template( diff --git a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js index a7d5804d3..8dafc8bf6 100644 --- a/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js +++ b/web/pgadmin/browser/server_groups/servers/static/js/server.ui.js @@ -155,7 +155,8 @@ export default class ServerSchema extends BaseUISchema { connect_now: true, password: undefined, save_password: false, - db_res: [], + db_res: undefined, + db_res_type: 'databases', passexec: undefined, passexec_expiration: undefined, service: undefined, @@ -468,11 +469,41 @@ export default class ServerSchema extends BaseUISchema { readonly: obj.isConnected, }, { - id: 'db_res', label: gettext('DB restriction'), type: 'select', group: gettext('Advanced'), - options: [], + id: 'db_res_type', label: gettext('DB restriction type'), type: 'toggle', + mode: ['properties', 'edit', 'create'], group: gettext('Advanced'), + options: [ + {'label': gettext('Databases'), value: 'databases'}, + {'label': gettext('SQL'), value: 'sql'}, + ], + readonly: obj.isConnectedOrShared, + depChange: ()=>{ + return { + db_res: null, + }; + } + }, + { + id: 'db_res', label: gettext('DB restriction'), group: gettext('Advanced'), mode: ['properties', 'edit', 'create'], readonly: obj.isConnectedOrShared, - controlProps: { - multiple: true, allowClear: false, creatable: true, noDropdown: true, placeholder: 'Specify the databases to be restrict...' + deps: ['db_res_type'], + type: (state) => { + if (state.db_res_type == 'databases') { + return { + type: 'select', + options: [], + controlProps: { + multiple: true, + allowClear: false, + creatable: true, + noDropdown: true, + placeholder: 'Specify the databases to be restrict...' + } + }; + } else { + return { + type: 'sql', + }; + } }, }, { diff --git a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json index 5cba9062d..b1a47a309 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json +++ b/web/pgadmin/browser/server_groups/servers/tests/servers_test_data.json @@ -240,6 +240,45 @@ "expected_data": { "status_code": 200 } + }, + { + "name": "Add server with post connection sql", + "url": "/browser/server/obj/", + "is_positive_test": true, + "test_data": { + "post_connection_sql": "set timezone to 'Asia/Kolkata'" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200 + } + }, + { + "name": "Add server with DB Restriction (Database names)", + "url": "/browser/server/obj/", + "is_positive_test": true, + "test_data": { + "db_res": ["dev", "qa"] + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200 + } + }, + { + "name": "Add server with DB Restriction (SQL)", + "url": "/browser/server/obj/", + "is_positive_test": true, + "test_data": { + "db_res": "SELECT datname FROM pg_database\nWHERE datistemplate = false AND datname ILIKE '%myprefix_%'\nORDER BY datname" + }, + "mocking_required": false, + "mock_data": {}, + "expected_data": { + "status_code": 200 + } } ], "is_password_saved": [ diff --git a/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py b/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py index ea9337e72..64b79e482 100644 --- a/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py +++ b/web/pgadmin/browser/server_groups/servers/tests/test_add_server.py @@ -91,6 +91,14 @@ class AddServerTest(BaseTestGenerator): if 'tags' in self.test_data: self.server['tags'] = self.test_data['tags'] + if 'post_connection_sql' in self.test_data: + self.server['post_connection_sql'] = ( + self.test_data)['post_connection_sql'] + + if 'db_res' in self.test_data: + self.server['db_res'] = ( + self.test_data)['db_res'] + if self.is_positive_test: if hasattr(self, 'with_save'): self.server['save_password'] = self.with_save diff --git a/web/pgadmin/browser/server_groups/servers/utils.py b/web/pgadmin/browser/server_groups/servers/utils.py index 9b5e6b7b9..32fc72362 100644 --- a/web/pgadmin/browser/server_groups/servers/utils.py +++ b/web/pgadmin/browser/server_groups/servers/utils.py @@ -14,9 +14,10 @@ import keyring from flask_login import current_user from werkzeug.exceptions import InternalServerError from flask import render_template -from pgadmin.utils.constants import KEY_RING_USERNAME_FORMAT, \ - KEY_RING_SERVICE_NAME, KEY_RING_TUNNEL_FORMAT, \ - KEY_RING_DESKTOP_USER, SSL_MODES +from pgadmin.utils.constants import ( + KEY_RING_USERNAME_FORMAT, KEY_RING_SERVICE_NAME, KEY_RING_TUNNEL_FORMAT, + KEY_RING_DESKTOP_USER, SSL_MODES, RESTRICTION_TYPE_DATABASES, + RESTRICTION_TYPE_SQL) from pgadmin.utils.crypto import encrypt, decrypt from pgadmin.model import db, Server from flask import current_app @@ -680,3 +681,35 @@ def delete_adhoc_servers(sid=None): except Exception: db.session.rollback() raise + + +def get_db_restriction(res_type, restriction): + """ + This function is used to return the database restriction based on + restriction type. + """ + if restriction and res_type == RESTRICTION_TYPE_DATABASES: + return restriction.split(',') + elif restriction and res_type == RESTRICTION_TYPE_SQL: + return restriction + return None + + +def get_db_disp_restriction(manager_obj): + """ + This function is used to return db disp restriction aand params + to run the query. + """ + db_disp_res = None + params = None + if (manager_obj and manager_obj.db_res and + manager_obj.db_res_type == RESTRICTION_TYPE_DATABASES): + db_disp_res = ", ".join( + ['%s'] * len(manager_obj.db_res.split(',')) + ) + params = tuple(manager_obj.db_res.split(',')) + elif (manager_obj and manager_obj.db_res and + manager_obj.db_res_type == RESTRICTION_TYPE_SQL): + db_disp_res = manager_obj.db_res + + return db_disp_res, params diff --git a/web/pgadmin/misc/properties/CollectionNodeProperties.jsx b/web/pgadmin/misc/properties/CollectionNodeProperties.jsx index 5b3893945..cc63a55e2 100644 --- a/web/pgadmin/misc/properties/CollectionNodeProperties.jsx +++ b/web/pgadmin/misc/properties/CollectionNodeProperties.jsx @@ -23,6 +23,7 @@ import Loader from 'sources/components/Loader'; import { evalFunc } from '../../static/js/utils'; import { usePgAdmin } from '../../static/js/PgAdminProvider'; import { getSwitchCell } from '../../static/js/components/PgReactTableStyled'; +import { parseApiError } from '../../static/js/api_instance'; const StyledBox = styled(Box)(({theme}) => ({ height: '100%', @@ -272,9 +273,11 @@ export default function CollectionNodeProperties({ setLoaderText(''); }) .catch((err) => { + setData([]); + setLoaderText(''); pgAdmin.Browser.notifier.alert( gettext('Failed to retrieve data from the server.'), - gettext(err.message) + parseApiError(err) ); }); setIsStale(false); diff --git a/web/pgadmin/misc/statistics/static/js/Statistics.jsx b/web/pgadmin/misc/statistics/static/js/Statistics.jsx index 87297a351..4a76af8b6 100644 --- a/web/pgadmin/misc/statistics/static/js/Statistics.jsx +++ b/web/pgadmin/misc/statistics/static/js/Statistics.jsx @@ -21,6 +21,7 @@ import { toPrettySize } from '../../../../static/js/utils'; import withStandardTabInfo from '../../../../static/js/helpers/withStandardTabInfo'; import { BROWSER_PANELS } from '../../../../browser/static/js/constants'; import { usePgAdmin } from '../../../../static/js/PgAdminProvider'; +import { parseApiError } from '../../../../static/js/api_instance'; const Root = styled('div')(({theme}) => ({ height : '100%', @@ -193,7 +194,7 @@ function Statistics({ nodeData, nodeItem, node, treeNodeInfo, isActive, isStale, } else { pgAdmin.Browser.notifier.alert( gettext('Failed to retrieve data from the server.'), - gettext(err.message) + parseApiError(err) ); setMsg(gettext('Failed to retrieve data from the server.')); } diff --git a/web/pgadmin/model/__init__.py b/web/pgadmin/model/__init__.py index c8f7fd899..057a2ab79 100644 --- a/web/pgadmin/model/__init__.py +++ b/web/pgadmin/model/__init__.py @@ -33,7 +33,7 @@ import config # ########################################################################## -SCHEMA_VERSION = 43 +SCHEMA_VERSION = 44 ########################################################################## # @@ -178,6 +178,7 @@ class Server(db.Model): lazy='joined' ) db_res = db.Column(db.Text(), nullable=True) + db_res_type = db.Column(db.String(32), default='databases') passexec_cmd = db.Column(db.Text(), nullable=True) passexec_expiration = db.Column(db.Integer(), nullable=True) bgcolor = db.Column(db.String(10), nullable=True) diff --git a/web/pgadmin/tools/sqleditor/__init__.py b/web/pgadmin/tools/sqleditor/__init__.py index eb26bdfa5..152ae82a8 100644 --- a/web/pgadmin/tools/sqleditor/__init__.py +++ b/web/pgadmin/tools/sqleditor/__init__.py @@ -68,7 +68,7 @@ from pgadmin.utils.preferences import Preferences from pgadmin.tools.sqleditor.utils.apply_explain_plan_wrapper import \ get_explain_query_length from pgadmin.browser.server_groups.servers.utils import \ - convert_connection_parameter + convert_connection_parameter, get_db_disp_restriction from pgadmin.misc.workspaces import check_and_delete_adhoc_server MODULE_NAME = 'sqleditor' @@ -2426,15 +2426,8 @@ def get_new_connection_database(sgid, sid=None): if sid: template_path = 'databases/sql/#{0}#'.format(manager.version) last_system_oid = 0 - server_node_res = manager - db_disp_res = None - params = None - if server_node_res and server_node_res.db_res: - db_disp_res = ", ".join( - ['%s'] * len(server_node_res.db_res.split(',')) - ) - params = tuple(server_node_res.db_res.split(',')) + db_disp_res, params = get_db_disp_restriction(manager) sql = render_template( "/".join([template_path, _NODES_SQL]), last_system_oid=last_system_oid, diff --git a/web/pgadmin/utils/constants.py b/web/pgadmin/utils/constants.py index 5c91968bb..7534498c8 100644 --- a/web/pgadmin/utils/constants.py +++ b/web/pgadmin/utils/constants.py @@ -160,3 +160,6 @@ DATA_TYPE_WITH_LENGTH = [1560, 'bit', 1561, 'bit[]', 1015, 'varchar[]', 'character varying[]', 'vector', 'vector[]', 'halfvec', 'halfvec[]', 'sparsevec', 'sparsevec[]'] + +RESTRICTION_TYPE_DATABASES = 'databases' +RESTRICTION_TYPE_SQL = 'sql' diff --git a/web/pgadmin/utils/driver/psycopg3/server_manager.py b/web/pgadmin/utils/driver/psycopg3/server_manager.py index dbd784da1..9505a6b76 100644 --- a/web/pgadmin/utils/driver/psycopg3/server_manager.py +++ b/web/pgadmin/utils/driver/psycopg3/server_manager.py @@ -81,6 +81,7 @@ class ServerManager(object): self.db_info = dict() self.server_types = None self.db_res = server.db_res + self.db_res_type = server.db_res_type self.name = server.name self.passexec = \ PasswordExec(server.passexec_cmd, server.host, server.port,